Skip to content

add pinocchio token-fundraiser example#616

Open
MarkFeder wants to merge 2 commits into
solana-foundation:mainfrom
MarkFeder:tokens-token-fundraiser-pinocchio
Open

add pinocchio token-fundraiser example#616
MarkFeder wants to merge 2 commits into
solana-foundation:mainfrom
MarkFeder:tokens-token-fundraiser-pinocchio

Conversation

@MarkFeder

Copy link
Copy Markdown
Contributor

Description

Adds a Pinocchio port of the tokens/token-fundraiser example, sitting alongside the existing anchor implementation. 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:

  • PDA-as-vault-authority — the fundraiser PDA ([b"fundraiser", maker]) is the vault's authority and signs disbursements via invoke_signed.
  • Per-contributor accounting — a contributor PDA ([b"contributor", fundraiser, contributor]) is created on first contribution (init-if-needed) and closed on refund.
  • Time- and cap-based validation — uses the Clock sysvar for the fundraising window and enforces a per-contributor 10% cap.

Instructions

  • Initialize — creates the fundraiser account (PDA-signed) and the vault ATA, validating the target against the decimals-scaled minimum.
  • Contribute — transfers tokens into the vault, enforcing the contribution cap and the active window.
  • CheckContributions — once the target is met, drains the vault to the maker (PDA-signed) and closes the fundraiser.
  • Refund — after the fundraiser ends without meeting the target, returns each contributor's deposit and closes their account.

Tests

tests/test.ts runs against solana-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

  • Follows the structure and scaffolding conventions of the merged tokens/escrow/pinocchio example.
  • Unlike the Anchor version, PDA bumps are passed in instruction data and persisted, so the program avoids re-deriving them on-chain.

@greptile-apps

greptile-apps Bot commented Jun 28, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a Pinocchio version of the token-fundraiser example. The main changes are:

  • New Pinocchio on-chain program for initialize, contribute, check, and refund flows.
  • Fundraiser and contributor account layouts with persisted PDA bumps and vault address.
  • Bankrun tests and TypeScript helpers for the new example.
  • Workspace, package, script, and README updates for the new implementation.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
tokens/token-fundraiser/pinocchio/program/src/instructions/contribute.rs Handles contribution validation, contributor account creation, vault transfers, and running contribution totals.
tokens/token-fundraiser/pinocchio/program/src/instructions/refund.rs Handles expired fundraiser refunds, vault checks, contributor PDA validation, token return, and contributor account closure.
tokens/token-fundraiser/pinocchio/program/src/instructions/initialize.rs Creates the fundraiser account and vault, validates the target amount, and stores fundraiser terms.
tokens/token-fundraiser/pinocchio/program/src/instructions/check_contributions.rs Settles successful fundraisers by checking the stored vault, draining raised tokens to the maker, and closing the fundraiser account.
tokens/token-fundraiser/pinocchio/program/src/state.rs Defines serialized fundraiser and contributor records used by the new Pinocchio program.

Reviews (4): Last reviewed commit: "fix(token-fundraiser): correct time-wind..." | Re-trigger Greptile

Comment on lines +79 to +82
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());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 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.

Suggested change
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());
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +61 to +64
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());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Expired Window Blocks Refunds

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.

Suggested change
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());
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +134 to +140
Transfer {
from: contributor_ata,
to: vault,
authority: contributor,
amount,
}
.invoke()?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P0 security 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +67 to +70
let vault_amount = TokenAccount::from_account_view(vault)?.amount();
if vault_amount >= fundraiser_state.amount_to_raise {
return Err(FundraiserError::TargetMet.into());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +85 to +87
if elapsed_days > fundraiser_state.duration {
return Err(FundraiserError::FundraiserEnded.into());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 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.

Suggested change
if elapsed_days > fundraiser_state.duration {
return Err(FundraiserError::FundraiserEnded.into());
}
if elapsed_days >= fundraiser_state.duration {
return Err(FundraiserError::FundraiserEnded.into());
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@MarkFeder MarkFeder force-pushed the tokens-token-fundraiser-pinocchio branch from 632307a to fdf4ff7 Compare July 8, 2026 21:13
@greptile-apps

greptile-apps Bot commented Jul 8, 2026

Copy link
Copy Markdown

Want your agent to iterate on Greptile's feedback? Try greploops.

MarkFeder added 2 commits July 9, 2026 09:37
…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.
@MarkFeder MarkFeder force-pushed the tokens-token-fundraiser-pinocchio branch from fdf4ff7 to cdac00b Compare July 9, 2026 07:38
@MarkFeder

Copy link
Copy Markdown
Contributor Author

@Perelyn-sama @dev-jodee — rebased onto latest main (picks up the ASM sbpf/Solana pin from #625), CI is now fully green. Ready for review whenever you have a chance 🙏

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