Written by
Hunter Davis
Published on
September 12, 2023
Copy link

Solana NFT: Modifying Compressed NFTs (2023)

Solana NFT: Why Modifications Are Needed

As a developer on Solana, interacting with compressed NFTs can seem like an uphill battle. This no longer needs to be the case with the ability for modifications, or changes to compressed NFTs through the Bubblegum program.

When a compressed NFT is minted, Merkle proofs must be provided with the transaction instruction to make any changes to that NFT (e.g., transfer, burn). For those unfamiliar, a Merkle proof is a set of hashes that can prove the leaf belongs to the tree.

In this guide, we'll cover:

  • The importance of modifications with compressed NFTs.
  • Setting up burn and transfer modifications to compressed NFTs.
  • Detailing the importance of the Digital Asset Standard (DAS) API.

Solana NFT: Prerequisites

Before diving in, ensure you have:

  • A fundamental grasp of JavaScript/TypeScript.
  • Some familiarity with compressed NFTs.
  • Git installed.
  • npm or yarn.
  • A clone of the example repository, available here.

Solana NFT: Environment Setup

  1. Clone our example repository:

git clone 

  1. Navigate into the project folder:

cd compression-examples

  1. Install npm:

npm install

  1. Create an .env file at the root:

API_KEY=YOUR_API_KEY
SECRET_KEY=YOUR_WALLET_SECRET_KEY

In this, input your Helius API key, and the payer wallet secret key. Make sure this wallet has Solana in it to fund the transactions.

Solana NFT: Modifications

Modifications require obtaining the current proofs for the instruction, alongside the asset's current ownership, authority, and compression hashes. You need to fetch this data from the Digital Asset Standard (DAS) API, which streamlines interactions with compressed assets.

The current list of modifications available through Bubblegum are:

  • Mint
  • Transfer
  • Burn
  • Delegate, CancelDelegate
  • Redeem, CancelRedeem
  • Decompress
  • VerifyCreator, SetAndVerifyCreator
  • VerifyCollection, SetAndVerifyCollection

To interact with compressed assets, you need to ensure you return:

In these examples, we will go over the burning and transferring of compressed NFTs we will mint.

NFT: Transfer

The transfer modification lets you move a compressed NFT from one owner's wallet to another. Both compressed NFT sales and listings use this instruction to display sales on marketplaces.

To set this up in our existing repository, we will go to our utils.ts file and take a look at our transferAsset function.

We will need to pass in the following:

  • Connection - A Solana RPC connection.
  • Current owner - The public key of the current NFT owner.
  • New owner for the NFT - The public key for the new owner.
  • Asset ID - Allows us to get current details on the specific asset you pass in.

We can see below in our example transferAsset function that we are initially requesting the asset proof and setting up something called a proofPath. This is a request made on the DAS API that returns the proof for the asset you are wanting to transfer and you are computing it to pass into the transaction instruction.


export const transferAsset = async (
  connectionWrapper: WrappedConnection,
  owner: Keypair,
  newOwner: Keypair,
  assetId: string
) => {
  console.log(
    `Transfering asset ${assetId} from ${owner.publicKey.toBase58()} to ${newOwner.publicKey.toBase58()}. 
    This will depend on indexer api calls to fetch the necessary data.`
  );
  let assetProof = await connectionWrapper.getAssetProof(assetId);
  if (!assetProof?.proof || assetProof.proof.length === 0) {
    throw new Error("Proof is empty");
  }
  let proofPath = assetProof.proof.map((node: string) => ({
    pubkey: new PublicKey(node),
    isSigner: false,
    isWritable: false,
  }));
  console.log("Successfully got proof path from RPC.");

// Additional code... 

};

Returning the asset proof is needed as you need to return the current state of the compressed asset, this is required for every transfer of a compressed NFT. You are computing this in the proofPath for it to be passed into our transaction instruction that we will see shortly.

Now that we have our proof path, we can use getAsset on the DAS API to return our leaf nonce, leaf delegate, data hash, and creator hash.

We can set that up below:


export const transferAsset = async (
  connectionWrapper: WrappedConnection,
  owner: Keypair,
  newOwner: Keypair,
  assetId: string
) => {
 
	// Previous code // 

	// Calling getAsset from DAS
  const rpcAsset = await connectionWrapper.getAsset(assetId);
  console.log(
    "Successfully got asset from RPC. Current owner: " +
      rpcAsset.ownership.owner
  );
  if (rpcAsset.ownership.owner !== owner.publicKey.toBase58()) {
    throw new Error(
      `NFT is not owned by the expected owner. Expected ${owner.publicKey.toBase58()} but got ${
        rpcAsset.ownership.owner
      }.`
    );
  }
// Leaf nonce from getAsset call.
  const leafNonce = rpcAsset.compression.leaf_id;
// Locating Tree Authority. 
  const treeAuthority = await getBubblegumAuthorityPDA(
    new PublicKey(assetProof.tree_id)
  );
// Leaf Owner/Delegate from getAsset call.
  const leafDelegate = rpcAsset.ownership.delegate
    ? new PublicKey(rpcAsset.ownership.delegate)
    : new PublicKey(rpcAsset.ownership.owner);
  );
};

