diff --git a/.github/.ghaignore b/.github/.ghaignore index f282d982..e69de29b 100644 --- a/.github/.ghaignore +++ b/.github/.ghaignore @@ -1,2 +0,0 @@ -# dependency issues -tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor diff --git a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/Anchor.toml b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/Anchor.toml index f36d5d2c..379bee00 100644 --- a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/Anchor.toml +++ b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/Anchor.toml @@ -13,4 +13,4 @@ cluster = "localnet" wallet = "~/.config/solana/id.json" [scripts] -test = "pnpm ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts" +test = "cargo test" diff --git a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/package.json b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/package.json new file mode 100644 index 00000000..7ba4cb9d --- /dev/null +++ b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/package.json @@ -0,0 +1,12 @@ +{ + "name": "extension-nft-anchor", + "version": "1.0.0", + "description": "Anchor 'chop tree' game minting Token-2022 NFTs with the metadata-pointer extension. Tested with a Rust LiteSVM integration test (anchor test -> cargo test).", + "private": true, + "license": "MIT", + "scripts": { + "build": "anchor build", + "test": "anchor test", + "build-and-test": "anchor build && anchor test" + } +} diff --git a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/pnpm-lock.yaml b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/pnpm-lock.yaml new file mode 100644 index 00000000..9b60ae17 --- /dev/null +++ b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/pnpm-lock.yaml @@ -0,0 +1,9 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} diff --git a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/Cargo.toml b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/Cargo.toml index 22da393f..abd18765 100644 --- a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/Cargo.toml +++ b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/Cargo.toml @@ -23,12 +23,27 @@ custom-panic = [] [dependencies] anchor-lang = { version = "1.0.0", features = ["init-if-needed"] } anchor-spl = { version = "1.0.0" } -# session-keys pinned to 2.0.3 — check compatibility with Anchor 1.0/Solana 3.x -session-keys = { version = "2.0.3", features = ["no-entrypoint"] } -# Removed solana-program pin (=2.1.15) — Anchor 1.0 requires Solana 3.x deps -spl-token-2022 = { version="6", features = [ "no-entrypoint" ] } -spl-token = { version = "4.0.1", features = [ "no-entrypoint" ] } -spl-token-metadata-interface = "0.7.0" +# session-keys 3.1.1 is the first release that supports Anchor >=0.28,<2.0 +# (so it builds against Anchor 1.0). Earlier 2.x releases pin Anchor <=0.30 +# and fail to compile against the Anchor 1.0 / Solana 3.x API. Provides the +# gasless session-token lesson via `#[session_auth_or]` / `SessionToken`. +session-keys = { version = "3.1.1", features = ["no-entrypoint"] } +# Token-2022 + token-metadata access goes through anchor-spl's bundled +# re-exports (`anchor_spl::token_interface::spl_token_2022`, which is +# `spl-token-2022-interface`, and `anchor_spl::token_2022_extensions:: +# spl_token_metadata_interface`). Pinning standalone `spl-token-2022` / +# `spl-token-metadata-interface` here pulls a second copy on a different +# `solana-pubkey` major than anchor-lang/anchor-spl use, which breaks the +# CPI builders with `Pubkey` type mismatches. Relying on anchor-spl's +# re-exports keeps a single, consistent type universe. + +[dev-dependencies] +litesvm = "0.11.0" +solana-keypair = "3.0.1" +solana-signer = "3.0.0" +solana-instruction = "3.0.0" +solana-pubkey = "3.0.0" +solana-kite = "0.3.0" [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/errors.rs b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/errors.rs index e4921a22..e855d93e 100644 --- a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/errors.rs +++ b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/errors.rs @@ -1,15 +1,13 @@ use anchor_lang::error_code; +// Anchor's IDL build allows only a single `#[error_code]` enum per program, so +// the game and program-level errors live in one enum. #[error_code] pub enum GameErrorCode { #[msg("Not enough energy")] NotEnoughEnergy, #[msg("Wrong Authority")] WrongAuthority, -} - -#[error_code] -pub enum ProgramErrorCode { #[msg("Invalid Mint account space")] InvalidMintAccountSpace, #[msg("Cant initialize metadata_pointer")] diff --git a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/instructions/chop_tree.rs b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/instructions/chop_tree.rs index 2120f3a7..e32ab30a 100644 --- a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/instructions/chop_tree.rs +++ b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/instructions/chop_tree.rs @@ -2,9 +2,10 @@ pub use crate::errors::GameErrorCode; pub use crate::state::game_data::GameData; use crate::{state::player_data::PlayerData, NftAuthority}; use anchor_lang::prelude::*; -use anchor_spl::token_interface::{Token2022}; +use anchor_lang::solana_program::program::invoke_signed; +use anchor_spl::token_2022_extensions::spl_token_metadata_interface; +use anchor_spl::token_interface::{spl_token_2022, Token2022}; use session_keys::{Session, SessionToken}; -use solana_program::program::invoke_signed; pub fn chop_tree(context: Context, counter: u16, amount: u64) -> Result<()> { // Save game_data bump on first creation (init_if_needed). See init_player.rs @@ -92,7 +93,7 @@ pub struct ChopTree<'info> { pub system_program: Program<'info, System>, /// CHECK: Make sure the ata to the mint is actually owned by the signer #[account(mut)] - pub mint: AccountInfo<'info>, + pub mint: UncheckedAccount<'info>, #[account( init_if_needed, seeds = [b"nft_authority".as_ref()], diff --git a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/instructions/mint_nft.rs b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/instructions/mint_nft.rs index d24bddea..980e6ad7 100644 --- a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/instructions/mint_nft.rs +++ b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/instructions/mint_nft.rs @@ -1,27 +1,28 @@ pub use crate::constants::TOKEN_METADATA_EXTENSION_SPACE; pub use crate::errors::GameErrorCode; -pub use crate::errors::ProgramErrorCode; pub use crate::state::game_data::GameData; -use anchor_lang::{ prelude::*, system_program }; +use anchor_lang::solana_program::program::{invoke, invoke_signed}; +use anchor_lang::{prelude::*, system_program}; use anchor_spl::{ - associated_token::{ self, AssociatedToken }, + associated_token::{self, AssociatedToken}, token_2022, - token_interface::{ spl_token_2022::instruction::AuthorityType, Token2022 }, + token_2022_extensions::spl_token_metadata_interface, + token_interface::{ + spl_token_2022::{self, extension::ExtensionType, instruction::AuthorityType, state::Mint}, + Token2022, + }, }; -use solana_program::program::{ invoke, invoke_signed }; -use spl_token_2022::{ extension::ExtensionType, state::Mint }; pub fn handle_mint_nft(context: Context) -> Result<()> { msg!("Mint nft with meta data extension and additional meta data"); - let space = match - ExtensionType::try_calculate_account_len::(&[ExtensionType::MetadataPointer]) - { - Ok(space) => space, - Err(_) => { - return err!(ProgramErrorCode::InvalidMintAccountSpace); - } - }; + let space = + match ExtensionType::try_calculate_account_len::(&[ExtensionType::MetadataPointer]) { + Ok(space) => space, + Err(_) => { + return err!(GameErrorCode::InvalidMintAccountSpace); + } + }; // Space required for the inline SPL Token Metadata extension TLV. The // metadata lives on the mint account itself (not a separate account) @@ -42,39 +43,44 @@ pub fn handle_mint_nft(context: Context) -> Result<()> { system_program::CreateAccount { from: context.accounts.signer.to_account_info(), to: context.accounts.mint.to_account_info(), - } + }, ), lamports_required, space as u64, - &context.accounts.token_program.key() + &context.accounts.token_program.key(), )?; // Assign the mint to the token program system_program::assign( - CpiContext::new(context.accounts.token_program.key(), system_program::Assign { - account_to_assign: context.accounts.mint.to_account_info(), - }), - &token_2022::ID + CpiContext::new( + context.accounts.token_program.key(), + system_program::Assign { + account_to_assign: context.accounts.mint.to_account_info(), + }, + ), + &token_2022::ID, )?; // Initialize the metadata pointer (Need to do this before initializing the mint) - let init_meta_data_pointer_ix = match - spl_token_2022::extension::metadata_pointer::instruction::initialize( + let init_meta_data_pointer_ix = + match spl_token_2022::extension::metadata_pointer::instruction::initialize( &Token2022::id(), &context.accounts.mint.key(), Some(context.accounts.nft_authority.key()), - Some(context.accounts.mint.key()) - ) - { - Ok(ix) => ix, - Err(_) => { - return err!(ProgramErrorCode::CantInitializeMetadataPointer); - } - }; + Some(context.accounts.mint.key()), + ) { + Ok(ix) => ix, + Err(_) => { + return err!(GameErrorCode::CantInitializeMetadataPointer); + } + }; invoke( &init_meta_data_pointer_ix, - &[context.accounts.mint.to_account_info(), context.accounts.nft_authority.to_account_info()] + &[ + context.accounts.mint.to_account_info(), + context.accounts.nft_authority.to_account_info(), + ], )?; // Initialize the mint cpi @@ -82,10 +88,11 @@ pub fn handle_mint_nft(context: Context) -> Result<()> { context.accounts.token_program.key(), token_2022::InitializeMint2 { mint: context.accounts.mint.to_account_info(), - } + }, ); - token_2022::initialize_mint2(mint_cpi_ix, 0, &context.accounts.nft_authority.key(), None).unwrap(); + token_2022::initialize_mint2(mint_cpi_ix, 0, &context.accounts.nft_authority.key(), None) + .unwrap(); // We use a PDA as a mint authority for the metadata account because // we want to be able to update the NFT from the program. @@ -93,7 +100,10 @@ pub fn handle_mint_nft(context: Context) -> Result<()> { let bump = context.bumps.nft_authority; let signer: &[&[&[u8]]] = &[&[seeds, &[bump]]]; - msg!("Init metadata {0}", context.accounts.nft_authority.to_account_info().key); + msg!( + "Init metadata {0}", + context.accounts.nft_authority.to_account_info().key + ); // Init the metadata account let init_token_meta_data_ix = &spl_token_metadata_interface::instruction::initialize( @@ -104,7 +114,7 @@ pub fn handle_mint_nft(context: Context) -> Result<()> { context.accounts.nft_authority.to_account_info().key, "Beaver".to_string(), "BVA".to_string(), - "https://arweave.net/MHK3Iopy0GgvDoM7LkkiAdg7pQqExuuWvedApCnzfj0".to_string() + "https://arweave.net/MHK3Iopy0GgvDoM7LkkiAdg7pQqExuuWvedApCnzfj0".to_string(), ); invoke_signed( @@ -113,7 +123,7 @@ pub fn handle_mint_nft(context: Context) -> Result<()> { context.accounts.mint.to_account_info().clone(), context.accounts.nft_authority.to_account_info().clone(), ], - signer + signer, )?; // Update the metadata account with an additional metadata field in this case the player level @@ -123,29 +133,27 @@ pub fn handle_mint_nft(context: Context) -> Result<()> { context.accounts.mint.key, context.accounts.nft_authority.to_account_info().key, spl_token_metadata_interface::state::Field::Key("level".to_string()), - "1".to_string() + "1".to_string(), ), &[ context.accounts.mint.to_account_info().clone(), context.accounts.nft_authority.to_account_info().clone(), ], - signer + signer, )?; // Create the associated token account - associated_token::create( - CpiContext::new( - context.accounts.associated_token_program.key(), - associated_token::Create { - payer: context.accounts.signer.to_account_info(), - associated_token: context.accounts.token_account.to_account_info(), - authority: context.accounts.signer.to_account_info(), - mint: context.accounts.mint.to_account_info(), - system_program: context.accounts.system_program.to_account_info(), - token_program: context.accounts.token_program.to_account_info(), - } - ) - )?; + associated_token::create(CpiContext::new( + context.accounts.associated_token_program.key(), + associated_token::Create { + payer: context.accounts.signer.to_account_info(), + associated_token: context.accounts.token_account.to_account_info(), + authority: context.accounts.signer.to_account_info(), + mint: context.accounts.mint.to_account_info(), + system_program: context.accounts.system_program.to_account_info(), + token_program: context.accounts.token_program.to_account_info(), + }, + ))?; // Mint one token to the associated token account of the player token_2022::mint_to( @@ -156,9 +164,9 @@ pub fn handle_mint_nft(context: Context) -> Result<()> { to: context.accounts.token_account.to_account_info(), authority: context.accounts.nft_authority.to_account_info(), }, - signer + signer, ), - 1 + 1, )?; // Freeze the mint authority so no more tokens can be minted to make it an NFT @@ -169,10 +177,10 @@ pub fn handle_mint_nft(context: Context) -> Result<()> { current_authority: context.accounts.nft_authority.to_account_info(), account_or_mint: context.accounts.mint.to_account_info(), }, - signer + signer, ), AuthorityType::MintTokens, - None + None, )?; Ok(()) @@ -186,7 +194,7 @@ pub struct MintNft<'info> { pub token_program: Program<'info, Token2022>, /// CHECK: We will create this one for the user #[account(mut)] - pub token_account: AccountInfo<'info>, + pub token_account: UncheckedAccount<'info>, #[account(mut)] pub mint: Signer<'info>, pub rent: Sysvar<'info, Rent>, diff --git a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/lib.rs b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/lib.rs index 6515214f..02ea5493 100644 --- a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/lib.rs +++ b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/lib.rs @@ -1,3 +1,7 @@ +// The Anchor `#[program]` macro expands to code that clippy flags as a +// diverging sub-expression; this allow is the accepted workaround in this repo. +#![allow(clippy::diverging_sub_expression)] + pub use crate::errors::GameErrorCode; pub use anchor_lang::prelude::*; pub use session_keys::{session_auth_or, Session, SessionError}; @@ -28,12 +32,15 @@ pub mod extension_nft { // lets the player either use their session token or their main wallet. (The counter is only // there so that the player can do multiple transactions in the same block. Without it multiple transactions // in the same block would result in the same signature and therefore fail.) + // NOTE: the `#[session_auth_or]` macro injects code that refers to the + // context binding by the literal name `ctx`, so this handler's context + // parameter must be named `ctx` (not `context`) for the macro to expand. #[session_auth_or( - context.accounts.player.authority.key() == context.accounts.signer.key(), + ctx.accounts.player.authority.key() == ctx.accounts.signer.key(), GameErrorCode::WrongAuthority )] - pub fn chop_tree(context: Context, _level_seed: String, counter: u16) -> Result<()> { - chop_tree::chop_tree(context, counter, 1) + pub fn chop_tree(ctx: Context, _level_seed: String, counter: u16) -> Result<()> { + chop_tree::chop_tree(ctx, counter, 1) } pub fn mint_nft(context: Context) -> Result<()> { diff --git a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/tests/test_extension_nft.rs b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/tests/test_extension_nft.rs new file mode 100644 index 00000000..89291327 --- /dev/null +++ b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/tests/test_extension_nft.rs @@ -0,0 +1,225 @@ +//! LiteSVM integration test for the `extension_nft` "chop tree" game program. +//! +//! It drives the full happy path against an in-memory validator: +//! 1. `init_player` — create the player + game-data PDAs. +//! 2. `mint_nft` — mint a Token-2022 NFT that carries its metadata inline via +//! the metadata-pointer + token-metadata extensions. +//! 3. `chop_tree` — gain wood/lose energy and push the new wood total into the +//! NFT metadata as an additional field. +//! +//! The session-keys lesson (`#[session_auth_or]`) is exercised through its +//! *fallback* branch: `chop_tree` is signed directly by the player's main +//! wallet with `session_token = None`, so the macro checks +//! `player.authority == signer`. This keeps the test self-contained — it does +//! not need the on-chain session-keys program as a fixture, because the program +//! never CPIs into it (the session token is only ever read as an account). +//! +//! IMPORTANT: CI runs `anchor keys sync` before building, which rewrites the +//! program's `declare_id!`. We therefore reference the id via `extension_nft::ID` +//! (the crate constant) rather than a hardcoded literal, so the test keeps +//! working after the id is regenerated. + +use { + anchor_lang::{ + prelude::Pubkey, solana_program::system_program, InstructionData, ToAccountMetas, + }, + litesvm::LiteSVM, + solana_instruction::Instruction, + solana_keypair::Keypair, + solana_kite::{create_wallet, get_pda_and_bump, send_transaction_from_instructions, Seed}, + solana_signer::Signer, +}; + +// Token-2022 and Associated-Token-Account program ids (the modern, fixed +// on-chain addresses bundled by LiteSVM). +const TOKEN_2022_ID: Pubkey = Pubkey::from_str_const("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"); +const ASSOCIATED_TOKEN_ID: Pubkey = + Pubkey::from_str_const("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"); +const RENT_SYSVAR_ID: Pubkey = + Pubkey::from_str_const("SysvarRent111111111111111111111111111111111"); + +const LEVEL_SEED: &str = "level1"; + +fn setup() -> (LiteSVM, Pubkey) { + let program_id = extension_nft::ID; + let mut svm = LiteSVM::new(); + let bytes = include_bytes!("../../../target/deploy/extension_nft.so"); + svm.add_program(program_id, bytes).unwrap(); + (svm, program_id) +} + +/// Derive the player PDA: seeds = [b"player", authority]. +fn player_pda(program_id: &Pubkey, authority: &Pubkey) -> Pubkey { + get_pda_and_bump( + &[Seed::from(b"player".as_ref()), Seed::from(*authority)], + program_id, + ) + .0 +} + +/// Derive the game-data PDA: seeds = [level_seed]. +fn game_data_pda(program_id: &Pubkey, level_seed: &str) -> Pubkey { + get_pda_and_bump(&[Seed::from(level_seed)], program_id).0 +} + +/// Derive the NFT-authority PDA: seeds = [b"nft_authority"]. +fn nft_authority_pda(program_id: &Pubkey) -> Pubkey { + get_pda_and_bump(&[Seed::from(b"nft_authority".as_ref())], program_id).0 +} + +/// Derive the associated token account for (wallet, mint) under Token-2022. +fn associated_token_address(wallet: &Pubkey, mint: &Pubkey) -> Pubkey { + Pubkey::find_program_address( + &[wallet.as_ref(), TOKEN_2022_ID.as_ref(), mint.as_ref()], + &ASSOCIATED_TOKEN_ID, + ) + .0 +} + +fn init_player_ix(program_id: &Pubkey, signer: &Pubkey) -> Instruction { + Instruction { + program_id: *program_id, + accounts: extension_nft::accounts::InitPlayer { + player: player_pda(program_id, signer), + game_data: game_data_pda(program_id, LEVEL_SEED), + signer: *signer, + system_program: system_program::id(), + } + .to_account_metas(None), + data: extension_nft::instruction::InitPlayer { + _level_seed: LEVEL_SEED.to_string(), + } + .data(), + } +} + +fn mint_nft_ix(program_id: &Pubkey, signer: &Pubkey, mint: &Pubkey) -> Instruction { + Instruction { + program_id: *program_id, + accounts: extension_nft::accounts::MintNft { + signer: *signer, + system_program: system_program::id(), + token_program: TOKEN_2022_ID, + token_account: associated_token_address(signer, mint), + mint: *mint, + rent: RENT_SYSVAR_ID, + associated_token_program: ASSOCIATED_TOKEN_ID, + nft_authority: nft_authority_pda(program_id), + } + .to_account_metas(None), + data: extension_nft::instruction::MintNft {}.data(), + } +} + +fn chop_tree_ix(program_id: &Pubkey, signer: &Pubkey, mint: &Pubkey, counter: u16) -> Instruction { + Instruction { + program_id: *program_id, + accounts: extension_nft::accounts::ChopTree { + // session_token is optional; pass None -> the macro falls back to + // the main-wallet authority check. + session_token: None, + player: player_pda(program_id, signer), + game_data: game_data_pda(program_id, LEVEL_SEED), + signer: *signer, + system_program: system_program::id(), + mint: *mint, + nft_authority: nft_authority_pda(program_id), + token_program: TOKEN_2022_ID, + } + .to_account_metas(None), + data: extension_nft::instruction::ChopTree { + _level_seed: LEVEL_SEED.to_string(), + counter, + } + .data(), + } +} + +/// Decode the borsh `PlayerData` account (after the 8-byte discriminator). +struct Player { + wood: u64, + energy: u64, +} + +fn fetch_player(svm: &LiteSVM, player: &Pubkey) -> Player { + use anchor_lang::AnchorDeserialize; + let account = svm.get_account(player).expect("player account exists"); + // Skip the 8-byte Anchor discriminator. + let mut data = &account.data[8..]; + // PlayerData layout: authority(32) name(4+len) level(1) xp(8) wood(8) + // energy(8) last_login(8) last_id(2) bump(1). + let _authority = <[u8; 32]>::deserialize(&mut data).unwrap(); + let _name = String::deserialize(&mut data).unwrap(); + let _level = u8::deserialize(&mut data).unwrap(); + let _xp = u64::deserialize(&mut data).unwrap(); + let wood = u64::deserialize(&mut data).unwrap(); + let energy = u64::deserialize(&mut data).unwrap(); + Player { wood, energy } +} + +#[test] +fn test_init_player_mint_and_chop() { + let (mut svm, program_id) = setup(); + let payer = create_wallet(&mut svm, 100_000_000_000).unwrap(); + let signer = payer.pubkey(); + + // 1. init_player + send_transaction_from_instructions( + &mut svm, + vec![init_player_ix(&program_id, &signer)], + &[&payer], + &signer, + ) + .expect("init_player should succeed"); + + let player_addr = player_pda(&program_id, &signer); + let player = fetch_player(&svm, &player_addr); + assert_eq!(player.wood, 0, "fresh player starts with no wood"); + assert_eq!( + player.energy, 100, + "fresh player starts at max energy (100)" + ); + + // 2. mint_nft — the mint account is a fresh keypair (it's a Signer in the + // instruction because the program creates it via a system CPI). + let mint = Keypair::new(); + send_transaction_from_instructions( + &mut svm, + vec![mint_nft_ix(&program_id, &signer, &mint.pubkey())], + &[&payer, &mint], + &signer, + ) + .expect("mint_nft should succeed"); + + // The mint account is now owned by the Token-2022 program and holds the + // inline metadata extension, so it is comfortably larger than a bare mint. + let mint_account = svm.get_account(&mint.pubkey()).expect("mint exists"); + assert_eq!( + mint_account.owner, TOKEN_2022_ID, + "mint owned by Token-2022" + ); + assert!( + mint_account.data.len() > 82, + "mint carries extension data (got {} bytes)", + mint_account.data.len() + ); + + // The associated token account should exist and hold the single NFT. + let ata = associated_token_address(&signer, &mint.pubkey()); + let ata_account = svm.get_account(&ata).expect("ATA created"); + assert_eq!(ata_account.owner, TOKEN_2022_ID, "ATA owned by Token-2022"); + + // 3. chop_tree — needs the existing mint so it can push the new wood total + // into the NFT metadata. Signed by the player's main wallet (no session). + send_transaction_from_instructions( + &mut svm, + vec![chop_tree_ix(&program_id, &signer, &mint.pubkey(), 1)], + &[&payer], + &signer, + ) + .expect("chop_tree should succeed"); + + let player = fetch_player(&svm, &player_addr); + assert_eq!(player.wood, 1, "player gained 1 wood from chopping"); + assert_eq!(player.energy, 99, "player spent 1 energy chopping"); +}