Skip to content

Fix inverted contribute/refund time gates in token-fundraiser (anchor)#614

Open
boymak wants to merge 2 commits into
solana-foundation:mainfrom
boymak:fix-token-fundraiser-time-gates
Open

Fix inverted contribute/refund time gates in token-fundraiser (anchor)#614
boymak wants to merge 2 commits into
solana-foundation:mainfrom
boymak:fix-token-fundraiser-time-gates

Conversation

@boymak

@boymak boymak commented Jun 25, 2026

Copy link
Copy Markdown

Summary

The token-fundraiser (Anchor) example gates contribute and refund on the campaign window with inverted comparisons:

  • contribute only accepted contributions after the campaign window had closed (require!(duration <= elapsed_days, FundraiserEnded)).
  • refund only allowed refunds while the campaign was still open (require!(duration >= elapsed_days, FundraiserNotEnded)).

So for any real (non-zero duration) fundraiser, contributions are rejected for the entire campaign and only allowed once it's over, while refunds are allowed during the campaign and blocked afterwards — the opposite of the intended behaviour.

The bug is invisible today because the test uses duration = 0, where both inverted conditions happen to evaluate correctly (0 <= 0 and 0 >= 0).

Fix

  • contribute now rejects once elapsed_days >= duration (campaign ended) → contributions accepted while the campaign is open.
  • refund now rejects while elapsed_days < duration (campaign still open) → refunds allowed only after it ends.

(The cast is parenthesised — (… as u16) < duration — to avoid Rust parsing as u16 < as a generic.)

Tests

The tests now create a non-zero (open) campaign so contributions are valid, and assert that a refund is rejected while the campaign is still open. A successful refund requires advancing the validator clock past duration (e.g. via bankrun's setClock).

Verification

The Anchor program compiles (cargo build-sbf). The identical semantic fix was applied to the Pinocchio port of this example in #613, where the corrected behaviour was verified end-to-end on a local solana-test-validator (contributions accepted while open, rejected after the campaign ends; refunds rejected while open).

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 solana-foundation#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) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes inverted time-gate comparisons in the token-fundraiser Anchor example, where contribute was blocked during the campaign window and refund was allowed while it was open — the exact opposite of the intended semantics. The bug was hidden by the test previously using duration = 0, where the inverted conditions happened to evaluate correctly.

  • contribute.rs: condition changed from duration <= elapsed_days to elapsed_days < duration, so contributions are accepted only while the campaign is open.
  • refund.rs: condition changed from duration >= elapsed_days to elapsed_days >= duration, so refunds are allowed only after the campaign has ended.
  • bankrun.test.ts: duration updated to 5 days, tests added to assert refund is rejected mid-campaign and accepted (via setClock) after the campaign ends, including balance and account-closure assertions.

Confidence Score: 5/5

The change is a targeted two-line inversion fix with no new dependencies or state mutations; the test suite now exercises both the open-campaign rejection and the post-campaign refund happy path.

Both operator flips are correct and symmetric, the parenthesisation of the as u16 cast is handled properly to avoid a Rust parsing ambiguity, and the updated tests cover the critical paths that were previously invisible behind a zero-duration fixture.

No files require special attention.

Important Files Changed

Filename Overview
tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/contribute.rs Inverted time-gate fixed: elapsed_days < duration now correctly blocks contributions once the campaign has ended.
tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/refund.rs Inverted time-gate fixed: elapsed_days >= duration now correctly blocks refunds while the campaign is still active.
tokens/token-fundraiser/anchor/tests/bankrun.test.ts Tests updated to use a 5-day campaign, adds a negative-path refund test mid-campaign with specific error assertion, and adds a positive-path refund test using bankrun setClock with balance and account-closure verification.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[User calls contribute] --> B{elapsed_days < duration?}
    B -- Yes: campaign open --> C[Accept contribution\ntransfer tokens to vault]
    B -- No: campaign ended --> D[Error: FundraiserEnded]

    E[User calls refund] --> F{elapsed_days >= duration?}
    F -- Yes: campaign ended --> G{vault.amount < amount_to_raise?}
    F -- No: campaign still open --> H[Error: FundraiserNotEnded]
    G -- Yes: goal not met --> I[Refund contributor\nclose contributor account]
    G -- No: goal was met --> J[Error: TargetMet]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[User calls contribute] --> B{elapsed_days < duration?}
    B -- Yes: campaign open --> C[Accept contribution\ntransfer tokens to vault]
    B -- No: campaign ended --> D[Error: FundraiserEnded]

    E[User calls refund] --> F{elapsed_days >= duration?}
    F -- Yes: campaign ended --> G{vault.amount < amount_to_raise?}
    F -- No: campaign still open --> H[Error: FundraiserNotEnded]
    G -- Yes: goal not met --> I[Refund contributor\nclose contributor account]
    G -- No: goal was met --> J[Error: TargetMet]
Loading

Reviews (2): Last reviewed commit: "Address review: narrow refund-rejection ..." | Re-trigger Greptile

Comment on lines +228 to +230
} catch {
rejected = true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Catch block too broad — masks unrelated failures

The bare catch {} sets rejected = true for any thrown error, not just the expected FundraiserNotEnded. If the contributor account was never created (e.g. an earlier contribute test failed), the RPC will throw an account-not-found error and rejected will still be true, making the assertion pass while the business-logic gate was never actually exercised. Narrowing the catch to check for the specific error code keeps the test meaningful even when the fixture is in a bad state.

Comment on lines +204 to 233
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");
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 No positive-path test for a post-campaign refund

The test suite now only verifies that a refund is rejected while the campaign is open; there is no test that confirms a refund succeeds after the campaign ends. The PR description notes this requires advancing bankrun's clock via setClock, but without that test the happy path of the fixed refund gate is never exercised, leaving the most critical behavior change unverified.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

…gn 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) <noreply@anthropic.com>
@boymak

boymak commented Jun 25, 2026

Copy link
Copy Markdown
Author

Thanks — addressed both test-quality points in 063ec63:

  1. Narrowed the refund-rejection assertion. The catch now asserts the error is FundraiserNotEnded rather than accepting any exception.

  2. Added a post-campaign (positive-path) refund test. It advances the clock past the 5-day window with banksClient.getClock() + context.setClock(...), then asserts a successful refund: the contribution is returned to the contributor, the vault is emptied, and the contributor account is closed.

That covers both sides of the corrected refund gate (rejected while open, allowed after the campaign ends) plus the corrected contribute gate (accepted while open).

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