How to mint, transfer and read large-scale NFT collections using Metaplex’s Bubblegum SDK.
2 ^ maxDepth
.
The max buffer size is effectively the maximum number of concurrent changes
that you can make to a tree within a single slot with the root hash still being
valid.
The canopy depth is the number of proof nodes that are stored onchain for
any given proof path. Verifying any leaf requires the complete proof path for
the tree. The complete proof path is made up of one proof node for every “layer”
of the tree, i.e. a max depth of 14 means there are 14 proof nodes. Every proof
node adds 32 bytes to a transaction, so large trees would quickly exceed the
maximum transaction size limit without caching proof nodes onchain.
Each of these three values, max depth, max buffer size, and canopy depth, comes
with a tradeoff. Increasing the value of any of these values increases the size
of the account used to store the tree, thus increasing the cost to create the
tree.
Choosing the max depth is fairly straightforward as it directly relates to the
number of leafs and therefore the amount of data you can store. If you need
1million cNFTs on a single tree, find the max depth that makes the following
expression true: 2^maxDepth > 1million
. The answer is 20.
Choosing a max buffer size is effectively a question of throughput: how many
concurrent writes do you need.
@nexis-network/spl-account-compression
SDK, the Metaplex Bubblegum
program, and the Bubblegum program’s corresponding TS SDK
@metaplex-foundation/mpl-bugglegum
.
image
url shown in
the example above.
@metaplex-foundation/js
library. Just make
sure you set isCollection
to true
.
Max Depth | Max Buffer Size | Max Number of cNFTs |
---|---|---|
3 | 8 | 8 |
5 | 8 | 32 |
14 | 64 | 16,384 |
14 | 256 | 16,384 |
14 | 1,024 | 16,384 |
14 | 2,048 | 16,384 |
15 | 64 | 32,768 |
16 | 64 | 65,536 |
17 | 64 | 131,072 |
18 | 64 | 262,144 |
19 | 64 | 524,288 |
20 | 64 | 1,048,576 |
20 | 256 | 1,048,576 |
20 | 1,024 | 1,048,576 |
20 | 2,048 | 1,048,576 |
24 | 64 | 16,777,216 |
24 | 256 | 16,777,216 |
24 | 512 | 16,777,216 |
24 | 1,024 | 16,777,216 |
24 | 2,048 | 16,777,216 |
26 | 512 | 67,108,864 |
26 | 1,024 | 67,108,864 |
26 | 2,048 | 67,108,864 |
30 | 512 | 1,073,741,824 |
30 | 1,024 | 1,073,741,824 |
30 | 2,048 | 1,073,741,824 |
createAllocTreeIx
helper function
from the @nexis-network/spl-account-compression
TS SDK to create the instruction for
creating the empty account.
@metaplex-foundation/mpl-bubblegum
TS SDK provides the
helper function createCreateTreeInstruction
for calling the create_tree
instruction on the Bubblegum program. As part of the call, you’ll need to derive
the treeAuthority
PDA expected by the program. This PDA uses the tree’s
address as a seed.
accounts
- An object representing the accounts required by the instruction.
This includes:
treeAuthority
- Bubblegum expects this to be a PDA derived using the
Merkle tree address as a seedmerkleTree
- The Merkle tree accountpayer
- The address paying for transaction fees, rent, etc.treeCreator
- The address to list as the tree creatorlogWrapper
- The program to use to expose the data to indexers through
logs; this should be the address of the SPL Noop program unless you have
some other custom implementationcompressionProgram
- The compression program to use for initializing the
Merkle tree; this should be the address of the SPL State Compression program
unless you have some other custom implementationargs
- An object representing additional arguments required by the
instruction. This includes:
maxBufferSize
- The max buffer size of the Merkle treemaxDepth
- The max depth of the Merkle treepublic
- When set to true
, anyone will be able to mint cNFTs from the
tree; when set to false
, only the tree creator or tree delegate will be
able to min cNFTs from the treecreate_tree
instruction on the Bubblegum
program. This instruction does three things:
mint_v1
or mint_to_collection_v1
, depending on whether
or not you want to the minted cNFT to be part of a collection.
Version 0.7 of the @metaplex-foundation/mpl-bubblegum
TS SDK provides helper
functions createMintV1Instruction
and createMintToCollectionV1Instruction
to
make it easier for you to create the instructions.
Both functions will require you to pass in the NFT metadata and a list of
accounts required to mint the cNFT. Below is an example of minting to a
collection:
accounts
and
args
. The args
parameter is simply the NFT metadata, while accounts
is an
object listing the accounts required by the instruction. There are admittedly a
lot of them:
payer
- the account that will pay for the transaction fees, rent, etc.merkleTree
- the Merkle tree accounttreeAuthority
- the tree authority; should be the same PDA you derived
previouslytreeDelegate
- the tree delegate; this is usually the same as the tree
creatorleafOwner
- the desired owner of the compressed NFT being mintedleafDelegate
- the desired delegate of the compressed NFT being minted; this
is usually the same as the leaf ownercollectionAuthority
- the authority of the collection NFTcollectionAuthorityRecordPda
- optional collection authority record PDA;
there typically is none, in which case you should put the Bubblegum program
addresscollectionMint
- the mint account for the collection NFTcollectionMetadata
- the metadata account for the collection NFTeditionAccount
- the master edition account of the collection NFTcompressionProgram
- the compression program to use; this should be the
address of the SPL State Compression program unless you have some other custom
implementationlogWrapper
- the program to use to expose the data to indexers through logs;
this should be the address of the SPL Noop program unless you have some other
custom implementationbubblegumSigner
- a PDA used by the Bubblegrum program to handle collection
verificationtokenMetadataProgram
- the token metadata program that was used for the
collection NFT; this is usually always the Metaplex Token Metadata programasset
represented in utf8 encodinggetLeafAssetId
helper function from the
Bubblegum SDK. With the asset ID, fetching the cNFT is fairly straightforward.
Simply use the getAsset
method provided by the supporting RPC provider:
content.metadata.attributes
or the image at
content.files.uri
.
getAsset
getSignaturesForAsset
searchAssets
getAssetProof
getAssetsByOwner
getAssetsByAuthority
getAssetsByCreator
getAssetsByGroup
createTransferInstruction
helper function, there is
more assembly required than usual. Specifically, the Bubblegum program needs to
verify that the entirety of the cNFT’s data is what the client asserts before a
transfer can occur. The entirety of the cNFT data has been hashed and stored as
a single leaf on the Merkle tree, and the Merkle tree is simply a hash of all
the tree’s leafs and branches. Because of this, you can’t simply tell the
program what account to look at and have it compare that account’s authority
or owner
field to the transaction signer.
Instead, you need to provide the entirety of the cNFT data and any of the Merkle
tree’s proof information that isn’t stored in the canopy. That way, the program
can independently prove that the provided cNFT data, and therefore the cNFT
owner, is accurate. Only then can the program safely determine if the
transaction signer should, in fact, be allowed to transfer the cNFT.
In broad terms, this involves a five step process:
AccountMeta
objectsgetAsset
and getAssetProof
methods to fetch the asset data and proof,
respectively.
ConcurrentMerkleTreeAccount
type from
@nexis-network/spl-account-compression
:
assetProof
.
However, you can exclude the same number of tail-end accounts from the proof as
the depth of the canopy.
createTransferInstruction
, requires the following arguments:
accounts
- a list of instruction accounts, as expected; they are as follows:
merkleTree
- the Merkle tree accounttreeAuthority
- the Merkle tree authorityleafOwner
- the owner of the leaf (cNFT) in questionleafDelegate
- the delegate of the leaf (cNFT) in question; if no delegate
has been added then this should be the same as leafOwner
newLeafOwner
- the address of the new owner post-transferlogWrapper
- the program to use to expose the data to indexers through
logs; this should be the address of the SPL Noop program unless you have
some other custom implementationcompressionProgram
- the compression program to use; this should be the
address of the SPL State Compression program unless you have some other
custom implementationanchorRemainingAccounts
- this is where you add the proof pathargs
- additional arguments required by the instruction; they are:
root
- the root Merkle tree node from the asset proof; this is provided by
the indexer as a string and must be converted to bytes firstdataHash
- the hash of the asset data retrieved from the indexer; this is
provided by the indexer as a string and must be converted to bytes firstcreatorHash
- the hash of the cNFT creator as retrieved from the indexer;
this is provided by the indexer as a string and must be converted to bytes
firstnonce
- used to ensure that no two leafs have the same hash; this value
should be the same as index
index
- the index where the cNFT’s leaf is located on the Merkle treestarter
branch of our
cNFT lab repository.
git clone https://github.com/Unboxed-Software/solana-cnft-demo.git
cd solana-cnft-demo
npm install
Take some time to familiarize yourself with the starter code provided. Most
important are the helper functions provided in utils.ts
and the URIs provided
in uri.ts
.
The uri.ts
file provides 10k URIs that you can use for the off-chain portion
of your NFT metadata. You can, of course, create your own metadata. But this
lesson isn’t explicitly about preparing metadata so we’ve provided some for you.
The utils.ts
file has a few helper functions to keep you from writing more
unnecessary boilerplate than you need to. They are as follows:
getOrCreateKeypair
will create a new keypair for you and save it to a .env
file, or if there’s already a private key in the .env
file it will
initialize a keypair from that.airdropSolIfNeeded
will airdrop some Devnet NZT to a specified address if
that address’s balance is below 1 NZT.createNftMetadata
will create the NFT metadata for a given creator public
key and index. The metadata it’s getting is just dummy metadata using the URI
corresponding to the provided index from the uri.ts
list of URIs.getOrCreateCollectionNFT
will fetch the collection NFT from the address
specified in .env
or if there is none it will create a new one and add the
address to .env
.index.ts
that calls creates a new Devnet
connection, calls getOrCreateKeypair
to initialize a “wallet,” and calls
airdropSolIfNeeded
to fund the wallet if its balance is low.
We will be writing all of our code in the index.ts
.
main
function in index.ts
. Let’s call it
createAndInitializeTree
. For this function to work, it will need the following
parameters:
connection
- a Connection
to use for interacting with the network.payer
- a Keypair
that will pay for transactions.maxDepthSizePair
- a ValidDepthSizePair
. This type comes from
@nexis-network/spl-account-compression
. It’s a simple object with properties
maxDepth
and maxBufferSize
that enforces a valid combination of the two
values.canopyDepth
- a number for the canopy depth In the body of the function,
we’ll generate a new address for the tree, then create the instruction for
allocating a new Merkle tree account by calling createAllocTreeIx
from
@nexis-network/spl-account-compression
.create_tree
on the Bubblegum program. This will
initialize the Merkle tree account and create a new tree config account on the
Bubblegum program.
This instruction needs us to provide the following:
accounts
- an object of required accounts; this includes:
treeAuthority
- this should be a PDA derived with the Merkle tree address
and the Bubblegum programmerkleTree
- the address of the Merkle treepayer
- the transaction fee payertreeCreator
- the address of the tree creator; we’ll make this the same as
payer
logWrapper
- make this the SPL_NOOP_PROGRAM_ID
compressionProgram
- make this the SPL_ACCOUNT_COMPRESSION_PROGRAM_ID
args
- a list of instruction arguments; this includes:
maxBufferSize
- the buffer size from our function’s maxDepthSizePair
parametermaxDepth
- the max depth from our function’s maxDepthSizePair
parameterpublic
- whether or no the tree should be public; we’ll set this to
false
payer
and the treeKeypair
.
createAndInitializeTree
from main
and provide small values for the max depth
and max buffer size.
npm run start
mintCompressedNftToCollection
. It will
need the following parameters:
connection
- a Connection
to use for interacting with the network.payer
- a Keypair
that will pay for transactions.treeAddress
- the Merkle tree’s addresscollectionDetails
- the details of the collection as type
CollectionDetails
from utils.ts
amount
- the number of cNFTs to mintbubblegumSigner
. This is a PDA derived from the string
"collection_cpi"
and the Bubblegum program and is essential for minting to
a collection.createNftMetadata
from our utils.ts
file.createMintToCollectionV1Instruction
from the Bubblegum SDK.amount
number of timescreateMintToCollectionV1Instruction
takes two arguments: accounts
and
args
. The latter is simply the NFT metadata. As with all complex instructions,
the primary hurdle is knowing which accounts to provide. So let’s go through
them real quick:
payer
- the account that will pay for the transaction fees, rent, etc.merkleTree
- the Merkle tree accounttreeAuthority
- the tree authority; should be the same PDA you derived
previouslytreeDelegate
- the tree delegate; this is usually the same as the tree
creatorleafOwner
- the desired owner of the compressed NFT being mintedleafDelegate
- the desired delegate of the compressed NFT being minted; this
is usually the same as the leaf ownercollectionAuthority
- the authority of the collection NFTcollectionAuthorityRecordPda
- optional collection authority record PDA;
there typically is none, in which case you should put the Bubblegum program
addresscollectionMint
- the mint account for the collection NFTcollectionMetadata
- the metadata account for the collection NFTeditionAccount
- the master edition account of the collection NFTcompressionProgram
- the compression program to use; this should be the
address of the SPL State Compression program unless you have some other custom
implementationlogWrapper
- the program to use to expose the data to indexers through logs;
this should be the address of the SPL Noop program unless you have some other
custom implementationbubblegumSigner
- a PDA used by the Bubblegrum program to handle collection
verificationtokenMetadataProgram
- the token metadata program that was used for the
collection NFT; this is usually always the Metaplex Token Metadata programmain
to call
getOrCreateCollectionNFT
then mintCompressedNftToCollection
:
npm run start
logNftDetails
that takes as parameters
treeAddress
and nftsMinted
.
At this point we don’t actually have a direct identifier of any kind that points
to our cNFT. To get that, we’ll need to know the leaf index that was used when
we minted our cNFT. We can then use that to derive the asset ID used by the Read
API and subsequently use the Read API to fetch our cNFT data.
In our case, we created a non-public tree and minted 8 cNFTs, so we know that
the leaf indexes used were 0-7. With this, we can use the getLeafAssetId
function from @metaplex-foundation/mpl-bubblegum
to get the asset ID.
Finally, we can use an RPC that supports the
Read API to
fetch the asset. We’ll be using
Helius,
but feel free to choose your own RPC provider. To use Helius, you’ll need to get
a free API Key from the Helius website. Then add your
RPC_URL
to your .env
file. For example:
getAsset
information in the body:
main
and re-run your script,
the data we get back in the console is very comprehensive. It includes all of
the data you’d expect in both the onchain and off-chain portion of a traditional
NFT. You can find the cNFT’s attributes, files, ownership and creator
information, and more.
AccountMeta
objectstransferNft
function that takes the following:
connection
- a Connection
objectassetId
- a PublicKey
objectsender
- a Keypair
object so we can sign the transactionreceiver
- a PublicKey
object representing the new ownertry catch
.
AccountMeta
objects, then removing any proof nodes at the
end that are already cached onchain in the canopy.
createTransferInstruction
, add it to a
transaction, then sign and send the transaction. This is what the entire
transferNft
function looks like when finished:
getLeafAssetId
. Then we’ll do the transfer. Finally, we’ll print out the
entire collection using our function logNftDetails
. You’ll note that the NFT
at index zero will now belong to our new wallet in the ownership
field.
solution
branch of the
lab repo.