diff --git a/tokens/pda-mint-authority/anchor/programs/token-minter/src/errors.rs b/tokens/pda-mint-authority/anchor/programs/token-minter/src/errors.rs new file mode 100644 index 000000000..8e52daa18 --- /dev/null +++ b/tokens/pda-mint-authority/anchor/programs/token-minter/src/errors.rs @@ -0,0 +1,7 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum TokenMinterError { + #[msg("Only the admin recorded at token creation may mint")] + Unauthorized, +} diff --git a/tokens/pda-mint-authority/anchor/programs/token-minter/src/instructions/create.rs b/tokens/pda-mint-authority/anchor/programs/token-minter/src/instructions/create.rs index e16b37c74..ec96f0300 100644 --- a/tokens/pda-mint-authority/anchor/programs/token-minter/src/instructions/create.rs +++ b/tokens/pda-mint-authority/anchor/programs/token-minter/src/instructions/create.rs @@ -1,11 +1,11 @@ // In this example the same PDA is used as both the address of the mint account and the mint authority // This is to demonstrate that the same PDA can be used for both the address of an account and CPI signing use { + crate::state::MintConfig, anchor_lang::prelude::*, anchor_spl::{ metadata::{ - create_metadata_accounts_v3, mpl_token_metadata::types::DataV2, - CreateMetadataAccountsV3, Metadata, + create_metadata_accounts_v3, mpl_token_metadata::types::DataV2, CreateMetadataAccountsV3, Metadata, }, token::{Mint, Token}, }, @@ -39,6 +39,17 @@ pub struct CreateToken<'info> { )] pub metadata_account: UncheckedAccount<'info>, + // Records who is allowed to mint, since the mint authority PDA itself signs + // unconditionally for whoever calls the mint instruction. + #[account( + init, + payer = payer, + space = MintConfig::LEN, + seeds = [b"mint_config"], + bump, + )] + pub mint_config: Account<'info, MintConfig>, + pub token_program: Program<'info, Token>, pub token_metadata_program: Program<'info, Metadata>, pub system_program: Program<'info, System>, @@ -86,6 +97,8 @@ pub fn create_token( None, // Collection details )?; + ctx.accounts.mint_config.admin = ctx.accounts.payer.key(); + msg!("Token created successfully."); Ok(()) diff --git a/tokens/pda-mint-authority/anchor/programs/token-minter/src/instructions/mint.rs b/tokens/pda-mint-authority/anchor/programs/token-minter/src/instructions/mint.rs index dfbd1781a..cd781ea9c 100644 --- a/tokens/pda-mint-authority/anchor/programs/token-minter/src/instructions/mint.rs +++ b/tokens/pda-mint-authority/anchor/programs/token-minter/src/instructions/mint.rs @@ -1,4 +1,5 @@ use { + crate::{errors::TokenMinterError, state::MintConfig}, anchor_lang::prelude::*, anchor_spl::{ associated_token::AssociatedToken, @@ -19,6 +20,14 @@ pub struct MintToken<'info> { )] pub mint_account: Account<'info, Mint>, + // Only the wallet recorded as admin at create_token time may mint. + #[account( + seeds = [b"mint_config"], + bump, + constraint = mint_config.admin == payer.key() @ TokenMinterError::Unauthorized, + )] + pub mint_config: Account<'info, MintConfig>, + // Create Associated Token Account, if needed // This is the account that will hold the minted tokens #[account( @@ -37,10 +46,7 @@ pub struct MintToken<'info> { pub fn mint_token(ctx: Context, amount: u64) -> Result<()> { msg!("Minting token to associated token account..."); msg!("Mint: {}", &ctx.accounts.mint_account.key()); - msg!( - "Token Address: {}", - &ctx.accounts.associated_token_account.key() - ); + msg!("Token Address: {}", &ctx.accounts.associated_token_account.key()); // PDA signer seeds let signer_seeds: &[&[&[u8]]] = &[&[b"mint", &[ctx.bumps.mint_account]]]; diff --git a/tokens/pda-mint-authority/anchor/programs/token-minter/src/lib.rs b/tokens/pda-mint-authority/anchor/programs/token-minter/src/lib.rs index 9d28f4ccc..7b5f3bf04 100644 --- a/tokens/pda-mint-authority/anchor/programs/token-minter/src/lib.rs +++ b/tokens/pda-mint-authority/anchor/programs/token-minter/src/lib.rs @@ -1,6 +1,8 @@ use anchor_lang::prelude::*; use instructions::*; +pub mod errors; pub mod instructions; +pub mod state; declare_id!("3LFrPHqwk5jMrmiz48BFj6NV2k4NjobgTe1jChzx3JGD"); diff --git a/tokens/pda-mint-authority/anchor/programs/token-minter/src/state.rs b/tokens/pda-mint-authority/anchor/programs/token-minter/src/state.rs new file mode 100644 index 000000000..b84cd36e3 --- /dev/null +++ b/tokens/pda-mint-authority/anchor/programs/token-minter/src/state.rs @@ -0,0 +1,13 @@ +use anchor_lang::prelude::*; + +// Tracks who is allowed to mint from this program's single global mint. The +// mint's own authority is a PDA that signs unconditionally, so this account is +// the only thing gating who may trigger it. +#[account] +pub struct MintConfig { + pub admin: Pubkey, +} + +impl MintConfig { + pub const LEN: usize = 8 + 32; +} diff --git a/tokens/pda-mint-authority/anchor/tests/litesvm.test.ts b/tokens/pda-mint-authority/anchor/tests/litesvm.test.ts index 0896f726f..140855986 100644 --- a/tokens/pda-mint-authority/anchor/tests/litesvm.test.ts +++ b/tokens/pda-mint-authority/anchor/tests/litesvm.test.ts @@ -1,11 +1,23 @@ import * as anchor from '@anchor-lang/core'; import { getAssociatedTokenAddressSync } from '@solana/spl-token'; -import { PublicKey } from '@solana/web3.js'; +import { Keypair, LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js'; +import { assert } from 'chai'; import { LiteSVMProvider } from 'anchor-litesvm'; import { LiteSVM } from 'litesvm'; import IDL from '../target/idl/token_minter.json'; import type { TokenMinter } from '../target/types/token_minter'; +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'); @@ -60,4 +72,22 @@ describe('NFT Minter', () => { console.log(` Associated Token Account Address: ${associatedTokenAccountAddress}`); console.log(` Transaction Signature: ${transactionSignature}`); }); + + it('rejects mint_token from a wallet that did not create the token', async () => { + const outsider = Keypair.generate(); + client.airdrop(outsider.publicKey, BigInt(LAMPORTS_PER_SOL)); + const outsiderAta = getAssociatedTokenAddressSync(mintPDA, outsider.publicKey); + + await expectAnchorError( + program.methods + .mintToken(new anchor.BN(100)) + .accountsPartial({ + payer: outsider.publicKey, + associatedTokenAccount: outsiderAta, + }) + .signers([outsider]) + .rpc(), + 'Unauthorized', + ); + }); });