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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ 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-09-14] - Token fundraiser contributors can close their accounts

A successful raise exits through `check_contributions`, which closes the vault
and the fundraiser account but cannot reach the contributor accounts: there is
one per contributor and the claim carries none of them. Their only other
closer, `refund`, runs only on a failed raise, so every contributor to a
successful raise held their rent in an account nothing could close.

- The token fundraiser gains `close_contributor` in its Anchor v2 and Quasar
ports: a contributor closes their own contributor account once the
fundraiser is gone, and the rent comes back. The one check is that the
passed fundraiser account is not owned by the program, else the new
`FundraiserStillOpen` error, so a live contribution still closes only
through `refund`. Tests, READMEs, and changelogs follow. The Anchor v1 port
is a frozen snapshot and does not change.

## [2026-09-10] - Market and pool accounts own their vaults

Five finance examples kept a dataless "authority" PDA beside their state
Expand Down
13 changes: 13 additions & 0 deletions finance/token-fundraiser/anchor/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## 2026-09-14

### Added

- `close_contributor`: a contributor closes their Contributor account once the
fundraiser is gone, taking back its rent. A successful raise closed the vault
and the Fundraiser account but left every Contributor account open, and
`refund`, their only other closer, runs only on a failed raise, so the rent
was stuck. The handler's one check is that the passed fundraiser account is
not owned by this program, else the new `FundraiserStillOpen` error. Two
tests cover the rent returning after a claim and the refusal while the
fundraiser exists.

## 2026-07-07

Added this changelog. Changes prior to this date were tracked in git history only.
16 changes: 14 additions & 2 deletions finance/token-fundraiser/anchor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
> no prebuilt binary for this pre-release). The Anchor v1 version of this example is in
> [`../anchor-v1`](../anchor-v1/).

Onchain crowdfunding on Solana: a program that collects tokens toward a target amount, like Kickstarter without a payment processor. 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.
Onchain crowdfunding on Solana: a program that collects tokens toward a target amount, like Kickstarter without a payment processor. 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 and each contributor closes their own record to take back its rent; 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 @@ -140,6 +140,14 @@ Retires a failed fundraiser so the maker can raise again. The Fundraiser PDA is

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

### `close_contributor`

[`programs/fundraiser/src/instructions/close_contributor.rs`](programs/fundraiser/src/instructions/close_contributor.rs), account constraints `CloseContributorAccountConstraints`.

Lets a contributor close their Contributor account once the fundraiser is gone, taking back its rent. A successful raise exits through `check_contributions`, which closes the vault and the Fundraiser account 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, so without this handler every contributor to a successful raise would hold their rent in an account nothing could close.

One check: the `fundraiser` account passed in is not owned by this program, else `FundraiserStillOpen`. A live fundraiser is program-owned; a closed one belongs to the system program again, whatever lamports it holds. The Contributor account's seeds bind it to that fundraiser address, so no other fundraiser can be substituted. The account is closed via `close = contributor`.

## 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 @@ -149,14 +157,18 @@ 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 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.
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, 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, and that `close_contributor` returns a contributor's rent after a successful claim and is refused while the fundraiser exists. Assertions check token balances and decoded account state rather than just transaction success.

## FAQ

### How do I build crowdfunding on Solana?

A maker opens a fundraiser with `initialize_fundraiser`, naming the token, target amount, and duration. Contributors deposit with `contribute` while the window is open, and the funds sit in a program-controlled vault that neither side can raid. When the target is reached, the maker claims the raise with `check_contributions`, which pays out the vault and closes the fundraiser.

### What happens to the contributor accounts after a successful raise?

The claim closes the vault and the Fundraiser account, but each Contributor account stays open with its rent inside. Its owner calls `close_contributor`, which checks that the fundraiser is gone and returns the rent.

### What happens if the fundraiser misses its target?

Contributors call `refund` after the deadline to reclaim exactly what they put in. Once refunds are complete, the maker calls `close_fundraiser` to retire the failed raise and can then open a new one.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,6 @@ pub enum FundraiserError {
RefundsOutstanding,
#[msg("Arithmetic overflow")]
MathOverflow,
#[msg("The fundraiser still exists, so the contributor account closes through refund")]
FundraiserStillOpen,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
use anchor_lang::prelude::*;

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

#[derive(Accounts)]
pub struct CloseContributorAccountConstraints {
#[account(mut)]
pub contributor: Signer,

/// 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.account().owned_by(&crate::ID) @ FundraiserError::FundraiserStillOpen,
)]
pub fundraiser: UncheckedAccount,

