From 3b8dd71959e2f1636ab9e94b6f9ed1bb72aa7538 Mon Sep 17 00:00:00 2001 From: boymak Date: Thu, 25 Jun 2026 11:44:33 +0300 Subject: [PATCH 1/2] Fix inverted contribute/refund time gates in token-fundraiser (anchor) The contribute and refund instructions gate on the campaign window with inverted comparisons: contributions were only accepted *after* the window had closed, and refunds were only allowed *while* the campaign was still open. The bug is masked by the tests using duration = 0, where both inverted conditions happen to evaluate correctly. - contribute now rejects once `elapsed_days >= duration` (campaign ended), so contributions are accepted while the campaign is open - refund now rejects while `elapsed_days < duration` (campaign still open), so refunds are allowed only after the campaign ends The tests now use a non-zero (open) campaign so contributions are valid, and assert that a refund is rejected while the campaign is still open. The same bug was fixed in the Pinocchio port of this example (PR #613), where the corrected behaviour was verified end-to-end on a local solana-test-validator. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fundraiser/src/instructions/contribute.rs | 5 +- .../fundraiser/src/instructions/refund.rs | 7 +-- .../anchor/tests/bankrun.test.ts | 51 ++++++++++--------- 3 files changed, 35 insertions(+), 28 deletions(-) diff --git a/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/contribute.rs b/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/contribute.rs index 1415cce52..ce8e07787 100644 --- a/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/contribute.rs +++ b/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/contribute.rs @@ -68,10 +68,11 @@ impl<'info> Contribute<'info> { FundraiserError::ContributionTooBig ); - // Check if the fundraising duration has been reached + // Contributions are only accepted while the campaign is still open, + // i.e. before `duration` days have elapsed since it started. let current_time = Clock::get()?.unix_timestamp; require!( - self.fundraiser.duration <= ((current_time - self.fundraiser.time_started) / SECONDS_TO_DAYS) as u16, + (((current_time - self.fundraiser.time_started) / SECONDS_TO_DAYS) as u16) < self.fundraiser.duration, crate::FundraiserError::FundraiserEnded ); diff --git a/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/refund.rs b/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/refund.rs index 86c4c9028..9b1b6dc9b 100644 --- a/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/refund.rs +++ b/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/refund.rs @@ -54,11 +54,12 @@ pub struct Refund<'info> { impl<'info> Refund<'info> { pub fn refund(&mut self) -> Result<()> { - // Check if the fundraising duration has been reached + // Refunds are only allowed once the campaign has ended, i.e. after + // `duration` days have elapsed since it started. let current_time = Clock::get()?.unix_timestamp; - + require!( - self.fundraiser.duration >= ((current_time - self.fundraiser.time_started) / SECONDS_TO_DAYS) as u16, + (((current_time - self.fundraiser.time_started) / SECONDS_TO_DAYS) as u16) >= self.fundraiser.duration, crate::FundraiserError::FundraiserNotEnded ); diff --git a/tokens/token-fundraiser/anchor/tests/bankrun.test.ts b/tokens/token-fundraiser/anchor/tests/bankrun.test.ts index ce11539aa..813e13712 100644 --- a/tokens/token-fundraiser/anchor/tests/bankrun.test.ts +++ b/tokens/token-fundraiser/anchor/tests/bankrun.test.ts @@ -1,3 +1,4 @@ +import assert from "node:assert"; import { describe, it } from "node:test"; import * as anchor from "@anchor-lang/core"; import { @@ -82,7 +83,7 @@ describe("fundraiser bankrun", async () => { const vault = getAssociatedTokenAddressSync(mint, fundraiser, true); const tx = await program.methods - .initialize(new BN(30000000), 0) + .initialize(new BN(30000000), 5) .accountsPartial({ maker: maker.publicKey, fundraiser, @@ -200,30 +201,34 @@ describe("fundraiser bankrun", async () => { } }); - it("Refund Contributions", async () => { + it("Refund is rejected while the campaign is still open", async () => { + // The campaign was created with a 5-day duration and has only just started, + // so a refund must be rejected until the campaign has ended. (A successful + // refund requires advancing the validator clock past `duration`, e.g. with + // bankrun's `setClock`.) const vault = getAssociatedTokenAddressSync(mint, fundraiser, true); - const contributorAccount = await program.account.contributor.fetch(contributor); - console.log("\nContributor balance", contributorAccount.amount.toString()); - - const tx = await program.methods - .refund() - .accountsPartial({ - contributor: provider.publicKey, - maker: maker.publicKey, - mintToRaise: mint, - fundraiser, - contributorAccount: contributor, - contributorAta: contributorATA, - vault, - tokenProgram: TOKEN_PROGRAM_ID, - systemProgram: anchor.web3.SystemProgram.programId, - }) - .rpc() - .then(confirm); + let rejected = false; + try { + await program.methods + .refund() + .accountsPartial({ + contributor: provider.publicKey, + maker: maker.publicKey, + mintToRaise: mint, + fundraiser, + contributorAccount: contributor, + contributorAta: contributorATA, + vault, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .rpc() + .then(confirm); + } catch { + rejected = true; + } - console.log("\nRefunded contributions", tx); - console.log("Your transaction signature", tx); - console.log("Vault balance", (await provider.connection.getTokenAccountBalance(vault)).value.amount); + assert.ok(rejected, "refund should be rejected while the campaign is still open"); }); }); From 063ec635a5147ef3d33f8cc132b29e8f1e23b326 Mon Sep 17 00:00:00 2001 From: boymak Date: Thu, 25 Jun 2026 11:59:51 +0300 Subject: [PATCH 2/2] Address review: narrow refund-rejection assertion and add post-campaign refund test - The refund-while-open test now asserts the caught error is `FundraiserNotEnded` instead of accepting any exception. - Adds a positive-path test that advances the bankrun clock past the campaign `duration` (via `banksClient.getClock()` + `context.setClock`) and asserts a successful refund: the contribution is returned, the vault is emptied, and the contributor account is closed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../anchor/tests/bankrun.test.ts | 66 ++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/tokens/token-fundraiser/anchor/tests/bankrun.test.ts b/tokens/token-fundraiser/anchor/tests/bankrun.test.ts index 813e13712..12f2d883b 100644 --- a/tokens/token-fundraiser/anchor/tests/bankrun.test.ts +++ b/tokens/token-fundraiser/anchor/tests/bankrun.test.ts @@ -12,7 +12,7 @@ import { import { PublicKey } from "@solana/web3.js"; import { BankrunProvider } from "anchor-bankrun"; import BN from "bn.js"; -import { startAnchor } from "solana-bankrun"; +import { Clock, startAnchor } from "solana-bankrun"; import IDL from "../target/idl/fundraiser.json"; import type { Fundraiser } from "../target/types/fundraiser"; @@ -225,10 +225,72 @@ describe("fundraiser bankrun", async () => { }) .rpc() .then(confirm); - } catch { + } catch (error) { rejected = true; + assert.ok( + String(error).includes("FundraiserNotEnded"), + `expected a FundraiserNotEnded error, got: ${error}`, + ); } assert.ok(rejected, "refund should be rejected while the campaign is still open"); }); + + it("Refunds the contributor after the campaign ends", async () => { + const vault = getAssociatedTokenAddressSync(mint, fundraiser, true); + + // Advance the clock past the 5-day campaign window so refunds are allowed. + const clock = await context.banksClient.getClock(); + context.setClock( + new Clock( + clock.slot, + clock.epochStartTimestamp, + clock.epoch, + clock.leaderScheduleEpoch, + clock.unixTimestamp + BigInt(6 * 24 * 60 * 60), + ), + ); + + const before = BigInt( + (await provider.connection.getTokenAccountBalance(contributorATA)).value.amount, + ); + + const tx = await program.methods + .refund() + .accountsPartial({ + contributor: provider.publicKey, + maker: maker.publicKey, + mintToRaise: mint, + fundraiser, + contributorAccount: contributor, + contributorAta: contributorATA, + vault, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .rpc() + .then(confirm); + + console.log("\nRefunded contributions", tx); + + // The 2_000_000 contributed is returned to the contributor. + const after = BigInt( + (await provider.connection.getTokenAccountBalance(contributorATA)).value.amount, + ); + assert.equal(after - before, BigInt(2_000_000), "contributor should be refunded their contribution"); + + // The vault is emptied and the contributor account is closed. + assert.equal( + (await provider.connection.getTokenAccountBalance(vault)).value.amount, + "0", + "vault should be empty after the refund", + ); + let closed = false; + try { + await program.account.contributor.fetch(contributor); + } catch { + closed = true; + } + assert.ok(closed, "contributor account should be closed after the refund"); + }); });