From 7c7f7a3f7104e3cf3cc57fdb90259982e0c82a4d Mon Sep 17 00:00:00 2001 From: Jose Ferrer Date: Mon, 10 Aug 2026 10:10:23 +0700 Subject: [PATCH 1/3] fix(tokens/token-fundraiser): bind check_contributions to the recorded mint contribute.rs and refund.rs both constrain the fundraiser account with has_one = mint_to_raise, so the mint passed in the transaction must equal the one recorded at initialize. checker.rs omits that constraint: its mint_to_raise, and the vault derived from it, are whatever the caller supplies. The goal check then runs against an unrelated token, and the close = maker on the same account destroys the campaign state that every contributor's refund depends on. Add the same has_one the two sibling instructions already carry, and a regression test that funds a vault for a substitute mint to the goal and asserts check_contributions rejects it and leaves the campaign intact. --- .../fundraiser/src/instructions/checker.rs | 1 + .../anchor/tests/checker-mint-binding.test.ts | 148 ++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 tokens/token-fundraiser/anchor/tests/checker-mint-binding.test.ts diff --git a/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/checker.rs b/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/checker.rs index 4299ff73d..b1b3a1da6 100644 --- a/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/checker.rs +++ b/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/checker.rs @@ -24,6 +24,7 @@ pub struct CheckContributions<'info> { mut, seeds = [b"fundraiser".as_ref(), maker.key().as_ref()], bump = fundraiser.bump, + has_one = mint_to_raise, close = maker, )] pub fundraiser: Account<'info, Fundraiser>, diff --git a/tokens/token-fundraiser/anchor/tests/checker-mint-binding.test.ts b/tokens/token-fundraiser/anchor/tests/checker-mint-binding.test.ts new file mode 100644 index 000000000..0c57d9536 --- /dev/null +++ b/tokens/token-fundraiser/anchor/tests/checker-mint-binding.test.ts @@ -0,0 +1,148 @@ +import * as anchor from '@anchor-lang/core'; +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + createAssociatedTokenAccountInstruction, + createInitializeMint2Instruction, + createMintToInstruction, + getAssociatedTokenAddressSync, + MINT_SIZE, + TOKEN_PROGRAM_ID, +} from '@solana/spl-token'; +import { PublicKey } from '@solana/web3.js'; +import { LiteSVMProvider } from 'anchor-litesvm'; +import { assert } from 'chai'; +import { LiteSVM } from 'litesvm'; +import IDL from '../target/idl/fundraiser.json'; +import type { Fundraiser } from '../target/types/fundraiser'; + +const PROGRAM_ID = new PublicKey(IDL.address); + +// Regression test for the missing mint binding on `checker.rs`. +// +// `contribute` and `refund` both carry `has_one = mint_to_raise` on the +// `fundraiser` account, so the mint supplied in the transaction must equal the +// one recorded at `initialize`. `check_contributions` omits that constraint, so +// its `mint_to_raise` (and the vault derived from it) is whatever the caller +// passes. A maker can therefore satisfy the goal check against a throwaway mint +// they control and trigger `close = maker`, destroying the real campaign that +// every contributor's refund depends on. +// +// With the constraint present, Anchor rejects the wrong mint before the handler +// runs and the campaign account survives. +describe('fundraiser checker mint binding', () => { + const client = new LiteSVM(); + client.addProgramFromFile(PROGRAM_ID, 'target/deploy/fundraiser.so'); + const provider = new LiteSVMProvider(client); + anchor.setProvider(provider); + const wallet = provider.wallet as anchor.Wallet; + const program = new anchor.Program(IDL, provider); + + const maker = anchor.web3.Keypair.generate(); + + const fundraiser = anchor.web3.PublicKey.findProgramAddressSync( + [Buffer.from('fundraiser'), maker.publicKey.toBuffer()], + program.programId, + )[0]; + + const AMOUNT_TO_RAISE = 3_000_000; // 3 tokens at 6 decimals + + let realMint: PublicKey; + let fakeMint: PublicKey; + + it('sets up a real campaign and a maker-controlled fake mint', async () => { + client.airdrop(maker.publicKey, BigInt(anchor.web3.LAMPORTS_PER_SOL)); + + // The real campaign mint, authority held by the provider wallet. + const realMintKp = anchor.web3.Keypair.generate(); + realMint = realMintKp.publicKey; + // The fake mint, authority held by the maker — the whole point of the attack. + const fakeMintKp = anchor.web3.Keypair.generate(); + fakeMint = fakeMintKp.publicKey; + + const lamports = await provider.connection.getMinimumBalanceForRentExemption(MINT_SIZE); + const setupTx = new anchor.web3.Transaction().add( + anchor.web3.SystemProgram.createAccount({ + fromPubkey: wallet.publicKey, + newAccountPubkey: realMint, + space: MINT_SIZE, + lamports, + programId: TOKEN_PROGRAM_ID, + }), + createInitializeMint2Instruction(realMint, 6, provider.publicKey, provider.publicKey), + anchor.web3.SystemProgram.createAccount({ + fromPubkey: wallet.publicKey, + newAccountPubkey: fakeMint, + space: MINT_SIZE, + lamports, + programId: TOKEN_PROGRAM_ID, + }), + createInitializeMint2Instruction(fakeMint, 6, maker.publicKey, maker.publicKey), + ); + await provider.sendAndConfirm(setupTx, [realMintKp, fakeMintKp]); + }); + + it('initializes the campaign against the real mint', async () => { + const vault = getAssociatedTokenAddressSync(realMint, fundraiser, true); + + await program.methods + .initialize(new anchor.BN(AMOUNT_TO_RAISE), 0) + .accountsPartial({ + maker: maker.publicKey, + fundraiser, + mintToRaise: realMint, + vault, + systemProgram: anchor.web3.SystemProgram.programId, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + }) + .signers([maker]) + .rpc(); + + // The campaign state exists and remembers the real mint. + const state = await program.account.fundraiser.fetch(fundraiser); + assert.strictEqual(state.mintToRaise.toBase58(), realMint.toBase58()); + }); + + it('rejects check_contributions against a mint other than the one recorded', async () => { + // The maker funds the fundraiser's ATA *for the fake mint* to the goal. + // Anyone may create an ATA on the PDA's behalf. + const fakeVault = getAssociatedTokenAddressSync(fakeMint, fundraiser, true); + const makerFakeAta = getAssociatedTokenAddressSync(fakeMint, maker.publicKey); + + const fundTx = new anchor.web3.Transaction().add( + createAssociatedTokenAccountInstruction(maker.publicKey, fakeVault, fundraiser, fakeMint), + createAssociatedTokenAccountInstruction(maker.publicKey, makerFakeAta, maker.publicKey, fakeMint), + createMintToInstruction(fakeMint, fakeVault, maker.publicKey, AMOUNT_TO_RAISE), + ); + await provider.sendAndConfirm(fundTx, [maker]); + + // Call the payout instruction with the fake mint and its funded vault. + let rejected = false; + try { + await program.methods + .checkContributions() + .accountsPartial({ + maker: maker.publicKey, + mintToRaise: fakeMint, + fundraiser, + makerAta: makerFakeAta, + vault: fakeVault, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .signers([maker]) + .rpc(); + } catch (_err) { + rejected = true; + } + + assert.isTrue( + rejected, + 'check_contributions accepted a mint other than the one recorded at initialize', + ); + + // The real campaign must still be alive — a wrong-mint call must not + // reach `close = maker`. + const state = await program.account.fundraiser.fetch(fundraiser); + assert.strictEqual(state.mintToRaise.toBase58(), realMint.toBase58()); + }); +}); From 54fd7d6842f5b089a34dd58c7566c72ad6506f9d Mon Sep 17 00:00:00 2001 From: Jose Ferrer Date: Mon, 10 Aug 2026 17:38:17 +0700 Subject: [PATCH 2/3] style: apply prettier to checker-mint-binding test --- .../anchor/tests/checker-mint-binding.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tokens/token-fundraiser/anchor/tests/checker-mint-binding.test.ts b/tokens/token-fundraiser/anchor/tests/checker-mint-binding.test.ts index 0c57d9536..eb2b34b5a 100644 --- a/tokens/token-fundraiser/anchor/tests/checker-mint-binding.test.ts +++ b/tokens/token-fundraiser/anchor/tests/checker-mint-binding.test.ts @@ -135,10 +135,7 @@ describe('fundraiser checker mint binding', () => { rejected = true; } - assert.isTrue( - rejected, - 'check_contributions accepted a mint other than the one recorded at initialize', - ); + assert.isTrue(rejected, 'check_contributions accepted a mint other than the one recorded at initialize'); // The real campaign must still be alive — a wrong-mint call must not // reach `close = maker`. From b0e83d62dbf2589b8bcd6d22be2d7959450c739e Mon Sep 17 00:00:00 2001 From: Jose Ferrer Date: Wed, 12 Aug 2026 17:36:23 +0700 Subject: [PATCH 3/3] test: assert the mint rejection with rejectedWith, not a bare try/catch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: a try/catch that flags any error is too vague. Follow the token-2022/transfer-hook pattern — assert the call rejects with the specific AnchorError (ConstraintHasOne) via chai-as-promised. --- tokens/token-fundraiser/anchor/package.json | 2 + tokens/token-fundraiser/anchor/pnpm-lock.yaml | 29 +++++++++ .../anchor/tests/checker-mint-binding.test.ts | 62 ++++++------------- 3 files changed, 51 insertions(+), 42 deletions(-) diff --git a/tokens/token-fundraiser/anchor/package.json b/tokens/token-fundraiser/anchor/package.json index 0c225a54c..3ce7ba3d6 100644 --- a/tokens/token-fundraiser/anchor/package.json +++ b/tokens/token-fundraiser/anchor/package.json @@ -12,10 +12,12 @@ "devDependencies": { "@types/bn.js": "^5.1.0", "@types/chai": "^5.2.3", + "@types/chai-as-promised": "^8.0.2", "@types/mocha": "^10.0.10", "@types/node": "^26.1.0", "anchor-litesvm": "^0.2.1", "chai": "^6.2.2", + "chai-as-promised": "^8.0.2", "litesvm": "^0.8.0", "mocha": "^11.7.5", "prettier": "^2.6.2", diff --git a/tokens/token-fundraiser/anchor/pnpm-lock.yaml b/tokens/token-fundraiser/anchor/pnpm-lock.yaml index 7f7ea27e8..f5d97074b 100644 --- a/tokens/token-fundraiser/anchor/pnpm-lock.yaml +++ b/tokens/token-fundraiser/anchor/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: '@types/chai': specifier: ^5.2.3 version: 5.2.3 + '@types/chai-as-promised': + specifier: ^8.0.2 + version: 8.0.2 '@types/mocha': specifier: ^10.0.10 version: 10.0.10 @@ -39,6 +42,9 @@ importers: chai: specifier: ^6.2.2 version: 6.2.2 + chai-as-promised: + specifier: ^8.0.2 + version: 8.0.2(chai@6.2.2) litesvm: specifier: ^0.8.0 version: 0.8.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) @@ -346,6 +352,9 @@ packages: '@types/bn.js@5.1.5': resolution: {integrity: sha512-V46N0zwKRF5Q00AZ6hWtN0T8gGmDUaUzLWQvHFo5yThtVwK/VCenFY3wXVbOvNfajEpsTfQM4IN9k/d6gUVX3A==} + '@types/chai-as-promised@8.0.2': + resolution: {integrity: sha512-meQ1wDr1K5KRCSvG2lX7n7/5wf70BeptTKst0axGvnN6zqaVpRqegoIbugiAPSqOW9K9aL8gDVrm7a2LXOtn2Q==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -453,6 +462,11 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} + chai-as-promised@8.0.2: + resolution: {integrity: sha512-1GadL+sEJVLzDjcawPM4kjfnL+p/9vrxiEUonowKOAzvVg0PixJUdtuDzdkDeQhK3zfOE76GqGkZIQ7/Adcrqw==} + peerDependencies: + chai: '>= 2.1.2 < 7' + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -469,6 +483,10 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -1241,6 +1259,10 @@ snapshots: dependencies: '@types/node': 26.1.2 + '@types/chai-as-promised@8.0.2': + dependencies: + '@types/chai': 5.2.3 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1350,6 +1372,11 @@ snapshots: camelcase@6.3.0: {} + chai-as-promised@8.0.2(chai@6.2.2): + dependencies: + chai: 6.2.2 + check-error: 2.1.3 + chai@6.2.2: {} chalk@4.1.2: @@ -1361,6 +1388,8 @@ snapshots: chalk@5.6.2: {} + check-error@2.1.3: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 diff --git a/tokens/token-fundraiser/anchor/tests/checker-mint-binding.test.ts b/tokens/token-fundraiser/anchor/tests/checker-mint-binding.test.ts index eb2b34b5a..1f2959563 100644 --- a/tokens/token-fundraiser/anchor/tests/checker-mint-binding.test.ts +++ b/tokens/token-fundraiser/anchor/tests/checker-mint-binding.test.ts @@ -10,25 +10,16 @@ import { } from '@solana/spl-token'; import { PublicKey } from '@solana/web3.js'; import { LiteSVMProvider } from 'anchor-litesvm'; -import { assert } from 'chai'; +import { assert, expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; import { LiteSVM } from 'litesvm'; import IDL from '../target/idl/fundraiser.json'; import type { Fundraiser } from '../target/types/fundraiser'; +use(chaiAsPromised); + const PROGRAM_ID = new PublicKey(IDL.address); -// Regression test for the missing mint binding on `checker.rs`. -// -// `contribute` and `refund` both carry `has_one = mint_to_raise` on the -// `fundraiser` account, so the mint supplied in the transaction must equal the -// one recorded at `initialize`. `check_contributions` omits that constraint, so -// its `mint_to_raise` (and the vault derived from it) is whatever the caller -// passes. A maker can therefore satisfy the goal check against a throwaway mint -// they control and trigger `close = maker`, destroying the real campaign that -// every contributor's refund depends on. -// -// With the constraint present, Anchor rejects the wrong mint before the handler -// runs and the campaign account survives. describe('fundraiser checker mint binding', () => { const client = new LiteSVM(); client.addProgramFromFile(PROGRAM_ID, 'target/deploy/fundraiser.so'); @@ -49,13 +40,11 @@ describe('fundraiser checker mint binding', () => { let realMint: PublicKey; let fakeMint: PublicKey; - it('sets up a real campaign and a maker-controlled fake mint', async () => { + it('sets up a real campaign mint and a maker-controlled fake mint', async () => { client.airdrop(maker.publicKey, BigInt(anchor.web3.LAMPORTS_PER_SOL)); - // The real campaign mint, authority held by the provider wallet. const realMintKp = anchor.web3.Keypair.generate(); realMint = realMintKp.publicKey; - // The fake mint, authority held by the maker — the whole point of the attack. const fakeMintKp = anchor.web3.Keypair.generate(); fakeMint = fakeMintKp.publicKey; @@ -98,14 +87,11 @@ describe('fundraiser checker mint binding', () => { .signers([maker]) .rpc(); - // The campaign state exists and remembers the real mint. const state = await program.account.fundraiser.fetch(fundraiser); assert.strictEqual(state.mintToRaise.toBase58(), realMint.toBase58()); }); it('rejects check_contributions against a mint other than the one recorded', async () => { - // The maker funds the fundraiser's ATA *for the fake mint* to the goal. - // Anyone may create an ATA on the PDA's behalf. const fakeVault = getAssociatedTokenAddressSync(fakeMint, fundraiser, true); const makerFakeAta = getAssociatedTokenAddressSync(fakeMint, maker.publicKey); @@ -116,29 +102,21 @@ describe('fundraiser checker mint binding', () => { ); await provider.sendAndConfirm(fundTx, [maker]); - // Call the payout instruction with the fake mint and its funded vault. - let rejected = false; - try { - await program.methods - .checkContributions() - .accountsPartial({ - maker: maker.publicKey, - mintToRaise: fakeMint, - fundraiser, - makerAta: makerFakeAta, - vault: fakeVault, - tokenProgram: TOKEN_PROGRAM_ID, - }) - .signers([maker]) - .rpc(); - } catch (_err) { - rejected = true; - } - - assert.isTrue(rejected, 'check_contributions accepted a mint other than the one recorded at initialize'); - - // The real campaign must still be alive — a wrong-mint call must not - // reach `close = maker`. + const checkPromise = program.methods + .checkContributions() + .accountsPartial({ + maker: maker.publicKey, + mintToRaise: fakeMint, + fundraiser, + makerAta: makerFakeAta, + vault: fakeVault, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .signers([maker]) + .rpc(); + + await expect(checkPromise).to.eventually.be.rejectedWith(anchor.AnchorError, 'ConstraintHasOne'); + const state = await program.account.fundraiser.fetch(fundraiser); assert.strictEqual(state.mintToRaise.toBase58(), realMint.toBase58()); });