Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions finance/fundraiser/anchor-v1/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Changelog

## 2026-10-01

### Fixed

- **A contributor account could outlive its fundraiser and count toward the next one.** `check_contributions` closed the Fundraiser account while the Contributor accounts, derived from its address, stayed open. The maker could then initialize a new fundraiser at the same address, and a leftover Contributor account would count as a contribution to it: `refund` would pay its old amount out of the new contributors' tokens. `check_contributions` now pays out the vault and sets a new `claimed` flag instead of closing anything, and the Fundraiser keeps a new `open_contributor_accounts` count. `close_fundraiser` closes a claimed fundraiser once that count is zero (else the new `ContributorAccountsOpen` error), so no Contributor account survives into the next raise. `test_stale_contributor_account_cannot_refund_from_next_raise`, `test_reinitialize_with_open_contributor_accounts_fails` and `test_close_fundraiser_with_open_contributor_accounts_fails` cover it.

### Changed

- `close_contributor` requires the fundraiser to be claimed (`FundraiserNotClaimed`, replacing `FundraiserStillOpen`) rather than gone, and decrements `open_contributor_accounts`.
- `refund` and `close_contributor` no longer require the contributor's signature. The tokens and rent still go only to the contributor, and anyone can send either, so the maker can refund or close every Contributor account without waiting on any contributor.
- `contribute` and `check_contributions` refuse a claimed fundraiser with the new `FundraiserClaimed` error.
- Program errors are public (`pub use error::*`) so the tests assert each failure's specific error code.

### Removed

- The per-contributor cap (`MAX_CONTRIBUTION_PERCENTAGE`, `PERCENTAGE_SCALER`, and the `ContributionTooBig` and `MaximumContributionsReached` errors). It limited each wallet, and a wallet costs nothing to create, so it did not stop one person funding most of a raise.

## 2026-09-28

- **Renamed from Token Fundraiser to Fundraiser.** The example moved from `finance/token-fundraiser` to `finance/fundraiser`: contributors receive no token, only a refund if the target is missed, so "Token" described something the program does not do. The program, its accounts, its instruction handlers and its tests are unchanged.
Expand Down
68 changes: 38 additions & 30 deletions finance/fundraiser/anchor-v1/README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
pub const MIN_AMOUNT_TO_RAISE: u64 = 3;
pub const SECONDS_TO_DAYS: i64 = 86400;
pub const MAX_CONTRIBUTION_PERCENTAGE: u64 = 10;
pub const PERCENTAGE_SCALER: u64 = 100;
12 changes: 6 additions & 6 deletions finance/fundraiser/anchor-v1/programs/fundraiser/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,8 @@ pub enum FundraiserError {
TargetNotMet,
#[msg("The amount to raise has been achieved")]
TargetMet,
#[msg("The contribution is too big")]
ContributionTooBig,
#[msg("The contribution is too small")]
ContributionTooSmall,
#[msg("The maximum amount to contribute has been reached")]
MaximumContributionsReached,
#[msg("The fundraiser has not ended yet")]
FundraiserNotEnded,
#[msg("The fundraiser has ended")]
Expand All @@ -22,6 +18,10 @@ pub enum FundraiserError {
RefundsOutstanding,
#[msg("Arithmetic overflow")]
MathOverflow,
#[msg("The fundraiser still exists, so the contributor account closes through refund")]
FundraiserStillOpen,
#[msg("The fundraiser has already been claimed")]
FundraiserClaimed,
#[msg("The fundraiser has not been claimed, so the contributor account closes through refund")]
FundraiserNotClaimed,
#[msg("Contributor accounts for this fundraiser are still open, so it cannot close yet")]
ContributorAccountsOpen,
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
use anchor_lang::prelude::*;
use anchor_spl::{
associated_token::AssociatedToken,
token_interface::{
close_account, transfer_checked, CloseAccount, Mint, TokenAccount, TokenInterface,
TransferChecked,
},
token_interface::{transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked},
};

use crate::{state::Fundraiser, FundraiserError};
Expand All @@ -20,7 +17,6 @@ pub struct CheckContributionsAccountConstraints<'info> {
mut,
seeds = [b"fundraiser".as_ref(), maker.key().as_ref()],
bump = fundraiser.bump,
close = maker,
)]
pub fundraiser: Account<'info, Fundraiser>,

