Skip to content

fix(nft-meta-data-pointer): require the player's NFT token account in chop_tree and reset the tree when it reaches MAX_WOOD_PER_TREE - #5

Open
SwineCoder101 wants to merge 2 commits into
mainfrom
fix/nft-meta-data-pointer-chop-tree-mint-check
Open

fix(nft-meta-data-pointer): require the player's NFT token account in chop_tree and reset the tree when it reaches MAX_WOOD_PER_TREE#5
SwineCoder101 wants to merge 2 commits into
mainfrom
fix/nft-meta-data-pointer-chop-tree-mint-check

Conversation

@SwineCoder101

Copy link
Copy Markdown
Owner

Bug: chop_tree rewrites the wood metadata of any NFT, and the tree counter resets one chop late

chop_tree takes the NFT mint as an unchecked AccountInfo and never verifies that the player holds that NFT, although its /// CHECK comment claims the ATA is validated. Because the program-owned nft_authority PDA is the metadata update authority of every NFT minted by mint_nft, any player (or any session key) can pass another player's mint and overwrite that NFT's on-chain wood field with their own count. This is a medium-severity authorization bug, not a style issue: the on-chain game state stored in a stranger's NFT can be tampered with by anyone who has a player account. A second, low-severity off-by-one in GameData::on_tree_chopped lets the shared tree counter reach MAX_WOOD_PER_TREE and only reset on the following chop.

Affected

  • anchor/ (program extension_nft)
    • anchor/programs/extension_nft/src/instructions/chop_tree.rs
    • anchor/programs/extension_nft/src/state/game_data.rs
    • anchor/programs/extension_nft/src/errors.rs (new error code)
    • anchor/programs/extension_nft/src/instructions/mint_nft.rs (AccountInfo -> UncheckedAccount, needed for clippy -D warnings)
    • app/idl/extension_nft.json, app/components/ChopTreeButton.tsx (client passes the new account)
    • anchor/tests/lumberjack.ts

Functionality

mint_nft creates a Token-2022 NFT whose mint carries the metadata-pointer + token-metadata extensions, sets the program PDA nft_authority as metadata update authority, and mints the single token to the caller's associated token account. chop_tree spends one energy, adds one wood to the caller's PlayerData, adds one to the shared GameData.total_wood_collected (which is supposed to reset to 0 once a tree of MAX_WOOD_PER_TREE wood has been fully chopped), and mirrors the player's wood count into the wood field of the metadata of the NFT passed as mint, signing with nft_authority.

