diff --git a/clients/js/test/updateMetadata.test.ts b/clients/js/test/updateMetadata.test.ts index 963b0985..6feb034d 100644 --- a/clients/js/test/updateMetadata.test.ts +++ b/clients/js/test/updateMetadata.test.ts @@ -1,4 +1,13 @@ -import { createNft } from '@metaplex-foundation/mpl-token-metadata'; +import { + createNft, + delegateDataV1, + TokenStandard, + findMetadataDelegateRecordPda, + MetadataDelegateRole, + delegateCollectionV1, + approveCollectionAuthority, + findCollectionAuthorityRecordPda, +} from '@metaplex-foundation/mpl-token-metadata'; import { defaultPublicKey, generateSigner, @@ -307,6 +316,267 @@ test('it can update metadata using collection update authority when collection i t.is(merkleTreeAccount.tree.rightMostPath.leaf, publicKey(updatedLeaf)); }); +test('it can update metadata using old collection authority when collection is verified', async (t) => { + // Given an empty Bubblegum tree. + const umi = await createUmi(); + const merkleTree = await createTree(umi); + const leafOwner = generateSigner(umi).publicKey; + let merkleTreeAccount = await fetchMerkleTree(umi, merkleTree); + + // And a Collection NFT. + const collectionMint = generateSigner(umi); + const collectionAuthority = generateSigner(umi); + await createNft(umi, { + mint: collectionMint, + authority: collectionAuthority, + name: 'My Collection', + uri: 'https://example.com/my-collection.json', + sellerFeeBasisPoints: percentAmount(5.5), // 5.5% + isCollection: true, + }).sendAndConfirm(umi); + + // When we mint a new NFT from the tree using the following metadata, with collection verified. + const metadata: MetadataArgsArgs = { + name: 'My NFT', + uri: 'https://example.com/my-nft.json', + sellerFeeBasisPoints: 550, // 5.5% + collection: { + key: collectionMint.publicKey, + verified: true, + }, + creators: [], + }; + await mintToCollectionV1(umi, { + leafOwner, + merkleTree, + metadata, + collectionMint: collectionMint.publicKey, + collectionAuthority, + }).sendAndConfirm(umi); + + // When we approve a collection authority record. + const newCollectionAuthority = generateSigner(umi); + let collectionAuthorityRecordPda = findCollectionAuthorityRecordPda(umi, { + mint: collectionMint.publicKey, + collectionAuthority: newCollectionAuthority.publicKey, + }); + + await approveCollectionAuthority(umi, { + collectionAuthorityRecord: collectionAuthorityRecordPda, + newCollectionAuthority: newCollectionAuthority.publicKey, + updateAuthority: collectionAuthority, + mint: collectionMint.publicKey, + }).sendAndConfirm(umi); + + // And when metadata is updated. + const updateArgs: UpdateArgsArgs = { + name: some('New name'), + uri: some('https://updated-example.com/my-nft.json'), + }; + await updateMetadata(umi, { + leafOwner, + merkleTree, + root: getCurrentRoot(merkleTreeAccount.tree), + nonce: 0, + index: 0, + currentMetadata: metadata, + proof: [], + updateArgs, + authority: newCollectionAuthority, + collectionMint: collectionMint.publicKey, + collectionAuthorityRecordPda, + }).sendAndConfirm(umi); + + // Then the leaf was updated in the merkle tree. + const updatedLeaf = hashLeaf(umi, { + merkleTree, + owner: leafOwner, + leafIndex: 0, + metadata: { + ...metadata, + name: 'New name', + uri: 'https://updated-example.com/my-nft.json', + }, + }); + merkleTreeAccount = await fetchMerkleTree(umi, merkleTree); + t.is(merkleTreeAccount.tree.rightMostPath.leaf, publicKey(updatedLeaf)); +}); + +test('it can update metadata using collection data delegate when collection is verified', async (t) => { + // Given an empty Bubblegum tree. + const umi = await createUmi(); + const merkleTree = await createTree(umi); + const leafOwner = generateSigner(umi).publicKey; + let merkleTreeAccount = await fetchMerkleTree(umi, merkleTree); + + // And a Collection NFT. + const collectionMint = generateSigner(umi); + const collectionAuthority = generateSigner(umi); + await createNft(umi, { + mint: collectionMint, + authority: collectionAuthority, + name: 'My Collection', + uri: 'https://example.com/my-collection.json', + sellerFeeBasisPoints: percentAmount(5.5), // 5.5% + isCollection: true, + }).sendAndConfirm(umi); + + // When we mint a new NFT from the tree using the following metadata, with collection verified. + const metadata: MetadataArgsArgs = { + name: 'My NFT', + uri: 'https://example.com/my-nft.json', + sellerFeeBasisPoints: 550, // 5.5% + collection: { + key: collectionMint.publicKey, + verified: true, + }, + creators: [], + }; + await mintToCollectionV1(umi, { + leafOwner, + merkleTree, + metadata, + collectionMint: collectionMint.publicKey, + collectionAuthority, + }).sendAndConfirm(umi); + + // When we approve a data delegate. + const dataDelegate = generateSigner(umi); + await delegateDataV1(umi, { + mint: collectionMint.publicKey, + authority: collectionAuthority, + delegate: dataDelegate.publicKey, + tokenStandard: TokenStandard.NonFungible, + }).sendAndConfirm(umi); + + let delegateRecordPda = findMetadataDelegateRecordPda(umi, { + mint: collectionMint.publicKey, + delegateRole: MetadataDelegateRole.Data, + delegate: dataDelegate.publicKey, + updateAuthority: collectionAuthority.publicKey, + }); + + // And when metadata is updated. + const updateArgs: UpdateArgsArgs = { + name: some('New name'), + uri: some('https://updated-example.com/my-nft.json'), + }; + await updateMetadata(umi, { + leafOwner, + merkleTree, + root: getCurrentRoot(merkleTreeAccount.tree), + nonce: 0, + index: 0, + currentMetadata: metadata, + proof: [], + updateArgs, + authority: dataDelegate, + collectionMint: collectionMint.publicKey, + collectionAuthorityRecordPda: delegateRecordPda, + }).sendAndConfirm(umi); + + // Then the leaf was updated in the merkle tree. + const updatedLeaf = hashLeaf(umi, { + merkleTree, + owner: leafOwner, + leafIndex: 0, + metadata: { + ...metadata, + name: 'New name', + uri: 'https://updated-example.com/my-nft.json', + }, + }); + merkleTreeAccount = await fetchMerkleTree(umi, merkleTree); + t.is(merkleTreeAccount.tree.rightMostPath.leaf, publicKey(updatedLeaf)); +}); + +test('it cannot update metadata using collection collection delegate when collection is verified', async (t) => { + // Given an empty Bubblegum tree. + const umi = await createUmi(); + const merkleTree = await createTree(umi); + const leafOwner = generateSigner(umi).publicKey; + let merkleTreeAccount = await fetchMerkleTree(umi, merkleTree); + + // And a Collection NFT. + const collectionMint = generateSigner(umi); + const collectionAuthority = generateSigner(umi); + await createNft(umi, { + mint: collectionMint, + authority: collectionAuthority, + name: 'My Collection', + uri: 'https://example.com/my-collection.json', + sellerFeeBasisPoints: percentAmount(5.5), // 5.5% + isCollection: true, + }).sendAndConfirm(umi); + + // When we mint a new NFT from the tree using the following metadata, with collection verified. + const metadata: MetadataArgsArgs = { + name: 'My NFT', + uri: 'https://example.com/my-nft.json', + sellerFeeBasisPoints: 550, // 5.5% + collection: { + key: collectionMint.publicKey, + verified: true, + }, + creators: [], + }; + await mintToCollectionV1(umi, { + leafOwner, + merkleTree, + metadata, + collectionMint: collectionMint.publicKey, + collectionAuthority, + }).sendAndConfirm(umi); + + // When we approve a data delegate. + const collectionDelegate = generateSigner(umi); + await delegateCollectionV1(umi, { + mint: collectionMint.publicKey, + authority: collectionAuthority, + delegate: collectionDelegate.publicKey, + tokenStandard: TokenStandard.NonFungible, + }).sendAndConfirm(umi); + + let delegate_record_pda = findMetadataDelegateRecordPda(umi, { + mint: collectionMint.publicKey, + delegateRole: MetadataDelegateRole.Collection, + delegate: collectionDelegate.publicKey, + updateAuthority: collectionAuthority.publicKey, + }); + + // And when metadata is updated. + const updateArgs: UpdateArgsArgs = { + name: some('New name'), + uri: some('https://updated-example.com/my-nft.json'), + }; + const promise = updateMetadata(umi, { + leafOwner, + merkleTree, + root: getCurrentRoot(merkleTreeAccount.tree), + nonce: 0, + index: 0, + currentMetadata: metadata, + proof: [], + updateArgs, + authority: collectionDelegate, + collectionMint: collectionMint.publicKey, + collectionAuthorityRecordPda: delegate_record_pda, + }).sendAndConfirm(umi); + + // Then we expect a program error. + await t.throwsAsync(promise, { name: 'InvalidDelegateRecord' }); + + // And the leaf was not updated in the merkle tree. + const notUpdatedLeaf = hashLeaf(umi, { + merkleTree, + owner: leafOwner, + leafIndex: 0, + metadata, + }); + merkleTreeAccount = await fetchMerkleTree(umi, merkleTree); + t.is(merkleTreeAccount.tree.rightMostPath.leaf, publicKey(notUpdatedLeaf)); +}); + test('it can update metadata using the getAssetWithProof helper with verified collection', async (t) => { // Given we increase the timeout for this test. t.timeout(20000); diff --git a/programs/bubblegum/program/src/asserts.rs b/programs/bubblegum/program/src/asserts.rs index 8ca8f6f9..3448915b 100644 --- a/programs/bubblegum/program/src/asserts.rs +++ b/programs/bubblegum/program/src/asserts.rs @@ -102,6 +102,7 @@ pub fn assert_has_collection_authority( mint: &Pubkey, collection_authority: &Pubkey, delegate_record: Option<&AccountInfo>, + metadata_delegate_role: MetadataDelegateRole, ) -> Result<()> { // Mint is the correct one for the metadata account. if collection_data.mint != *mint { @@ -112,7 +113,7 @@ pub fn assert_has_collection_authority( let (ca_pda, ca_bump) = CollectionAuthorityRecord::find_pda(mint, collection_authority); let (md_pda, md_bump) = MetadataDelegateRecord::find_pda( mint, - MetadataDelegateRole::Collection, + metadata_delegate_role, &collection_data.update_authority, collection_authority, ); diff --git a/programs/bubblegum/program/src/processor/mod.rs b/programs/bubblegum/program/src/processor/mod.rs index 328ab9f6..e3d5e113 100644 --- a/programs/bubblegum/program/src/processor/mod.rs +++ b/programs/bubblegum/program/src/processor/mod.rs @@ -1,4 +1,5 @@ use anchor_lang::prelude::*; +use mpl_token_metadata::types::MetadataDelegateRole; use solana_program::{account_info::AccountInfo, pubkey::Pubkey}; use spl_account_compression::wrap_application_data_v1; @@ -197,6 +198,7 @@ fn process_collection_verification_mpl_only<'info>( collection_mint.key, collection_authority.key, collection_authority_record, + MetadataDelegateRole::Collection, )?; // Update collection in metadata args. Note since this is a mutable reference, diff --git a/programs/bubblegum/program/src/processor/update_metadata.rs b/programs/bubblegum/program/src/processor/update_metadata.rs index e81ef661..9a7db643 100644 --- a/programs/bubblegum/program/src/processor/update_metadata.rs +++ b/programs/bubblegum/program/src/processor/update_metadata.rs @@ -1,4 +1,5 @@ use anchor_lang::prelude::*; +use mpl_token_metadata::types::MetadataDelegateRole; use spl_account_compression::{program::SplAccountCompression, wrap_application_data_v1, Noop}; use crate::{ @@ -85,6 +86,7 @@ fn assert_authority_matches_collection<'info>( collection_mint.key, collection_authority.key, collection_authority_record.as_ref(), + MetadataDelegateRole::Data, )?; Ok(()) diff --git a/programs/bubblegum/program/tests/collection.rs b/programs/bubblegum/program/tests/collection.rs index e4ac096e..2797dc03 100644 --- a/programs/bubblegum/program/tests/collection.rs +++ b/programs/bubblegum/program/tests/collection.rs @@ -1,13 +1,15 @@ #![cfg(feature = "test-sbf")] pub mod utils; +use anchor_lang::solana_program::instruction::InstructionError; +use bubblegum::error::BubblegumError; use solana_program::native_token::LAMPORTS_PER_SOL; -use solana_program_test::tokio; +use solana_program_test::{tokio, BanksClientError}; -use solana_sdk::{signature::Keypair, signer::Signer}; +use solana_sdk::{signature::Keypair, signer::Signer, transaction::TransactionError}; use utils::context::BubblegumTestContext; -use crate::utils::{Airdrop, DirtyClone}; +use crate::utils::{Airdrop, DirtyClone, Error::BanksClient}; // Test for multiple combinations? const MAX_DEPTH: usize = 14; @@ -80,7 +82,7 @@ async fn verify_collection_with_old_delegate() { } #[tokio::test] -async fn verify_collection_with_new_delegate() { +async fn verify_collection_with_new_collection_delegate() { // Uses MetadataDelegate to verify a collection item. let mut context = BubblegumTestContext::new().await.unwrap(); @@ -131,3 +133,73 @@ async fn verify_collection_with_new_delegate() { .await .unwrap(); } + +#[tokio::test] +async fn cannot_verify_collection_with_new_data_delegate() { + // Attempts to use MetadataDelegate with incorrect role to verify a collection item. + + let mut context = BubblegumTestContext::new().await.unwrap(); + + let (mut tree, mut leaves) = context + .default_create_and_mint::(DEFAULT_NUM_MINTS) + .await + .unwrap(); + + let payer = context.payer().dirty_clone(); + + // Set up our old delegate record: collection_authority_record. + let delegate = Keypair::new(); + delegate + .airdrop(context.mut_test_context(), LAMPORTS_PER_SOL) + .await + .unwrap(); + + let mut collection_asset = context.default_collection.dirty_clone(); + let mut program_context = context.owned_test_context(); + + let args = mpl_token_metadata::types::DelegateArgs::DataV1 { + authorization_data: None, + }; + + let record = collection_asset + .delegate( + &mut program_context, + payer.dirty_clone(), + delegate.pubkey(), + args, + ) + .await + .unwrap() + .unwrap(); + + // Get the first leaf and try to verify it with the delegate as the authority. + let leaf = leaves.first_mut().unwrap(); + + // Cannot verify collection. + let result = tree + .delegate_verify_collection( + leaf, + &delegate, + collection_asset.mint.pubkey(), + collection_asset.metadata, + collection_asset.edition.unwrap(), + record, + ) + .await; + + if let Err(err) = result { + if let BanksClient(BanksClientError::TransactionError(e)) = *err { + assert_eq!( + e, + TransactionError::InstructionError( + 0, + InstructionError::Custom(BubblegumError::InvalidDelegateRecord.into()), + ) + ); + } else { + panic!("Wrong variant"); + } + } else { + panic!("Should have failed"); + } +}