#[account(
mut,
seeds = [b"contributor", fundraiser.address().as_ref(), contributor.address().as_ref()],
bump = contributor_account.bump,
close = contributor,
)]
pub contributor_account: BorshAccount<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.
///
/// 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<()> {
Ok(())
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
pub mod checker;
pub mod close;
pub mod close_contributor;
pub mod contribute;
pub mod initialize_fundraiser;
pub mod refund;

pub use checker::*;
pub use close::*;
pub use close_contributor::*;
pub use contribute::*;
pub use initialize_fundraiser::*;
pub use refund::*;
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,12 @@ pub mod fundraiser {

Ok(())
}

pub fn close_contributor(
context: &mut Context<CloseContributorAccountConstraints>,
) -> Result<()> {
handle_close_contributor(&mut context.accounts)?;

Ok(())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,23 @@ fn build_check_contributions_instruction(
)
}

fn build_close_contributor_instruction(
setup: &FundraiserSetup,
contributor: &Address,
contributor_account_pda: &Address,
) -> Instruction {
Instruction::new_with_bytes(
setup.program_id,
&fundraiser::instruction::CloseContributor {}.data(),
fundraiser::accounts::CloseContributorAccountConstraints {
contributor: *contributor,
fundraiser: setup.fundraiser_pda,
contributor_account: *contributor_account_pda,
}
.to_account_metas(None),
)
}

fn build_close_fundraiser_instruction(setup: &FundraiserSetup, maker_ata: &Address) -> Instruction {
Instruction::new_with_bytes(
setup.program_id,
Expand Down Expand Up @@ -989,3 +1006,130 @@ fn test_check_contributions_ignores_direct_vault_donations() {
"Fundraiser account must stay open after a failed claim"
);
}

#[test]
fn test_close_contributor_after_successful_claim_returns_rent() {
let mut setup = full_setup();
initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS);

// 10 contributors at the 10% cap reach the target exactly.
let mut contributors = Vec::new();
for _ in 0..10 {
let (contributor, contributor_ata, contributor_account_pda) =
create_funded_contributor(&mut setup);
let contribute_instruction = build_contribute_instruction(
&setup,
&contributor.pubkey(),
&contributor_ata,
&contributor_account_pda,
MAX_CONTRIBUTION,
);
send_transaction_from_instructions(
&mut setup.svm,
vec![contribute_instruction],
&[&contributor],
&contributor.pubkey(),
)
.unwrap();
contributors.push((contributor, contributor_account_pda));
}

let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint);
let check_instruction = build_check_contributions_instruction(&setup, &maker_ata);
send_transaction_from_instructions(
&mut setup.svm,
vec![check_instruction],
&[&setup.maker],
&setup.maker.pubkey(),
)
.unwrap();
assert!(
setup.svm.get_account(&setup.fundraiser_pda).is_none(),
"Fundraiser account must be closed after a successful claim"
);

// The claim closed the fundraiser and the vault, but every contributor
// account is still open with its rent inside.
let (contributor, contributor_account_pda) = &contributors[0];
let rent = setup
.svm
.get_account(contributor_account_pda)
.expect("Contributor account survives the claim")
.lamports;
let lamports_before = setup
.svm
.get_account(&contributor.pubkey())
.unwrap()
.lamports;

let close_instruction =
build_close_contributor_instruction(&setup, &contributor.pubkey(), contributor_account_pda);
send_transaction_from_instructions(
&mut setup.svm,
vec![close_instruction],
&[contributor],
&contributor.pubkey(),
)
.unwrap();

assert!(
setup.svm.get_account(contributor_account_pda).is_none(),
"Contributor account must be closed"
);
let lamports_after = setup
.svm
.get_account(&contributor.pubkey())
.unwrap()
.lamports;
// The contributor paid the transaction fee out of the same balance, so
// the rent came back less that fee.
let fee = 5_000;
assert_eq!(
lamports_after,
lamports_before + rent - fee,
"The contributor account's rent must return to the contributor"
);
}

#[test]
fn test_close_contributor_while_fundraiser_open_fails() {
let mut setup = full_setup();
initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS);

