Skip to content

fix(token-fundraiser): scale minimums by 10^decimals and gate target/refund on current_amount - #9

Open
SwineCoder101 wants to merge 2 commits into
mainfrom
fix/token-fundraiser-decimals-math
Open

fix(token-fundraiser): scale minimums by 10^decimals and gate target/refund on current_amount#9
SwineCoder101 wants to merge 2 commits into
mainfrom
fix/token-fundraiser-decimals-math

Conversation

@SwineCoder101

Copy link
Copy Markdown
Owner

Bug: decimal minimums are never enforced and the raw vault balance gates target-met / refunds

Three logic bugs in the fundraiser program. The "at least 3 tokens" and "at least 1 token" minimums are computed with the wrong exponent base, so they enforce nothing (and the first one panics for exotic mints). Separately, check_contributions and refund decide whether the target was met from vault.amount instead of the program's own current_amount counter, so anyone can flip a campaign to "target met" with a plain SPL transfer into the vault. That lets the maker sweep a vault that never reached its target through the program, bypasses the per-contributor 10% cap, and locks every contributor out of refund until the maker withdraws.

Affected

  • anchor/programs/fundraiser/src/instructions/initialize.rs
  • anchor/programs/fundraiser/src/instructions/contribute.rs
  • anchor/programs/fundraiser/src/instructions/checker.rs
  • anchor/programs/fundraiser/src/instructions/refund.rs
  • anchor/programs/fundraiser/src/error.rs (new DecimalsOverflow variant, appended)
  • anchor/readme.MD (code snippets mirrored the buggy checks)

There is no native/ or pinocchio/ variant of this example.

Functionality

  • initialize(amount, duration) creates a campaign and must reject a target below 3 whole tokens of mint_to_raise (MIN_AMOUNT_TO_RAISE = 3).
  • contribute(amount) must reject contributions below 1 whole token, cap each contributor at 10% of the target, and add the amount to fundraiser.current_amount.
  • check_contributions lets the maker withdraw once the target has been reached; refund lets a contributor take their contribution back after the deadline if the target was not reached. Both must judge "target reached" by what was actually contributed through the program.

The bug

  1. initialize.rs:44-47 (pre-fix) checks amount >= MIN_AMOUNT_TO_RAISE.pow(decimals), i.e. 3^decimals. For a 6-decimal mint that is 729 base units (0.000729 tokens), for 9 decimals 19,683, for 0 decimals just 1 — never "3 tokens". With overflow-checks on, 3u64.pow(decimals) also panics for decimals >= 41.
  2. contribute.rs:60-63 (pre-fix) checks amount >= 1_u64.pow(decimals), which is always 1. Only amount == 0 is rejected; any dust amount passes.
  3. checker.rs:53-56 and refund.rs:65-68 (pre-fix) compare self.vault.amount against amount_to_raise. fundraiser.current_amount is maintained by contribute/refund but never read. The vault is an ordinary ATA, so anyone can spl-token transfer into it without touching the program:
    • check_contributions then passes and the maker sweeps the whole vault even though contributors never reached the target through the program (and the 10% per-contributor cap is meaningless, since the "missing" amount can be dropped in directly).
    • refund fails with TargetMet for every contributor after the deadline, locking their funds until the maker chooses to withdraw.

Reproduce

cd tokens/token-fundraiser/anchor
pnpm install --frozen-lockfile
anchor build --ignore-keys
pnpm mocha --import=tsx -t 1000000 tests/minimums-and-accounting.test.ts

Against the unmodified program, tests/minimums-and-accounting.test.ts fails all four targeted tests:

  fundraiser minimums and accounting
    ✔ sets up a 6-decimal mint and funds the contributor
    minimum amounts scale with the mint decimals
      1) rejects a target below 3 whole tokens
      2) rejects a contribution below 1 whole token
    target and refund gating use the tracked current_amount
      ✔ pads two campaign vaults to their target
      3) check_contributions does not treat a padded vault as target met
      4) refund still succeeds after the deadline when the vault was padded past target

  2 passing (66ms)
  4 failing

  1) rejects a target below 3 whole tokens:
     AssertionError: expected the transaction to fail with InvalidAmount: expected undefined to not equal undefined
  2) rejects a contribution below 1 whole token:
     AssertionError: expected the transaction to fail with ContributionTooSmall: expected undefined to not equal undefined
  3) check_contributions does not treat a padded vault as target met:
     AssertionError: expected the transaction to fail with TargetNotMet: expected undefined to not equal undefined
  4) refund still succeeds after the deadline when the vault was padded past target:
     Error: AnchorError thrown in programs/fundraiser/src/instructions/refund.rs:65. Error Code: TargetMet. Error Number: 6001. Error Message: The amount to raise has been achieved.

Tests 1-3 are transactions that must be rejected but are accepted (a 1,000-base-unit target on a 6-decimal mint; a 500-base-unit contribution; a maker withdrawal after a direct 29-token transfer padded the vault to a 30-token target with only 1 token contributed). Test 4 is the contributor's post-deadline refund on the same padded setup, which is wrongly rejected with TargetMet.