You can see that the getAsset request is being made to return the owner/delegate and leaf nonce. We are also defining our tree authority just by using the tree ID (that we returned from our assetProof request).

Now that we have these defined, we can start building our transaction instruction.


export const transferAsset = async (
  connectionWrapper: WrappedConnection,
  owner: Keypair,
  newOwner: Keypair,
  assetId: string
) => {

	// Previous code // 

	// Transfer Instruction. 
  let transferIx = createTransferInstruction(
    {
      treeAuthority, // Tree authority 
      leafOwner: new PublicKey(rpcAsset.ownership.owner), // Current NFT owner
      leafDelegate: leafDelegate, // Leaf delegate/owner returned
      newLeafOwner: newOwner.publicKey, // New wallet to transfer our NFT.
      merkleTree: new PublicKey(assetProof.tree_id), // Merkle tree public key.
      logWrapper: SPL_NOOP_PROGRAM_ID, // NOOP program ID.
      compressionProgram: SPL_ACCOUNT_COMPRESSION_PROGRAM_ID, // Compression Program ID.
      anchorRemainingAccounts: proofPath, // Proofs computed to pass in
    },
    {
      root: bufferToArray(bs58.decode(assetProof.root)), // Root from getAssetProof returned
      dataHash: bufferToArray(
        bs58.decode(rpcAsset.compression.data_hash.trim()) // Data Hash from getAsset
      ),
      creatorHash: bufferToArray(
        bs58.decode(rpcAsset.compression.creator_hash.trim()) // Creator Hash from getAsset
      ),
      nonce: leafNonce, // Leaf nonce from getAsset
      index: leafNonce,
    }
  );
};

To do this for a transferred asset, you will need to pass in all of our variables that we returned from the getAsset, getAssetProof, and the Metaplex SDK: leaf delegate/owner, data hash, root, and leaf nonce.

You will also notice we are passing in a few program IDs. These are the public keys for the compression and noop programs that are imported at the top of the file. You can alternatively pass these in as a public key.

Now that this is in place, we can set up sending our transaction instruction:


export const transferAsset = async (
  connectionWrapper: WrappedConnection,
  owner: Keypair,
  newOwner: Keypair,
  assetId: string
) => {

 // Previous code // 

  const tx = new Transaction().add(transferIx);
  tx.feePayer = owner.publicKey;
  try {
    const sig = await sendAndConfirmTransaction(
      connectionWrapper,
      tx,
      [owner],
      {
        commitment: "confirmed",
        skipPreflight: true,
      }
    );
    return sig;
  } catch (e) {
    console.error("Failed to transfer compressed asset", e);
    throw e;
  }
};

In the above code, we are defining our transaction by using tx and adding a Transaction type for our transferIx from solana/web3.js .

We are then submitting it by passing in our:

  • Solana connection
  • Transaction Instructions
  • Payer
  • Commitment

We can now submit this transaction by running:


npm run e2e


This function will mint a compressed NFT and its collection, then transfers it to a wallet specified.

Please note the asset ID as we will use this for the burn modification.

Here is the entire transferAsset function:


export const transferAsset = async (
  connectionWrapper: WrappedConnection,
  owner: Keypair,
  newOwner: Keypair,
  assetId: string
) => {
  console.log(
    `Transfering asset ${assetId} from ${owner.publicKey.toBase58()} to ${newOwner.publicKey.toBase58()}. 
    This will depend on indexer api calls to fetch the necessary data.`
  );
  let assetProof = await connectionWrapper.getAssetProof(assetId);
  if (!assetProof?.proof || assetProof.proof.length === 0) {
    throw new Error("Proof is empty");
  }
  let proofPath = assetProof.proof.map((node: string) => ({
    pubkey: new PublicKey(node),
    isSigner: false,
    isWritable: false,
  }));
  console.log("Successfully got proof path from RPC.");

  const rpcAsset = await connectionWrapper.getAsset(assetId);
  console.log(
    "Successfully got asset from RPC. Current owner: " +
      rpcAsset.ownership.owner
  );
  if (rpcAsset.ownership.owner !== owner.publicKey.toBase58()) {
    throw new Error(
      `NFT is not owned by the expected owner. Expected ${owner.publicKey.toBase58()} but got ${
        rpcAsset.ownership.owner
      }.`
    );
  }
  const leafNonce = rpcAsset.compression.leaf_id;
  const treeAuthority = await getBubblegumAuthorityPDA(
    new PublicKey(assetProof.tree_id)
  );
  const leafDelegate = rpcAsset.ownership.delegate
    ? new PublicKey(rpcAsset.ownership.delegate)
    : new PublicKey(rpcAsset.ownership.owner);
  let transferIx = createTransferInstruction(
    {
      treeAuthority,
      leafOwner: new PublicKey(rpcAsset.ownership.owner),
      leafDelegate: leafDelegate,
      newLeafOwner: newOwner.publicKey,
      merkleTree: new PublicKey(assetProof.tree_id),
      logWrapper: SPL_NOOP_PROGRAM_ID,
      compressionProgram: SPL_ACCOUNT_COMPRESSION_PROGRAM_ID,
      anchorRemainingAccounts: proofPath,
    },
    {
      root: bufferToArray(bs58.decode(assetProof.root)),
      dataHash: bufferToArray(
        bs58.decode(rpcAsset.compression.data_hash.trim())
      ),
      creatorHash: bufferToArray(
        bs58.decode(rpcAsset.compression.creator_hash.trim())
      ),
      nonce: leafNonce,
      index: leafNonce,
    }
  );
  const tx = new Transaction().add(transferIx);
  tx.feePayer = owner.publicKey;
  try {
    const sig = await sendAndConfirmTransaction(
      connectionWrapper,
      tx,
      [owner],
      {
        commitment: "confirmed",
        skipPreflight: true,
      }
    );
    return sig;
  } catch (e) {
    console.error("Failed to transfer compressed asset", e);
    throw e;
  }
};

