Token Fundraiser
ERC-20 crowdfund with a goal and a deadline. The supporter mapping becomes one PDA per contributor, closed on refund.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/// @title Crowdfund
/// @notice One-shot ERC-20 crowdfund. The creator declares a goal and a
/// deadline; supporters call `contribute()` to commit tokens. If the
/// goal is reached before the deadline, the creator can `claim()`
/// the entire pot. If it isn't, supporters can `refund()` whatever
/// they put in.
///
/// Reference: the Solana version is the `tokens/token-fundraiser` example
/// from solana-developers/program-examples — same lifecycle, different
/// state model.
contract Crowdfund {
IERC20 public immutable token;
address public immutable creator;
uint256 public immutable goal;
uint256 public immutable deadline;
uint256 public totalRaised;
bool public claimed;
/// Per-supporter ledger so refunds know who put in what.
mapping(address => uint256) public contributions;
error Ended();
error NotEnded();
error NotCreator();
error GoalMet();
error GoalNotMet();
error AlreadyClaimed();
error NothingToRefund();
error ZeroAmount();
event Contributed(address indexed supporter, uint256 amount);
event Claimed(address indexed creator, uint256 amount);
event Refunded(address indexed supporter, uint256 amount);
constructor(
IERC20 _token,
address _creator,
uint256 _goal,
uint256 _duration
) {
token = _token;
creator = _creator;
goal = _goal;
deadline = block.timestamp + _duration;
}
/// Supporter pulls tokens into the contract and ledger is updated.
function contribute(uint256 amount) external {
if (amount == 0) revert ZeroAmount();
if (block.timestamp >= deadline) revert Ended();
token.transferFrom(msg.sender, address(this), amount);
contributions[msg.sender] += amount;
totalRaised += amount;
emit Contributed(msg.sender, amount);
}
/// Creator sweeps the pot once the goal is met. Single-shot.
function claim() external {
if (msg.sender != creator) revert NotCreator();
if (claimed) revert AlreadyClaimed();
if (totalRaised < goal) revert GoalNotMet();
claimed = true;
uint256 amount = totalRaised;
token.transfer(creator, amount);
emit Claimed(creator, amount);
}
/// Supporter pulls their tokens back if the goal wasn't met by the
/// deadline.
function refund() external {
if (block.timestamp < deadline) revert NotEnded();
if (totalRaised >= goal) revert GoalMet();
uint256 amount = contributions[msg.sender];
if (amount == 0) revert NothingToRefund();
contributions[msg.sender] = 0;
token.transfer(msg.sender, amount);
emit Refunded(msg.sender, amount);
}
}
//! Idiomatic Solana version. Per-supporter PDA replaces the `Vec`, so
//! contributors don't serialize on a shared write-hot account, the cap is
//! gone, and `refund()` looks up its own row in O(1) by PDA derivation.
//!
//! Lifecycle matches `tokens/token-fundraiser` from
//! solana-developers/program-examples — see `04-diff.md` for the
//! section-by-section differences from the naive port.
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Mint, Token, TokenAccount, Transfer};
declare_id!("FundrSyROpt1m1zedAnchOr1234567890ABCDEFGHJK");
const FUNDRAISER_SEED: &[u8] = b"fundraiser";
const VAULT_SEED: &[u8] = b"vault";
const CONTRIBUTOR_SEED: &[u8] = b"contributor";
#[program]
pub mod fundraiser {
use super::*;
pub fn initialize(
ctx: Context<Initialize>,
goal: u64,
duration: i64,
) -> Result<()> {
require!(goal > 0, FundraiserError::ZeroGoal);
require!(duration > 0, FundraiserError::ZeroDuration);
let f = &mut ctx.accounts.fundraiser;
f.token = ctx.accounts.token_mint.key();
f.creator = ctx.accounts.creator.key();
f.goal = goal;
f.deadline = Clock::get()?.unix_timestamp + duration;
f.total_raised = 0;
f.claimed = false;
f.bump = ctx.bumps.fundraiser;
Ok(())
}
pub fn contribute(ctx: Context<Contribute>, amount: u64) -> Result<()> {
require!(amount > 0, FundraiserError::ZeroAmount);
require!(
Clock::get()?.unix_timestamp < ctx.accounts.fundraiser.deadline,
FundraiserError::Ended
);
// Pull tokens from the supporter into the per-fundraiser vault.
let cpi = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.supporter_ata.to_account_info(),
to: ctx.accounts.vault.to_account_info(),
authority: ctx.accounts.supporter.to_account_info(),
},
);
token::transfer(cpi, amount)?;
// Update aggregate + per-supporter ledger. `init_if_needed` makes the
// first contribution create the PDA, subsequent ones top it up.
ctx.accounts.fundraiser.total_raised = ctx.accounts.fundraiser
.total_raised
.checked_add(amount)
.ok_or(FundraiserError::Overflow)?;
let c = &mut ctx.accounts.contributor;
There's no lookup loop here — the supporter's Contributor account is already loaded into ctx.accounts.contributor by the time the handler runs. Anchor validates that the right PDA was passed in and, via the init_if_needed constraint on the struct, creates it on the first contribution. A Solidity dev expects mapping[key] to be O(1) at the language level; here the framework does the lookup before any handler code runs, so a hand-rolled scan would be doing work the runtime already does for free.
c.amount = c.amount
.checked_add(amount)
.ok_or(FundraiserError::Overflow)?;
Ok(())
}
pub fn claim(ctx: Context<Claim>) -> Result<()> {
let f = &mut ctx.accounts.fundraiser;
require!(!f.claimed, FundraiserError::AlreadyClaimed);
require!(f.total_raised >= f.goal, FundraiserError::GoalNotMet);
f.claimed = true;
let amount = f.total_raised;
let creator_key = f.creator;
let bump = f.bump;
let signer_seeds: &[&[&[u8]]] =
&[&[FUNDRAISER_SEED, creator_key.as_ref(), &[bump]]];
let cpi = CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.vault.to_account_info(),
to: ctx.accounts.creator_ata.to_account_info(),
authority: f.to_account_info(),
},
signer_seeds,
);
token::transfer(cpi, amount)?;
Ok(())
}
pub fn refund(ctx: Context<Refund>) -> Result<()> {
require!(
Clock::get()?.unix_timestamp >= ctx.accounts.fundraiser.deadline,
FundraiserError::NotEnded
);
require!(
ctx.accounts.fundraiser.total_raised < ctx.accounts.fundraiser.goal,
FundraiserError::GoalMet
total_raised is decremented before the token::transfer CPI that pays the supporter back — the checks-effects-interactions pattern from Solidity reentrancy auditing. Solana's runtime locks every writable account for the whole transaction so reentrancy isn't actually a bug class here, but updating invariants before external calls is still cheap insurance: if the CPI fails partway through, on-chain state stays consistent with what the program thinks happened.
);
let amount = ctx.accounts.contributor.amount;
require!(amount > 0, FundraiserError::NothingToRefund);
// Aggregate decrements first so the on-chain invariant holds even
// if the CPI fails mid-call.
ctx.accounts.fundraiser.total_raised = ctx.accounts.fundraiser
.total_raised
.checked_sub(amount)
.ok_or(FundraiserError::Overflow)?;
let creator_key = ctx.accounts.fundraiser.creator;
let bump = ctx.accounts.fundraiser.bump;
let signer_seeds: &[&[&[u8]]] =
&[&[FUNDRAISER_SEED, creator_key.as_ref(), &[bump]]];
let cpi = CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.vault.to_account_info(),
to: ctx.accounts.supporter_ata.to_account_info(),
authority: ctx.accounts.fundraiser.to_account_info(),
},
signer_seeds,
);
token::transfer(cpi, amount)?;
// Closing the contributor PDA refunds rent and prevents replay —
// a second refund() for the same supporter will fail at account
// validation, not just at the `amount > 0` check.
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(mut)]
pub creator: Signer<'info>,
pub token_mint: Account<'info, Mint>,
#[account(
init,
payer = creator,
space = 8 + Fundraiser::INIT_SPACE,
seeds = [FUNDRAISER_SEED, creator.key().as_ref()],
bump,
)]
pub fundraiser: Account<'info, Fundraiser>,
#[account(
init,
payer = creator,
token::mint = token_mint,
token::authority = fundraiser,
seeds = [VAULT_SEED, fundraiser.key().as_ref()],
bump,
)]
pub vault: Account<'info, TokenAccount>,
pub token_program: Program<'info, Token>,
pub system_program: Program<'info, System>,
pub rent: Sysvar<'info, Rent>,
}
#[derive(Accounts)]
pub struct Contribute<'info> {
#[account(mut)]
pub supporter: Signer<'info>,
#[account(
mut,
seeds = [FUNDRAISER_SEED, fundraiser.creator.as_ref()],
bump = fundraiser.bump,
)]
pub fundraiser: Account<'info, Fundraiser>,
#[account(
mut,
seeds = [VAULT_SEED, fundraiser.key().as_ref()],
bump,
)]
pub vault: Account<'info, TokenAccount>,
#[account(mut)]
pub supporter_ata: Account<'info, TokenAccount>,
#[account(
init_if_needed,
The contributor constraint uses init_if_needed, so contribute is one instruction whether it's the supporter's first contribution or their tenth — Anchor creates the PDA on the first call and no-ops the create on later calls. Solidity's mapping[address] += amount has no "create" step because the slot is conceptually always there; without init_if_needed you'd ship two instructions (register_contributor then contribute) for the same UX. Safe here because the PDA seeds bind the account to a specific (fundraiser, supporter) pair — no caller can hijack the slot without that signer.
payer = supporter,
space = 8 + Contributor::INIT_SPACE,
seeds = [
CONTRIBUTOR_SEED,
fundraiser.key().as_ref(),
supporter.key().as_ref(),
],
bump,
)]
pub contributor: Account<'info, Contributor>,
pub token_program: Program<'info, Token>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct Claim<'info> {
/// Account validation enforces signer == creator via the PDA seeds —
/// no runtime require!(creator == f.creator) needed.
#[account(mut)]
pub creator: Signer<'info>,
#[account(
mut,
seeds = [FUNDRAISER_SEED, creator.key().as_ref()],
bump = fundraiser.bump,
has_one = creator,
)]
pub fundraiser: Account<'info, Fundraiser>,
#[account(
mut,
seeds = [VAULT_SEED, fundraiser.key().as_ref()],
bump,
)]
pub vault: Account<'info, TokenAccount>,
#[account(mut)]
pub creator_ata: Account<'info, TokenAccount>,
pub token_program: Program<'info, Token>,
}
#[derive(Accounts)]
pub struct Refund<'info> {
#[account(mut)]
pub supporter: Signer<'info>,
#[account(
mut,
seeds = [FUNDRAISER_SEED, fundraiser.creator.as_ref()],
bump = fundraiser.bump,
)]
pub fundraiser: Account<'info, Fundraiser>,
This contract has no central contributor ledger — each supporter owns their own Contributor account, derived from [b"contributor", fundraiser, supporter]. Solidity would put every supporter at a slot in mapping(address => uint256) contributions inside the contract; on Solana the runtime locks every writable account a transaction touches, so a single shared ledger would force all simultaneous contributions to serialize. Per-supporter PDAs make the writes disjoint and unblock parallel execution.
#[account(
mut,
seeds = [VAULT_SEED, fundraiser.key().as_ref()],
bump,
)]
pub vault: Account<'info, TokenAccount>,
#[account(mut)]
pub supporter_ata: Account<'info, TokenAccount>,
#[account(
mut,
close = supporter,
When a supporter refunds, their Contributor account is destroyed by the close = supporter constraint on the struct, and the rent deposit returns to them. Solidity would mark the row "refunded" by setting its amount to zero and gating future calls on amount > 0, because mappings can't actually be deleted. On Solana you delete the account — a duplicate refund then fails at account validation (the PDA doesn't exist) rather than relying on a runtime sentinel check inside the handler.
seeds = [
CONTRIBUTOR_SEED,
fundraiser.key().as_ref(),
supporter.key().as_ref(),
],
bump,
)]
pub contributor: Account<'info, Contributor>,
pub token_program: Program<'info, Token>,
}
#[account]
#[derive(InitSpace)]
pub struct Fundraiser {
pub token: Pubkey,
pub creator: Pubkey,
pub goal: u64,
pub deadline: i64,
pub total_raised: u64,
pub claimed: bool,
pub bump: u8,
// No more Vec — per-supporter state moved to its own PDA.
}
#[account]
#[derive(InitSpace)]
pub struct Contributor {
pub amount: u64,
}
#[error_code]
pub enum FundraiserError {
#[msg("contribution must be > 0")]
ZeroAmount,
#[msg("goal must be > 0")]
ZeroGoal,
#[msg("duration must be > 0")]
ZeroDuration,
#[msg("fundraiser has ended")]
Ended,
#[msg("fundraiser has not yet ended")]
NotEnded,
#[msg("goal already met — no refunds")]
GoalMet,
#[msg("goal not yet met")]
GoalNotMet,
#[msg("creator already claimed the pot")]
AlreadyClaimed,
#[msg("nothing to refund for this signer")]
NothingToRefund,
#[msg("arithmetic overflow")]
Overflow,
}
Why these changes
This is a one-shot ERC-20 crowdfund: a creator opens a fundraiser with a token, a goal, and a deadline; supporters deposit tokens before the deadline; if the goal is met the creator claims the whole pot, otherwise supporters can withdraw their original deposit. The Solidity version keeps a single mapping(address => uint256) contributions on the contract plus an aggregate totalRaised, and gates claim() on msg.sender == creator.
On Solana the same protocol becomes a singleton Fundraiser account holding the config + total_raised, one Contributor PDA per supporter holding their deposit, and a token vault owned by the program. Token movement runs as CPIs into the SPL Token program rather than through a custom balance ledger; access control is enforced by Anchor account constraints (has_one, seed-binding) before the handler runs instead of by require() lines inside it; and a refund actually deletes the supporter's Contributor account rather than zeroing a field. The reference Solana shape is tokens/token-fundraiser in solana-developers/program-examples — same four-instruction lifecycle (initialize, contribute, claim, refund).
mapping(address => uint256) contributions → per-supporter PDA
In Solidity, mapping(address => uint256) contributions puts every supporter at a deterministic storage slot inside the contract — contributions[alice] lands at keccak256(alice, slot). The naive port tried to imitate the Solidity layout by stuffing a Vec<Contribution> into one shared state account.
The Solana equivalent is one account per supporter — a PDA (Program-Derived Address; same "deterministic address from key" idea, but each entry is its own on-chain account at the derived address, not a slot inside the program). That works mechanically but loses the per-account property Solana needs: the runtime locks every writable account a transaction touches, so two supporters contributing at the same time would serialize on the shared state account. With one PDA per supporter, the two writes run in parallel.
Unbounded supporters — the naive MAX_CONTRIBUTORS = 50 cap is gone. Two unrelated contributions are now genuinely concurrent (Solana's parallel-execution scheduler can run them on different cores). Every contribute/refund touches only that supporter's PDA plus the aggregate total_raised field, instead of mutating a singleton state account.
require(msg.sender == creator) → has_one + seed binding
Two layers of protection that both happen before the handler runs:
- **Seed binding.
** The Fundraiser PDA's address is derived from [b"fundraiser", creator. key(). as_ref()], so a wrong signer would derive a different address and Anchor would fail to load the account at all. 2. **has_one = creator. ** Anchor compares the loaded Fundraiser. creator field against the creator account passed in this instruction; mismatch → load fails before the handler body executes.
Authorization is checked at the runtime boundary, not buried in instruction logic. Reviewers see the access-control rule at the top of the account struct, the same way a Solidity reviewer would look for onlyOwner modifiers — except here it's enforced before any state mutation is even reachable. One fewer way to forget the check on a new instruction.
PDA seed strings consolidated
A PDA's address is determined entirely by its seeds. A one-byte typo in one of two places where the same seed is referenced derives a different address — and the bug is silent: the program just signs CPIs for a PDA it doesn't actually control, or loads an account that doesn't match what it intended.
Most PDA-related production bugs are this exact shape. Consolidating to one constant removes the divergence risk.
Hard to silently diverge. Reviewers audit the program's PDA namespace in one place. The Solidity-equivalent practice is "use named constants for storage-slot indexes"; same hygiene, higher stakes here because Solana can't recover from a wrong-address sign.
Mappings have no size cap, neither does the port
The cap existed only because the Vec<Contribution> had to fit inside one account's data — Anchor needs a known max size at init time to allocate space (and pay the right rent).
With per-supporter PDAs (§S1), each supporter brings their own account, so there's nothing to cap.
Unbounded scale. No TooManyContributors error path for the client to handle.
contributions[user] += amount semantics → init_if_needed
Solidity's mapping(address => uint256) contributions += amount has no "create" step — the slot is conceptually always there (mappings are infinite by default).
The closest Solana analog is init_if_needed: it creates the underlying account the first time, and after that it's just there. Without init_if_needed you'd need two instructions: a register_contributor to create the account, then contribute to top it up — bad UX for an EVM developer used to one-shot writes.
One instruction does the work of two. The client never has to know whether it's the supporter's first contribution.