Expand Down Expand Up @@ -48,25 +44,41 @@ pub struct CheckContributionsAccountConstraints<'info> {
pub associated_token_program: Program<'info, AssociatedToken>,
}

/// Pays the vault out to the maker once the target is met, and marks the
/// fundraiser claimed.
///
/// The fundraiser account and the vault stay open: contributor accounts are
/// derived from the fundraiser's address, so the fundraiser must outlive every
/// one of them. Otherwise the maker could initialize a new fundraiser at the
/// same address, and contributor accounts left over from this raise would
/// count as contributions to the new one. `close_contributor` closes them,
/// then `close_fundraiser` closes the fundraiser and the vault.
pub fn handle_check_contributions(
accounts: &mut CheckContributionsAccountConstraints,
) -> Result<()> {
require!(
!accounts.fundraiser.claimed,
FundraiserError::FundraiserClaimed
);

// Compare the state-tracked total, not the vault balance, so tokens
// donated directly to the vault cannot trigger an early release.
require!(
accounts.fundraiser.current_amount >= accounts.fundraiser.amount_to_raise,
FundraiserError::TargetNotMet
);

// The vault is owned by the fundraiser PDA, so both CPIs are signed with
// its seeds.
accounts.fundraiser.claimed = true;

// The vault is owned by the fundraiser PDA, so the CPI is signed with its
// seeds.
let signer_seeds: [&[&[u8]]; 1] = [&[
b"fundraiser".as_ref(),
accounts.maker.to_account_info().key.as_ref(),
&[accounts.fundraiser.bump],
]];

// Drain the whole vault (including any direct donations) to the maker.
// Pay the whole vault (including any direct donations) to the maker.
let transfer_accounts = TransferChecked {
from: accounts.vault.to_account_info(),
mint: accounts.mint_to_raise.to_account_info(),
Expand All @@ -84,15 +96,5 @@ pub fn handle_check_contributions(
accounts.mint_to_raise.decimals,
)?;

// Close the empty vault so its rent goes back to the maker.
let close_accounts = CloseAccount {
account: accounts.vault.to_account_info(),
destination: accounts.maker.to_account_info(),
authority: accounts.fundraiser.to_account_info(),
};
let close_context =
CpiContext::new_with_signer(accounts.token_program.key(), close_accounts, &signer_seeds);
close_account(close_context)?;

Ok(())
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,38 +49,47 @@ pub struct CloseFundraiserAccountConstraints<'info> {
pub associated_token_program: Program<'info, AssociatedToken>,
}

/// Retires a failed fundraiser so the maker can raise again.
/// Closes a finished fundraiser and its vault so the maker can raise again.
///
/// The fundraiser PDA is derived from the maker's key alone, so while a
/// failed fundraiser's account exists the maker can never initialize
/// another one. This handler closes it once the deadline has passed, the
/// target was missed, and every contribution has been refunded.
/// The fundraiser PDA is derived from the maker's public key alone, so while
/// a fundraiser account exists the maker cannot initialize another one. It
/// closes once no contributor account written for it is still open: after a
/// claim, once `close_contributor` has closed each one; after a failed raise,
/// once the deadline has passed and `refund` has closed each one.
pub fn handle_close_fundraiser(accounts: &mut CloseFundraiserAccountConstraints) -> Result<()> {
// Closing is allowed only after the fundraiser has ended:
// elapsed_days >= duration.
let current_time = Clock::get()?.unix_timestamp;
let elapsed_days = current_time
.checked_sub(accounts.fundraiser.time_started)
.ok_or(FundraiserError::MathOverflow)?
.checked_div(SECONDS_TO_DAYS)
.ok_or(FundraiserError::MathOverflow)?;
require!(
elapsed_days >= accounts.fundraiser.duration as i64,
FundraiserError::FundraiserNotEnded
);
if !accounts.fundraiser.claimed {
// Closing an unclaimed fundraiser is allowed only after it has ended:
// elapsed_days >= duration.
let current_time = Clock::get()?.unix_timestamp;
let elapsed_days = current_time
.checked_sub(accounts.fundraiser.time_started)
.ok_or(FundraiserError::MathOverflow)?
.checked_div(SECONDS_TO_DAYS)
.ok_or(FundraiserError::MathOverflow)?;
require!(
elapsed_days >= accounts.fundraiser.duration as i64,
FundraiserError::FundraiserNotEnded
);

// A successful fundraiser exits through check_contributions, which
// already closes these accounts.
require!(
accounts.fundraiser.current_amount < accounts.fundraiser.amount_to_raise,
FundraiserError::TargetMet
);
// A raise that met its target closes after the maker claims it.
require!(
accounts.fundraiser.current_amount < accounts.fundraiser.amount_to_raise,
FundraiserError::TargetMet
);

// Closing the vault while contributions remain would strand the
// refunds, so every contributor must have been refunded first.
require!(
accounts.fundraiser.current_amount == 0,
FundraiserError::RefundsOutstanding
);
}

// Closing the vault while contributions remain would strand the
// refunds, so every contributor must have taken theirs first.
// A contributor account left open would be read as a contribution to the
// next fundraiser at this address.
require!(
accounts.fundraiser.current_amount == 0,
FundraiserError::RefundsOutstanding
accounts.fundraiser.open_contributor_accounts == 0,
FundraiserError::ContributorAccountsOpen
);

// The vault is owned by the fundraiser PDA, so both CPIs are signed with
Expand All @@ -91,9 +100,9 @@ pub fn handle_close_fundraiser(accounts: &mut CloseFundraiserAccountConstraints)
&[accounts.fundraiser.bump],
]];

