ETH TO SOL
$npx skills add solana-foundation/eth-to-sol-skill

Token Fundraiser

ERC-20 crowdfund with a goal and a deadline. The supporter mapping becomes one PDA per contributor, closed on refund.

Solidity LOC
92
Anchor LOC
300
Cost / tx
$2-50 → $0.00025
Framework
Anchor 1.0.2
Source · SolidityCrowdfund.solETHEREUM
// 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);
    }
}

translate ↓
Output · Anchor / Rustprograms/fundraiser/src/lib.rsSOLANA
//! 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;
`contributions[msg.sender]` → preloaded PDA reference

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
Invariant-first state update in `refund`

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,
`contributions[user] += amount` semantics → `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>,
`mapping(address => uint256) contributions` → per-supporter PDA

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,
`contributions[user] = 0` sentinel → account close

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

01state model

mapping(address => uint256) contributions → per-supporter PDA

On Ethereum

In Solidity, mapping(address => uint256) contributions puts every supporter at a deterministic storage slot inside the contractcontributions[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.

On Solana

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.

The payoff

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.

02security

require(msg.sender == creator)has_one + seed binding

On Ethereum

Two layers of protection that both happen before the handler runs:

  1. **Seed binding.
On Solana

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

The payoff

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.

03cpi & program reuse

PDA seed strings consolidated

On Ethereum

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.

On Solana

Most PDA-related production bugs are this exact shape. Consolidating to one constant removes the divergence risk.

The payoff

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.

04compute & rent

Mappings have no size cap, neither does the port

On Ethereum

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

On Solana

With per-supporter PDAs (§S1), each supporter brings their own account, so there's nothing to cap.

The payoff

Unbounded scale. No TooManyContributors error path for the client to handle.

05idioms

contributions[user] += amount semantics → init_if_needed

On Ethereum

Solidity's mapping(address => uint256) contributions += amount has no "create" step — the slot is conceptually always there (mappings are infinite by default).

On Solana

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.

The payoff

One instruction does the work of two. The client never has to know whether it's the supporter's first contribution.