diff --git a/tokens/pda-mint-authority/native/program/src/instructions/create.rs b/tokens/pda-mint-authority/native/program/src/instructions/create.rs index 477cd1d38..b7208a171 100644 --- a/tokens/pda-mint-authority/native/program/src/instructions/create.rs +++ b/tokens/pda-mint-authority/native/program/src/instructions/create.rs @@ -14,7 +14,7 @@ use { spl_token_interface::{instruction as token_instruction, state::Mint}, }; -use crate::state::MintAuthorityPda; +use crate::state::{MintAuthorityPda, MintConfig}; #[derive(BorshSerialize, BorshDeserialize, Debug)] pub struct CreateTokenArgs { @@ -28,6 +28,7 @@ pub fn create_token(program_id: &Pubkey, accounts: &[AccountInfo], args: CreateT let mint_account = next_account_info(accounts_iter)?; let mint_authority = next_account_info(accounts_iter)?; + let mint_config = next_account_info(accounts_iter)?; let metadata_account = next_account_info(accounts_iter)?; let payer = next_account_info(accounts_iter)?; let rent = next_account_info(accounts_iter)?; @@ -39,6 +40,10 @@ pub fn create_token(program_id: &Pubkey, accounts: &[AccountInfo], args: CreateT Pubkey::find_program_address(&[MintAuthorityPda::SEED_PREFIX.as_bytes()], program_id); assert!(&mint_authority_pda.eq(mint_authority.key)); + let (mint_config_pda, mint_config_bump) = + Pubkey::find_program_address(&[MintConfig::SEED_PREFIX.as_bytes(), mint_account.key.as_ref()], program_id); + assert!(&mint_config_pda.eq(mint_config.key)); + // First create the account for the Mint // msg!("Creating mint account..."); @@ -97,6 +102,23 @@ pub fn create_token(program_id: &Pubkey, accounts: &[AccountInfo], args: CreateT &[&[MintAuthorityPda::SEED_PREFIX.as_bytes(), &[bump]]], )?; + // Records who is allowed to call mint_to, since the mint authority PDA itself + // signs unconditionally for whoever calls it. + msg!("Creating mint config account..."); + invoke_signed( + &system_instruction::create_account( + payer.key, + mint_config.key, + (Rent::get()?).minimum_balance(MintConfig::SIZE), + MintConfig::SIZE as u64, + program_id, + ), + &[mint_config.clone(), payer.clone(), system_program.clone()], + &[&[MintConfig::SEED_PREFIX.as_bytes(), mint_account.key.as_ref(), &[mint_config_bump]]], + )?; + let config = MintConfig { admin: *payer.key }; + config.serialize(&mut &mut mint_config.data.borrow_mut()[..])?; + msg!("Token mint created successfully."); Ok(()) diff --git a/tokens/pda-mint-authority/native/program/src/instructions/mint.rs b/tokens/pda-mint-authority/native/program/src/instructions/mint.rs index 50b1d868c..b172cfb10 100644 --- a/tokens/pda-mint-authority/native/program/src/instructions/mint.rs +++ b/tokens/pda-mint-authority/native/program/src/instructions/mint.rs @@ -1,16 +1,18 @@ use { + borsh::BorshDeserialize, solana_program::{ account_info::{next_account_info, AccountInfo}, entrypoint::ProgramResult, msg, program::{invoke, invoke_signed}, + program_error::ProgramError, pubkey::Pubkey, }, spl_associated_token_account_interface::instruction as associated_token_account_instruction, spl_token_interface::instruction as token_instruction, }; -use crate::state::MintAuthorityPda; +use crate::state::{MintAuthorityPda, MintConfig}; pub fn mint_to(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { let accounts_iter = &mut accounts.iter(); @@ -19,6 +21,7 @@ pub fn mint_to(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { let metadata_account = next_account_info(accounts_iter)?; let edition_account = next_account_info(accounts_iter)?; let mint_authority = next_account_info(accounts_iter)?; + let mint_config = next_account_info(accounts_iter)?; let associated_token_account = next_account_info(accounts_iter)?; let payer = next_account_info(accounts_iter)?; let _rent = next_account_info(accounts_iter)?; @@ -27,10 +30,26 @@ pub fn mint_to(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { let associated_token_program = next_account_info(accounts_iter)?; let token_metadata_program = next_account_info(accounts_iter)?; + if !payer.is_signer { + return Err(ProgramError::MissingRequiredSignature); + } + let (mint_authority_pda, bump) = Pubkey::find_program_address(&[MintAuthorityPda::SEED_PREFIX.as_bytes()], program_id); assert!(&mint_authority_pda.eq(mint_authority.key)); + let (mint_config_pda, _) = + Pubkey::find_program_address(&[MintConfig::SEED_PREFIX.as_bytes(), mint_account.key.as_ref()], program_id); + assert!(&mint_config_pda.eq(mint_config.key)); + if mint_config.owner != program_id { + return Err(ProgramError::IncorrectProgramId); + } + // Only the wallet recorded as admin at create_token time may mint. + if MintConfig::deserialize(&mut &mint_config.data.borrow()[..])?.admin != *payer.key { + msg!("Only the admin recorded at token creation may mint"); + return Err(ProgramError::InvalidArgument); + } + if associated_token_account.lamports() == 0 { msg!("Creating associated token account..."); invoke( diff --git a/tokens/pda-mint-authority/native/program/src/state/mod.rs b/tokens/pda-mint-authority/native/program/src/state/mod.rs index 7da766c8a..ea3ac06fc 100644 --- a/tokens/pda-mint-authority/native/program/src/state/mod.rs +++ b/tokens/pda-mint-authority/native/program/src/state/mod.rs @@ -1,4 +1,5 @@ use borsh::{BorshDeserialize, BorshSerialize}; +use solana_program::pubkey::Pubkey; #[derive(BorshDeserialize, BorshSerialize)] pub struct MintAuthorityPda { @@ -9,3 +10,13 @@ impl MintAuthorityPda { pub const SEED_PREFIX: &'static str = "mint_authority"; pub const SIZE: usize = 8 + 8; } + +#[derive(BorshDeserialize, BorshSerialize)] +pub struct MintConfig { + pub admin: Pubkey, +} + +impl MintConfig { + pub const SEED_PREFIX: &'static str = "mint_config"; + pub const SIZE: usize = 32; +} diff --git a/tokens/pda-mint-authority/native/tests/test.ts b/tokens/pda-mint-authority/native/tests/test.ts index 4a03a53a8..fabd4ad59 100644 --- a/tokens/pda-mint-authority/native/tests/test.ts +++ b/tokens/pda-mint-authority/native/tests/test.ts @@ -32,6 +32,7 @@ describe('NFT Minter', () => { let mintKeypair: KeyPairSigner; let mintAuthorityAddress: Address; let mintAuthorityBump: number; + let mintConfigAddress: Address; let metadataAddress: Address; let editionAddress: Address; @@ -50,6 +51,12 @@ describe('NFT Minter', () => { mintKeypair = await generateKeyPairSigner(); + // The mint config PDA is bound to this mint's address. + [mintConfigAddress] = await getProgramDerivedAddress({ + programAddress: programId, + seeds: ['mint_config', addressEncoder.encode(mintKeypair.address)], + }); + [metadataAddress] = await getProgramDerivedAddress({ programAddress: TOKEN_METADATA_PROGRAM_ADDRESS, seeds: [ @@ -97,6 +104,7 @@ describe('NFT Minter', () => { const ix = createCreateInstruction( mintKeypair, mintAuthorityAddress, + mintConfigAddress, metadataAddress, payer, programId, @@ -118,6 +126,10 @@ describe('NFT Minter', () => { const metadataInfo = svm.getAccount(metadataAddress); assert(metadataInfo.exists, 'metadata account not created'); assert(metadataInfo.programAddress === TOKEN_METADATA_PROGRAM_ADDRESS, 'metadata account has wrong owner'); + + const mintConfigInfo = svm.getAccount(mintConfigAddress); + assert(mintConfigInfo.exists, 'mint config PDA not created'); + assert(mintConfigInfo.programAddress === programId, 'mint config PDA not owned by the program'); }); it('Mint the NFT to your wallet!', async () => { @@ -132,6 +144,7 @@ describe('NFT Minter', () => { metadataAddress, editionAddress, mintAuthorityAddress, + mintConfigAddress, associatedTokenAccountAddress, payer, programId, @@ -148,4 +161,75 @@ describe('NFT Minter', () => { assert(editionInfo.exists, 'edition account not created'); assert(editionInfo.programAddress === TOKEN_METADATA_PROGRAM_ADDRESS, 'edition account has wrong owner'); }); + + it('rejects mint from a wallet that did not create the token', async () => { + // A fresh mint is required: after the happy-path mint, Metaplex already holds + // the mint authority via the master edition, so a second mint would fail even + // without the admin check. This test must fail *only* because of that check. + const otherMint = await generateKeyPairSigner(); + const [otherMintConfig] = await getProgramDerivedAddress({ + programAddress: programId, + seeds: ['mint_config', addressEncoder.encode(otherMint.address)], + }); + const [otherMetadata] = await getProgramDerivedAddress({ + programAddress: TOKEN_METADATA_PROGRAM_ADDRESS, + seeds: [ + 'metadata', + addressEncoder.encode(TOKEN_METADATA_PROGRAM_ADDRESS), + addressEncoder.encode(otherMint.address), + ], + }); + const [otherEdition] = await getProgramDerivedAddress({ + programAddress: TOKEN_METADATA_PROGRAM_ADDRESS, + seeds: [ + 'metadata', + addressEncoder.encode(TOKEN_METADATA_PROGRAM_ADDRESS), + addressEncoder.encode(otherMint.address), + 'edition', + ], + }); + + await sendTransaction( + createCreateInstruction( + otherMint, + mintAuthorityAddress, + otherMintConfig, + otherMetadata, + payer, + programId, + 'Homer NFT', + 'HOMR', + 'https://raw.githubusercontent.com/solana-developers/program-examples/new-examples/tokens/tokens/.assets/nft.json', + ), + ); + + const outsider = await generateKeyPairSigner(); + svm.airdrop(outsider.address, lamports(10_000_000_000n)); + + const [outsiderAta] = await findAssociatedTokenPda({ + mint: otherMint.address, + owner: outsider.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + + const ix = createMintInstruction( + otherMint.address, + otherMetadata, + otherEdition, + mintAuthorityAddress, + otherMintConfig, + outsiderAta, + outsider, + programId, + ); + const transactionMessage = pipe( + createTransactionMessage({ version: 0 }), + m => setTransactionMessageFeePayerSigner(outsider, m), + m => svm.setTransactionMessageLifetimeUsingLatestBlockhash(m), + m => appendTransactionMessageInstruction(ix, m), + ); + const signedTx = await signTransactionMessageWithSigners(transactionMessage); + const result = svm.sendTransaction(signedTx); + assert(result instanceof FailedTransactionMetadata, 'expected the transaction to fail'); + }); }); diff --git a/tokens/pda-mint-authority/native/ts/instructions/create.ts b/tokens/pda-mint-authority/native/ts/instructions/create.ts index dfc5b6c11..cc0c3c84b 100644 --- a/tokens/pda-mint-authority/native/ts/instructions/create.ts +++ b/tokens/pda-mint-authority/native/ts/instructions/create.ts @@ -24,6 +24,7 @@ export const createEncoder = getStructEncoder([ export function createCreateInstruction( mint: TransactionSigner, mintAuthority: Address, + mintConfig: Address, metadata: Address, payer: TransactionSigner, programId: Address, @@ -36,6 +37,7 @@ export function createCreateInstruction( accounts: [ { address: mint.address, role: AccountRole.WRITABLE_SIGNER, signer: mint }, { address: mintAuthority, role: AccountRole.WRITABLE }, + { address: mintConfig, role: AccountRole.WRITABLE }, { address: metadata, role: AccountRole.WRITABLE }, { address: payer.address, role: AccountRole.WRITABLE_SIGNER, signer: payer }, { address: SYSVAR_RENT_ADDRESS, role: AccountRole.READONLY }, diff --git a/tokens/pda-mint-authority/native/ts/instructions/mint.ts b/tokens/pda-mint-authority/native/ts/instructions/mint.ts index b73361538..68b5fab5b 100644 --- a/tokens/pda-mint-authority/native/ts/instructions/mint.ts +++ b/tokens/pda-mint-authority/native/ts/instructions/mint.ts @@ -12,6 +12,7 @@ export function createMintInstruction( metadata: Address, edition: Address, mintAuthority: Address, + mintConfig: Address, associatedTokenAccount: Address, payer: TransactionSigner, programId: Address, @@ -23,6 +24,7 @@ export function createMintInstruction( { address: metadata, role: AccountRole.WRITABLE }, { address: edition, role: AccountRole.WRITABLE }, { address: mintAuthority, role: AccountRole.WRITABLE }, + { address: mintConfig, role: AccountRole.READONLY }, { address: associatedTokenAccount, role: AccountRole.WRITABLE }, { address: payer.address, role: AccountRole.WRITABLE_SIGNER, signer: payer }, { address: SYSVAR_RENT_ADDRESS, role: AccountRole.READONLY }, diff --git a/tokens/pda-mint-authority/pinocchio/program/src/instructions/create.rs b/tokens/pda-mint-authority/pinocchio/program/src/instructions/create.rs index dc6f3f0a6..6724b0362 100644 --- a/tokens/pda-mint-authority/pinocchio/program/src/instructions/create.rs +++ b/tokens/pda-mint-authority/pinocchio/program/src/instructions/create.rs @@ -12,7 +12,7 @@ use pinocchio_system::instructions::CreateAccount; use pinocchio_token::instructions::InitializeMint2; use crate::instructions::{CreateTokenArgs, MINT_SIZE, TOKEN_DECIMALS, TOKEN_METADATA_PROGRAM_ID}; -use crate::state::MintAuthorityPda; +use crate::state::{MintAuthorityPda, MintConfig}; /// Discriminator of the Metaplex `CreateMetadataAccountV3` instruction (variant /// 33 of the Token Metadata program's instruction enum). @@ -25,11 +25,12 @@ const CREATE_METADATA_ACCOUNT_V3: u8 = 33; /// Accounts: /// 0. `[signer, writable]` mint account (a fresh keypair to initialize) /// 1. `[]` mint authority PDA (also the metadata update authority) -/// 2. `[writable]` metadata account (the Metaplex metadata PDA) -/// 3. `[signer, writable]` payer (funds the new accounts) -/// 4. `[]` system program -/// 5. `[]` token program -/// 6. `[]` token metadata program +/// 2. `[writable]` mint config PDA (created here; records the payer) +/// 3. `[writable]` metadata account (the Metaplex metadata PDA) +/// 4. `[signer, writable]` payer (funds the new accounts) +/// 5. `[]` system program +/// 6. `[]` token program +/// 7. `[]` token metadata program /// /// Instruction data: Borsh `[name: string, symbol: string, uri: string]`. /// @@ -39,7 +40,7 @@ const CREATE_METADATA_ACCOUNT_V3: u8 = 33; pub fn create_token(program_id: &Address, accounts: &mut [AccountView], data: &[u8]) -> ProgramResult { // `token_program` and `token_metadata_program` are unused directly, but must // be supplied so they are present in the transaction for the CPIs below. - let [mint_account, mint_authority, metadata_account, payer, system_program, _token_program, _token_metadata_program] = + let [mint_account, mint_authority, mint_config, metadata_account, payer, system_program, _token_program, _token_metadata_program] = accounts else { return Err(ProgramError::NotEnoughAccountKeys); @@ -58,6 +59,13 @@ pub fn create_token(program_id: &Address, accounts: &mut [AccountView], data: &[ return Err(ProgramError::InvalidSeeds); } + // Confirm the supplied account is the mint-config PDA bound to this mint. + let (mint_config_pda, mint_config_bump) = + Address::find_program_address(&[MintConfig::SEED_PREFIX, mint_account.address().as_array()], program_id); + if mint_config.address() != &mint_config_pda { + return Err(ProgramError::InvalidSeeds); + } + // Fund the mint account with enough lamports to stay rent-exempt, read from // the Rent sysvar. let rent = Rent::get()?; @@ -101,6 +109,23 @@ pub fn create_token(program_id: &Address, accounts: &mut [AccountView], data: &[ &signers, )?; + // Records who is allowed to call `mint_to`, since the mint authority PDA + // itself signs unconditionally for whoever calls it. + log!("Creating mint config account"); + let config_lamports = rent.try_minimum_balance(MintConfig::ACCOUNT_SPACE)?; + let bump_bytes = [mint_config_bump]; + let seeds = + [Seed::from(MintConfig::SEED_PREFIX), Seed::from(mint_account.address().as_ref()), Seed::from(&bump_bytes)]; + CreateAccount { + from: payer, + to: mint_config, + lamports: config_lamports, + space: MintConfig::ACCOUNT_SPACE as u64, + owner: program_id, + } + .invoke_signed(&[Signer::from(&seeds)])?; + MintConfig { admin: *payer.address() }.serialize(&mut mint_config.try_borrow_mut()?)?; + log!("Token mint created successfully"); Ok(()) } diff --git a/tokens/pda-mint-authority/pinocchio/program/src/instructions/mint.rs b/tokens/pda-mint-authority/pinocchio/program/src/instructions/mint.rs index 2e2d717fa..c8ae13661 100644 --- a/tokens/pda-mint-authority/pinocchio/program/src/instructions/mint.rs +++ b/tokens/pda-mint-authority/pinocchio/program/src/instructions/mint.rs @@ -11,7 +11,7 @@ use pinocchio_log::log; use pinocchio_token::instructions::MintTo; use crate::instructions::TOKEN_METADATA_PROGRAM_ID; -use crate::state::MintAuthorityPda; +use crate::state::{MintAuthorityPda, MintConfig}; /// Discriminator of the Metaplex `CreateMasterEditionV3` instruction (variant 17 /// of the Token Metadata program's instruction enum). @@ -28,23 +28,28 @@ const CREATE_MASTER_EDITION_V3: u8 = 17; /// 1. `[writable]` metadata account /// 2. `[writable]` master edition account (the edition PDA) /// 3. `[]` mint authority PDA (also the metadata update authority) -/// 4. `[writable]` payer's associated token account (the destination) -/// 5. `[signer, writable]` payer (funds the accounts and owns the NFT) -/// 6. `[]` system program -/// 7. `[]` token program -/// 8. `[]` associated token program -/// 9. `[]` token metadata program +/// 4. `[]` mint config PDA (records who may mint) +/// 5. `[writable]` payer's associated token account (the destination) +/// 6. `[signer, writable]` payer (funds the accounts and owns the NFT) +/// 7. `[]` system program +/// 8. `[]` token program +/// 9. `[]` associated token program +/// 10. `[]` token metadata program /// /// Instruction data: none. pub fn mint_to(program_id: &Address, accounts: &mut [AccountView]) -> ProgramResult { // `associated_token_program` and `token_metadata_program` are unused // directly, but must be supplied so they are present for the CPIs below. - let [mint_account, metadata_account, edition_account, mint_authority, associated_token_account, payer, system_program, token_program, _associated_token_program, _token_metadata_program] = + let [mint_account, metadata_account, edition_account, mint_authority, mint_config, associated_token_account, payer, system_program, token_program, _associated_token_program, _token_metadata_program] = accounts else { return Err(ProgramError::NotEnoughAccountKeys); }; + if !payer.is_signer() { + return Err(ProgramError::MissingRequiredSignature); + } + // Recover the PDA bump recorded by `init` and confirm the supplied account is // the mint-authority PDA. The canonical bump is already known, so derive the // address directly with `create_program_address` rather than searching for it @@ -56,6 +61,21 @@ pub fn mint_to(program_id: &Address, accounts: &mut [AccountView]) -> ProgramRes return Err(ProgramError::InvalidSeeds); } + // Confirm the supplied account is the mint-config PDA bound to this mint, + // then that the caller is the wallet recorded as admin at create_token time. + let (mint_config_pda, _) = + Address::find_program_address(&[MintConfig::SEED_PREFIX, mint_account.address().as_array()], program_id); + if mint_config.address() != &mint_config_pda { + return Err(ProgramError::InvalidSeeds); + } + if !mint_config.owned_by(program_id) { + return Err(ProgramError::IncorrectProgramId); + } + if MintConfig::deserialize(&mint_config.try_borrow()?)?.admin != *payer.address() { + log!("Only the admin recorded at token creation may mint"); + return Err(ProgramError::InvalidArgument); + } + // Signer seeds for the mint-authority PDA, reused by both CPIs below. let bump_bytes = [bump]; let seeds = [Seed::from(MintAuthorityPda::SEED_PREFIX), Seed::from(&bump_bytes)]; diff --git a/tokens/pda-mint-authority/pinocchio/program/src/state.rs b/tokens/pda-mint-authority/pinocchio/program/src/state.rs index 8455331cb..14952ea47 100644 --- a/tokens/pda-mint-authority/pinocchio/program/src/state.rs +++ b/tokens/pda-mint-authority/pinocchio/program/src/state.rs @@ -1,4 +1,4 @@ -use pinocchio::error::ProgramError; +use pinocchio::{error::ProgramError, Address}; /// Persistent record stored in the mint-authority PDA. /// @@ -31,3 +31,38 @@ impl MintAuthorityPda { Ok(Self { bump }) } } + +/// Persistent record stored in the mint-config PDA. +/// +/// The PDA is derived from `[b"mint_config", mint]`, created alongside each +/// token and bound to its mint, recording the wallet that created it. The +/// mint-authority PDA signs unconditionally for whoever calls `mint_to`, so +/// this account is the only thing restricting minting to that wallet. +pub struct MintConfig { + /// Wallet recorded at token creation; the only caller allowed to mint. + pub admin: Address, +} + +impl MintConfig { + /// First seed for the mint-config PDA: `[SEED_PREFIX, mint]`. + pub const SEED_PREFIX: &'static [u8] = b"mint_config"; + + /// Bytes allocated for the account: the admin address. + pub const ACCOUNT_SPACE: usize = 32; + + /// Writes the admin into the first 32 bytes of `dst`. + pub fn serialize(&self, dst: &mut [u8]) -> Result<(), ProgramError> { + dst.get_mut(..32).ok_or(ProgramError::AccountDataTooSmall)?.copy_from_slice(self.admin.as_array()); + Ok(()) + } + + /// Reads the admin from the first 32 bytes of `src`. + pub fn deserialize(src: &[u8]) -> Result { + let bytes: [u8; 32] = src + .get(..32) + .ok_or(ProgramError::InvalidAccountData)? + .try_into() + .map_err(|_| ProgramError::InvalidAccountData)?; + Ok(Self { admin: Address::new_from_array(bytes) }) + } +} diff --git a/tokens/pda-mint-authority/pinocchio/tests/test.ts b/tokens/pda-mint-authority/pinocchio/tests/test.ts index 5a56abefb..d80d142f9 100644 --- a/tokens/pda-mint-authority/pinocchio/tests/test.ts +++ b/tokens/pda-mint-authority/pinocchio/tests/test.ts @@ -83,6 +83,7 @@ describe('PDA Mint Authority (Pinocchio)', () => { let mint: Awaited>; let mintAuthorityPda: ReturnType; let mintAuthorityBump: number; + let mintConfigPda: ReturnType; before(async () => { svm = new LiteSVM(); @@ -106,6 +107,12 @@ describe('PDA Mint Authority (Pinocchio)', () => { }); mintAuthorityPda = pda; mintAuthorityBump = bump; + + const [configPda] = await getProgramDerivedAddress({ + programAddress: programId, + seeds: ['mint_config', addressEncoder.encode(mint.address)], + }); + mintConfigPda = configPda; }); async function send[0]>( @@ -157,6 +164,7 @@ describe('PDA Mint Authority (Pinocchio)', () => { accounts: [ { address: mint.address, role: AccountRole.WRITABLE_SIGNER, signer: mint }, // mint account { address: mintAuthorityPda, role: AccountRole.READONLY }, // mint authority PDA + { address: mintConfigPda, role: AccountRole.WRITABLE }, // mint config PDA { address: metadataAddress, role: AccountRole.WRITABLE }, // metadata account { address: payer.address, role: AccountRole.WRITABLE_SIGNER, signer: payer }, // payer { address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY }, // system program @@ -174,6 +182,10 @@ describe('PDA Mint Authority (Pinocchio)', () => { if (!metadataAccount?.exists) throw new Error('Metadata account not found'); assert.equal(metadataAccount.programAddress, TOKEN_METADATA_PROGRAM_ID); assert.isTrue(Buffer.from(metadataAccount.data).toString('utf-8').includes('Homer NFT')); + + const mintConfigAccount = svm.getAccount(mintConfigPda); + if (!mintConfigAccount?.exists) throw new Error('Mint config PDA not found'); + assert.equal(mintConfigAccount.programAddress, programId); }); it('Mint the NFT to your wallet!', async () => { @@ -192,6 +204,7 @@ describe('PDA Mint Authority (Pinocchio)', () => { { address: metadataAddress, role: AccountRole.WRITABLE }, // metadata account { address: editionAddress, role: AccountRole.WRITABLE }, // master edition account { address: mintAuthorityPda, role: AccountRole.READONLY }, // mint authority PDA + { address: mintConfigPda, role: AccountRole.READONLY }, // mint config PDA { address: ata, role: AccountRole.WRITABLE }, // associated token account { address: payer.address, role: AccountRole.WRITABLE_SIGNER, signer: payer }, // payer { address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY }, // system program @@ -216,4 +229,80 @@ describe('PDA Mint Authority (Pinocchio)', () => { if (!editionAccount?.exists) throw new Error('Master edition account not found'); assert.equal(editionAccount.programAddress, TOKEN_METADATA_PROGRAM_ID); }); + + it('rejects mint from a wallet that did not create the token', async () => { + // A fresh mint is required: after the happy-path mint, Metaplex already holds + // the mint authority via the master edition, so a second mint would fail even + // without the admin check. This test must fail *only* because of that check. + const otherMint = await generateKeyPairSigner(); + const [otherMintConfig] = await getProgramDerivedAddress({ + programAddress: programId, + seeds: ['mint_config', addressEncoder.encode(otherMint.address)], + }); + const otherMetadata = await getMetadataAddress(otherMint.address); + const otherEdition = await getMasterEditionAddress(otherMint.address); + + const createData = createTokenArgsEncoder.encode({ + instruction: CREATE, + nftTitle: 'Homer NFT', + nftSymbol: 'HOMR', + nftUri: 'https://raw.githubusercontent.com/solana-developers/program-examples/new-examples/tokens/tokens/.assets/nft.json', + }); + + await send({ + programAddress: programId, + accounts: [ + { address: otherMint.address, role: AccountRole.WRITABLE_SIGNER, signer: otherMint }, + { address: mintAuthorityPda, role: AccountRole.READONLY }, + { address: otherMintConfig, role: AccountRole.WRITABLE }, + { address: otherMetadata, role: AccountRole.WRITABLE }, + { address: payer.address, role: AccountRole.WRITABLE_SIGNER, signer: payer }, + { address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY }, + { address: TOKEN_PROGRAM_ADDRESS, role: AccountRole.READONLY }, + { address: TOKEN_METADATA_PROGRAM_ID, role: AccountRole.READONLY }, + ], + data: new Uint8Array(createData), + }); + + const outsider = await generateKeyPairSigner(); + svm.airdrop(outsider.address, lamports(10_000_000_000n)); + + const [outsiderAta] = await findAssociatedTokenPda({ + owner: outsider.address, + mint: otherMint.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + + const result = svm.sendTransaction( + await signTransactionMessageWithSigners( + pipe( + createTransactionMessage({ version: 0 }), + m => setTransactionMessageFeePayerSigner(outsider, m), + m => svm.setTransactionMessageLifetimeUsingLatestBlockhash(m), + m => + appendTransactionMessageInstruction( + { + programAddress: programId, + accounts: [ + { address: otherMint.address, role: AccountRole.WRITABLE }, + { address: otherMetadata, role: AccountRole.WRITABLE }, + { address: otherEdition, role: AccountRole.WRITABLE }, + { address: mintAuthorityPda, role: AccountRole.READONLY }, + { address: otherMintConfig, role: AccountRole.READONLY }, + { address: outsiderAta, role: AccountRole.WRITABLE }, + { address: outsider.address, role: AccountRole.WRITABLE_SIGNER, signer: outsider }, + { address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY }, + { address: TOKEN_PROGRAM_ADDRESS, role: AccountRole.READONLY }, + { address: ASSOCIATED_TOKEN_PROGRAM_ADDRESS, role: AccountRole.READONLY }, + { address: TOKEN_METADATA_PROGRAM_ID, role: AccountRole.READONLY }, + ], + data: new Uint8Array([MINT]), + }, + m, + ), + ), + ), + ); + assert(result instanceof FailedTransactionMetadata, 'expected the transaction to fail'); + }); });