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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ 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-10-03] - Fundraiser: `close_contributor` is `close_contribution`

### Changed

- `finance/fundraiser` (Anchor v2, Anchor v1, Quasar) renames the account that
records one contributor's contributions from `Contributor` to
`Contribution`, and its closing handler from `close_contributor` to
`close_contribution`: the handler closes that account, not the contributor.
The PDA seed prefix becomes `"contribution"`, the Fundraiser's
`open_contributor_accounts` becomes `open_contributions`, and the
`ContributorAccountsOpen` error becomes `ContributionsOpen`. No behavior
changes.

## [2026-10-03] - Order book tests cover the costliest cancel

`cancel_order` finds an order's place in the tree by walking its side from the
Expand Down
6 changes: 6 additions & 0 deletions finance/fundraiser/anchor-v1/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 2026-10-03

### Changed

- **The per-contributor account is `Contribution`, closed by `close_contribution`.** The handler named `close_contributor` closed the account recording one contributor's contributions, not the contributor, so it is now `close_contribution` (account constraints `CloseContributionAccountConstraints`). The account struct `Contributor` is now `Contribution`, its handler field `contributor_account` is `contribution`, and its PDA seed prefix is `"contribution"` instead of `"contributor"`. The Fundraiser's `open_contributor_accounts` count is `open_contributions`, and the error `ContributorAccountsOpen` is `ContributionsOpen`. The `contributor` account (the contributor's wallet) keeps its name. No behavior changes.

## 2026-10-01

### Fixed
Expand Down
38 changes: 19 additions & 19 deletions finance/fundraiser/anchor-v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ pub struct Fundraiser {
pub time_started: i64,
pub duration: u16,
pub claimed: bool,
pub open_contributor_accounts: u32,
pub open_contributions: u32,
pub bump: u8,
}
```
Expand All @@ -39,7 +39,7 @@ Fields:
- `time_started` - when the fundraiser was created.
- `duration` - fundraising window in days.
- `claimed` - set by `check_contributions`. A claimed fundraiser accepts no more contributions and no second claim.
- `open_contributor_accounts` - how many Contributor accounts written for this fundraiser are still open. `contribute` adds one when it creates a Contributor account; `refund` and `close_contributor` each subtract one when they close one. `close_fundraiser` requires zero.
- `open_contributions` - how many Contribution accounts written for this fundraiser are still open. `contribute` adds one when it creates a Contribution account; `refund` and `close_contribution` each subtract one when they close one. `close_fundraiser` requires zero.
- `bump` - canonical bump for the Fundraiser [PDA](https://solana.com/docs/terminology#program-derived-address-pda).

The `InitSpace` derive macro implements the `Space` trait, which calculates the size of the account (not counting the [Anchor](https://solana.com/docs/terminology#anchor) discriminator).
Expand All @@ -49,16 +49,16 @@ A per-contributor record:
```rust
#[account]
#[derive(InitSpace)]
pub struct Contributor {
pub struct Contribution {
pub amount: u64,
pub bump: u8,
}
```

- `amount` - total amount contributed by this contributor.
- `bump` - canonical bump for the Contributor PDA.
- `bump` - canonical bump for the Contribution PDA.

The Contributor PDA uses `init_if_needed`, which only runs the init branch on first call. On first init (when `bump == 0`) the handler stores `bumps.contributor_account` into `bump` and adds one to `open_contributor_accounts`; see [`instructions/contribute.rs`](programs/fundraiser/src/instructions/contribute.rs).
The Contribution PDA uses `init_if_needed`, which only runs the init branch on first call. On first init (when `bump == 0`) the handler stores `bumps.contribution` into `bump` and adds one to `open_contributions`; see [`instructions/contribute.rs`](programs/fundraiser/src/instructions/contribute.rs).

### Constants

Expand Down Expand Up @@ -94,7 +94,7 @@ The maker signs and pays for two new accounts:
- `fundraiser` - the state account, derived from `b"fundraiser"` and the maker's public key. Anchor calculates the canonical bump and the handler stores it.
- `vault` - the [ATA](https://solana.com/docs/terminology#associated-token-account-ata) that receives contributions, owned by the Fundraiser PDA.

The handler requires `amount >= MIN_AMOUNT_TO_RAISE * 10^decimals` (the target must be at least 3 major units of the mint, expressed in minor units), then initializes the Fundraiser state with `current_amount = 0`, `claimed = false`, `open_contributor_accounts = 0`, and `time_started` from the `Clock` sysvar. A target below the minimum fails with `InvalidAmount`.
The handler requires `amount >= MIN_AMOUNT_TO_RAISE * 10^decimals` (the target must be at least 3 major units of the mint, expressed in minor units), then initializes the Fundraiser state with `current_amount = 0`, `claimed = false`, `open_contributions = 0`, and `time_started` from the `Clock` sysvar. A target below the minimum fails with `InvalidAmount`.

### `contribute`

Expand All @@ -106,15 +106,15 @@ A contributor signs and the handler performs three checks in order:
2. Not yet claimed: `!claimed`, else `FundraiserClaimed`. The deadline may still be days away after a claim, and the vault has already been paid out.
3. Time window: contributions are allowed while `elapsed_days < duration`, where `elapsed_days = (now - time_started) / SECONDS_TO_DAYS`. Once `elapsed_days` reaches `duration` the handler fails with `FundraiserEnded`.

If all checks pass, `Fundraiser.current_amount` and `Contributor.amount` are updated (a contributor's later contributions add to the same Contributor account), then `amount` is transferred from `contributor_ata` to `vault` with `transfer_checked`.
If all checks pass, `Fundraiser.current_amount` and `Contribution.amount` are updated (a contributor's later contributions add to the same Contribution account), then `amount` is transferred from `contributor_ata` to `vault` with `transfer_checked`.

### `check_contributions`

[`programs/fundraiser/src/instructions/checker.rs`](programs/fundraiser/src/instructions/checker.rs), account constraints `CheckContributionsAccountConstraints`.

Lets the maker claim the funds once the target is met. Requires `!claimed`, else `FundraiserClaimed`, and `fundraiser.current_amount >= amount_to_raise` (the state-tracked total, so direct donations to the vault cannot unlock the claim early), else `TargetNotMet`. The handler sets `claimed` and transfers the entire vault balance (including any direct donations) to `maker_ata` with `transfer_checked`, signed with the Fundraiser PDA's seeds.

The Fundraiser account and the empty vault stay open. Contributor accounts are derived from the Fundraiser's address, so the Fundraiser must outlive every one of them: if it closed here, the maker could initialize a new fundraiser at the same address, and the Contributor accounts left over from this raise would count as contributions to the new one, so `refund` would pay their old amounts out of the new contributors' tokens. `close_contributor` closes the Contributor accounts, then `close_fundraiser` closes the Fundraiser and the vault.
The Fundraiser account and the empty vault stay open. Contribution accounts are derived from the Fundraiser's address, so the Fundraiser must outlive every one of them: if it closed here, the maker could initialize a new fundraiser at the same address, and the Contribution accounts left over from this raise would count as contributions to the new one, so `refund` would pay their old amounts out of the new contributors' tokens. `close_contribution` closes the Contribution accounts, then `close_fundraiser` closes the Fundraiser and the vault.

### `refund`

Expand All @@ -125,7 +125,7 @@ Returns a contribution after a failed fundraiser. The contributor does not have
1. Refunds are allowed only after the fundraiser has ended: `elapsed_days >= duration`, else `FundraiserNotEnded`.
2. The target was not met: `fundraiser.current_amount < amount_to_raise` (again the state-tracked total, so donated tokens cannot block refunds), else `TargetMet`.

The handler subtracts the contributor's recorded amount from `current_amount` zeroes the Contributor record, and subtracts one from `open_contributor_accounts` 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.
The handler subtracts the contributor's recorded amount from `current_amount` zeroes the Contribution record, and subtracts one from `open_contributions` before the transfer CPI, then sends the tokens from the vault back to `contributor_ata` with `transfer_checked` (PDA signer). The Contribution account is closed via `close = contributor`, refunding its rent to the contributor.

### `close_fundraiser`

Expand All @@ -139,17 +139,17 @@ For an unclaimed fundraiser, three checks:
2. The target was not met: `fundraiser.current_amount < amount_to_raise`, else `TargetMet` (a raise that met its target closes after the maker claims it).
3. Every contribution has been refunded: `fundraiser.current_amount == 0`, else `RefundsOutstanding` (closing the vault earlier would strand the remaining refunds).

For every fundraiser, claimed or not: `open_contributor_accounts == 0`, else `ContributorAccountsOpen`. A Contributor account left open would be read as a contribution to the next fundraiser at this address.
For every fundraiser, claimed or not: `open_contributions == 0`, else `ContributionsOpen`. A Contribution account left open would be read as a contribution to the next fundraiser at this address.

Anything still in the vault at this point is a direct donation outside the program's accounting; the handler pays 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`
### `close_contribution`

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

Closes a Contributor account once its fundraiser has been claimed, returning the rent to the contributor. On a successful raise the contribution has been paid out to the maker, so the account holds only rent, and `close_fundraiser` cannot run until every one of them is closed.
Closes a Contribution account once its fundraiser has been claimed, returning the rent to the contributor. On a successful raise the contribution has been paid out to the maker, so the account holds only rent, and `close_fundraiser` cannot run until every one of them is closed.

One check: `fundraiser.claimed`, else `FundraiserNotClaimed`. While the fundraiser is unclaimed the contribution can still be refunded, so `refund` is the way to close it. The contributor does not have to sign: the rent goes to them whoever sends the transaction, so the maker can close every Contributor account and then the fundraiser. The Contributor account's seeds bind it to the fundraiser's address, and the handler subtracts one from `open_contributor_accounts`. The account is closed via `close = contributor`.
One check: `fundraiser.claimed`, else `FundraiserNotClaimed`. While the fundraiser is unclaimed the contribution can still be refunded, so `refund` is the way to close it. The contributor does not have to sign: the rent goes to them whoever sends the transaction, so the maker can close every Contribution account and then the fundraiser. The Contribution account's seeds bind it to the fundraiser's address, and the handler subtracts one from `open_contributions`. The account is closed via `close = contributor`.

## Testing

Expand All @@ -162,21 +162,21 @@ 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. Every failing case asserts the specific program error.

It checks that the claim pays the maker and marks the fundraiser claimed, that a second claim and a contribution after the claim are refused, that direct vault donations do not unlock the claim, and that anyone can refund a contributor or close their Contributor account after a claim, with the tokens and rent going to the contributor.
It checks that the claim pays the maker and marks the fundraiser claimed, that a second claim and a contribution after the claim are refused, that direct vault donations do not unlock the claim, and that anyone can refund a contributor or close their Contribution account after a claim, with the tokens and rent going to the contributor.

`test_stale_contributor_account_cannot_refund_from_next_raise` runs the attack the open-account count exists to stop: a raise succeeds, its Contributor accounts and the fundraiser are closed, the maker starts a second raise at the same address, and a first-raise contributor's refund from the second raise fails while every second-raise contributor gets back exactly what they put in. `test_reinitialize_with_open_contributor_accounts_fails` and `test_close_fundraiser_with_open_contributor_accounts_fails` check that the second raise cannot start while any first-raise Contributor account is open.
`test_stale_contribution_cannot_refund_from_next_raise` runs the attack the open-account count exists to stop: a raise succeeds, its Contribution accounts and the fundraiser are closed, the maker starts a second raise at the same address, and a first-raise contributor's refund from the second raise fails while every second-raise contributor gets back exactly what they put in. `test_reinitialize_with_open_contributions_fails` and `test_close_fundraiser_with_open_contributions_fails` check that the second raise cannot start while any first-raise Contribution account is open.

`close_fundraiser` is tested on both paths (after a failed raise, only after the deadline, only when the target was missed and refunds are complete; after a claim, only once every Contributor account is closed), including that it pays direct donations to the maker and that the same maker can then initialize a fresh fundraiser. Assertions check token balances and decoded account state rather than just transaction success.
`close_fundraiser` is tested on both paths (after a failed raise, only after the deadline, only when the target was missed and refunds are complete; after a claim, only once every Contribution account is closed), including that it pays direct donations to the maker and that the same maker can then initialize a fresh fundraiser. 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 marks the fundraiser claimed.

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

Each stays open with its rent inside until someone calls `close_contributor`, which checks that the fundraiser has been claimed and returns the rent to the contributor. Anyone can send it, so the maker can close them all, then call `close_fundraiser` to close the Fundraiser account and the vault.
Each stays open with its rent inside until someone calls `close_contribution`, which checks that the fundraiser has been claimed and returns the rent to the contributor. Anyone can send it, so the maker can close them all, then call `close_fundraiser` to close the Fundraiser account and the vault.

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

Expand Down
8 changes: 5 additions & 3 deletions finance/fundraiser/anchor-v1/programs/fundraiser/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ pub enum FundraiserError {
MathOverflow,
#[msg("The fundraiser has already been claimed")]
FundraiserClaimed,
#[msg("The fundraiser has not been claimed, so the contributor account closes through refund")]
#[msg(
"The fundraiser has not been claimed, so the contribution account closes through refund"
)]
FundraiserNotClaimed,
#[msg("Contributor accounts for this fundraiser are still open, so it cannot close yet")]
ContributorAccountsOpen,
#[msg("Contribution accounts for this fundraiser are still open, so it cannot close yet")]
ContributionsOpen,
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,11 @@ pub struct CheckContributionsAccountConstraints<'info> {
/// 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
/// The fundraiser account and the vault stay open: contribution 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,
/// same address, and contribution accounts left over from this raise would
/// count as contributions to the new one. `close_contribution` closes them,
/// then `close_fundraiser` closes the fundraiser and the vault.
pub fn handle_check_contributions(
accounts: &mut CheckContributionsAccountConstraints,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ pub struct CloseFundraiserAccountConstraints<'info> {
///
/// 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,
/// closes once no contribution account written for it is still open: after a
/// claim, once `close_contribution` 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<()> {
if !accounts.fundraiser.claimed {
Expand Down Expand Up @@ -85,11 +85,11 @@ pub fn handle_close_fundraiser(accounts: &mut CloseFundraiserAccountConstraints)
);
}

// A contributor account left open would be read as a contribution to the
// A contribution account left open would be read as a contribution to the
// next fundraiser at this address.
require!(
accounts.fundraiser.open_contributor_accounts == 0,
FundraiserError::ContributorAccountsOpen
accounts.fundraiser.open_contributions == 0,
FundraiserError::ContributionsOpen
);

// The vault is owned by the fundraiser PDA, so both CPIs are signed with
Expand Down
Loading
Loading