Skip to content

Commit af38af8

Browse files
author
Mike MacCana
committed
Fundraiser: rename close_contributor to close_contribution
The handler closed the account recording one contributor's contributions, not the contributor. Across the Anchor v2, Anchor v1 and Quasar copies: - Contributor -> Contribution (account struct), contributor_account -> contribution (handler field), PDA seed "contributor" -> "contribution" - close_contributor -> close_contribution, CloseContributorAccountConstraints -> CloseContributionAccountConstraints - Fundraiser.open_contributor_accounts -> open_contributions, ContributorAccountsOpen -> ContributionsOpen - tests, READMEs, Kani model and changelogs follow The contributor account (the contributor's wallet) keeps its name. No behavior changes. Claude-Session: https://claude.ai/code/session_01Jzjsod5Bqrr1QHY5hGQYQb
1 parent b461f9d commit af38af8

46 files changed

Lines changed: 500 additions & 469 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@ All notable changes to this repository are documented here.
44

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

7+
## [2026-10-03] - Fundraiser: `close_contributor` is `close_contribution`
8+
9+
### Changed
10+
11+
- `finance/fundraiser` (Anchor v2, Anchor v1, Quasar) renames the account that
12+
records one contributor's contributions from `Contributor` to
13+
`Contribution`, and its closing handler from `close_contributor` to
14+
`close_contribution`: the handler closes that account, not the contributor.
15+
The PDA seed prefix becomes `"contribution"`, the Fundraiser's
16+
`open_contributor_accounts` becomes `open_contributions`, and the
17+
`ContributorAccountsOpen` error becomes `ContributionsOpen`. No behavior
18+
changes.
19+
720
## [2026-10-03] - Order book tests cover the costliest cancel
821

922
`cancel_order` finds an order's place in the tree by walking its side from the

‎finance/fundraiser/anchor-v1/CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## 2026-10-03
4+
5+
### Changed
6+
7+
- **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.
8+
39
## 2026-10-01
410

511
### Fixed