The bug

  1. anchor/programs/extension_nft/src/instructions/chop_tree.rs:87-89 (before the fix):

    /// CHECK: Make sure the ata to the mint is actually owned by the signer
    #[account(mut)]
    pub mint: AccountInfo<'info>,

    No ATA is in the context and nothing ties mint to player; the comment describes a check that does not exist. chop_tree.rs:35-48 then calls update_field(wood = player.wood) on whatever mint was passed, signed by nft_authority, which is the update authority of every NFT the program has ever minted (mint_nft.rs:98-112). Scenario: player A mints an NFT and chops twice (wood = "2"). Player B (own PlayerData, no relation to A's NFT) calls chop_tree with A's mint. The transaction succeeds and A's NFT metadata now reads wood = "1". B can equally do this through a session key.

  2. anchor/programs/extension_nft/src/state/game_data.rs:14 (before the fix) compares the old value:

    if self.total_wood_collected >= MAX_WOOD_PER_TREE {

    With total_wood_collected = MAX_WOOD_PER_TREE - 1 a chop stores MAX_WOOD_PER_TREE instead of resetting; the "New Tree coming up" reset only happens on the next chop, so every tree yields MAX_WOOD_PER_TREE + 1 wood.

Reproduce

cd tokens/token-2022/nft-meta-data-pointer/anchor
pnpm install --frozen-lockfile
anchor build --ignore-keys
anchor test --validator legacy      # runs cargo test + tests/lumberjack.ts

(If port 8899 is busy, start solana-test-validator --reset --rpc-port 9099 ... --bpf-program H31ofLpWqeAzF2Pg54HSPQGYifJad843tTJg8vCYVoh3 target/deploy/extension_nft.so and run ANCHOR_PROVIDER_URL=http://127.0.0.1:9099 ANCHOR_WALLET=~/.config/solana/id.json pnpm mocha --import=tsx -t 1000000 'tests/**/*.ts'.)

Tests: Chop tree with another player's NFT is rejected (tests/lumberjack.ts) and state::game_data::tests::resets_when_total_reaches_max (Rust unit test). Against the unmodified program:

    ✔ Mint nft! (602ms)
    ✔ Init player (540ms)
    ✔ Chop tree with own NFT updates the wood metadata field (1065ms)
    1) Chop tree with another player's NFT is rejected

  3 passing (4s)
  1 failing

  1) extension_nft
       Chop tree with another player's NFT is rejected:

      wood metadata of the foreign NFT was overwritten
      + expected - actual

      -1
      +2
test state::game_data::tests::resets_when_total_reaches_max ... FAILED
---- state::game_data::tests::resets_when_total_reaches_max stdout ----
Total wood chopped: 100000
assertion `left == right` failed
  left: 100000
 right: 0

Fix

  • chop_tree.rs: mint is now InterfaceAccount<'info, Mint> (must be a real token mint) and the context gains player_token_account: InterfaceAccount<'info, TokenAccount> constrained with associated_token::mint = mint, associated_token::authority = player.authority, associated_token::token_program = token_program and amount == 1 (new error GameErrorCode::NftNotOwned). The ATA is keyed on player.authority, not signer, so the session-key flow keeps working while a session key can only update the NFT of the player it was issued for. A foreign mint now fails at account validation (AccountNotInitialized when the ATA does not exist, NftNotOwned when it exists with balance 0) before any metadata is written.
  • game_data.rs: compare the new total (v >= MAX_WOOD_PER_TREE) so the counter resets on the chop that completes the tree. The reset semantics were ambiguous in the README, so "reset when the new total reaches MAX_WOOD_PER_TREE" was chosen; the tree then yields exactly MAX_WOOD_PER_TREE wood.
  • mint_nft.rs: token_account typed as UncheckedAccount instead of the deprecated AccountInfo, so cargo clippy -- -D warnings passes on the crate.
  • Client: the regenerated IDL is copied to app/idl/extension_nft.json and ChopTreeButton.tsx passes the player's Token-2022 ATA (findAssociatedTokenPda) as playerTokenAccount in both the session-key and main-wallet paths. The Unity client (unity/) is generated from the IDL and already did not pass mint; it was not updated.

Verification

    ✔ Mint nft! (117ms)
    ✔ Init player (530ms)
    ✔ Chop tree with own NFT updates the wood metadata field (1055ms)
chop_tree rejected: AnchorError caused by account: player_token_account. Error Code: AccountNotInitialized. Error Number: 3012. Error Message: The program expected this account to be already initialized.
    ✔ Chop tree with another player's NFT is rejected (1053ms)

  4 passing (3s)
$ cargo test -p extension_nft --lib
test state::game_data::tests::resets_when_total_reaches_max ... ok
test state::game_data::tests::keeps_counting_below_max ... ok
test test_id ... ok
test result: ok. 3 passed; 0 failed

$ cargo clippy -p extension_nft -- -D warnings   # clean
$ pnpm exec tsc --noEmit -p tsconfig.json         # anchor/, clean
$ pnpm typecheck                                  # app/, regenerates the Codama client, clean

… mint and the tree counter resets one chop late
… chop_tree and reset the tree when it reaches MAX_WOOD_PER_TREE
@SwineCoder101
SwineCoder101 force-pushed the fix/nft-meta-data-pointer-chop-tree-mint-check branch from d2b7511 to 1351b32 Compare August 27, 2026 12:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant