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 on-chain fundraiser program with initialize, contribute, settle, and refund instructions.
  • Persistent fundraiser and contributor account layouts for Pinocchio.
  • Token-vault setup and PDA signing for fundraiser-owned transfers.
  • Bankrun tests and package/build wiring for the new example.
  • README and workspace updates for the new implementation.

Confidence Score: 4/5

This is close, but the time boundary should be fixed before merging.

  • The vault substitution fixes now check the caller-supplied vault against the recorded fundraiser vault.
  • The contribution and refund paths still overlap at the exact end-day boundary.
  • That overlap can let deposits happen after refunds are already allowed.

tokens/token-fundraiser/pinocchio/program/src/instructions/contribute.rs

Important Files Changed

Filename Overview
tokens/token-fundraiser/pinocchio/program/src/instructions/contribute.rs Adds contribution validation, contributor account creation, token transfer, and accounting, with one remaining end-boundary mismatch.
tokens/token-fundraiser/pinocchio/program/src/instructions/refund.rs Adds refund handling for expired unsuccessful fundraisers and checks the supplied vault against recorded fundraiser state.
tokens/token-fundraiser/pinocchio/program/src/instructions/check_contributions.rs Adds successful-fundraiser settlement and validates the recorded vault before draining it to the maker.

Reviews (2): 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());
}

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

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.

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.

…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.
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());
}

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