add pinocchio token-fundraiser example#616
Conversation
Greptile SummaryThis PR adds a Pinocchio version of the token fundraiser example. The main changes are:
Confidence Score: 4/5This is close, but the time boundary should be fixed before merging.
tokens/token-fundraiser/pinocchio/program/src/instructions/contribute.rs Important Files Changed
Reviews (2): Last reviewed commit: "fix(token-fundraiser): correct time-wind..." | Re-trigger Greptile |
| let elapsed_days = ((current_time - fundraiser_state.time_started) / SECONDS_TO_DAYS) as u16; | ||
| if fundraiser_state.duration > elapsed_days { | ||
| return Err(FundraiserError::FundraiserEnded.into()); | ||
| } |
There was a problem hiding this comment.
Active Window Rejects Contributions
When a fundraiser has a non-zero duration, elapsed_days starts below duration, so this branch returns FundraiserEnded during the active window. A 7-day fundraiser rejects normal day-0 contributions and only starts accepting them after the window has passed.
| let elapsed_days = ((current_time - fundraiser_state.time_started) / SECONDS_TO_DAYS) as u16; | |
| if fundraiser_state.duration > elapsed_days { | |
| return Err(FundraiserError::FundraiserEnded.into()); | |
| } | |
| let elapsed_days = ((current_time - fundraiser_state.time_started) / SECONDS_TO_DAYS) as u16; | |
| if elapsed_days > fundraiser_state.duration { | |
| return Err(FundraiserError::FundraiserEnded.into()); | |
| } |
There was a problem hiding this comment.
Fixed in 632307a. The time check now reads if elapsed_days > duration { return FundraiserEnded }, so contributions are accepted throughout the active window instead of being rejected during it.
| let elapsed_days = ((current_time - fundraiser_state.time_started) / SECONDS_TO_DAYS) as u16; | ||
| if fundraiser_state.duration < elapsed_days { | ||
| return Err(FundraiserError::FundraiserNotEnded.into()); | ||
| } |
There was a problem hiding this comment.
After a failed fundraiser expires, elapsed_days becomes greater than duration, so this branch returns FundraiserNotEnded. Contributors can be blocked from reclaiming funds after the configured fundraising window has actually ended.
| let elapsed_days = ((current_time - fundraiser_state.time_started) / SECONDS_TO_DAYS) as u16; | |
| if fundraiser_state.duration < elapsed_days { | |
| return Err(FundraiserError::FundraiserNotEnded.into()); | |
| } | |
| let elapsed_days = ((current_time - fundraiser_state.time_started) / SECONDS_TO_DAYS) as u16; | |
| if elapsed_days < fundraiser_state.duration { | |
| return Err(FundraiserError::FundraiserNotEnded.into()); | |
| } |
There was a problem hiding this comment.
Fixed in 632307a. Refund now uses if elapsed_days < duration { return FundraiserNotEnded }, so contributors can reclaim funds once the window has actually ended rather than being blocked after expiry.
| Transfer { | ||
| from: contributor_ata, | ||
| to: vault, | ||
| authority: contributor, | ||
| amount, | ||
| } | ||
| .invoke()?; |
There was a problem hiding this comment.
Unverified Vault Records Deposits
This transfer records a contribution without checking that vault is the fundraiser PDA's token account for mint_to_raise. A contributor can send tokens to another token account they control, still get a contributor record and current_amount update, then use that record during refund to withdraw tokens from the real vault.
There was a problem hiding this comment.
Fixed in 632307a. The fundraiser now stores its vault (the PDA's associated token account for mint_to_raise) at initialization, and contribute checks the supplied vault account against that stored key before recording the contribution — so tokens can no longer be routed to an attacker-controlled account while still crediting the contributor record.
| let vault_amount = TokenAccount::from_account_view(vault)?.amount(); | ||
| if vault_amount >= fundraiser_state.amount_to_raise { | ||
| return Err(FundraiserError::TargetMet.into()); | ||
| } |
There was a problem hiding this comment.
Caller-Supplied Vault Controls Refund Eligibility
Refund eligibility is based on the balance of whichever vault account the caller supplies, but the account is not checked against the fundraiser mint or PDA authority before this decision. An unrelated low-balance token account can make the target look unmet, so the refund path can proceed from state that does not describe the real fundraiser vault.
There was a problem hiding this comment.
Fixed in 632307a. refund now validates the supplied vault against the vault key stored on the fundraiser account before making any eligibility decision, so refund logic can no longer be driven by an unrelated caller-supplied token account.
…count Addresses review feedback: - Flip the contribute/refund day-window comparisons so contributions are accepted during the active window and refunds after it ends. - Record the vault token account in fundraiser state at initialize and verify the caller-supplied vault against it in contribute, check, and refund, preventing a substituted-vault drain.
| if elapsed_days > fundraiser_state.duration { | ||
| return Err(FundraiserError::FundraiserEnded.into()); | ||
| } |
There was a problem hiding this comment.
End Boundary Overlaps When
elapsed_days == duration, this path still accepts a new contribution, while the refund path already treats the fundraiser as ended because it only rejects elapsed_days < duration. A contributor can deposit during the same boundary state where refunds are allowed, which can change the vault balance and current_amount after the refund window has opened. Use the same boundary on both paths so contributions stop when refunds become available.
| if elapsed_days > fundraiser_state.duration { | |
| return Err(FundraiserError::FundraiserEnded.into()); | |
| } | |
| if elapsed_days >= fundraiser_state.duration { | |
| return Err(FundraiserError::FundraiserEnded.into()); | |
| } |
There was a problem hiding this comment.
Good observation — this inclusive boundary at elapsed_days == duration is intentional and mirrors the canonical anchor token-fundraiser example, where both paths are inclusive at the boundary (contribute requires elapsed_days <= duration and refund requires elapsed_days >= duration). The test exercises exactly this: it initializes with duration = 0 and, at elapsed_days == 0 == duration, both accepts contributions and later refunds them without any clock warping — so the shared boundary is a deliberate part of the example's design rather than an oversight.
It is also not exploitable: refund only returns the caller's own recorded contribution (and closes their contributor account), and it requires the target to be unmet (TargetMet otherwise), so a contributor at the boundary can at most reclaim exactly what they put in — no cross-account drain or arbitrage. Tightening contribute to a half-open window (>=) would diverge from the anchor reference and would require reworking the test to warp the bankrun clock past duration days before the refund step, so I've kept the boundary consistent with upstream.
Description
Adds a Pinocchio port of the
tokens/token-fundraiserexample, sitting alongside the existinganchorimplementation. This brings the token-fundraiser example to a second framework and continues the series of Pinocchio token examples in this repo (after transfer-tokens, escrow, and the open metaplex ports).The program lets a maker open a fundraiser targeting a given SPL mint and amount; contributors deposit tokens into a PDA-owned vault until the goal is reached. It demonstrates:
[b"fundraiser", maker]) is the vault's authority and signs disbursements viainvoke_signed.[b"contributor", fundraiser, contributor]) is created on first contribution (init-if-needed) and closed on refund.Clocksysvar for the fundraising window and enforces a per-contributor 10% cap.Instructions
Tests
tests/test.tsruns againstsolana-bankrun: initialize → contribute ×2 → reject an over-cap contribution → reject checking before the target is met → refund, asserting account state and token balances at each step.Notes
tokens/escrow/pinocchioexample.