// Refunds have already drained every tracked contribution, so anything
// left in the vault is a direct donation; sweep it to the maker rather
// than burn it with the account.
// The claim or the refunds have already paid out every tracked
// contribution, so anything left in the vault is a direct donation; pay
// it to the maker rather than burn it with the account.
if accounts.vault.amount > 0 {
let transfer_accounts = TransferChecked {
from: accounts.vault.to_account_info(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
use anchor_lang::prelude::*;

use crate::{state::Contributor, FundraiserError};
use crate::{
state::{Contributor, Fundraiser},
FundraiserError,
};

#[derive(Accounts)]
pub struct CloseContributorAccountConstraints<'info> {
/// Not a signer: the rent goes to the contributor, whoever sends the
/// transaction. So a maker can close every contributor account and then
/// the fundraiser without waiting on any contributor.
#[account(mut)]
pub contributor: Signer<'info>,
pub contributor: SystemAccount<'info>,

/// CHECK: the fundraiser this contributor account was written for. The
/// contributor account's seeds bind it to this address, so no other
/// fundraiser can be substituted. The constraint requires the account to
/// be gone: a live fundraiser is owned by this program, and a closed one
/// belongs to the system program again, whatever lamports it holds.
#[account(
constraint = *fundraiser.owner != crate::ID @ FundraiserError::FundraiserStillOpen,
mut,
constraint = fundraiser.claimed @ FundraiserError::FundraiserNotClaimed,
)]
pub fundraiser: UncheckedAccount<'info>,
pub fundraiser: Account<'info, Fundraiser>,

#[account(
mut,
Expand All @@ -26,20 +28,20 @@ pub struct CloseContributorAccountConstraints<'info> {
pub contributor_account: Account<'info, Contributor>,
}