let (contributor, contributor_ata, contributor_account_pda) =
create_funded_contributor(&mut setup);
let contribute_instruction = build_contribute_instruction(
&setup,
&contributor.pubkey(),
&contributor_ata,
&contributor_account_pda,
MAX_CONTRIBUTION,
);
send_transaction_from_instructions(
&mut setup.svm,
vec![contribute_instruction],
&[&contributor],
&contributor.pubkey(),
)
.unwrap();

// The fundraiser is live, so the contribution is live too: closing the
// record now would erase what the vault owes this contributor.
let close_instruction = build_close_contributor_instruction(
&setup,
&contributor.pubkey(),
&contributor_account_pda,
);
let result = send_transaction_from_instructions(
&mut setup.svm,
vec![close_instruction],
&[&contributor],
&contributor.pubkey(),
);
assert!(
result.is_err(),
"Closing a contributor account must fail while its fundraiser exists"
);
let contributor_state = read_contributor_state(&setup.svm, &contributor_account_pda);
assert_eq!(contributor_state.amount, MAX_CONTRIBUTION);
}
13 changes: 13 additions & 0 deletions finance/token-fundraiser/quasar/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## [2026-09-14]

### Added

- `close_contributor` (discriminator 4): a contributor closes their Contributor
account once the fundraiser is gone, taking back its rent. A successful raise
closed the vault and the Fundraiser account but left every Contributor
account open, and `refund`, their only other closer, runs only on a failed
raise, so the rent was stuck. The handler's one check is that the passed
fundraiser account is not owned by this program, else the new
`FundraiserStillOpen` error. Two tests cover the rent returning after a claim
and the refusal while the fundraiser exists.

## [2026-07-22]

### Changed
Expand Down
3 changes: 2 additions & 1 deletion finance/token-fundraiser/quasar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ See also: the [repository catalog](../../../README.md) and the [Anchor variant](
- `contribute` (contributor signs): rejects a zero amount and, after the deadline, fails with `FundraiserEnded`. Creates the contributor's Contributor PDA on first use (idempotent init, contributor pays the rent), adds the amount to both `current_amount` and the contributor's record with checked arithmetic, transfers tokens from the contributor's token account into the vault, then verifies the vault gained exactly the contributed amount (`BalanceMismatch` otherwise).
- `check_contributions` (maker signs): fails with `TargetNotMet` unless `current_amount >= amount_to_raise`. Transfers the whole vault balance to the maker's token account with the Fundraiser PDA signing, then closes the vault and the Fundraiser account, returning their rent to the maker.
- `refund` (contributor signs): fails with `FundraiserNotEnded` before the deadline and with `TargetMet` if the fundraiser succeeded. Pays the contributor's recorded amount back from the vault with the PDA signing, subtracts it from `current_amount`, verifies the vault lost exactly that amount, and closes the Contributor account back to the contributor.
- `close_contributor` (contributor signs): fails with `FundraiserStillOpen` while the passed fundraiser account is still owned by this program. A successful raise closes the vault and the Fundraiser account but leaves every Contributor account open, and `refund` runs only on a failed raise, so this is how a contributor to a successful raise takes back their rent. The Contributor account's seeds bind it to the fundraiser address, so no other fundraiser can be substituted; the account is closed back to the contributor.

Errors are defined in `src/error.rs` as a `#[error_code]` enum starting at code 6000.

Expand All @@ -40,4 +41,4 @@ In-process tests via **Quasar SVM** (`quasar-svm` in `Quasar.toml`):
quasar test
```

The tests in `src/tests.rs` drive the real instruction handlers end to end (initialize, contribute, check_contributions, refund), assert vault and contributor token balances plus account state after every step, and use `QuasarSvm::warp_to_timestamp` to test both sides of the deadline. They also cover the rejection paths: contributing after the deadline, refunding early or after a successful raise, paying out below target, passing a vault not bound to the fundraiser, and refunding against another contributor's record. No local validator is needed.
The tests in `src/tests.rs` drive the real instruction handlers end to end (initialize, contribute, check_contributions, refund, close_contributor), assert vault and contributor token balances plus account state after every step, and use `QuasarSvm::warp_to_timestamp` to test both sides of the deadline. They also cover the rejection paths: contributing after the deadline, refunding early or after a successful raise, paying out below target, passing a vault not bound to the fundraiser, refunding against another contributor's record, and closing a contributor account while its fundraiser still exists. No local validator is needed.
Loading
Loading