Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::state::CollectionAuthority;
use anchor_lang::prelude::*;
use anchor_spl::{
associated_token::AssociatedToken,
Expand Down Expand Up @@ -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>,
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::{errors::MintNftError, state::CollectionAuthority};
use anchor_lang::prelude::*;

use anchor_spl::metadata::mpl_token_metadata::instructions::{
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you please remove a lot of the comments are surperfluous / self explaining. For future prs, make sure your agent doesn't add that many comments / trim down comments or remove them if its self explanatory

// 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>,
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
}
2 changes: 2 additions & 0 deletions tokens/nft-operations/anchor/programs/mint-nft/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ use anchor_lang::prelude::*;
declare_id!("3EMcczaGi9ivdLxvvFwRbGYeEUEHpGwabXegARw4jLxa");

pub mod contexts;
pub mod errors;
pub mod state;

pub use contexts::*;

Expand Down
16 changes: 16 additions & 0 deletions tokens/nft-operations/anchor/programs/mint-nft/src/state.rs
Original file line number Diff line number Diff line change
@@ -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;
}
53 changes: 52 additions & 1 deletion tokens/nft-operations/anchor/tests/litesvm.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>, 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');

Expand All @@ -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;

Expand Down Expand Up @@ -63,6 +80,7 @@ describe('mint-nft litesvm', () => {
user: wallet.publicKey,
mint: collectionMint,
mintAuthority,
collectionAuthority,
metadata,
masterEdition,
destination,
Expand Down Expand Up @@ -130,6 +148,7 @@ describe('mint-nft litesvm', () => {
mint,
mintAuthority,
collectionMint,
collectionAuthority,
collectionMetadata,
collectionMasterEdition,
systemProgram: SystemProgram.programId,
Expand All @@ -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',
);
});
});
7 changes: 7 additions & 0 deletions tokens/nft-operations/anchor/tests/mint-nft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -58,6 +63,7 @@ describe('mint-nft', () => {
user: wallet.publicKey,
mint: collectionMint,
mintAuthority,
collectionAuthority,
metadata,
masterEdition,
destination,
Expand Down Expand Up @@ -125,6 +131,7 @@ describe('mint-nft', () => {
mint,
mintAuthority,
collectionMint,
collectionAuthority,
collectionMetadata,
collectionMasterEdition,
systemProgram: SystemProgram.programId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -49,13 +54,38 @@ 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()?;
let lamports = rent.try_minimum_balance(MINT_SIZE)?;
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,
Expand Down
28 changes: 28 additions & 0 deletions tokens/nft-operations/pinocchio/program/src/instructions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use alloc::vec::Vec;

use pinocchio::{
cpi::{invoke_signed, Signer},
error::ProgramError,
instruction::{InstructionAccount, InstructionView},
AccountView, ProgramResult,
};
Expand All @@ -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<NftOperationsError> 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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<T>` 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)];
Expand Down
Loading
Loading