diff --git a/tokens/nft-operations/anchor/programs/mint-nft/src/contexts/create_collection.rs b/tokens/nft-operations/anchor/programs/mint-nft/src/contexts/create_collection.rs index 5a7693b58..6d377d850 100644 --- a/tokens/nft-operations/anchor/programs/mint-nft/src/contexts/create_collection.rs +++ b/tokens/nft-operations/anchor/programs/mint-nft/src/contexts/create_collection.rs @@ -1,3 +1,4 @@ +use crate::state::CollectionAuthority; use anchor_lang::prelude::*; use anchor_spl::{ associated_token::AssociatedToken, @@ -44,6 +45,16 @@ pub struct CreateCollection<'info> { )] /// CHECK: This account is not initialized and is being used for signing purposes only pub mint_authority: UncheckedAccount<'info>, + // Records `user` as this collection's creator, so verify_collection can + // later confirm only they (not an arbitrary caller) may verify members. + #[account( + init, + payer = user, + space = CollectionAuthority::LEN, + seeds = [b"collection_authority", mint.key().as_ref()], + bump, + )] + pub collection_authority: Account<'info, CollectionAuthority>, #[account(mut)] /// CHECK: This account will be initialized by the metaplex program metadata: UncheckedAccount<'info>, @@ -65,6 +76,7 @@ pub struct CreateCollection<'info> { impl<'info> CreateCollection<'info> { pub fn create_collection(&mut self, bumps: &CreateCollectionBumps) -> Result<()> { + self.collection_authority.creator = self.user.key(); let metadata = &self.metadata.to_account_info(); let master_edition = &self.master_edition.to_account_info(); diff --git a/tokens/nft-operations/anchor/programs/mint-nft/src/contexts/verify_collection.rs b/tokens/nft-operations/anchor/programs/mint-nft/src/contexts/verify_collection.rs index d9ebd32d5..7ba775b63 100644 --- a/tokens/nft-operations/anchor/programs/mint-nft/src/contexts/verify_collection.rs +++ b/tokens/nft-operations/anchor/programs/mint-nft/src/contexts/verify_collection.rs @@ -1,3 +1,4 @@ +use crate::{errors::MintNftError, state::CollectionAuthority}; use anchor_lang::prelude::*; use anchor_spl::metadata::mpl_token_metadata::instructions::{ @@ -28,6 +29,15 @@ pub struct VerifyCollectionMint<'info> { /// CHECK: This account is not initialized and is being used for signing purposes only pub mint_authority: UncheckedAccount<'info>, pub collection_mint: Account<'info, Mint>, + // Only the wallet recorded as this collection's creator at create_collection + // time may verify NFTs into it — the mint_authority PDA otherwise signs + // unconditionally for whoever calls this instruction. + #[account( + seeds = [b"collection_authority", collection_mint.key().as_ref()], + bump, + constraint = collection_authority.creator == authority.key() @ MintNftError::Unauthorized, + )] + pub collection_authority: Account<'info, CollectionAuthority>, #[account(mut)] pub collection_metadata: Account<'info, MetadataAccount>, pub collection_master_edition: Account<'info, MasterEditionAccount>, diff --git a/tokens/nft-operations/anchor/programs/mint-nft/src/errors.rs b/tokens/nft-operations/anchor/programs/mint-nft/src/errors.rs new file mode 100644 index 000000000..8643ba474 --- /dev/null +++ b/tokens/nft-operations/anchor/programs/mint-nft/src/errors.rs @@ -0,0 +1,7 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum MintNftError { + #[msg("Only the collection's original creator may verify members of it")] + Unauthorized, +} diff --git a/tokens/nft-operations/anchor/programs/mint-nft/src/lib.rs b/tokens/nft-operations/anchor/programs/mint-nft/src/lib.rs index 09164ee35..15cbd3e54 100644 --- a/tokens/nft-operations/anchor/programs/mint-nft/src/lib.rs +++ b/tokens/nft-operations/anchor/programs/mint-nft/src/lib.rs @@ -3,6 +3,8 @@ use anchor_lang::prelude::*; declare_id!("3EMcczaGi9ivdLxvvFwRbGYeEUEHpGwabXegARw4jLxa"); pub mod contexts; +pub mod errors; +pub mod state; pub use contexts::*; diff --git a/tokens/nft-operations/anchor/programs/mint-nft/src/state.rs b/tokens/nft-operations/anchor/programs/mint-nft/src/state.rs new file mode 100644 index 000000000..16bd943a5 --- /dev/null +++ b/tokens/nft-operations/anchor/programs/mint-nft/src/state.rs @@ -0,0 +1,16 @@ +use anchor_lang::prelude::*; + +// Records which wallet created a given collection. The collection's actual +// Metaplex update authority is the program's global `[b"authority"]` PDA, +// which signs verify_collection's CPI unconditionally for whoever calls the +// instruction — this account is what actually gates who may do so. Scoped +// per collection_mint so verifying one collection never grants authority +// over any other collection created through this program. +#[account] +pub struct CollectionAuthority { + pub creator: Pubkey, +} + +impl CollectionAuthority { + pub const LEN: usize = 8 + 32; +} diff --git a/tokens/nft-operations/anchor/tests/litesvm.test.ts b/tokens/nft-operations/anchor/tests/litesvm.test.ts index 808182f3e..15204b6f2 100644 --- a/tokens/nft-operations/anchor/tests/litesvm.test.ts +++ b/tokens/nft-operations/anchor/tests/litesvm.test.ts @@ -1,11 +1,23 @@ import * as anchor from '@anchor-lang/core'; import { ASSOCIATED_TOKEN_PROGRAM_ID, getAssociatedTokenAddressSync, TOKEN_PROGRAM_ID } from '@solana/spl-token'; -import { Keypair, PublicKey, SystemProgram } from '@solana/web3.js'; +import { Keypair, LAMPORTS_PER_SOL, PublicKey, SystemProgram } from '@solana/web3.js'; +import { assert } from 'chai'; import { LiteSVMProvider } from 'anchor-litesvm'; import { LiteSVM } from 'litesvm'; import IDL from '../target/idl/mint_nft.json' with { type: 'json' }; import type { MintNft } from '../target/types/mint_nft.ts'; +const expectAnchorError = async (promise: Promise, code: string) => { + let caught: any; + try { + await promise; + } catch (error) { + caught = error; + } + assert.isDefined(caught, `expected the transaction to fail with ${code}`); + assert.strictEqual(caught?.error?.errorCode?.code, code, `expected ${code}, got: ${caught}`); +}; + const PROGRAM_ID = new PublicKey(IDL.address); const METADATA_PROGRAM_ID = new PublicKey('metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s'); @@ -28,6 +40,11 @@ describe('mint-nft litesvm', () => { const collectionKeypair = Keypair.generate(); const collectionMint = collectionKeypair.publicKey; + const collectionAuthority = anchor.web3.PublicKey.findProgramAddressSync( + [Buffer.from('collection_authority'), collectionMint.toBuffer()], + program.programId, + )[0]; + const mintKeypair = Keypair.generate(); const mint = mintKeypair.publicKey; @@ -63,6 +80,7 @@ describe('mint-nft litesvm', () => { user: wallet.publicKey, mint: collectionMint, mintAuthority, + collectionAuthority, metadata, masterEdition, destination, @@ -130,6 +148,7 @@ describe('mint-nft litesvm', () => { mint, mintAuthority, collectionMint, + collectionAuthority, collectionMetadata, collectionMasterEdition, systemProgram: SystemProgram.programId, @@ -141,4 +160,36 @@ describe('mint-nft litesvm', () => { }); console.log('\nCollection Verified! Your transaction signature', tx); }); + + it('rejects verify_collection from a wallet that did not create the collection', async () => { + const outsider = Keypair.generate(); + client.airdrop(outsider.publicKey, BigInt(LAMPORTS_PER_SOL)); + + const mintMetadata = await getMetadata(mint); + const collectionMetadata = await getMetadata(collectionMint); + const collectionMasterEdition = await getMasterEdition(collectionMint); + + await expectAnchorError( + program.methods + .verifyCollection() + .accountsPartial({ + authority: outsider.publicKey, + metadata: mintMetadata, + mint, + mintAuthority, + collectionMint, + collectionAuthority, + collectionMetadata, + collectionMasterEdition, + systemProgram: SystemProgram.programId, + sysvarInstruction: anchor.web3.SYSVAR_INSTRUCTIONS_PUBKEY, + tokenMetadataProgram: TOKEN_METADATA_PROGRAM_ID, + }) + .signers([outsider]) + .rpc({ + skipPreflight: true, + }), + 'Unauthorized', + ); + }); }); diff --git a/tokens/nft-operations/anchor/tests/mint-nft.ts b/tokens/nft-operations/anchor/tests/mint-nft.ts index ea24c6258..63773ee8d 100644 --- a/tokens/nft-operations/anchor/tests/mint-nft.ts +++ b/tokens/nft-operations/anchor/tests/mint-nft.ts @@ -23,6 +23,11 @@ describe('mint-nft', () => { const collectionKeypair = Keypair.generate(); const collectionMint = collectionKeypair.publicKey; + const collectionAuthority = anchor.web3.PublicKey.findProgramAddressSync( + [Buffer.from('collection_authority'), collectionMint.toBuffer()], + program.programId, + )[0]; + const mintKeypair = Keypair.generate(); const mint = mintKeypair.publicKey; @@ -58,6 +63,7 @@ describe('mint-nft', () => { user: wallet.publicKey, mint: collectionMint, mintAuthority, + collectionAuthority, metadata, masterEdition, destination, @@ -125,6 +131,7 @@ describe('mint-nft', () => { mint, mintAuthority, collectionMint, + collectionAuthority, collectionMetadata, collectionMasterEdition, systemProgram: SystemProgram.programId, diff --git a/tokens/nft-operations/pinocchio/program/src/instructions/create_collection.rs b/tokens/nft-operations/pinocchio/program/src/instructions/create_collection.rs index 56bb776e0..6729b9972 100644 --- a/tokens/nft-operations/pinocchio/program/src/instructions/create_collection.rs +++ b/tokens/nft-operations/pinocchio/program/src/instructions/create_collection.rs @@ -10,28 +10,33 @@ use pinocchio_system::instructions::CreateAccount; use pinocchio_token::instructions::{InitializeMint2, MintTo}; use crate::instructions::{ - build_metadata_data, create_master_edition_cpi, create_metadata_cpi, AUTHORITY_SEED, MINT_SIZE, TOKEN_DECIMALS, + build_metadata_data, create_master_edition_cpi, create_metadata_cpi, AUTHORITY_SEED, COLLECTION_AUTHORITY_LEN, + COLLECTION_AUTHORITY_SEED, MINT_SIZE, TOKEN_DECIMALS, }; /// Creates a collection NFT: a 0-decimal mint whose authority is the program's /// `[b"authority"]` PDA, with Metaplex metadata (marked as a sized collection) -/// and a master edition. The single token is minted to the user's ATA. +/// and a master edition. The single token is minted to the user's ATA. Also +/// creates a `collection_authority` account recording `user` as this +/// collection's creator, so `verify_collection` can later confirm only they +/// may verify members of it. /// /// Accounts: /// 0. `[signer, writable]` user (payer) /// 1. `[signer, writable]` mint account (a fresh keypair) /// 2. `[]` mint authority PDA (`[b"authority"]`, also update authority) -/// 3. `[writable]` metadata account (Metaplex PDA) -/// 4. `[writable]` master edition account (Metaplex PDA) -/// 5. `[writable]` user's associated token account (the destination) -/// 6. `[]` system program -/// 7. `[]` token program -/// 8. `[]` associated token program -/// 9. `[]` token metadata program +/// 3. `[writable]` collection authority PDA (`[b"collection_authority", mint]`) +/// 4. `[writable]` metadata account (Metaplex PDA) +/// 5. `[writable]` master edition account (Metaplex PDA) +/// 6. `[writable]` user's associated token account (the destination) +/// 7. `[]` system program +/// 8. `[]` token program +/// 9. `[]` associated token program +/// 10. `[]` token metadata program /// /// Instruction data: none. pub fn create_collection(program_id: &Address, accounts: &mut [AccountView]) -> ProgramResult { - let [user, mint, mint_authority, metadata, master_edition, destination, system_program, token_program, _associated_token_program, _token_metadata_program] = + let [user, mint, mint_authority, collection_authority, metadata, master_edition, destination, system_program, token_program, _associated_token_program, _token_metadata_program] = accounts else { return Err(ProgramError::NotEnoughAccountKeys); @@ -49,6 +54,12 @@ pub fn create_collection(program_id: &Address, accounts: &mut [AccountView]) -> return Err(ProgramError::InvalidSeeds); } + let (collection_authority_pda, collection_authority_bump) = + Address::find_program_address(&[COLLECTION_AUTHORITY_SEED, mint.address().as_ref()], program_id); + if collection_authority.address() != &collection_authority_pda { + return Err(ProgramError::InvalidSeeds); + } + // Create and initialize the mint, with the PDA as mint/freeze authority. // Rent-exempt minimum is read from the Rent sysvar. let rent = Rent::get()?; @@ -56,6 +67,25 @@ pub fn create_collection(program_id: &Address, accounts: &mut [AccountView]) -> log!("Creating mint account"); CreateAccount { from: user, to: mint, lamports, space: MINT_SIZE as u64, owner: &pinocchio_token::ID }.invoke()?; + log!("Creating collection authority account"); + let collection_authority_bump_bytes = [collection_authority_bump]; + let collection_authority_seeds = [ + Seed::from(COLLECTION_AUTHORITY_SEED), + Seed::from(mint.address().as_ref()), + Seed::from(&collection_authority_bump_bytes), + ]; + let collection_authority_signers = [Signer::from(&collection_authority_seeds)]; + let collection_authority_lamports = rent.try_minimum_balance(COLLECTION_AUTHORITY_LEN)?; + CreateAccount { + from: user, + to: collection_authority, + lamports: collection_authority_lamports, + space: COLLECTION_AUTHORITY_LEN as u64, + owner: program_id, + } + .invoke_signed(&collection_authority_signers)?; + collection_authority.try_borrow_mut()?.copy_from_slice(user.address().as_ref()); + log!("Initializing mint account"); InitializeMint2 { mint, diff --git a/tokens/nft-operations/pinocchio/program/src/instructions/mod.rs b/tokens/nft-operations/pinocchio/program/src/instructions/mod.rs index afa377f11..3935bccbb 100644 --- a/tokens/nft-operations/pinocchio/program/src/instructions/mod.rs +++ b/tokens/nft-operations/pinocchio/program/src/instructions/mod.rs @@ -2,6 +2,7 @@ use alloc::vec::Vec; use pinocchio::{ cpi::{invoke_signed, Signer}, + error::ProgramError, instruction::{InstructionAccount, InstructionView}, AccountView, ProgramResult, }; @@ -25,6 +26,33 @@ pub const TOKEN_DECIMALS: u8 = 0; /// is never initialized — it exists only to sign the Metaplex CPIs. pub const AUTHORITY_SEED: &[u8] = b"authority"; +/// Seed prefix for a collection's authority-tracking PDA +/// (`[b"collection_authority", collection_mint]`). Records which wallet created +/// the collection, since the `[b"authority"]` PDA that actually signs Metaplex +/// CPIs is global and signs unconditionally for whoever calls +/// `verify_collection` — this account is what gates that. +pub const COLLECTION_AUTHORITY_SEED: &[u8] = b"collection_authority"; + +/// Size (in bytes) of a collection-authority account: just the raw 32-byte +/// creator pubkey. No discriminator — this program has only one custom account +/// type, and the account only ever exists at its derived PDA address if this +/// program's own `create_collection` created it there. +pub const COLLECTION_AUTHORITY_LEN: usize = 32; + +/// Errors specific to this program's own authorization checks (distinct from +/// the underlying Metaplex/SPL CPI errors). +#[derive(Debug, Clone, Copy)] +pub enum NftOperationsError { + /// The signer is not the collection's recorded creator. + Unauthorized = 1, +} + +impl From for ProgramError { + fn from(e: NftOperationsError) -> Self { + ProgramError::Custom(e as u32) + } +} + /// The Metaplex Token Metadata program ID /// (`metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s`). pub const TOKEN_METADATA_PROGRAM_ID: pinocchio::Address = diff --git a/tokens/nft-operations/pinocchio/program/src/instructions/verify_collection.rs b/tokens/nft-operations/pinocchio/program/src/instructions/verify_collection.rs index 4f9cd0ad0..d4fa76598 100644 --- a/tokens/nft-operations/pinocchio/program/src/instructions/verify_collection.rs +++ b/tokens/nft-operations/pinocchio/program/src/instructions/verify_collection.rs @@ -6,26 +6,33 @@ use pinocchio::{ }; use pinocchio_log::log; -use crate::instructions::{build_verify_collection_data, AUTHORITY_SEED, TOKEN_METADATA_PROGRAM_ID}; +use crate::instructions::{ + build_verify_collection_data, NftOperationsError, AUTHORITY_SEED, COLLECTION_AUTHORITY_LEN, + COLLECTION_AUTHORITY_SEED, TOKEN_METADATA_PROGRAM_ID, +}; /// Verifies an NFT as a member of its collection via the Metaplex `Verify` /// instruction (`VerificationArgs::CollectionV1`), signed by the collection's -/// update authority — the program's `[b"authority"]` PDA. +/// update authority — the program's `[b"authority"]` PDA. Only the wallet +/// recorded as the collection's creator (in `collection_authority`, set at +/// `create_collection` time) may do this — the PDA itself would otherwise +/// sign unconditionally for whoever calls this instruction. /// /// Accounts: /// 0. `[signer, writable]` payer (transaction fee payer) /// 1. `[]` mint authority PDA (`[b"authority"]`, the collection update authority) -/// 2. `[writable]` metadata account of the NFT being verified -/// 3. `[]` collection mint -/// 4. `[writable]` collection metadata account -/// 5. `[]` collection master edition account -/// 6. `[]` system program -/// 7. `[]` instructions sysvar -/// 8. `[]` token metadata program +/// 2. `[]` collection authority PDA (`[b"collection_authority", collection_mint]`) +/// 3. `[writable]` metadata account of the NFT being verified +/// 4. `[]` collection mint +/// 5. `[writable]` collection metadata account +/// 6. `[]` collection master edition account +/// 7. `[]` system program +/// 8. `[]` instructions sysvar +/// 9. `[]` token metadata program /// /// Instruction data: none. pub fn verify_collection(program_id: &Address, accounts: &mut [AccountView]) -> ProgramResult { - let [payer, mint_authority, metadata, collection_mint, collection_metadata, collection_master_edition, system_program, sysvar_instructions, token_metadata_program] = + let [payer, mint_authority, collection_authority, metadata, collection_mint, collection_metadata, collection_master_edition, system_program, sysvar_instructions, token_metadata_program] = accounts else { return Err(ProgramError::NotEnoughAccountKeys); @@ -43,6 +50,22 @@ pub fn verify_collection(program_id: &Address, accounts: &mut [AccountView]) -> return Err(ProgramError::InvalidSeeds); } + let (collection_authority_pda, _) = + Address::find_program_address(&[COLLECTION_AUTHORITY_SEED, collection_mint.address().as_ref()], program_id); + if collection_authority.address() != &collection_authority_pda { + return Err(ProgramError::InvalidSeeds); + } + // Pinocchio has no automatic owner/length checks the way Anchor's + // `Account` does — verify both explicitly before trusting the data. + if collection_authority.owner() != program_id || collection_authority.data_len() != COLLECTION_AUTHORITY_LEN { + log!("Unauthorized: collection authority account missing or invalid"); + return Err(NftOperationsError::Unauthorized.into()); + } + if collection_authority.try_borrow()?.as_ref() != payer.address().as_ref() { + log!("Unauthorized: signer is not the collection's original creator"); + return Err(NftOperationsError::Unauthorized.into()); + } + // Sign for the mint-authority PDA (the collection's update authority). let bump_bytes = [bump]; let seeds = [Seed::from(AUTHORITY_SEED), Seed::from(&bump_bytes)]; diff --git a/tokens/nft-operations/pinocchio/tests/test.ts b/tokens/nft-operations/pinocchio/tests/test.ts index c2e5c6e2b..2c51f12d9 100644 --- a/tokens/nft-operations/pinocchio/tests/test.ts +++ b/tokens/nft-operations/pinocchio/tests/test.ts @@ -67,6 +67,17 @@ async function getAssociatedTokenAddress(mint: ReturnType, owner return ata; } +async function getCollectionAuthorityAddress( + programAddress: ReturnType, + mint: ReturnType, +) { + const [pda] = await getProgramDerivedAddress({ + programAddress, + seeds: ['collection_authority', addressEncoder.encode(mint)], + }); + return pda; +} + describe('NFT Operations (Pinocchio)', () => { let svm: LiteSVM; let programId: ReturnType; @@ -119,6 +130,7 @@ describe('NFT Operations (Pinocchio)', () => { const metadata = await getMetadataAddress(collectionMint.address); const masterEdition = await getMasterEditionAddress(collectionMint.address); const destination = await getAssociatedTokenAddress(collectionMint.address, payer.address); + const collectionAuthority = await getCollectionAuthorityAddress(programId, collectionMint.address); await send({ programAddress: programId, @@ -126,6 +138,7 @@ describe('NFT Operations (Pinocchio)', () => { { address: payer.address, role: AccountRole.WRITABLE_SIGNER, signer: payer }, // user { address: collectionMint.address, role: AccountRole.WRITABLE_SIGNER, signer: collectionMint }, // mint { address: mintAuthorityPda, role: AccountRole.READONLY }, // mint authority PDA + { address: collectionAuthority, role: AccountRole.WRITABLE }, // collection authority PDA { address: metadata, role: AccountRole.WRITABLE }, // metadata { address: masterEdition, role: AccountRole.WRITABLE }, // master edition { address: destination, role: AccountRole.WRITABLE }, // destination ATA @@ -188,20 +201,77 @@ describe('NFT Operations (Pinocchio)', () => { assert.equal(editionAccount.programAddress, TOKEN_METADATA_PROGRAM_ID); }); + it('rejects verify_collection from a wallet that did not create the collection', async () => { + const metadata = await getMetadataAddress(nftMint.address); + const collectionMetadata = await getMetadataAddress(collectionMint.address); + const collectionMasterEdition = await getMasterEditionAddress(collectionMint.address); + const collectionAuthority = await getCollectionAuthorityAddress(programId, collectionMint.address); + + const attacker = await generateKeyPairSigner(); + svm.airdrop(attacker.address, lamports(10_000_000_000n)); + + const transactionMessage = pipe( + createTransactionMessage({ version: 0 }), + m => setTransactionMessageFeePayerSigner(attacker, m), + m => svm.setTransactionMessageLifetimeUsingLatestBlockhash(m), + m => + appendTransactionMessageInstruction( + { + programAddress: programId, + accounts: [ + { address: attacker.address, role: AccountRole.WRITABLE_SIGNER, signer: attacker }, // payer + { address: mintAuthorityPda, role: AccountRole.READONLY }, // mint authority PDA + { address: collectionAuthority, role: AccountRole.READONLY }, // collection authority PDA + { address: metadata, role: AccountRole.WRITABLE }, // NFT metadata + { address: collectionMint.address, role: AccountRole.READONLY }, // collection mint + { address: collectionMetadata, role: AccountRole.WRITABLE }, // collection metadata + { address: collectionMasterEdition, role: AccountRole.READONLY }, // collection master edition + { address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY }, // system program + { address: SYSVAR_INSTRUCTIONS_ADDRESS, role: AccountRole.READONLY }, // instructions sysvar + { address: TOKEN_METADATA_PROGRAM_ID, role: AccountRole.READONLY }, // token metadata program + ], + data: new Uint8Array([VERIFY_COLLECTION]), + }, + m, + ), + ); + const signedTx = await signTransactionMessageWithSigners(transactionMessage); + const result = svm.sendTransaction(signedTx); + + assert.instanceOf(result, FailedTransactionMetadata, 'expected the attacker verify to fail'); + const failure = result as FailedTransactionMetadata; + // Assert the specific Unauthorized rejection (NftOperationsError::Unauthorized = 1), + // not just "it failed" — both the custom error code and the on-chain log it pairs + // with, so a coincidental, unrelated failure wouldn't false-pass this test. + assert.include( + failure.err().toString(), + 'InstructionErrorCustom { code: 1 }', + `expected NftOperationsError::Unauthorized (code 1), got: ${failure.err().toString()}`, + ); + assert.include( + failure.meta().logs().join('\n'), + "Unauthorized: signer is not the collection's original creator", + ); + }); + it('Verifies the NFT as part of the collection', async () => { const metadata = await getMetadataAddress(nftMint.address); const collectionMetadata = await getMetadataAddress(collectionMint.address); const collectionMasterEdition = await getMasterEditionAddress(collectionMint.address); + const collectionAuthority = await getCollectionAuthorityAddress(programId, collectionMint.address); // Metaplex `Verify` performs strict checks: the signer must be the // collection's update authority (our PDA), the collection metadata and // master edition must be valid, and the NFT must reference the collection. - // A successful transaction therefore proves the whole flow is correct. + // A successful transaction therefore proves the whole flow is correct — + // combined with the negative test above, which proves an unrelated + // caller cannot trigger that same signature. await send({ programAddress: programId, accounts: [ { address: payer.address, role: AccountRole.WRITABLE_SIGNER, signer: payer }, // payer { address: mintAuthorityPda, role: AccountRole.READONLY }, // mint authority PDA + { address: collectionAuthority, role: AccountRole.READONLY }, // collection authority PDA { address: metadata, role: AccountRole.WRITABLE }, // NFT metadata { address: collectionMint.address, role: AccountRole.READONLY }, // collection mint { address: collectionMetadata, role: AccountRole.WRITABLE }, // collection metadata