Update: https://github.com/metaplex-foundation/metaplex-program-library/issues/1129
Unfortunately, this is a limitation/bug/known issue with Anchor and
using multipl UncheckedAccounts that are optional signers: see this
issue coral-xyz/anchor#1899. There may be a way to fix this by
creating a transfer_v2 with a different account structure, but
otherwise I don't think we can fix transfer without a breaking change,
so you'll need to use the manual cpi invoke method for now, sorry!
Credit to SolAndy's cnft-vault example.
It looks like the CPI works if we build it manually. For example:
use anchor_lang::prelude::*;
use mpl_bubblegum::program::Bubblegum;
use solana_program::pubkey::Pubkey;
use spl_account_compression::{program::SplAccountCompression, Noop};
declare_id!("ApT1qWmvuGbpjTyDXhB3U2yjxvb612xDRoeYqsUjUVgo");
#[program]
pub mod cnft_transfer {
use super::*;
pub fn transfer_compressed_nft<'a, 'b, 'c, 'info>(
ctx: Context<'a, 'b, 'c, 'info, TransferCompressedNft<'info>>,
root: [u8; 32],
data_hash: [u8; 32],
creator_hash: [u8; 32],
nonce: u64,
index: u32,
) -> Result<()> {
// remaining_accounts are the accounts that make up the required proof
let remaining_accounts_len = ctx.remaining_accounts.len();
let mut accounts = Vec::with_capacity(
8 // space for the 8 AccountMetas that are always included (below)
+ remaining_accounts_len,
);
accounts.extend(vec![
AccountMeta::new_readonly(ctx.accounts.tree_authority.key(), false),
AccountMeta::new_readonly(ctx.accounts.leaf_owner.key(), true),
AccountMeta::new_readonly(ctx.accounts.leaf_delegate.key(), false),
AccountMeta::new_readonly(ctx.accounts.new_leaf_owner.key(), false),
AccountMeta::new(ctx.accounts.merkle_tree.key(), false),
AccountMeta::new_readonly(ctx.accounts.log_wrapper.key(), false),
AccountMeta::new_readonly(ctx.accounts.compression_program.key(), false),
AccountMeta::new_readonly(ctx.accounts.system_program.key(), false),
]);
let transfer_discriminator: [u8; 8] = [163, 52, 200, 231, 140, 3, 69, 186];
let mut data = Vec::with_capacity(
8 // The length of transfer_discriminator,
+ root.len()
+ data_hash.len()
+ creator_hash.len()
+ 8 // The length of the nonce
+ 8, // The length of the index
);
data.extend(transfer_discriminator);
data.extend(root);
data.extend(data_hash);
data.extend(creator_hash);
data.extend(nonce.to_le_bytes());
data.extend(index.to_le_bytes());
let mut account_infos = Vec::with_capacity(
8 // space for the 8 AccountInfos that are always included (below)
+ remaining_accounts_len,
);
account_infos.extend(vec![
ctx.accounts.tree_authority.to_account_info(),
ctx.accounts.leaf_owner.to_account_info(),
ctx.accounts.leaf_delegate.to_account_info(),
ctx.accounts.new_leaf_owner.to_account_info(),
ctx.accounts.merkle_tree.to_account_info(),
ctx.accounts.log_wrapper.to_account_info(),
ctx.accounts.compression_program.to_account_info(),
ctx.accounts.system_program.to_account_info(),
]);
// Add "accounts" (hashes) that make up the merkle proof from the remaining accounts.
for acc in ctx.remaining_accounts.iter() {
accounts.push(AccountMeta::new_readonly(acc.key(), false));
account_infos.push(acc.to_account_info());
}
let instruction = solana_program::instruction::Instruction {
program_id: ctx.accounts.bubblegum_program.key(),
accounts,
data,
};
msg!("manual cpi call to bubblegum program transfer instruction");
solana_program::program::invoke(&instruction, &account_infos[..])?;
Ok(())
}
pub fn burn_compressed_nft<'a, 'b, 'c, 'info>(
ctx: Context<'a, 'b, 'c, 'info, BurnCompressedNft<'info>>,
root: [u8; 32],
data_hash: [u8; 32],
creator_hash: [u8; 32],
nonce: u64,
index: u32,
) -> Result<()> {
// remaining_accounts are the accounts that make up the required proof
let remaining_accounts_len = ctx.remaining_accounts.len();
let mut accounts = Vec::with_capacity(
7 // space for the 7 AccountMetas that are always included (below)
+ remaining_accounts_len,
);
accounts.extend(vec![
AccountMeta::new_readonly(ctx.accounts.tree_authority.key(), false),
AccountMeta::new_readonly(ctx.accounts.leaf_owner.key(), true),
AccountMeta::new_readonly(ctx.accounts.leaf_delegate.key(), false),
AccountMeta::new(ctx.accounts.merkle_tree.key(), false),
AccountMeta::new_readonly(ctx.accounts.log_wrapper.key(), false),
AccountMeta::new_readonly(ctx.accounts.compression_program.key(), false),
AccountMeta::new_readonly(ctx.accounts.system_program.key(), false),
]);
let burn_discriminator: [u8; 8] = [116, 110, 29, 56, 107, 219, 42, 93];
let mut data = Vec::with_capacity(
8 // The length of burn_discriminator,
+ root.len()
+ data_hash.len()
+ creator_hash.len()
+ 8 // The length of the nonce
+ 8, // The length of the index
);
data.extend(burn_discriminator);
data.extend(root);
data.extend(data_hash);
data.extend(creator_hash);
data.extend(nonce.to_le_bytes());
data.extend(index.to_le_bytes());
let mut account_infos = Vec::with_capacity(
7 // space for the 7 AccountInfos that are always included (below)
+ remaining_accounts_len,
);
account_infos.extend(vec![
ctx.accounts.tree_authority.to_account_info(),
ctx.accounts.leaf_owner.to_account_info(),
ctx.accounts.leaf_delegate.to_account_info(),
ctx.accounts.merkle_tree.to_account_info(),
ctx.accounts.log_wrapper.to_account_info(),
ctx.accounts.compression_program.to_account_info(),
ctx.accounts.system_program.to_account_info(),
]);
// Add "accounts" (hashes) that make up the merkle proof from the remaining accounts.
for acc in ctx.remaining_accounts.iter() {
accounts.push(AccountMeta::new_readonly(acc.key(), false));
account_infos.push(acc.to_account_info());
}
let instruction = solana_program::instruction::Instruction {
program_id: ctx.accounts.bubblegum_program.key(),
accounts,
data,
};
msg!("manual cpi call to bubblegum program burn instruction");
solana_program::program::invoke(&instruction, &account_infos[..])?;
Ok(())
}
}
#[derive(Accounts)]
pub struct TransferCompressedNft<'info> {
#[account(mut)]
pub leaf_owner: Signer<'info>,
#[account(mut)]
pub leaf_delegate: Signer<'info>,
/// CHECK:
#[account(
mut,
seeds = [merkle_tree.key().as_ref()],
bump,
seeds::program = bubblegum_program.key()
)]
pub tree_authority: UncheckedAccount<'info>,
/// CHECK:
#[account(mut)]
pub merkle_tree: UncheckedAccount<'info>,
/// CHECK:
#[account(mut)]
pub new_leaf_owner: UncheckedAccount<'info>,
pub log_wrapper: Program<'info, Noop>,
pub compression_program: Program<'info, SplAccountCompression>,
pub bubblegum_program: Program<'info, Bubblegum>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct BurnCompressedNft<'info> {
#[account(mut)]
pub leaf_owner: Signer<'info>,
#[account(mut)]
pub leaf_delegate: Signer<'info>,
/// CHECK:
#[account(
mut,
seeds = [merkle_tree.key().as_ref()],
bump,
seeds::program = bubblegum_program.key()
)]
pub tree_authority: UncheckedAccount<'info>,
/// CHECK:
#[account(mut)]
pub merkle_tree: UncheckedAccount<'info>,
pub log_wrapper: Program<'info, Noop>,
pub compression_program: Program<'info, SplAccountCompression>,
pub bubblegum_program: Program<'info, Bubblegum>,
pub system_program: Program<'info, System>,
}
The bubblegum transfer and burn instruction discriminators can be found here
and here.
Then, you can built the transaction like this:
const connection = new WrapperConnection(process.env.RPC_URL, "confirmed")
...
const assetId = new PublicKey("...")
const asset = await connection.getAsset(assetId)
const assetProof = await connection.getAssetProof(assetId)
// get tree account from tree address
const treeAddress = new PublicKey(asset.compression.tree)
const treeAccount = await ConcurrentMerkleTreeAccount.fromAccountAddress(
connection,
treeAddress
)
const treeAuthority = treeAccount.getAuthority()
// get canopy depth from tree account
const canopyDepth = treeAccount.getCanopyDepth()
// get "proof path" from asset proof, these are the accounts that need to be passed to the program as remaining accounts
// may also be empty if tree is small enough, and canopy depth is large enough
const proofPath: AccountMeta[] = assetProof.proof
.map((node: string) => ({
pubkey: new PublicKey(node),
isSigner: false,
isWritable: false,
}))
.slice(0, assetProof.proof.length - (!!canopyDepth ? canopyDepth : 0))
// get root, data hash, creator hash, nonce, and index from asset and asset proof
const root = [...new PublicKey(assetProof.root.trim()).toBytes()]
const dataHash = [
...new PublicKey(asset.compression.data_hash.trim()).toBytes(),
]
const creatorHash = [
...new PublicKey(asset.compression.creator_hash.trim()).toBytes(),
]
const nonce = asset.compression.leaf_id
const index = asset.compression.leaf_id
// test burn
const tx = await program.methods
.burnCompressedNft(root, dataHash, creatorHash, new BN(nonce), index)
.accounts({
leafOwner: payer.publicKey,
leafDelegate: payer.publicKey,
merkleTree: treeAddress,
treeAuthority: treeAuthority,
logWrapper: SPL_NOOP_PROGRAM_ID,
bubblegumProgram: BUBBLEGUM_PROGRAM_ID,
compressionProgram: SPL_ACCOUNT_COMPRESSION_PROGRAM_ID,
})
.remainingAccounts(proofPath)
.transaction()
const txSignature = await sendAndConfirmTransaction(
connection,
tx,
[payer]
)
WrapperConnection
is from this compressed nft demo repo: https://github.com/solana-developers/compressed-nfts. There's also a video walkthrough of the compressed-nfts demo repo here.
You'll need to use the Metaplex Read Api and a RPC provider that supports it to fetch the metadata of a compressed nft.
Transfer via CPI: https://explorer.solana.com/tx/2TiDMpwh34MB9cvaTyVN9RqEWBArmesAXzE3dckGqCXSHYRfbj6sigzyxmSAwxbpcdLqJrKFr5thaiDEPrLWSKan?cluster=devnet
Burn via CPI:
https://explorer.solana.com/tx/2kJJxgMFJTEvJbupHpepwEV93VbneMqniJumdov7X7isk5a8LEwJDsFRMMjWYEN1uoZKtwNnXYXZnxntSLGZfi4C?cluster=devnet
Although still unsure why using mpl_bubblegum
for the CPI causes the LeafAuthorityMustSign
error. Anyone happen to know why building the instruction using mpl_bubblegum
like below throws an error?
pub fn transfer_compressed_nft<'a, 'b, 'c, 'info>(
ctx: Context<'a, 'b, 'c, 'info, TransferCompressedNft<'info>>,
root: [u8; 32],
data_hash: [u8; 32],
creator_hash: [u8; 32],
nonce: u64,
index: u32,
) -> Result<()> {
let cpi_ctx = CpiContext::new(
ctx.accounts.bubblegum_program.to_account_info(),
Transfer {
tree_authority: ctx.accounts.tree_authority.to_account_info(),
leaf_owner: ctx.accounts.leaf_owner.to_account_info(),
leaf_delegate: ctx.accounts.leaf_delegate.to_account_info(),
new_leaf_owner: ctx.accounts.new_leaf_owner.to_account_info(),
merkle_tree: ctx.accounts.merkle_tree.to_account_info(),
log_wrapper: ctx.accounts.log_wrapper.to_account_info(),
compression_program: ctx.accounts.compression_program.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
},
)
.with_remaining_accounts(ctx.remaining_accounts.to_vec());
transfer(cpi_ctx, root, data_hash, creator_hash, nonce, index)?;
Ok(())
}
burn
andtransfer
, and only when called via CPI within an anchor program. Unsure why this check is throwing the error only when the instruction is called using a CPI: github.com/metaplex-foundation/metaplex-program-library/blob/…