NFT: Burn

Burning cNFTs purges the NFT from the Merkle tree. This allows for diverse use cases, like burning for rewards, removing spam assets, or addressing custom requirements. This was used in the Tensor mint where 10 cNFTs were burned for new 1 cNFT to be minted.

To burn a compressed NFT, we will need to pass in the following:

  • Connection - A Solana RPC connection.
  • Current owner - The public key of the current NFT owner.
  • Asset ID - Allows us to get current details on the specific asset you pass in.

We can see this in our utils.ts under burnAsset:


export const burnAsset = async (
  connectionWrapper: WrappedConnection,
  owner: Keypair,
  assetId?: string
) => {
  let assetProof = await connectionWrapper.getAssetProof(assetId);
  const rpcAsset = await connectionWrapper.getAsset(assetId);
  const leafNonce = rpcAsset.compression.leaf_id;
  let proofPath = assetProof.proof.map((node: string) => ({
    pubkey: new PublicKey(node),
    isSigner: false,
    isWritable: false,
}));
const treeAuthority = await getBubblegumAuthorityPDA(
    new PublicKey(assetProof.tree_id)
  );
  const leafDelegate = rpcAsset.ownership.delegate
    ? new PublicKey(rpcAsset.ownership.delegate)
    : new PublicKey(rpcAsset.ownership.owner);

// Remaining code // 

}

This is the same as transferAsset as it is using getAssetProof to return the root and tree ID. You are also calling getAsset to return the creator hash, data hash, leaf nonce, and current owner.

This makes it easy to know what values you need returned here to modify your compressed asset.

Now, we can set up our transaction instruction below this code:


export const burnAsset = async (
  connectionWrapper: WrappedConnection,
  owner: Keypair,
  assetId?: string
) => {

	// Previous code // 

// Burn transaction instruction // 
  const burnIx = createBurnInstruction(
    {
      treeAuthority,
      leafOwner: new PublicKey(rpcAsset.ownership.owner),
      leafDelegate,
      merkleTree: new PublicKey(assetProof.tree_id),
      logWrapper: SPL_NOOP_PROGRAM_ID,
      compressionProgram: SPL_ACCOUNT_COMPRESSION_PROGRAM_ID,
      anchorRemainingAccounts: proofPath,

    },
    {
      root: bufferToArray(bs58.decode(assetProof.root)),
      dataHash: bufferToArray(
        bs58.decode(rpcAsset.compression.data_hash.trim())
      ),
      creatorHash: bufferToArray(
        bs58.decode(rpcAsset.compression.creator_hash.trim())
      ),
      nonce: leafNonce,
      index: leafNonce,
    }
  );
 // Later code... //
};

Now that we have this set up to burn, we can submit our transaction in a very similar method to the transfer instruction:


export const burnAsset = async (
  connectionWrapper: WrappedConnection,
  owner: Keypair,
  assetId?: string
) => {

// Previous code // 

const tx = new Transaction().add(burnIx);
  tx.feePayer = owner.publicKey;
  try {
    const sig = await sendAndConfirmTransaction(
      connectionWrapper,
      tx,
      [owner],
      {
        commitment: "confirmed",
        skipPreflight: true,
      }
    );
    return sig;
  } catch (e) {
    console.error("Failed to burn compressed asset", e);
    throw e;
  }
}

We are then submitting it by passing in our:

  • Solana connection
  • Transaction Instructions
  • Payer
  • Commitment

Once this function is run, it will burn the asset for the ID you provided. This will remove the asset data from the tree and apply a burnt=true flag on the compressed asset. By default, these burned assets will return when you use the DAS API.

In your terminal, you can now run:


npm run burn -- --assetId=

Solana NFTs: Conclusion

Compressed NFTs provide a dynamic means of engaging with the NFT ecosystem on Solana. Through Merkle proofs obtained through DAS and precise transaction instructions, developers can modify and interact with these unique digital assets. By understanding the nuances of the transfer and burn modifications, developers are empowered to build more reliable applications.