Summary
- Non-Fungible Tokens (NFTs) are onchain digital assets. They are indivisible, meaning they cannot be split into fractional parts, and unique.
- Metadata attaches additional properties to Assets and Collection. Metadata includes the token name and a link to an offchain JSON file. This JSON file contains links to artwork and other media files, any special traits the NFT has, and more.
- The Metaplex Core Program is an onchain program that sheds the complexity and technical debt of previous standards and provides a clean and simple core spec for digital assets using a single account design
Lesson
In this lesson, we'll explore how Core Assets are represented and demonstrate how to create and update them using the mpl-core SDK.
Solana Non-Fungible Tokens (NFTs) were commonly represented as SPL tokens with an additional metadata account associated with each token mint and created using both the Token Program and the Metaplex Token Metadata Program.
However, with the introduction of the new Metaplex Core Program, NFTs have their own program and standard that leverage a single account design and has a flexible plugin system that that enables developers to natively modify asset behavior and functionality
In this lesson, we'll explore how Core Assets are represented and demonstrate
how to create and update them using the mpl-core
SDK.
NFTs on Solana
All NFTs characteristics were previously achievable with a combination of the SPL Token Program and the Metaplex Token Metadata Program by setting the following boundaries:
- 0 decimals, so it cannot be divided into parts.
- Supply of 1, so only 1 of these tokens exists.
- No mint authority to ensure that the supply never changes.
- Additional accounts including, Metadata, Master Edition and Token Record to store addotional information like name, uri, ... and inform that the previous boundaries are enforced.
This came with a big overhead and lots of inefficiencies for a market as big as NFTs and Digital Asset in general.
Thanks to the Metaplex Core Program, all this characteristics are now included at the protocol level!
The Metaplex Core program
The Metaplex Core Program is the newest NFT Digital Asset standard from Metaplex
- Unlike the Token Metadata Program, Collections are separate accounts with a distinct data model from the Assets accounts. They store only collection-specific metadata, such as the collection name and collection image.
- Assets are now a single account model and design that don't rely on additional accounts such as Associated Token Accounts or Metadata Accounts. Instead, when a Core Assets is created, the Metaplex Core Program stores the ownership and metadata directly on the Asset.
- Both Assets and Collections can integrate with lifecycle events such as creation, transfer, and burning, enabling custom behaviors through Plugins. For instance, plugins allow features like royalty enforcement or on-chain attributes to be added with a single instruction, eliminating the need for extra code.
In the following sections, we'll cover the basics of using the
metaplex-foundation/mpl-core
SDK with Umi to prepare off-chain metadata,
create and update Assets, and add them into a collection. For more information
about metaplex-foundation/mpl-core
visit the
Metaplex Developer Docs.
Umi
Umi is a framework built by Metaplex that can register JS/TS clients built with Kinobi/Codama that interact with on-chain programs. While it can interface with clients from various programs, it's most commonly used with all the Metaplex programs.
Note: Umi uses different implementation types for common web3.js functions and concepts, such as Keypairs, PublicKeys, and Connections. Fortunately, it's easy to convert between web3.js and Umi equivalents.
For more deetails, visit the Metaplex Developer Docs.
Installing and setting up Umi
First, create a new Umi instance. We can do this by either providing our own RPC
endpoint, or use the public facing Solana endpoints provided by the
clusterApiUrl
method.
import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";import { clusterApiUrl } from "@solana/web3.js";const umi = createUmi(clusterApiUrl("devnet"));
Next, set the identity for your Umi instance (the keypair what will be used to
sign transactions) and load the necessary plugins, in this case, the mplCore
plugin.
import { mplCore } from "@metaplex-foundation/mpl-core";import { keypairIdentity } from "@metaplex-foundation/umi";import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";import { getKeypairFromFile } from "@solana-developers/helpers";import { promises as fs } from "fs";const umi = createUmi("https://api.devnet.solana.com").use(mplCore());// load keypair from local file system// See https://github.com/solana-developers/helpers?tab=readme-ov-file#get-a-keypair-from-a-keypair-fileconst localKeypair = await getKeypairFromFile();// convert to Umi compatible keypairconst umiKeypair = umi.eddsa.createKeypairFromSecretKey(localKeypair.secretKey);// load the MPL metadata program plugin and assign a signer to our umi instanceumi.use(keypairIdentity(umiKeypair));
Uploading images
Before creating an Asset, you must prepare and upload any assets you plan to associate with it such as images, animation files, and off chain metadata.
Preparing and uploading an image involves converting to a buffer first. You can
convert the file to a
generic file using
the createGenericFile()
function and finally uploading it to the designated
Storage Driver.
The GenericFile
type allows Umi to support different file variations despite
the difference of browser files and local file system files i.e. those on your
computer.
In action, uploading an image named random-image.png
from your computer would
take the following steps:
-
Reading the file using
fs.readFile()
into a buffer. -
Creating a generic file type with the files MIME Type from the buffer and filePath.
-
Uploading file to designated storage provider.
// Add an uploader to your umi instance. In this case we're going to use Irysimport { irysUploader } from "@metaplex-foundation/umi-uploader-irys";// ...create Umi as shown beforeumi.use(irysUploader());let filePath = "random-image.png";const buffer = await fs.readFile(filePath);let file = createGenericFile(buffer, filePath, {// chose the correct file MIME type https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_typescontentType: "image/jpeg",});const [image] = await umi.uploader.upload([file]);
The function will return the uri where the image is stored.
Uploading the metadata
After uploading the image, it's time to upload the offchain JSON metadata using
the uploadJson()
method. This will return a uri where the JSON metadata is
stored.
Remember, the offchain portion of the metadata includes things like the image uri we just generated as well as additional information like the name and description of the Asset. While you can technically include anything you'd like in this JSON object, in most cases, you should follow the standard JSON schema for Core to ensure compatibility with wallets, programs, and applications.
To create the metadata, use the uploadJson()
method provided by the SDK. This
method accepts a metadata object and returns a uri that points to the uploaded
metadata.
const metadata = {name: "My NFT",description: "This is an NFT on Solana",image, // Uri of the Imageexternal_url: "https://example.com",properties: {files: [{uri: image,type: "image/jpeg",},],category: "image",},};const uri = await umi.uploader.uploadJson(metadata);
Now that you know how to create the metadata for your Collections and Assets,
remember to modify the values as you see fit and use the response to fill out
the uri
field.
Creating the Collection
Once you have uploaded the appropiate files to respresent your Core Collection (image, metadata.json) you can finally create the Core Collection itself.
The createCollection
method allows you to create a collection with the
provided data
const collection = generateSigner(umi);console.log("\nCollection Address: ", collection.publicKey.toString());const { signature, result } = await createCollection(umi, {collection,name: "My Collection",uri: collectionUri,}).sendAndConfirm(umi, { send: { commitment: "finalized" } });
The sendAndConfirm
method is what takes care of signing our transaction and
sending it. It also provides available options such as setting pre-flight checks
and our desired commitment level for the transaction, which defaults to
confirmed
if not provided.
This method returns an object containing the transaction signature and a result.
The result object contains the outcome of our transaction. If successful, the
err
inside this will be set to null otherwise it'll contain the error for the
failed transaction.
By default, the SDK sets the payer
and the updateAuthority
property
respecitvely using the payer and signer identity of the Umi instance we created
previously like this:
const { signature, result } = await createCollection(umi, {...createCollectionArgs,payer: umi.payer.publickey,updateAuthority: umi.identity.publicKey,}).sendAndConfirm(umi, { send: { commitment: "finalized" } });
Creating the Asset
The create
method allows you to create a new Asset similarly to how is done
for Collections
const asset = generateSigner(umi);console.log("\nAsset Address: ", asset.publicKey.toString());const { signature, result } = await create(umi, {asset,name: "My Asset",uri: assetUri,}).sendAndConfirm(umi, { send: { commitment: "finalized" } });
For this method there are two additional optional fields:
- the
owner
field that if not supplied will default to the signer identity of the Umi instance like this:
const { signature, result } = await create(umi, {...createArgs,owner: umi.identity.publicKey,}).sendAndConfirm(umi, { send: { commitment: "finalized" } });
- the
collection
field is only needed when we want to add the asset to a collection
const asset = generateSigner(umi);const collection = await fetchCollection(umi,publicKey("CORE_COLLECTION_ADDRESS"),);const { signature, result } = await create(umi, {asset,collection,name: "My Asset",uri: assetUri,}).sendAndConfirm(umi, { send: { commitment: "finalized" } });
Note: once you add an Asset to a Collection you'll need to pass in a
CollectionV1
object in the collection field (can be obtained by using the
fetchCollection()
function) of subsequent instructions. Leaving the collection
field empty will cause invalidations during transactions causing them to fail.
Updating the Asset & Collection
The following steps works for both Assets and Collection, the only difference
will be employing the update
method for Assets and the updateCollection
method for Collections
The update
method allows you to update all the fields present in the account
like collection
, name
, updateAuthority
and the uri
const asset = await fetchAsset(umi, publickey("CORE_ASSET_ADDRESS"));const collection = await fetchCollection(umi, publicKey("CORE_ASSET_ADDRESS"));const { signature, result } = await update(umi, {asset,collection,name: 'My new NFT'uri: newAssetUrinewCollection: publicKey("NEW_CORE_COLLECTION_ADDRESS")}).sendAndConfirm(umi, { send: { commitment: "finalized" } });
When we add an Asset to a Collection, to not waste any additional bytes, the
collection gets stored in the updateAuthority
field of the asset. So if you
want to change the collection address for the asset, you can update the
updateAuthority
field of the update
function, instead of using the
newCollection
field, like this:
const asset = await fetchAsset(umi, publickey("CORE_ASSET_ADDRESS"));const collection = await fetchCollection(umi, publicKey("CORE_ASSET_ADDRESS"));const { signature, result } = await update(umi, {asset,collection,newUpdateAuthority: updateAuthority("Collection", [publicKey("NEW_CORE_COLLECTION_ADDRESS"),]),}).sendAndConfirm(umi, { send: { commitment: "finalized" } });
Note: We can use the same method to add the Asset to a Collection if it isn't part of a collection currently.
A noted difference from the token-metadata
program is that it not impossible
to create an immutable Asset
using the create
method. The only way to make
an asset immutable is to change the updateAuthority
to None
in a futher
update.
const asset = await fetchAsset(umi, publickey("CORE_ASSET_ADDRESS"));const collection = await fetchCollection(umi, publicKey("CORE_ASSET_ADDRESS"));const { signature, result } = await update(umi, {asset,collection,newUpdateAuthority: updateAuthority("None"),}).sendAndConfirm(umi, { send: { commitment: "finalized" } });
Note: any fields you don't include in the call to updateV1
will stay the
same, by design.
Lab
In this lab, we'll go through the steps to create a Core Asset using the
Metaplex Umi framework, add the Asset to the Collection and update its metadata
in subsequent transactions. By the end, you will have a basic understanding of
how to use the Metaplex Umi and the mpl-core
SDK to interact with digital
assets on Solana.
Part 1: Creating an Core collection
To begin, make a new folder and install the relevant dependencies:
npm i @solana/web3.js npm i @solana/web3.js npm i @solana-developers/helpers npm i @metaplex-foundation/mpl-core npm i @metaplex-foundation/umi-bundle-defaults npm i @metaplex-foundation/umi-uploader-irys npm i --save-dev esrun
Then create a file called create-metaplex-core-collection.ts
, and add our
imports:
import { createCollection, mplCore } from "@metaplex-foundation/mpl-core";import {createGenericFile,generateSigner,keypairIdentity,percentAmount,sol,} from "@metaplex-foundation/umi";import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";import { irysUploader } from "@metaplex-foundation/umi-uploader-irys";import {airdropIfRequired,getExplorerLink,getKeypairFromFile,} from "@solana-developers/helpers";import { clusterApiUrl, Connection, LAMPORTS_PER_SOL } from "@solana/web3.js";import { promises as fs } from "fs";import * as path from "path";
Connect to devnet, load a user and Airdrop some SOL if needed:
// create a new connection to Solana's devnet clusterconst connection = new Connection(clusterApiUrl("devnet"));// load keypair from local file system// See https://github.com/solana-developers/helpers?tab=readme-ov-file#get-a-keypair-from-a-keypair-file// assumes that the keypair is already generated using `solana-keygen new`const user = await getKeypairFromFile();await airdropIfRequired(connection,user.publicKey,1 * LAMPORTS_PER_SOL,0.1 * LAMPORTS_PER_SOL,);console.log("Loaded user:", user.publicKey.toBase58());
Create a new Umi instance, assign it the loaded keypair, load the mplCore
to
interact with the metadata program and irysUploader
to upload our files.
// create a new connection to Solana's devnet clusterconst umi = createUmi(connection).use(mplCore()).use(irysUploader());// convert to umi compatible keypairconst umiKeypair = umi.eddsa.createKeypairFromSecretKey(user.secretKey);// assigns a signer to our umi instance, and loads the MPL metadata program and Irys uploader plugins.umi.use(keypairIdentity(umiKeypair));
Download the assets and collection image from the links below and save them inside your working directory:
-
Collection image: https://github.com/solana-developers/professional-education/blob/main/labs/metaplex-umi/collection.png
-
Asset image: https://github.com/solana-developers/professional-education/blob/main/labs/metaplex-umi/nft.png
We will use these images as our collection and asset cover images respectively.
We will use Irys as our storage provider that Metaplex conveniently supports
through the umi-uploader-irys
plugin, we can use that to upload our files. The
plugin, also takes care of sending the right amount of lamports for storage fees
so we don't have to worry about making this on our own.
Upload the offchain metadata to Irys:
const collectionImagePath = "collection.png";const buffer = await fs.readFile(collectionImagePath);let file = createGenericFile(buffer, collectionImagePath, {contentType: "image/png",});const [image] = await umi.uploader.upload([file]);console.log("image uri:", image);const metadata = {name: "My Collection",description: "My Collection description",image,external_url: "https://example.com",properties: {files: [{uri: image,type: "image/jpeg",},],category: "image",},};// upload offchain json to Arweave using irysconst uri = await umi.uploader.uploadJson(metadata);console.log("Collection offchain metadata URI:", uri);
Then actually create the collection:
// generate mint keypairconst collection = generateSigner(umi);// create and mint a Collectionawait createCollection(umi, {collection,name: "My Collection",uri,}).sendAndConfirm(umi, { send: { commitment: "finalized" } });let explorerLink = getExplorerLink("address", collection.publicKey, "devnet");console.log(`Collection: ${explorerLink}`);console.log(`Collection address is: ${collection.publicKey}`);console.log("✅ Finished successfully!");
We advise using esrun to run the scripts because it allows you to use top level await without having to wrap your code inside asynchronous function.
Run the create-metaplex-core-collection.ts
script
npx esrun create-metaplex-core-collection.ts
The output should look like this:
% npx esrun create-metaplex-core-collection.tsLoaded user: 2YkGRHjwD3jqcu4ie6pL9Axpdx5AKa6KDyj8bF473Vk5image uri: https://arweave.net/EBRzcUrhbiTfSnx2oD1SZacGMbq2WeFtLUWSD5tAuP7Collection offchain metadata URI: https://gateway.irys.xyz/HTK7UZsUqiVcnZ9ez2eNKPQLiU3Xg1hyic1ghwj6gXXECollection: https://explorer.solana.com/address/ACJgjrstigNsgukPuZWZay1L2DXeJPwTn6EyxB5hwWrK?cluster=devnetCollection address is: ACJgjrstigNsgukPuZWZay1L2DXeJPwTn6EyxB5hwWrK✅ Finished successfully!
Congratulations! You've created a Metaplex Core Collection. Check this out on Solana Explorer using the URL above!
Keep the Collection addres since we're going to use it in the next step.
2. Creating a Core Asset and adding it to the Collection
We'll now create a Core Asset that's a member of the Collection we just Created.
Start by creating a new file called create-metaplex-core-asset.ts
. The setup
for this will look the same as the previous file, with slightly different
imports:
import {mplCore,create,fetchCollection,} from "@metaplex-foundation/mpl-core";import {createGenericFile,generateSigner,keypairIdentity,publicKey as UMIPublicKey,} from "@metaplex-foundation/umi";import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";import { irysUploader } from "@metaplex-foundation/umi-uploader-irys";import {airdropIfRequired,getExplorerLink,getKeypairFromFile,} from "@solana-developers/helpers";import { clusterApiUrl, Connection, LAMPORTS_PER_SOL } from "@solana/web3.js";import { promises as fs } from "fs";import * as path from "path";// create a new connection to Solana's devnet clusterconst connection = new Connection(clusterApiUrl("devnet"));// load keypair from local file system// assumes that the keypair is already generated using `solana-keygen new`const user = await getKeypairFromFile();console.log("Loaded user:", user.publicKey.toBase58());await airdropIfRequired(connection,user.publicKey,1 * LAMPORTS_PER_SOL,0.1 * LAMPORTS_PER_SOL,);const umi = createUmi(connection).use(mplCore()).use(irysUploader());// convert to umi compatible keypairconst umiKeypair = umi.eddsa.createKeypairFromSecretKey(user.secretKey);// assigns a signer to our umi instance, and loads the MPL metadata program and Irys uploader plugins.umi.use(keypairIdentity(umiKeypair));
We can then put out files into Irys:
const assetImagePath = "asset.png";const buffer = await fs.readFile(assetImagePath);let file = createGenericFile(buffer, assetImagePath, {contentType: "image/png",});// upload image and get image uriconst [image] = await umi.uploader.upload([file]);console.log("image uri:", image);const metadata = {name: "My Asset",description: "My Asset Description",image,external_url: "https://example.com",attributes: [{trait_type: "trait1",value: "value1",},{trait_type: "trait2",value: "value2",},],properties: {files: [{uri: image,type: "image/jpeg",},],category: "image",},};// upload offchain json using irys and get metadata uriconst uri = await umi.uploader.uploadJson(metadata);console.log("Asset offchain metadata URI:", uri);
Then we create an Asset and we add it to the Collection we just created:
// Substitute in your collection NFT address from create-metaplex-nft-collection.tsconst collection = await fetchCollection(umi,UMIPublicKey("YOUR_COLLECTION_ADDRESS_HERE"),);const asset = generateSigner(umi);// create and mint NFTawait create(umi, {asset,collection,name: "My Asset",uri,}).sendAndConfirm(umi, { send: { commitment: "finalized" } });let explorerLink = getExplorerLink("address", asset.publicKey, "devnet");console.log(`Asset: ${explorerLink}`);console.log(`Asset address: ${asset.publicKey}`);
Run npx esrun create-metaplex-core-asset
. If all goes well, you will see the
following:
% npx esrun create-metaplex-core-assetLoaded user: 2YkGRHjwD3jqcu4ie6pL9Axpdx5AKa6KDyj8bF473Vk5image uri: https://arweave.net/79wKgR6VAuS3RfneBCNx3RMxzhAPUdqBaK4Ah4KQuWArAsset offchain metadata URI: https://gateway.irys.xyz/7F7YdG9mGFXaq51qPbog5QmymovansiipABtM2nDNLAVAsset: https://explorer.solana.com/address/BXfRBtgVRmEnwQaLqCAproeuSMNdkgWYptHvjvHekHht?cluster=devnetAsset address: BXfRBtgVRmEnwQaLqCAproeuSMNdkgWYptHvjvHekHht✅ Finished successfully!
Inspect your Asset at the address given to confirm that the asset has been minted!
3. Update the NFT
Create a new file, called update-metaplex-core-asset.ts
. The imports will be
similar to our previous files:
import {mplCore,update,fetchAsset,fetchCollection,} from "@metaplex-foundation/mpl-core";import {createGenericFile,generateSigner,keypairIdentity,percentAmount,publicKey as UMIPublicKey,} from "@metaplex-foundation/umi";import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";import { irysUploader } from "@metaplex-foundation/umi-uploader-irys";import {airdropIfRequired,getExplorerLink,getKeypairFromFile,} from "@solana-developers/helpers";import { clusterApiUrl, Connection, LAMPORTS_PER_SOL } from "@solana/web3.js";import { promises as fs } from "fs";import * as path from "path";// create a new connection to Solana's devnet clusterconst connection = new Connection(clusterApiUrl("devnet"));// load keypair from local file system// See https://github.com/solana-developers/helpers?tab=readme-ov-file#get-a-keypair-from-a-keypair-file// assumes that the keypair is already generated using `solana-keygen new`const user = await getKeypairFromFile();console.log("Loaded user:", user.publicKey.toBase58());await airdropIfRequired(connection,user.publicKey,1 * LAMPORTS_PER_SOL,0.1 * LAMPORTS_PER_SOL,);// create a new connection to Solana's devnet clusterconst umi = createUmi(connection).use(mplCore()).use(irysUploader());// convert to umi compatible keypairconst umiKeypair = umi.eddsa.createKeypairFromSecretKey(user.secretKey);// assigns a signer to our umi instance, and loads the MPL metadata program and Irys uploader plugins.umi.use(keypairIdentity(umiKeypair));
Fetch both Asset and Collection, using the address from the previous example and try to update the uri and the name of the Asset:
const assetImagePath = "asset.png";const buffer = await fs.readFile(assetImagePath);let file = createGenericFile(buffer, assetImagePath, {contentType: "image/png",});// upload new image and get image uriconst [image] = await umi.uploader.upload([file]);console.log("image uri:", image);const metadata = {name: "My Updated Asset",description: "My Updated Asset Description",image,external_url: "https://example.com",attributes: [{trait_type: "trait1",value: "value1",},{trait_type: "trait2",value: "value2",},],properties: {files: [{uri: image,type: "image/jpeg",},],category: "image",},};// upload offchain json using irys and get metadata uriconst uri = await umi.uploader.uploadJson(metadata);console.log("Asset offchain metadata URI:", uri);// Fetch the accounts using the addressconst asset = await fetchAsset(umi, UMIPublicKey("YOUR_ASSET_ADDRESS_HERE"));const collection = await fetchCollection(umi,UMIPublicKey("YOUR_COLLECTION_ADDRESS_HERE"),);await update(umi, {asset,collection,name: "My Updated Asset",uri,}).sendAndConfirm(umi);let explorerLink = getExplorerLink("address", asset.publicKey, "devnet");console.log(`Asset updated with new metadata URI: ${explorerLink}`);console.log("✅ Finished successfully!");
Run npx esrun update-metaplex-core-asset.ts
. You should see something like:
% npx esrun update-metaplex-core-asset.tsLoaded user: 2YkGRHjwD3jqcu4ie6pL9Axpdx5AKa6KDyj8bF473Vk5image uri: https://arweave.net/DtdF6YbCbSV6y5eeet6Hk2oJq2afFwKzZmrLpfFkyLymAsset offchain metadata URI: https://gateway.irys.xyz/DQysfQZ9CuGveSYDWKZwhjUX7VnRSzfaPtvojo44rhJwAsset updated with new metadata URI: https://explorer.solana.com/address/BXfRBtgVRmEnwQaLqCAproeuSMNdkgWYptHvjvHekHht?cluster=devnet✅ Finished successfully!
Inspect the updated NFT on Solana Explorer!
Congratulations! You've successfully learned how to use the Metaplex mpl-core SDK to create, update, and add an Asset to a Collection. That's everything you need to build out your own collection for just about any use case. You could build a new event ticketing platform, revamp a retail business membership program, or even digitize your school's student ID system. The possibilities are endless!
Challenge
The steps covered above for creating an NFT would be incredibly tedious to execute for thousands of NFTs in one go. Many providers, including Metaplex, Magic Eden, and Tensor have so-called 'fair launch' tools that take care of minting large quantities of NFTs and ensuring they are sold within the parameters set by their creators. Dive into how Metaplex support fair launches through Candy Machine!
Completed the lab?
Push your code to GitHub and tell us what you thought of this lesson! // TODO