/// Closes a contributor account once its fundraiser is gone, returning the
/// rent to the contributor.
///
/// A successful raise exits through `check_contributions`, which closes the
/// vault and the fundraiser but cannot reach the contributor accounts: there
/// is one per contributor and the claim carries none of them. Their other
/// closer, `refund`, runs only on a failed raise. Without this handler every
/// contributor to a successful raise would hold their rent in an account
/// nothing could close.
/// Closes a contributor account once its fundraiser has been claimed,
/// returning the rent to the contributor.
///
/// The one check is that the fundraiser account no longer exists, which is
/// the `constraint` above; the `close = contributor` constraint then returns
/// the rent. While the fundraiser exists the contribution is live, and
/// `refund` is the way to close it.
pub fn handle_close_contributor(_accounts: &mut CloseContributorAccountConstraints) -> Result<()> {
/// `refund` closes contributor accounts on a failed raise. On a successful
/// one the contribution has been paid out to the maker, so the account only
/// holds rent, and `close_fundraiser` cannot run until every one of them is
/// closed. While the fundraiser is unclaimed the contribution can still be
/// refunded, so this handler refuses with `FundraiserNotClaimed`.
pub fn handle_close_contributor(accounts: &mut CloseContributorAccountConstraints) -> Result<()> {
accounts.fundraiser.open_contributor_accounts = accounts
.fundraiser
.open_contributor_accounts
.checked_sub(1)
.ok_or(FundraiserError::MathOverflow)?;

Ok(())
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use anchor_spl::token_interface::{

use crate::{
state::{Contributor, Fundraiser},
FundraiserError, MAX_CONTRIBUTION_PERCENTAGE, PERCENTAGE_SCALER, SECONDS_TO_DAYS,
FundraiserError, SECONDS_TO_DAYS,
};

#[derive(Accounts)]
Expand Down Expand Up @@ -53,18 +53,6 @@ pub struct ContributeAccountConstraints<'info> {
pub system_program: Program<'info, System>,
}

/// Caps a single contributor at MAX_CONTRIBUTION_PERCENTAGE percent of the
/// target. Multiplies in u128 so the product cannot overflow u64.
fn calculate_max_contribution(amount_to_raise: u64) -> Result<u64> {
(amount_to_raise as u128)
.checked_mul(MAX_CONTRIBUTION_PERCENTAGE as u128)
.ok_or(FundraiserError::MathOverflow)?
.checked_div(PERCENTAGE_SCALER as u128)
.ok_or(FundraiserError::MathOverflow)?
.try_into()
.map_err(|_| error!(FundraiserError::MathOverflow))
}

pub fn handle_contribute(
accounts: &mut ContributeAccountConstraints,
amount: u64,
Expand All @@ -79,13 +67,13 @@ pub fn handle_contribute(
FundraiserError::ContributionTooSmall
);

let max_contribution = calculate_max_contribution(accounts.fundraiser.amount_to_raise)?;
// A claimed fundraiser has paid its vault out to the maker, so a later
// contribution would go to the maker with no refund path.
require!(
amount <= max_contribution,
FundraiserError::ContributionTooBig
!accounts.fundraiser.claimed,
FundraiserError::FundraiserClaimed
);

// Contributions are allowed while elapsed_days < duration.
let current_time = Clock::get()?.unix_timestamp;
let elapsed_days = current_time
.checked_sub(accounts.fundraiser.time_started)
Expand All @@ -97,16 +85,11 @@ pub fn handle_contribute(
FundraiserError::FundraiserEnded
);

// The contributor's cumulative total must also stay within the cap.
let cumulative_contribution = accounts
.contributor_account
.amount
.checked_add(amount)
.ok_or(FundraiserError::MathOverflow)?;
require!(
cumulative_contribution <= max_contribution,
FundraiserError::MaximumContributionsReached
);

// Checks-effects-interactions: update state before the transfer CPI.
accounts.fundraiser.current_amount = accounts
Expand All @@ -116,10 +99,16 @@ pub fn handle_contribute(
.ok_or(FundraiserError::MathOverflow)?;
accounts.contributor_account.amount = cumulative_contribution;

// Save the contributor PDA bump on first init (init_if_needed only
// runs the init branch once; stored bump is zero until set).
// On first init (init_if_needed only runs the init branch once; the
// stored bump is zero until set), save the contributor PDA bump and count
// the new contributor account against the fundraiser.
if accounts.contributor_account.bump == 0 {
accounts.contributor_account.bump = bumps.contributor_account;
accounts.fundraiser.open_contributor_accounts = accounts
.fundraiser
.open_contributor_accounts
.checked_add(1)
.ok_or(FundraiserError::MathOverflow)?;
}

// Transfer the funds from the contributor to the vault.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ pub fn handle_initialize_fundraiser(
current_amount: 0,
time_started: Clock::get()?.unix_timestamp,
duration,
claimed: false,
open_contributor_accounts: 0,
bump: bumps.fundraiser,
});

Expand Down
Loading
Loading