‎finance/fundraiser/anchor-v1/README.md‎

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ pub struct Fundraiser {
2525
pub time_started: i64,
2626
pub duration: u16,
2727
pub claimed: bool,
28-
pub open_contributor_accounts: u32,
28+
pub open_contributions: u32,
2929
pub bump: u8,
3030
}
3131
```
@@ -39,7 +39,7 @@ Fields:
3939
- `time_started` - when the fundraiser was created.
4040
- `duration` - fundraising window in days.
4141
- `claimed` - set by `check_contributions`. A claimed fundraiser accepts no more contributions and no second claim.
42-
- `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.
42+
- `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.
4343
- `bump` - canonical bump for the Fundraiser [PDA](https://solana.com/docs/terminology#program-derived-address-pda).
4444

4545
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).
@@ -49,16 +49,16 @@ A per-contributor record:
4949
```rust
5050
#[account]
5151
#[derive(InitSpace)]
52-
pub struct Contributor {
52+
pub struct Contribution {
5353
pub amount: u64,
5454
pub bump: u8,
5555
}
5656
```
5757

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

61-
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).
61+
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).
6262

6363
### Constants
6464

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

97-
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`.
97+
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`.
9898

9999
### `contribute`
100100

@@ -106,15 +106,15 @@ A contributor signs and the handler performs three checks in order:
106106
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.
107107
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`.
108108

109-
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`.
109+
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`.
110110

111111
### `check_contributions`
112112

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

115115
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.
116116

117-
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.
117+
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.
118118

119119
### `refund`
120120

@@ -125,7 +125,7 @@ Returns a contribution after a failed fundraiser. The contributor does not have
125125
1. Refunds are allowed only after the fundraiser has ended: `elapsed_days >= duration`, else `FundraiserNotEnded`.
126126
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`.
127127

128-
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.
128+
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.
129129

130130
### `close_fundraiser`
131131

@@ -139,17 +139,17 @@ For an unclaimed fundraiser, three checks:
139139
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).
140140
3. Every contribution has been refunded: `fundraiser.current_amount == 0`, else `RefundsOutstanding` (closing the vault earlier would strand the remaining refunds).
141141

142-
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.
142+
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.
143143

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

146-
### `close_contributor`
146+
### `close_contribution`
147147

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

150-
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.
150+
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.
151151

152-
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`.
152+
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`.
153153

154154
## Testing
155155

@@ -162,21 +162,21 @@ cargo test
162162

163163
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.
164164

165-
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.
165+
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.
166166

167-
`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.
167+
`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.
168168

169-
`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.
169+
`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.
170170

171171
## FAQ
172172

173173
### How do I build crowdfunding on Solana?
174174

175175
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.
176176

177-
### What happens to the contributor accounts after a successful raise?
177+
### What happens to the contribution accounts after a successful raise?
178178

179-
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.
179+
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.
180180

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

‎finance/fundraiser/anchor-v1/programs/fundraiser/src/error.rs‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ pub enum FundraiserError {
2020
MathOverflow,
2121
#[msg("The fundraiser has already been claimed")]
2222
FundraiserClaimed,
23-
#[msg("The fundraiser has not been claimed, so the contributor account closes through refund")]
23+
#[msg(
24+
"The fundraiser has not been claimed, so the contribution account closes through refund"
25+
)]
2426
FundraiserNotClaimed,
25-
#[msg("Contributor accounts for this fundraiser are still open, so it cannot close yet")]
26-
ContributorAccountsOpen,
27+
#[msg("Contribution accounts for this fundraiser are still open, so it cannot close yet")]
28+
ContributionsOpen,
2729
}

‎finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/checker.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,11 @@ pub struct CheckContributionsAccountConstraints<'info> {
4747
/// Pays the vault out to the maker once the target is met, and marks the
4848
/// fundraiser claimed.
4949
///
50-
/// The fundraiser account and the vault stay open: contributor accounts are
50+
/// The fundraiser account and the vault stay open: contribution accounts are
5151
/// derived from the fundraiser's address, so the fundraiser must outlive every
5252
/// one of them. Otherwise the maker could initialize a new fundraiser at the
53-
/// same address, and contributor accounts left over from this raise would
54-
/// count as contributions to the new one. `close_contributor` closes them,
53+
/// same address, and contribution accounts left over from this raise would
54+
/// count as contributions to the new one. `close_contribution` closes them,
5555
/// then `close_fundraiser` closes the fundraiser and the vault.
5656
pub fn handle_check_contributions(
5757
accounts: &mut CheckContributionsAccountConstraints,

‎finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/close.rs‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ pub struct CloseFundraiserAccountConstraints<'info> {
5353
///
5454
/// The fundraiser PDA is derived from the maker's public key alone, so while
5555
/// a fundraiser account exists the maker cannot initialize another one. It
56-
/// closes once no contributor account written for it is still open: after a
57-
/// claim, once `close_contributor` has closed each one; after a failed raise,
56+
/// closes once no contribution account written for it is still open: after a
57+
/// claim, once `close_contribution` has closed each one; after a failed raise,
5858
/// once the deadline has passed and `refund` has closed each one.
5959
pub fn handle_close_fundraiser(accounts: &mut CloseFundraiserAccountConstraints) -> Result<()> {
6060
if !accounts.fundraiser.claimed {
@@ -85,11 +85,11 @@ pub fn handle_close_fundraiser(accounts: &mut CloseFundraiserAccountConstraints)
8585
);
8686
}
8787

88-
// A contributor account left open would be read as a contribution to the
88+
// A contribution account left open would be read as a contribution to the
8989
// next fundraiser at this address.
9090
require!(
91-
accounts.fundraiser.open_contributor_accounts == 0,
92-
FundraiserError::ContributorAccountsOpen
91+
accounts.fundraiser.open_contributions == 0,
92+
FundraiserError::ContributionsOpen
9393
);
9494

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

0 commit comments

Comments
 (0)