token-fundraiser: add pinocchio example - #698
Conversation
Ports the Anchor fundraiser to Pinocchio, so the example exists in both flavors. A maker opens a campaign with a target and a deadline; contributors deposit SPL tokens into a vault owned by the fundraiser PDA, each capped at 10% of the target; the maker collects once the target is met, and contributors refund themselves if the deadline passes without it. Follows the conventions in tokens/escrow/pinocchio: client-derived PDA bumps passed in instruction data and re-checked with create_program_address, and associated-token-account derivation checks on every account that funds move into or out of. Notable differences from the Anchor version, all deliberate: - transferChecked instead of transfer, so the token program enforces the mint and decimals rather than trusting the caller's accounts. - The minimum-target check uses checked_pow. Mint decimals are a u8 that SPL Token does not cap, the minimum target is 3^decimals, and 3^41 overflows u64; with overflow-checks on in the release profile an unchecked pow aborts the instruction (ProgramFailedToComplete) instead of returning an error. Covered by a test that opens a campaign against a decimals=41 mint. - Elapsed days are compared as i64 rather than cast to u16, avoiding a truncating cast on the deadline comparison. - check_contributions closes the drained vault as well as the fundraiser account, so the vault's rent is not stranded under a closed PDA. Tests: 21 litesvm cases over @solana/kit covering both lifecycles plus the negative paths (deadline boundary, per-contributor cap, target gates, substituted vault, redirected refund destination, non-maker payout). Verified by mutation testing: each of 13 deliberately broken invariants is caught by the test that should catch it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TYjn7QPXQ65mhYBM2fZqzo
Greptile SummaryThe PR adds a Pinocchio implementation and test suite for the token fundraiser example, registers its Rust crate in the workspace, and links it from the repository README.
Confidence Score: 4/5The large-target contribution failure should be fixed before merging because the program currently accepts campaigns that can never receive a contribution. Initialization accepts the full u64 target range, but contribution processing computes 10% through a checked multiplication by 10, causing every contribution to fail for targets above u64::MAX / 10 even though the intended cap remains representable. Files Needing Attention: tokens/token-fundraiser/pinocchio/program/src/instructions/mod.rs and tokens/token-fundraiser/pinocchio/program/src/instructions/initialize.rs Important Files Changed
Reviews (1): Last reviewed commit: "token-fundraiser: add pinocchio example" | Re-trigger Greptile |
| pub(crate) fn max_contribution(amount_to_raise: u64) -> Result<u64, ProgramError> { | ||
| amount_to_raise | ||
| .checked_mul(crate::constants::MAX_CONTRIBUTION_PERCENTAGE) | ||
| .map(|scaled| scaled / crate::constants::PERCENTAGE_SCALER) | ||
| .ok_or_else(|| FundraiserError::ArithmeticOverflow.into()) | ||
| } |
There was a problem hiding this comment.
When a campaign target exceeds u64::MAX / 10, max_contribution overflows while multiplying before dividing, causing every contribution to fail with ArithmeticOverflow even though the intended 10% cap is representable.
| pub(crate) fn max_contribution(amount_to_raise: u64) -> Result<u64, ProgramError> { | |
| amount_to_raise | |
| .checked_mul(crate::constants::MAX_CONTRIBUTION_PERCENTAGE) | |
| .map(|scaled| scaled / crate::constants::PERCENTAGE_SCALER) | |
| .ok_or_else(|| FundraiserError::ArithmeticOverflow.into()) | |
| } | |
| pub(crate) fn max_contribution(amount_to_raise: u64) -> Result<u64, ProgramError> { | |
| Ok(amount_to_raise | |
| / (crate::constants::PERCENTAGE_SCALER / crate::constants::MAX_CONTRIBUTION_PERCENTAGE)) | |
| } |
Knowledge Base Used: Token escrow, swaps, and fundraising
Adds
tokens/token-fundraiser/pinocchio, so the fundraiser example exists in Pinocchio as well as Anchor. The README asks for missing framework variants, and this was one of the token examples that only hadanchor/.What it does
A maker opens a campaign with a target and a deadline. Contributors deposit SPL tokens into a vault owned by the fundraiser PDA, each capped at 10% of the target. The maker collects once the target is met; contributors refund themselves if the deadline passes without it.
Four instructions —
initialize,contribute,check_contributions,refund— behaviorally matchingtokens/token-fundraiser/anchor.Conventions followed
Modeled on
tokens/escrow/pinocchio: client-derived PDA bumps passed in instruction data and re-checked on-chain withcreate_program_address, and associated-token-account derivation checks on every account that funds move into or out of. Tests are mocha-via-tsx over@solana/kit+ litesvm 1.x, per AGENTS.md. The crate is a root workspace member.Deliberate differences from the Anchor version
transferCheckedinstead oftransfer, so the token program enforces the mint and decimals rather than trusting the caller's accounts.checked_powfor the minimum-target check. Mint decimals are au8that SPL Token does not cap, the minimum target is3^decimals, and3^41overflowsu64. Withoverflow-checks = truein the release profile, an uncheckedpowaborts the instruction withProgramFailedToCompleteinstead of returning an error. There's a test that opens a campaign against adecimals=41mint and asserts the cleanInvalidAmount.i64rather than cast tou16, avoiding a truncating cast on the deadline comparison.check_contributionscloses the drained vault as well as the fundraiser account, so the vault's rent isn't stranded under a closed PDA.Testing
21 litesvm cases covering both lifecycles (funded → payout, expired → refund) plus the negative paths: deadline boundary, per-contributor cap (single and cumulative), target gates in both directions, substituted vault, redirected refund destination, and non-maker payout attempt.
Each negative test asserts the specific
ProgramError::Customcode rather than just "it failed", so a test can't pass for the wrong reason.Validated by mutation testing: 13 invariants were each deliberately broken one at a time (deadline gate, vault ATA check, contributor ATA checks, per-contributor cap, target gates, maker identity check,
checked_pow, …) and every one was caught by the test that should catch it — no silent survivors.Locally verified:
cargo fmt --check,cargo clippy -- -D warnings, workspace-membership gate, repo-wideprettier --check,tsc --noEmit,pnpm build,pnpm build-and-test, andcargo test— all green, including from a clean checkout containing only the committed files.Note for reviewers
contributecreates the contributor PDA with a plainCreateAccount, matchingtokens/escrow/pinocchio. Anchor'sinit_if_neededadditionally handles the case where someone pre-funds the PDA address with lamports to grief it. I kept the existing Pinocchio idiom in this repo rather than diverging, but happy to add the transfer/allocate/assign fallback if you'd prefer parity there.