Fix

  • initialize.rs: the minimum is MIN_AMOUNT_TO_RAISE * 10^decimals, computed with checked_pow/checked_mul. Overflow (a mint with too many decimals to express a whole token in u64) returns the new DecimalsOverflow error instead of panicking.
  • contribute.rs: the minimum contribution is 10^decimals, computed with checked_pow and the same overflow error.
  • checker.rs / refund.rs: the target-met checks read fundraiser.current_amount. check_contributions still transfers vault.amount, so once the target is genuinely reached the maker also receives any tokens that were sent to the vault directly; the fundraiser account is closed in the same instruction, so nothing is left stranded. refund still transfers exactly contributor_account.amount, so a padded vault can never be drained by contributors.
  • error.rs: DecimalsOverflow is appended, so existing error codes are unchanged.
  • readme.MD: the four code snippets and two prose lines that reproduced the buggy checks now match the program.

Existing fixtures (30-token targets, 1-token contributions, a 3-token target in checker-mint-binding.test.ts) already satisfy the corrected minimums, so no test data needed adjusting. The untouched cargo fmt pass on the edited files also normalized their import layout.

Verification

cd tokens/token-fundraiser/anchor
pnpm install --frozen-lockfile
anchor build --ignore-keys
pnpm mocha --import=tsx -t 1000000 tests/minimums-and-accounting.test.ts tests/litesvm.test.ts tests/checker-mint-binding.test.ts
  fundraiser minimums and accounting
    ✔ sets up a 6-decimal mint and funds the contributor
    minimum amounts scale with the mint decimals
      ✔ rejects a target below 3 whole tokens
      ✔ rejects a contribution below 1 whole token
    target and refund gating use the tracked current_amount
      ✔ pads two campaign vaults to their target
      ✔ check_contributions does not treat a padded vault as target met
      ✔ refund still succeeds after the deadline when the vault was padded past target

  fundraiser litesvm
    ✔ Test Preparation
    ✔ Initialize Fundaraiser
    ✔ Contribute to Fundraiser
    ✔ Contribute to Fundraiser
    ✔ Contribute to Fundraiser - Robustness Test
    ✔ Refund is rejected while the fundraiser is still active
    ✔ Check contributions - Robustness Test
    ✔ Fundraiser closes to contributions once the duration has elapsed
    ✔ Refund Contributions

  fundraiser checker mint binding
    ✔ sets up a real campaign mint and a maker-controlled fake mint
    ✔ initializes the campaign against the real mint
    ✔ rejects check_contributions against a mint other than the one recorded

  18 passing (111ms)

The full Anchor.toml test script (anchor test --validator legacy in CI, which also runs the validator-based tests/fundraiser.ts) was run against a fresh solana-test-validator --reset with the fixed .so preloaded at the declare_id! address:

solana-test-validator --reset --rpc-port 9199 --mint <wallet> \
  --bpf-program Eoiuq1dXvHxh6dLx3wh9gj8kSAUpga11krTrbfF5XYsC target/deploy/fundraiser.so
ANCHOR_PROVIDER_URL=http://127.0.0.1:9199 ANCHOR_WALLET=~/.config/solana/id.json \
  pnpm mocha --import=tsx -t 1000000 tests/**/*.ts
  fundraiser checker mint binding
    ✔ (3 tests)
  fundraiser
    ✔ Test Preparation (2067ms)
    ✔ Initialize Fundaraiser (454ms)
    ✔ Contribute to Fundraiser (462ms)
    ✔ Contribute to Fundraiser (475ms)
    ✔ Contribute to Fundraiser - Robustness Test
    ✔ Check contributions - Robustness Test
    ✔ Refund is rejected while the fundraiser is still active
  fundraiser litesvm
    ✔ (9 tests)
  fundraiser minimums and accounting
    ✔ (6 tests)

  25 passing (4s)
cargo clippy -p fundraiser -- -D warnings -A clippy::diverging_sub_expression   # same flags as CI

passes (the allowed lint is a known false positive from Anchor 1.0's #[program] expansion and fires identically on the unmodified code).

…lt balance gates target/refund

initialize accepts targets below 3 whole tokens (3^decimals instead of
3*10^decimals) and contribute accepts sub-token amounts (1^decimals is
always 1). check_contributions and refund read the raw vault balance, so a
plain SPL transfer into the vault flips a campaign to target-met, letting
the maker sweep it and locking contributors out of refunds.
…refund on current_amount

initialize required 3^decimals base units and contribute required
1^decimals (always 1), so neither minimum enforced anything; both now use
checked 10^decimals math and return DecimalsOverflow instead of panicking
on exotic mints. check_contributions and refund compared the raw vault
balance against the target, so a direct SPL transfer into the vault let
the maker withdraw early and blocked every contributor's refund; both now
read the program-tracked current_amount. check_contributions still sweeps
the full vault balance on a genuine success.
@SwineCoder101
SwineCoder101 force-pushed the fix/token-fundraiser-decimals-math branch from 1b19b49 to 27261e4 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