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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to this repository are documented here.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [2026-07-10] - Failed fundraisers can be retired

### Added

- `token-fundraiser` (Anchor): a `close_fundraiser` instruction handler. The Fundraiser PDA is derived from the maker's key alone, so a failed raise used to lock its maker out of ever raising again. The maker can now retire a failed fundraiser (after the deadline, target missed, all contributions refunded), sweeping any direct vault donations to themselves and recovering both rent deposits, then initialize a fresh fundraiser. New error variant `RefundsOutstanding`.
- `token-fundraiser` (Anchor): tests for both contribution caps (`test_contribute_above_cap_fails`, `test_cumulative_contributions_above_cap_fail`) and for every branch of the close path (before deadline, target met, refunds outstanding, donation sweep, and close-then-raise-again).

## [2026-06-30] - Anchor 1.1.2

### Changed
Expand Down
16 changes: 14 additions & 2 deletions finance/token-fundraiser/anchor/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Token Fundraiser

Create a fundraiser that collects tokens. A **maker** creates a fundraiser [account](https://solana.com/docs/terminology#account), specifies the [mint](https://solana.com/docs/terminology#token-mint) they want to receive, the target amount, and a duration in days. **Contributors** contribute while the window is open. If the target is reached, the maker claims the funds; if it is not reached by the deadline, contributors can refund.
Create a fundraiser that collects tokens. A **maker** creates a fundraiser [account](https://solana.com/docs/terminology#account), specifies the [mint](https://solana.com/docs/terminology#token-mint) they want to receive, the target amount, and a duration in days. **Contributors** contribute while the window is open. If the target is reached, the maker claims the funds; if it is not reached by the deadline, contributors can refund, and once refunds are complete the maker can retire the fundraiser and open a new one.

## Architecture

Expand Down Expand Up @@ -121,6 +121,18 @@ Lets a contributor reclaim their contribution after a failed fundraiser. Two che

The handler subtracts the contributor's recorded amount from `current_amount` and zeroes the Contributor record before the transfer CPI, then sends the tokens from the vault back to `contributor_ata` with `transfer_checked` (PDA signer). The Contributor account is closed via `close = contributor`, refunding its rent to the contributor.

### `close_fundraiser`

[`programs/fundraiser/src/instructions/close.rs`](programs/fundraiser/src/instructions/close.rs), account constraints `CloseFundraiserAccountConstraints`.

Retires a failed fundraiser so the maker can raise again. The Fundraiser PDA is derived from `b"fundraiser"` and the maker's public key alone, so while a failed fundraiser's account exists the maker can never initialize another one. Three checks:

1. The fundraiser has ended: `elapsed_days >= duration`, else `FundraiserNotEnded`.
2. The target was not met: `fundraiser.current_amount < amount_to_raise`, else `TargetMet` (a successful raise exits through `check_contributions`, which already closes these accounts).
3. Every contribution has been refunded: `fundraiser.current_amount == 0`, else `RefundsOutstanding` (closing the vault earlier would strand the remaining refunds).

Anything still in the vault at this point is a direct donation outside the program's accounting; the handler sweeps it to `maker_ata` with `transfer_checked` rather than burning it, then closes the vault with `close_account` (both CPIs signed with the Fundraiser PDA's seeds). The Fundraiser state account is closed via `close = maker`.

## Testing

The tests are Rust integration tests using [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) and [solana-kite](https://crates.io/crates/solana-kite), in [`programs/fundraiser/tests/test_fundraiser.rs`](programs/fundraiser/tests/test_fundraiser.rs). They load the compiled program with `include_bytes!`, so build the program first and rebuild after every program change:
Expand All @@ -130,4 +142,4 @@ cargo build-sbf
cargo test
```

The suite uses a nonzero duration and warps the LiteSVM `Clock` sysvar to exercise both sides of every deadline: contributing inside the window succeeds, contributing after the deadline fails, refunding before the deadline fails, and refunding after the deadline succeeds when the target was not met. It also verifies that the claim pays the maker and closes the vault, that direct vault donations do not unlock the claim, and asserts token balances and decoded account state rather than just transaction success.
The suite uses a nonzero duration and warps the LiteSVM `Clock` sysvar to exercise both sides of every deadline: contributing inside the window succeeds, contributing after the deadline fails, refunding before the deadline fails, and refunding after the deadline succeeds when the target was not met. It exercises both contribution caps (a single contribution over the 10% cap, and contributions that cumulatively exceed it), and verifies that the claim pays the maker and closes the vault, that direct vault donations do not unlock the claim, and that `close_fundraiser` retires a failed raise (only after the deadline, only when the target was missed, only once refunds are complete, sweeping direct donations to the maker) and lets the same maker initialize a fresh fundraiser. Assertions check token balances and decoded account state rather than just transaction success.
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ pub enum FundraiserError {
FundraiserEnded,
#[msg("The amount to raise is below the minimum of 3 major units")]
InvalidAmount,
#[msg("Contributions have not all been refunded yet")]
RefundsOutstanding,
#[msg("Arithmetic overflow")]
MathOverflow,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
use anchor_lang::prelude::*;
use anchor_spl::{
associated_token::AssociatedToken,
token_interface::{
close_account, transfer_checked, CloseAccount, Mint, TokenAccount, TokenInterface,
TransferChecked,
},
};

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

#[derive(Accounts)]
pub struct CloseFundraiserAccountConstraints<'info> {
#[account(mut)]
pub maker: Signer<'info>,

pub mint_to_raise: InterfaceAccount<'info, Mint>,

#[account(
mut,
has_one = mint_to_raise,
seeds = [b"fundraiser".as_ref(), maker.key().as_ref()],
bump = fundraiser.bump,
close = maker,
)]
pub fundraiser: Account<'info, Fundraiser>,

#[account(
mut,
associated_token::mint = mint_to_raise,
associated_token::authority = fundraiser,
associated_token::token_program = token_program,
)]
pub vault: InterfaceAccount<'info, TokenAccount>,

#[account(
init_if_needed,
payer = maker,
associated_token::mint = mint_to_raise,
associated_token::authority = maker,
associated_token::token_program = token_program,
)]
pub maker_ata: InterfaceAccount<'info, TokenAccount>,

pub token_program: Interface<'info, TokenInterface>,

pub system_program: Program<'info, System>,

pub associated_token_program: Program<'info, AssociatedToken>,
}

/// Retires a failed fundraiser 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.
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
);

// A successful fundraiser exits through check_contributions, which
// already closes these accounts.
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 taken theirs first.
require!(
accounts.fundraiser.current_amount == 0,
FundraiserError::RefundsOutstanding
);

// The vault is owned by the fundraiser PDA, so both CPIs are signed with
// its seeds.
let signer_seeds: [&[&[u8]]; 1] = [&[
b"fundraiser".as_ref(),
accounts.maker.to_account_info().key.as_ref(),
&[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.
if accounts.vault.amount > 0 {
let transfer_accounts = TransferChecked {
from: accounts.vault.to_account_info(),
mint: accounts.mint_to_raise.to_account_info(),
to: accounts.maker_ata.to_account_info(),
authority: accounts.fundraiser.to_account_info(),
};
let transfer_context = CpiContext::new_with_signer(
accounts.token_program.key(),
transfer_accounts,
&signer_seeds,
);
transfer_checked(
transfer_context,
accounts.vault.amount,
accounts.mint_to_raise.decimals,
)?;
}

// Close the empty vault so its rent goes back to the maker. The
// fundraiser account itself is closed by its close = maker constraint.
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 @@ -2,8 +2,10 @@ pub mod initialize;
pub mod contribute;
pub mod checker;
pub mod refund;
pub mod close;

pub use initialize::*;
pub use contribute::*;
pub use checker::*;
pub use refund::*;
pub use refund::*;
pub use close::*;
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,10 @@ pub mod fundraiser {

Ok(())
}

pub fn close_fundraiser(mut context: Context<CloseFundraiserAccountConstraints>) -> Result<()> {
handle_close_fundraiser(&mut context.accounts)?;

Ok(())
}
}
Loading
Loading