diff --git a/finance/fundraiser/anchor-v1/CHANGELOG.md b/finance/fundraiser/anchor-v1/CHANGELOG.md index c18c05f0d..912e35d7a 100644 --- a/finance/fundraiser/anchor-v1/CHANGELOG.md +++ b/finance/fundraiser/anchor-v1/CHANGELOG.md @@ -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. diff --git a/finance/fundraiser/anchor-v1/README.md b/finance/fundraiser/anchor-v1/README.md index 971a4d619..946c0f8a8 100644 --- a/finance/fundraiser/anchor-v1/README.md +++ b/finance/fundraiser/anchor-v1/README.md @@ -6,7 +6,7 @@ > `avm install 1.2.0 && avm use 1.2.0`. The Anchor v2 version of this example is in > [`../anchor`](../anchor/). -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. +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, anyone closes each contributor's record to return its rent to that contributor, and the maker then closes the fundraiser; if it is not reached by the deadline, anyone can refund each contributor, and once refunds are complete the maker closes the fundraiser. Either way, the maker can then open a new one. This example was called **Token Fundraiser** (`finance/token-fundraiser`) until it was renamed: contributors receive no token, only a refund if the target is missed. @@ -24,6 +24,8 @@ pub struct Fundraiser { pub current_amount: u64, pub time_started: i64, pub duration: u16, + pub claimed: bool, + pub open_contributor_accounts: u32, pub bump: u8, } ``` @@ -36,6 +38,8 @@ Fields: - `current_amount` - total amount contributed through the `contribute` handler. This tracked total, not the vault balance, is what `check_contributions` and `refund` compare against the target, so tokens sent directly to the vault cannot trigger an early release or block refunds. - `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. - `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). @@ -54,7 +58,7 @@ pub struct Contributor { - `amount` - total amount contributed by this contributor. - `bump` - canonical bump for the Contributor PDA. -The Contributor PDA uses `init_if_needed`, which only runs the init branch on first call. The handler stores `bumps.contributor_account` into `bump` on first init (when `bump == 0`); see [`instructions/contribute.rs`](programs/fundraiser/src/instructions/contribute.rs). +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). ### Constants @@ -63,11 +67,9 @@ From [`constants.rs`](programs/fundraiser/src/constants.rs): ```rust 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; ``` -`MAX_CONTRIBUTION_PERCENTAGE / PERCENTAGE_SCALER` = 10%, the per-contributor cap. `MIN_AMOUNT_TO_RAISE` is the minimum target in major units. +`MIN_AMOUNT_TO_RAISE` is the minimum target in major units. ### Code layout @@ -79,75 +81,75 @@ All token accounts use `anchor_spl::token_interface` types (`InterfaceAccount= 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` 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_contributor_accounts = 0`, and `time_started` from the `Clock` sysvar. A target below the minimum fails with `InvalidAmount`. ### `contribute` [`programs/fundraiser/src/instructions/contribute.rs`](programs/fundraiser/src/instructions/contribute.rs), account constraints `ContributeAccountConstraints`. -A contributor signs and the handler performs four checks in order: +A contributor signs and the handler performs three checks in order: 1. Minimum contribution: `amount >= 10^decimals` (one major unit of the mint), else `ContributionTooSmall`. -2. Per-call cap: `amount <= amount_to_raise * MAX_CONTRIBUTION_PERCENTAGE / PERCENTAGE_SCALER` (10% of the target), else `ContributionTooBig`. +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`. -4. Cumulative cap: the contributor's running total (existing + new) must not exceed the same 10% cap, else `MaximumContributionsReached`. -If all checks pass, `Fundraiser.current_amount` and `Contributor.amount` are updated, then `amount` is transferred from `contributor_ata` to `vault` with `transfer_checked`. +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`. ### `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 `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 then, signing both CPIs with the Fundraiser PDA's seeds: +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. -1. Transfers the entire vault balance (including any direct donations) to `maker_ata` with `transfer_checked`. -2. Closes the empty vault token account with `close_account`, returning its rent to the maker. - -The Fundraiser state account is closed via the `close = maker` constraint, so the maker also recovers that [rent](https://solana.com/docs/terminology#rent). +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. ### `refund` [`programs/fundraiser/src/instructions/refund.rs`](programs/fundraiser/src/instructions/refund.rs), account constraints `RefundAccountConstraints`. -Lets a contributor reclaim their contribution after a failed fundraiser. Two checks: +Returns a contribution after a failed fundraiser. The contributor does not have to sign: the tokens go to their token account and the rent to them, whoever sends the transaction, so the maker can refund every contributor and close a failed fundraiser without waiting on any of them. Two checks: 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` 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. +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. ### `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: +Closes a finished fundraiser and its vault so the maker can raise again. The Fundraiser PDA is derived from `b"fundraiser"` and the maker's public key alone, so while a Fundraiser account exists the maker cannot initialize another one. + +For an unclaimed fundraiser, 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). +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). -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`. +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. + +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` [`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. +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. -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`. +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`. ## Testing @@ -158,22 +160,28 @@ 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, 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. +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. + +`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. + +`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. ## FAQ ### How do I build crowdfunding on Solana? -A maker opens a fundraiser with `initialize`, 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. +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? -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. +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. ### 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. +After the deadline, `refund` returns each contributor exactly what they put in; anyone can send it. Once refunds are complete, the maker calls `close_fundraiser` to retire the failed raise and can then open a new one. ### How is this fundraiser tested and verified? -`anchor build` then `cargo test` runs LiteSVM tests that warp the clock across the deadline to exercise contribution windows, per-contributor caps, claims, refunds, and closing. The arithmetic has [Kani](https://github.com/model-checking/kani) model checks in [`../kani-proofs/`](../kani-proofs/). +`anchor build` then `cargo test` runs LiteSVM tests that warp the clock across the deadline to exercise contribution windows, claims, refunds, and closing on both paths. The arithmetic has [Kani](https://github.com/model-checking/kani) model checks in [`../kani-proofs/`](../kani-proofs/). diff --git a/finance/fundraiser/anchor-v1/programs/fundraiser/src/constants.rs b/finance/fundraiser/anchor-v1/programs/fundraiser/src/constants.rs index 2af19dd91..87e10ebc5 100644 --- a/finance/fundraiser/anchor-v1/programs/fundraiser/src/constants.rs +++ b/finance/fundraiser/anchor-v1/programs/fundraiser/src/constants.rs @@ -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; diff --git a/finance/fundraiser/anchor-v1/programs/fundraiser/src/error.rs b/finance/fundraiser/anchor-v1/programs/fundraiser/src/error.rs index 06e94d8a1..f386accc6 100644 --- a/finance/fundraiser/anchor-v1/programs/fundraiser/src/error.rs +++ b/finance/fundraiser/anchor-v1/programs/fundraiser/src/error.rs @@ -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")] @@ -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, } diff --git a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/checker.rs b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/checker.rs index 8eec93b13..3b7c3780f 100644 --- a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/checker.rs +++ b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/checker.rs @@ -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}; @@ -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>, @@ -48,9 +44,23 @@ 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!( @@ -58,15 +68,17 @@ pub fn handle_check_contributions( 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(), @@ -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(()) } diff --git a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/close.rs b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/close.rs index 5bd758fa1..ab371d28c 100644 --- a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/close.rs +++ b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/close.rs @@ -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 @@ -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(), diff --git a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/close_contributor.rs b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/close_contributor.rs index 60920692e..d1d97c749 100644 --- a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/close_contributor.rs +++ b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/close_contributor.rs @@ -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, @@ -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(()) } diff --git a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/contribute.rs b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/contribute.rs index 67e73d2f6..4f2f76aed 100644 --- a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/contribute.rs +++ b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/contribute.rs @@ -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)] @@ -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 { - (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, @@ -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) @@ -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 @@ -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. diff --git a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/initialize_fundraiser.rs b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/initialize_fundraiser.rs index b105d17a5..e8d651387 100644 --- a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/initialize_fundraiser.rs +++ b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/initialize_fundraiser.rs @@ -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, }); diff --git a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/refund.rs b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/refund.rs index a58e33dd2..626afe7d2 100644 --- a/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/refund.rs +++ b/finance/fundraiser/anchor-v1/programs/fundraiser/src/instructions/refund.rs @@ -10,8 +10,12 @@ use crate::{ #[derive(Accounts)] pub struct RefundAccountConstraints<'info> { + /// Not a signer: the tokens go to the contributor's token account and the + /// rent to the contributor, whoever sends the transaction. So a maker can + /// refund every contributor and close a failed fundraiser without waiting + /// on any of them. #[account(mut)] - pub contributor: Signer<'info>, + pub contributor: SystemAccount<'info>, pub maker: SystemAccount<'info>, @@ -84,6 +88,11 @@ pub fn handle_refund(accounts: &mut RefundAccountConstraints) -> Result<()> { .checked_sub(refund_amount) .ok_or(FundraiserError::MathOverflow)?; accounts.contributor_account.amount = 0; + accounts.fundraiser.open_contributor_accounts = accounts + .fundraiser + .open_contributor_accounts + .checked_sub(1) + .ok_or(FundraiserError::MathOverflow)?; // Transfer the funds from the vault back to the contributor. The vault is // owned by the fundraiser PDA, so the CPI is signed with its seeds. diff --git a/finance/fundraiser/anchor-v1/programs/fundraiser/src/lib.rs b/finance/fundraiser/anchor-v1/programs/fundraiser/src/lib.rs index 83e1c2272..0c01c929f 100644 --- a/finance/fundraiser/anchor-v1/programs/fundraiser/src/lib.rs +++ b/finance/fundraiser/anchor-v1/programs/fundraiser/src/lib.rs @@ -8,7 +8,7 @@ mod instructions; mod state; pub use constants::*; -use error::*; +pub use error::*; use instructions::*; #[program] diff --git a/finance/fundraiser/anchor-v1/programs/fundraiser/src/state/fundraiser.rs b/finance/fundraiser/anchor-v1/programs/fundraiser/src/state/fundraiser.rs index ceaf9c2ed..bc5a458bf 100644 --- a/finance/fundraiser/anchor-v1/programs/fundraiser/src/state/fundraiser.rs +++ b/finance/fundraiser/anchor-v1/programs/fundraiser/src/state/fundraiser.rs @@ -9,5 +9,13 @@ pub struct Fundraiser { pub current_amount: u64, pub time_started: i64, pub duration: u16, + /// Set by `check_contributions`. A claimed fundraiser accepts no more + /// contributions and no second claim, and stays open until every + /// contributor account written for it has been closed. + pub claimed: bool, + /// How many contributor accounts written for this fundraiser are still + /// open. `close_fundraiser` requires zero, so a new fundraiser at the + /// same address never starts with contributor accounts from an old one. + pub open_contributor_accounts: u32, pub bump: u8, } diff --git a/finance/fundraiser/anchor-v1/programs/fundraiser/tests/test_fundraiser.rs b/finance/fundraiser/anchor-v1/programs/fundraiser/tests/test_fundraiser.rs index 023266289..7022d5106 100644 --- a/finance/fundraiser/anchor-v1/programs/fundraiser/tests/test_fundraiser.rs +++ b/finance/fundraiser/anchor-v1/programs/fundraiser/tests/test_fundraiser.rs @@ -4,7 +4,7 @@ use { InstructionData, ToAccountMetas, }, borsh::BorshDeserialize, - fundraiser::SECONDS_TO_DAYS, + fundraiser::{FundraiserError, SECONDS_TO_DAYS}, litesvm::LiteSVM, solana_keypair::Keypair, solana_kite::{ @@ -20,10 +20,14 @@ const MINT_DECIMALS: u8 = 6; const ONE_TOKEN: u64 = 1_000_000; /// Comfortably above the program's 3-major-unit minimum target. const AMOUNT_TO_RAISE: u64 = 30 * ONE_TOKEN; -/// The per-contributor cap is 10% of the target. -const MAX_CONTRIBUTION: u64 = AMOUNT_TO_RAISE / 10; +/// Three unequal contributions that together reach the target exactly. +const CONTRIBUTIONS_REACHING_TARGET: [u64; 3] = [12 * ONE_TOKEN, 10 * ONE_TOKEN, 8 * ONE_TOKEN]; +/// A contribution well short of the target on its own. +const CONTRIBUTION: u64 = 4 * ONE_TOKEN; const DURATION_DAYS: u16 = 7; -const CONTRIBUTOR_STARTING_BALANCE: u64 = 10 * ONE_TOKEN; +const CONTRIBUTOR_STARTING_BALANCE: u64 = 20 * ONE_TOKEN; +/// LiteSVM's fee for a transaction with one signature. +const TRANSACTION_FEE: u64 = 5_000; fn token_program_id() -> Pubkey { "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" @@ -55,6 +59,8 @@ struct FundraiserState { current_amount: u64, _time_started: i64, duration: u16, + claimed: bool, + open_contributor_accounts: u32, _bump: u8, } @@ -286,6 +292,154 @@ fn build_close_fundraiser_instruction(setup: &FundraiserSetup, maker_ata: &Pubke ) } +struct FundedContributor { + keypair: Keypair, + ata: Pubkey, + contributor_account_pda: Pubkey, +} + +/// Sends `contribute` for the given contributor and amount, signed by the +/// contributor. +fn contribute( + setup: &mut FundraiserSetup, + contributor: &FundedContributor, + amount: u64, +) -> Result<(), String> { + let contribute_instruction = build_contribute_instruction( + setup, + &contributor.keypair.pubkey(), + &contributor.ata, + &contributor.contributor_account_pda, + amount, + ); + send_transaction_from_instructions( + &mut setup.svm, + vec![contribute_instruction], + &[&contributor.keypair], + &contributor.keypair.pubkey(), + ) + .map(|_| ()) + .map_err(|error| format!("{error:?}")) +} + +fn new_contributor(setup: &mut FundraiserSetup) -> FundedContributor { + let (keypair, ata, contributor_account_pda) = create_funded_contributor(setup); + FundedContributor { + keypair, + ata, + contributor_account_pda, + } +} + +/// Creates three contributors whose contributions reach the target exactly. +fn fund_to_target(setup: &mut FundraiserSetup) -> Vec { + CONTRIBUTIONS_REACHING_TARGET + .iter() + .map(|amount| { + let contributor = new_contributor(setup); + contribute(setup, &contributor, *amount).unwrap(); + contributor + }) + .collect() +} + +/// Sends `refund` for `contributor`, paid for by `fee_payer`, who need not be the contributor. +fn refund( + setup: &mut FundraiserSetup, + fee_payer: &Keypair, + contributor: &FundedContributor, +) -> Result<(), String> { + let refund_instruction = build_refund_instruction( + setup, + &contributor.keypair.pubkey(), + &contributor.ata, + &contributor.contributor_account_pda, + ); + send_transaction_from_instructions( + &mut setup.svm, + vec![refund_instruction], + &[fee_payer], + &fee_payer.pubkey(), + ) + .map(|_| ()) + .map_err(|error| format!("{error:?}")) +} + +/// Sends `check_contributions`, signed by the maker. +fn claim(setup: &mut FundraiserSetup) -> Result { + let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); + let check_instruction = build_check_contributions_instruction(setup, &maker_ata); + let maker = setup.maker.insecure_clone(); + send_transaction_from_instructions( + &mut setup.svm, + vec![check_instruction], + &[&maker], + &maker.pubkey(), + ) + .map(|_| maker_ata) + .map_err(|error| format!("{error:?}")) +} + +/// Sends `close_contributor` for `contributor`, signed and paid for by +/// `fee_payer`. +fn close_contributor( + setup: &mut FundraiserSetup, + fee_payer: &Keypair, + contributor: &FundedContributor, +) -> Result<(), String> { + let close_instruction = build_close_contributor_instruction( + setup, + &contributor.keypair.pubkey(), + &contributor.contributor_account_pda, + ); + send_transaction_from_instructions( + &mut setup.svm, + vec![close_instruction], + &[fee_payer], + &fee_payer.pubkey(), + ) + .map(|_| ()) + .map_err(|error| format!("{error:?}")) +} + +/// Sends `close_fundraiser`, signed by the maker. +fn close_fundraiser(setup: &mut FundraiserSetup) -> Result<(), String> { + let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); + let close_instruction = build_close_fundraiser_instruction(setup, &maker_ata); + let maker = setup.maker.insecure_clone(); + send_transaction_from_instructions( + &mut setup.svm, + vec![close_instruction], + &[&maker], + &maker.pubkey(), + ) + .map(|_| ()) + .map_err(|error| format!("{error:?}")) +} + +fn lamports(setup: &FundraiserSetup, address: &Pubkey) -> u64 { + setup + .svm + .get_account(address) + .map_or(0, |account| account.lamports) +} + +/// Anchor numbers a program's `#[error_code]` variants from 6000. +const ANCHOR_ERROR_CODE_OFFSET: u32 = 6000; + +/// Asserts that a transaction failed with the given program error. +fn assert_error(result: Result, expected_error: FundraiserError) { + let error = result.expect_err("transaction should have failed"); + let expected_code = format!( + "Custom({})", + ANCHOR_ERROR_CODE_OFFSET + expected_error as u32 + ); + assert!( + error.contains(&expected_code), + "expected {expected_code}, got: {error}" + ); +} + #[test] fn test_initialize_fundraiser() { let mut setup = full_setup(); @@ -296,6 +450,8 @@ fn test_initialize_fundraiser() { assert_eq!(fundraiser_state.amount_to_raise, AMOUNT_TO_RAISE); assert_eq!(fundraiser_state.current_amount, 0); assert_eq!(fundraiser_state.duration, DURATION_DAYS); + assert!(!fundraiser_state.claimed); + assert_eq!(fundraiser_state.open_contributor_accounts, 0); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), @@ -332,11 +488,9 @@ fn test_initialize_below_minimum_target_fails() { vec![initialize_instruction], &[&setup.maker], &setup.maker.pubkey(), - ); - assert!( - result.is_err(), - "Target below 3 major units must be rejected" - ); + ) + .map_err(|error| format!("{error:?}")); + assert_error(result, FundraiserError::InvalidAmount); assert!( setup.svm.get_account(&setup.fundraiser_pda).is_none(), "Fundraiser account must not exist after a failed initialize" @@ -347,76 +501,78 @@ fn test_initialize_below_minimum_target_fails() { fn test_contribute_inside_window_succeeds() { 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 contributor = new_contributor(&mut setup); // One day in: well inside the 7-day window. warp_days_forward(&mut setup.svm, 1); - - 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(); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), - MAX_CONTRIBUTION + CONTRIBUTION ); assert_eq!( - get_token_account_balance(&setup.svm, &contributor_ata).unwrap(), - CONTRIBUTOR_STARTING_BALANCE - MAX_CONTRIBUTION + get_token_account_balance(&setup.svm, &contributor.ata).unwrap(), + CONTRIBUTOR_STARTING_BALANCE - CONTRIBUTION ); let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); - assert_eq!(fundraiser_state.current_amount, MAX_CONTRIBUTION); + assert_eq!(fundraiser_state.current_amount, CONTRIBUTION); + assert_eq!(fundraiser_state.open_contributor_accounts, 1); - let contributor_state = read_contributor_state(&setup.svm, &contributor_account_pda); - assert_eq!(contributor_state.amount, MAX_CONTRIBUTION); + let contributor_state = + read_contributor_state(&setup.svm, &contributor.contributor_account_pda); + assert_eq!(contributor_state.amount, CONTRIBUTION); } #[test] -fn test_contribute_after_deadline_fails() { +fn test_contributions_accumulate_in_one_contributor_account() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - - // One day past the deadline. - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); + let first_contribution = 5 * ONE_TOKEN; + let second_contribution = 3 * ONE_TOKEN; + contribute(&mut setup, &contributor, first_contribution).unwrap(); + warp_days_forward(&mut setup.svm, 1); + contribute(&mut setup, &contributor, second_contribution).unwrap(); - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - ONE_TOKEN, + let contributor_state = + read_contributor_state(&setup.svm, &contributor.contributor_account_pda); + assert_eq!( + contributor_state.amount, + first_contribution + second_contribution ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), + + // A second contribution adds to the existing account rather than + // creating another, so the fundraiser still counts one. + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert_eq!( + fundraiser_state.current_amount, + first_contribution + second_contribution ); - assert!(result.is_err(), "Contributing after the deadline must fail"); + assert_eq!(fundraiser_state.open_contributor_accounts, 1); +} + +#[test] +fn test_contribute_after_deadline_fails() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); + + // The deadline falls exactly DURATION_DAYS after the start. + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); + assert_error( + contribute(&mut setup, &contributor, ONE_TOKEN), + FundraiserError::FundraiserEnded, + ); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), 0 ); assert_eq!( - get_token_account_balance(&setup.svm, &contributor_ata).unwrap(), + get_token_account_balance(&setup.svm, &contributor.ata).unwrap(), CONTRIBUTOR_STARTING_BALANCE ); } @@ -425,26 +581,11 @@ fn test_contribute_after_deadline_fails() { fn test_contribute_below_one_major_unit_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); - 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, - ONE_TOKEN - 1, - ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ); - assert!( - result.is_err(), - "Contributions below one major unit must fail" + assert_error( + contribute(&mut setup, &contributor, ONE_TOKEN - 1), + FundraiserError::ContributionTooSmall, ); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), @@ -456,40 +597,17 @@ fn test_contribute_below_one_major_unit_fails() { fn test_refund_before_deadline_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, ONE_TOKEN).unwrap(); - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); + // One day short of the deadline. + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 - 1); - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - ONE_TOKEN, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); - - // Still inside the window: refund must fail with FundraiserNotEnded. - let refund_instruction = build_refund_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![refund_instruction], - &[&contributor], - &contributor.pubkey(), + let fee_payer = contributor.keypair.insecure_clone(); + assert_error( + refund(&mut setup, &fee_payer, &contributor), + FundraiserError::FundraiserNotEnded, ); - assert!(result.is_err(), "Refunding before the deadline must fail"); - assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), ONE_TOKEN @@ -502,105 +620,74 @@ fn test_refund_before_deadline_fails() { fn test_refund_after_deadline_target_not_met_succeeds() { 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(); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); // Past the deadline, target not met: refund must succeed. - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); - let refund_instruction = build_refund_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![refund_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); + let fee_payer = contributor.keypair.insecure_clone(); + refund(&mut setup, &fee_payer, &contributor).unwrap(); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), 0 ); assert_eq!( - get_token_account_balance(&setup.svm, &contributor_ata).unwrap(), + get_token_account_balance(&setup.svm, &contributor.ata).unwrap(), CONTRIBUTOR_STARTING_BALANCE ); let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); assert_eq!(fundraiser_state.current_amount, 0); + assert_eq!(fundraiser_state.open_contributor_accounts, 0); assert!( - setup.svm.get_account(&contributor_account_pda).is_none(), + setup + .svm + .get_account(&contributor.contributor_account_pda) + .is_none(), "Contributor account must be closed after refund" ); } #[test] -fn test_refund_when_target_met_fails() { +fn test_anyone_can_refund_a_contributor() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); - // 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_ata, contributor_account_pda)); - } - - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); + // The maker sends the refund. The tokens and the rent still go to the + // contributor, who signs nothing. + let rent = lamports(&setup, &contributor.contributor_account_pda); + let contributor_lamports_before = lamports(&setup, &contributor.keypair.pubkey()); + let maker = setup.maker.insecure_clone(); + refund(&mut setup, &maker, &contributor).unwrap(); - let (contributor, contributor_ata, contributor_account_pda) = &contributors[0]; - let refund_instruction = build_refund_instruction( - &setup, - &contributor.pubkey(), - contributor_ata, - contributor_account_pda, + assert_eq!( + get_token_account_balance(&setup.svm, &contributor.ata).unwrap(), + CONTRIBUTOR_STARTING_BALANCE ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![refund_instruction], - &[contributor], - &contributor.pubkey(), + assert_eq!( + lamports(&setup, &contributor.keypair.pubkey()), + contributor_lamports_before + rent ); - assert!( - result.is_err(), - "Refunding must fail once the target has been met" +} + +#[test] +fn test_refund_when_target_met_fails() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributors = fund_to_target(&mut setup); + + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); + + let fee_payer = contributors[0].keypair.insecure_clone(); + assert_error( + refund(&mut setup, &fee_payer, &contributors[0]), + FundraiserError::TargetMet, ); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), @@ -609,195 +696,189 @@ fn test_refund_when_target_met_fails() { } #[test] -fn test_check_contributions_success_pays_maker_and_closes_vault() { +fn test_check_contributions_success_pays_maker_and_marks_claimed() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + fund_to_target(&mut setup); - // 10 contributors at the 10% cap reach the target exactly. - 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(); - } + let maker_ata = claim(&mut setup).unwrap(); assert_eq!( - get_token_account_balance(&setup.svm, &setup.vault).unwrap(), + get_token_account_balance(&setup.svm, &maker_ata).unwrap(), AMOUNT_TO_RAISE ); + assert_eq!( + get_token_account_balance(&setup.svm, &setup.vault).unwrap(), + 0 + ); - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let check_instruction = build_check_contributions_instruction(&setup, &maker_ata); - send_transaction_from_instructions( + // The fundraiser stays open, marked claimed, until every contributor + // account written for it is closed. + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert!(fundraiser_state.claimed); + assert_eq!( + fundraiser_state.open_contributor_accounts, + CONTRIBUTIONS_REACHING_TARGET.len() as u32 + ); +} + +#[test] +fn test_second_claim_fails() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + fund_to_target(&mut setup); + let maker_ata = claim(&mut setup).unwrap(); + + // A donation to the vault after the claim must not make a second claim + // possible. + mint_tokens_to_token_account( &mut setup.svm, - vec![check_instruction], - &[&setup.maker], - &setup.maker.pubkey(), + &setup.mint, + &setup.vault, + ONE_TOKEN, + &setup.payer, ) .unwrap(); + setup.svm.expire_blockhash(); + assert_error(claim(&mut setup), FundraiserError::FundraiserClaimed); assert_eq!( get_token_account_balance(&setup.svm, &maker_ata).unwrap(), AMOUNT_TO_RAISE ); - assert!( - setup.svm.get_account(&setup.vault).is_none(), - "Vault token account must be closed after a successful claim" - ); - assert!( - setup.svm.get_account(&setup.fundraiser_pda).is_none(), - "Fundraiser account must be closed after a successful claim" - ); } #[test] -fn test_contribute_above_cap_fails() { +fn test_contribute_after_claim_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + fund_to_target(&mut setup); + claim(&mut setup).unwrap(); - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - - // One minor unit over the 10% cap must fail with ContributionTooBig. - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - MAX_CONTRIBUTION + 1, - ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ); - assert!( - result.is_err(), - "A single contribution above the 10% cap must fail" + // The deadline is still days away, but the vault has been paid out. + warp_days_forward(&mut setup.svm, 1); + let late_contributor = new_contributor(&mut setup); + assert_error( + contribute(&mut setup, &late_contributor, CONTRIBUTION), + FundraiserError::FundraiserClaimed, ); assert_eq!( - get_token_account_balance(&setup.svm, &setup.vault).unwrap(), - 0 + get_token_account_balance(&setup.svm, &late_contributor.ata).unwrap(), + CONTRIBUTOR_STARTING_BALANCE ); } #[test] -fn test_cumulative_contributions_above_cap_fail() { +fn test_check_contributions_ignores_direct_vault_donations() { 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); - - // Each call is under the cap on its own; the second pushes the - // cumulative total over it and must fail with - // MaximumContributionsReached. - let first_contribution = 2 * ONE_TOKEN; - let second_contribution = MAX_CONTRIBUTION - ONE_TOKEN; - - let first_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - first_contribution, - ); - send_transaction_from_instructions( + // Mint the full target straight into the vault, bypassing contribute. + // The state-tracked current_amount stays 0, so the claim must fail. + mint_tokens_to_token_account( &mut setup.svm, - vec![first_instruction], - &[&contributor], - &contributor.pubkey(), + &setup.mint, + &setup.vault, + AMOUNT_TO_RAISE, + &setup.payer, ) .unwrap(); - let second_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - second_contribution, - ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![second_instruction], - &[&contributor], - &contributor.pubkey(), - ); + assert_error(claim(&mut setup), FundraiserError::TargetNotMet); + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert!(!fundraiser_state.claimed); +} + +#[test] +fn test_close_contributor_after_claim_returns_rent() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributors = fund_to_target(&mut setup); + claim(&mut setup).unwrap(); + + let contributor = &contributors[0]; + let rent = lamports(&setup, &contributor.contributor_account_pda); + let lamports_before = lamports(&setup, &contributor.keypair.pubkey()); + + let fee_payer = contributor.keypair.insecure_clone(); + close_contributor(&mut setup, &fee_payer, contributor).unwrap(); + assert!( - result.is_err(), - "Contributions that cumulatively exceed the 10% cap must fail" + setup + .svm + .get_account(&contributor.contributor_account_pda) + .is_none(), + "Contributor account must be closed" ); - + // The contributor paid the transaction fee out of the same balance, so + // the rent came back less that fee. assert_eq!( - get_token_account_balance(&setup.svm, &setup.vault).unwrap(), - first_contribution + lamports(&setup, &contributor.keypair.pubkey()), + lamports_before + rent - TRANSACTION_FEE + ); + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert_eq!( + fundraiser_state.open_contributor_accounts, + CONTRIBUTIONS_REACHING_TARGET.len() as u32 - 1 ); - let contributor_state = read_contributor_state(&setup.svm, &contributor_account_pda); - assert_eq!(contributor_state.amount, first_contribution); } #[test] -fn test_close_fundraiser_after_failed_raise_allows_a_new_raise() { +fn test_anyone_can_close_contributor_accounts_after_claim() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributors = fund_to_target(&mut setup); + claim(&mut setup).unwrap(); + + // The maker closes every contributor account; each rent deposit goes to + // its contributor, who signs nothing. + let maker = setup.maker.insecure_clone(); + for contributor in &contributors { + let rent = lamports(&setup, &contributor.contributor_account_pda); + let lamports_before = lamports(&setup, &contributor.keypair.pubkey()); + close_contributor(&mut setup, &maker, contributor).unwrap(); + assert_eq!( + lamports(&setup, &contributor.keypair.pubkey()), + lamports_before + rent + ); + } - 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(); + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert_eq!(fundraiser_state.open_contributor_accounts, 0); +} - // The raise fails; the contributor takes their refund. - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); - let refund_instruction = build_refund_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![refund_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); +#[test] +fn test_close_contributor_before_claim_fails() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); + + // The fundraiser is unclaimed, so the contribution can still be + // refunded: closing the account now would erase what the vault owes. + let fee_payer = contributor.keypair.insecure_clone(); + assert_error( + close_contributor(&mut setup, &fee_payer, &contributor), + FundraiserError::FundraiserNotClaimed, + ); + let contributor_state = + read_contributor_state(&setup.svm, &contributor.contributor_account_pda); + assert_eq!(contributor_state.amount, CONTRIBUTION); +} - // The maker retires the failed fundraiser. - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let close_instruction = build_close_fundraiser_instruction(&setup, &maker_ata); - send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[&setup.maker], - &setup.maker.pubkey(), - ) - .unwrap(); +#[test] +fn test_close_fundraiser_after_failed_raise_allows_a_new_raise() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); + // The raise fails; the contributor takes their refund. + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); + let fee_payer = contributor.keypair.insecure_clone(); + refund(&mut setup, &fee_payer, &contributor).unwrap(); + + close_fundraiser(&mut setup).unwrap(); assert!( setup.svm.get_account(&setup.vault).is_none(), "Vault token account must be closed with the fundraiser" @@ -827,17 +908,12 @@ fn test_close_fundraiser_before_deadline_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let close_instruction = build_close_fundraiser_instruction(&setup, &maker_ata); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[&setup.maker], - &setup.maker.pubkey(), - ); - assert!( - result.is_err(), - "Closing a fundraiser before its deadline must fail" + // One day short of the deadline. + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 - 1); + + assert_error( + close_fundraiser(&mut setup), + FundraiserError::FundraiserNotEnded, ); assert!( setup.svm.get_account(&setup.fundraiser_pda).is_some(), @@ -849,86 +925,33 @@ fn test_close_fundraiser_before_deadline_fails() { fn test_close_fundraiser_with_unrefunded_contributions_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(); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); // Past the deadline but the contribution has not been refunded, so // closing would strand it in the vault. - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let close_instruction = build_close_fundraiser_instruction(&setup, &maker_ata); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[&setup.maker], - &setup.maker.pubkey(), - ); - assert!( - result.is_err(), - "Closing must fail while contributions remain unrefunded" + assert_error( + close_fundraiser(&mut setup), + FundraiserError::RefundsOutstanding, ); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), - MAX_CONTRIBUTION + CONTRIBUTION ); } #[test] -fn test_close_fundraiser_when_target_met_fails() { +fn test_close_fundraiser_when_target_met_but_unclaimed_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + fund_to_target(&mut setup); - // 10 contributors at the 10% cap reach the target exactly. - 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(); - } + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); - - // A successful raise exits through check_contributions, never close. - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let close_instruction = build_close_fundraiser_instruction(&setup, &maker_ata); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[&setup.maker], - &setup.maker.pubkey(), - ); - assert!( - result.is_err(), - "Closing must fail when the target was met; the claim is the exit" - ); + // A raise that met its target closes only after the maker claims it. + assert_error(close_fundraiser(&mut setup), FundraiserError::TargetMet); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), AMOUNT_TO_RAISE @@ -936,200 +959,169 @@ fn test_close_fundraiser_when_target_met_fails() { } #[test] -fn test_close_fundraiser_sweeps_direct_donations_to_maker() { +fn test_close_fundraiser_with_open_contributor_accounts_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributors = fund_to_target(&mut setup); + claim(&mut setup).unwrap(); - // Tokens sent straight to the vault are outside the program's - // accounting; on close they go to the maker instead of being burned - // with the account. - let donation = 5 * ONE_TOKEN; - mint_tokens_to_token_account( - &mut setup.svm, - &setup.mint, - &setup.vault, - donation, - &setup.payer, - ) - .unwrap(); - - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); - - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let close_instruction = build_close_fundraiser_instruction(&setup, &maker_ata); - send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[&setup.maker], - &setup.maker.pubkey(), - ) - .unwrap(); + // Close all but one contributor account. + let maker = setup.maker.insecure_clone(); + for contributor in &contributors[1..] { + close_contributor(&mut setup, &maker, contributor).unwrap(); + } - assert_eq!( - get_token_account_balance(&setup.svm, &maker_ata).unwrap(), - donation + assert_error( + close_fundraiser(&mut setup), + FundraiserError::ContributorAccountsOpen, ); - assert!(setup.svm.get_account(&setup.fundraiser_pda).is_none()); - assert!(setup.svm.get_account(&setup.vault).is_none()); + assert!(setup.svm.get_account(&setup.fundraiser_pda).is_some()); } #[test] -fn test_check_contributions_ignores_direct_vault_donations() { +fn test_reinitialize_with_open_contributor_accounts_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + fund_to_target(&mut setup); + claim(&mut setup).unwrap(); - // Mint the full target straight into the vault, bypassing contribute. - // The state-tracked current_amount stays 0, so the claim must fail. - mint_tokens_to_token_account( - &mut setup.svm, - &setup.mint, - &setup.vault, - AMOUNT_TO_RAISE, - &setup.payer, - ) - .unwrap(); - - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let check_instruction = build_check_contributions_instruction(&setup, &maker_ata); + // The claimed fundraiser account still exists, so a new fundraiser + // cannot be initialized at its address. + setup.svm.expire_blockhash(); + let initialize_instruction = Instruction::new_with_bytes( + setup.program_id, + &fundraiser::instruction::InitializeFundraiser { + amount: AMOUNT_TO_RAISE, + duration: DURATION_DAYS, + } + .data(), + fundraiser::accounts::InitializeFundraiserAccountConstraints { + maker: setup.maker.pubkey(), + mint_to_raise: setup.mint, + fundraiser: setup.fundraiser_pda, + vault: setup.vault, + system_program: system_program::id(), + token_program: token_program_id(), + associated_token_program: ata_program_id(), + } + .to_account_metas(None), + ); let result = send_transaction_from_instructions( &mut setup.svm, - vec![check_instruction], + vec![initialize_instruction], &[&setup.maker], &setup.maker.pubkey(), ); assert!( result.is_err(), - "Direct donations to the vault must not unlock the claim" - ); - assert!( - setup.svm.get_account(&setup.fundraiser_pda).is_some(), - "Fundraiser account must stay open after a failed claim" + "A new fundraiser must not start while the claimed one exists" ); + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert!(fundraiser_state.claimed); } #[test] -fn test_close_contributor_after_successful_claim_returns_rent() { +fn test_stale_contributor_account_cannot_refund_from_next_raise() { 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)); + // Raise one succeeds and the maker claims it. + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let first_raise_contributors = fund_to_target(&mut setup); + claim(&mut setup).unwrap(); + + // The maker closes every contributor account from raise one, then the + // fundraiser, and starts raise two at the same address. + let maker = setup.maker.insecure_clone(); + for contributor in &first_raise_contributors { + close_contributor(&mut setup, &maker, contributor).unwrap(); } + close_fundraiser(&mut setup).unwrap(); + setup.svm.expire_blockhash(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - 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" + // Raise two collects less than the target and fails. + let second_raise_contributors: Vec = (0..2) + .map(|_| { + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); + contributor + }) + .collect(); + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); + + // A raise-one contributor tries to take a refund from raise two. Their + // contributor account was closed with raise one, so there is nothing to + // refund. + let stale_contributor = &first_raise_contributors[0]; + let fee_payer = stale_contributor.keypair.insecure_clone(); + assert!(refund(&mut setup, &fee_payer, stale_contributor).is_err()); + assert_eq!( + get_token_account_balance(&setup.svm, &setup.vault).unwrap(), + 2 * CONTRIBUTION ); - // 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; + // Every raise-two contributor is refunded in full. + for contributor in &second_raise_contributors { + let fee_payer = contributor.keypair.insecure_clone(); + refund(&mut setup, &fee_payer, contributor).unwrap(); + assert_eq!( + get_token_account_balance(&setup.svm, &contributor.ata).unwrap(), + CONTRIBUTOR_STARTING_BALANCE + ); + } + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert_eq!(fundraiser_state.current_amount, 0); + assert_eq!(fundraiser_state.open_contributor_accounts, 0); +} - 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(); +#[test] +fn test_close_fundraiser_after_claim_allows_a_new_raise() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributors = fund_to_target(&mut setup); + claim(&mut setup).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" - ); + let maker = setup.maker.insecure_clone(); + for contributor in &contributors { + close_contributor(&mut setup, &maker, contributor).unwrap(); + } + close_fundraiser(&mut setup).unwrap(); + assert!(setup.svm.get_account(&setup.fundraiser_pda).is_none()); + assert!(setup.svm.get_account(&setup.vault).is_none()); + + setup.svm.expire_blockhash(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert!(!fundraiser_state.claimed); + assert_eq!(fundraiser_state.current_amount, 0); } #[test] -fn test_close_contributor_while_fundraiser_open_fails() { +fn test_close_fundraiser_sweeps_direct_donations_to_maker() { 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( + // Tokens sent straight to the vault are outside the program's + // accounting; on close they go to the maker instead of being burned + // with the account. + let donation = 5 * ONE_TOKEN; + mint_tokens_to_token_account( &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), + &setup.mint, + &setup.vault, + donation, + &setup.payer, ) .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" + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); + close_fundraiser(&mut setup).unwrap(); + + let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); + assert_eq!( + get_token_account_balance(&setup.svm, &maker_ata).unwrap(), + donation ); - let contributor_state = read_contributor_state(&setup.svm, &contributor_account_pda); - assert_eq!(contributor_state.amount, MAX_CONTRIBUTION); + assert!(setup.svm.get_account(&setup.fundraiser_pda).is_none()); + assert!(setup.svm.get_account(&setup.vault).is_none()); } diff --git a/finance/fundraiser/anchor/CHANGELOG.md b/finance/fundraiser/anchor/CHANGELOG.md index 6f0c1965a..a5da1b256 100644 --- a/finance/fundraiser/anchor/CHANGELOG.md +++ b/finance/fundraiser/anchor/CHANGELOG.md @@ -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. diff --git a/finance/fundraiser/anchor/README.md b/finance/fundraiser/anchor/README.md index 31b463231..30bdf33de 100644 --- a/finance/fundraiser/anchor/README.md +++ b/finance/fundraiser/anchor/README.md @@ -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 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. +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, anyone closes each contributor's record to return its rent to that contributor, and the maker then closes the fundraiser; if it is not reached by the deadline, anyone can refund each contributor, and once refunds are complete the maker closes the fundraiser. Either way, the maker can then open a new one. This example was called **Token Fundraiser** (`finance/token-fundraiser`) until it was renamed: contributors receive no token, only a refund if the target is missed. @@ -24,6 +24,8 @@ pub struct Fundraiser { pub current_amount: u64, pub time_started: i64, pub duration: u16, + pub claimed: bool, + pub open_contributor_accounts: u32, pub bump: u8, } ``` @@ -36,6 +38,8 @@ Fields: - `current_amount` - total amount contributed through the `contribute` handler. This tracked total, not the vault balance, is what `check_contributions` and `refund` compare against the target, so tokens sent directly to the vault cannot trigger an early release or block refunds. - `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. - `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). @@ -55,7 +59,7 @@ pub struct Contributor { - `amount` - total amount contributed by this contributor. - `bump` - canonical bump for the Contributor PDA. -The Contributor PDA uses `init_if_needed`, which only runs the init branch on first call. The handler stores `bumps.contributor_account` into `bump` on first init (when `bump == 0`); see [`instructions/contribute.rs`](programs/fundraiser/src/instructions/contribute.rs). +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). ### Constants @@ -64,11 +68,9 @@ From [`constants.rs`](programs/fundraiser/src/constants.rs): ```rust 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; ``` -`MAX_CONTRIBUTION_PERCENTAGE / PERCENTAGE_SCALER` = 10%, the per-contributor cap. `MIN_AMOUNT_TO_RAISE` is the minimum target in major units. +`MIN_AMOUNT_TO_RAISE` is the minimum target in major units. ### Code layout @@ -80,75 +82,75 @@ All token accounts use `anchor_spl::token_interface` types (`InterfaceAccount= 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` 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_contributor_accounts = 0`, and `time_started` from the `Clock` sysvar. A target below the minimum fails with `InvalidAmount`. ### `contribute` [`programs/fundraiser/src/instructions/contribute.rs`](programs/fundraiser/src/instructions/contribute.rs), account constraints `ContributeAccountConstraints`. -A contributor signs and the handler performs four checks in order: +A contributor signs and the handler performs three checks in order: 1. Minimum contribution: `amount >= 10^decimals` (one major unit of the mint), else `ContributionTooSmall`. -2. Per-call cap: `amount <= amount_to_raise * MAX_CONTRIBUTION_PERCENTAGE / PERCENTAGE_SCALER` (10% of the target), else `ContributionTooBig`. +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`. -4. Cumulative cap: the contributor's running total (existing + new) must not exceed the same 10% cap, else `MaximumContributionsReached`. -If all checks pass, `Fundraiser.current_amount` and `Contributor.amount` are updated, then `amount` is transferred from `contributor_ata` to `vault` with `transfer_checked`. +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`. ### `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 `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 then, signing both CPIs with the Fundraiser PDA's seeds: +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. -1. Transfers the entire vault balance (including any direct donations) to `maker_ata` with `transfer_checked`. -2. Closes the empty vault token account with `close_account`, returning its rent to the maker. - -The Fundraiser state account is closed via the `close = maker` constraint, so the maker also recovers that [rent](https://solana.com/docs/terminology#rent). +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. ### `refund` [`programs/fundraiser/src/instructions/refund.rs`](programs/fundraiser/src/instructions/refund.rs), account constraints `RefundAccountConstraints`. -Lets a contributor reclaim their contribution after a failed fundraiser. Two checks: +Returns a contribution after a failed fundraiser. The contributor does not have to sign: the tokens go to their token account and the rent to them, whoever sends the transaction, so the maker can refund every contributor and close a failed fundraiser without waiting on any of them. Two checks: 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` 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. +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. ### `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: +Closes a finished fundraiser and its vault so the maker can raise again. The Fundraiser PDA is derived from `b"fundraiser"` and the maker's public key alone, so while a Fundraiser account exists the maker cannot initialize another one. + +For an unclaimed fundraiser, 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). +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). -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`. +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. + +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` [`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. +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. -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`. +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`. ## Testing @@ -159,22 +161,28 @@ 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, 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. +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. + +`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. + +`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. ## 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. +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? -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. +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. ### 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. +After the deadline, `refund` returns each contributor exactly what they put in; anyone can send it. Once refunds are complete, the maker calls `close_fundraiser` to retire the failed raise and can then open a new one. ### How is this fundraiser tested and verified? -`anchor build` then `cargo test` runs LiteSVM tests that warp the clock across the deadline to exercise contribution windows, per-contributor caps, claims, refunds, and closing. The arithmetic has [Kani](https://github.com/model-checking/kani) model checks in [`../kani-proofs/`](../kani-proofs/). +`anchor build` then `cargo test` runs LiteSVM tests that warp the clock across the deadline to exercise contribution windows, claims, refunds, and closing on both paths. The arithmetic has [Kani](https://github.com/model-checking/kani) model checks in [`../kani-proofs/`](../kani-proofs/). diff --git a/finance/fundraiser/anchor/programs/fundraiser/src/constants.rs b/finance/fundraiser/anchor/programs/fundraiser/src/constants.rs index 2af19dd91..87e10ebc5 100644 --- a/finance/fundraiser/anchor/programs/fundraiser/src/constants.rs +++ b/finance/fundraiser/anchor/programs/fundraiser/src/constants.rs @@ -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; diff --git a/finance/fundraiser/anchor/programs/fundraiser/src/error.rs b/finance/fundraiser/anchor/programs/fundraiser/src/error.rs index 06e94d8a1..f386accc6 100644 --- a/finance/fundraiser/anchor/programs/fundraiser/src/error.rs +++ b/finance/fundraiser/anchor/programs/fundraiser/src/error.rs @@ -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")] @@ -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, } diff --git a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/checker.rs b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/checker.rs index 16b5916bd..0d58eabc4 100644 --- a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/checker.rs +++ b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/checker.rs @@ -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}; @@ -20,7 +17,6 @@ pub struct CheckContributionsAccountConstraints { mut, seeds = [b"fundraiser".as_ref(), maker.address().as_ref()], bump = fundraiser.bump, - close = maker, )] pub fundraiser: BorshAccount, @@ -48,9 +44,23 @@ pub struct CheckContributionsAccountConstraints { pub associated_token_program: Program, } +/// 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!( @@ -58,27 +68,29 @@ pub fn handle_check_contributions( FundraiserError::TargetNotMet ); + accounts.fundraiser.claimed = true; + // Read these before any of the CPI handles below take their borrows. let maker_address = *accounts.maker.address(); let vault_amount = accounts.vault.amount(); let mint_decimals = accounts.mint_to_raise.decimals(); - // `fundraiser` signs both CPIs below. It is a data account holding a live + // `fundraiser` signs the CPI below. It is a data account holding a live // borrow on its buffer, so release it across the CPIs. The runtime rejects // a CPI that borrows an account we still hold. Take it back after. let fundraiser_bump = accounts.fundraiser.bump; accounts.fundraiser.release_borrow()?; let fundraiser_view = *accounts.fundraiser.account(); - // The vault is owned by the fundraiser PDA, so both CPIs are signed with - // its seeds. + // 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(), maker_address.as_ref(), &[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.cpi_handle_mut(), mint: accounts.mint_to_raise.cpi_handle(), @@ -92,19 +104,6 @@ pub fn handle_check_contributions( ); transfer_checked(transfer_context, vault_amount, mint_decimals)?; - // Close the empty vault so its rent goes back to the maker. - let close_accounts = CloseAccount { - account: accounts.vault.cpi_handle_mut(), - destination: accounts.maker.cpi_handle_mut(), - authority: CpiHandle::readonly(&fundraiser_view), - }; - let close_context = CpiContext::new_with_signer( - accounts.token_program.address(), - close_accounts, - &signer_seeds, - ); - close_account(close_context)?; - // Take the borrow back before the derive's exit path touches it again. accounts.fundraiser.reacquire_borrow_mut()?; diff --git a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/close.rs b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/close.rs index 4873bf3b7..cf7b0ab97 100644 --- a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/close.rs +++ b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/close.rs @@ -49,38 +49,47 @@ pub struct CloseFundraiserAccountConstraints { pub associated_token_program: Program, } -/// 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 ); // Read these before any of the CPI handles below take their borrows. @@ -103,9 +112,9 @@ pub fn handle_close_fundraiser(accounts: &mut CloseFundraiserAccountConstraints) &[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.cpi_handle_mut(), diff --git a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/close_contributor.rs b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/close_contributor.rs index 944f3fb35..20121d869 100644 --- a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/close_contributor.rs +++ b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/close_contributor.rs @@ -1,21 +1,23 @@ use anchor_lang::prelude::*; -use crate::{state::Contributor, FundraiserError}; +use crate::{ + state::{Contributor, Fundraiser}, + FundraiserError, +}; #[derive(Accounts)] pub struct CloseContributorAccountConstraints { + /// 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, + pub contributor: SystemAccount, - /// 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, + mut, + constraint = fundraiser.claimed @ FundraiserError::FundraiserNotClaimed, )] - pub fundraiser: UncheckedAccount, + pub fundraiser: BorshAccount, #[account( mut, @@ -26,20 +28,20 @@ pub struct CloseContributorAccountConstraints { pub contributor_account: BorshAccount, } -/// 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(()) } diff --git a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/contribute.rs b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/contribute.rs index d6b7e306f..6713235d0 100644 --- a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/contribute.rs +++ b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/contribute.rs @@ -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)] @@ -53,18 +53,6 @@ pub struct ContributeAccountConstraints { pub system_program: Program, } -/// 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 { - (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(|_| FundraiserError::MathOverflow.into()) -} - pub fn handle_contribute( accounts: &mut ContributeAccountConstraints, amount: u64, @@ -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) @@ -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 @@ -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. diff --git a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/initialize_fundraiser.rs b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/initialize_fundraiser.rs index 0f7adf7ce..17a35dae9 100644 --- a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/initialize_fundraiser.rs +++ b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/initialize_fundraiser.rs @@ -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, }; diff --git a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/refund.rs b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/refund.rs index 7e54c7a00..42f4150a6 100644 --- a/finance/fundraiser/anchor/programs/fundraiser/src/instructions/refund.rs +++ b/finance/fundraiser/anchor/programs/fundraiser/src/instructions/refund.rs @@ -10,8 +10,12 @@ use crate::{ #[derive(Accounts)] pub struct RefundAccountConstraints { + /// Not a signer: the tokens go to the contributor's token account and the + /// rent to the contributor, whoever sends the transaction. So a maker can + /// refund every contributor and close a failed fundraiser without waiting + /// on any of them. #[account(mut)] - pub contributor: Signer, + pub contributor: SystemAccount, pub maker: SystemAccount, @@ -84,6 +88,11 @@ pub fn handle_refund(accounts: &mut RefundAccountConstraints) -> Result<()> { .checked_sub(refund_amount) .ok_or(FundraiserError::MathOverflow)?; accounts.contributor_account.amount = 0; + accounts.fundraiser.open_contributor_accounts = accounts + .fundraiser + .open_contributor_accounts + .checked_sub(1) + .ok_or(FundraiserError::MathOverflow)?; // Read these before any CPI handle below takes its borrow. `maker` is a // read-only account here, so asking it for a writable handle would panic. diff --git a/finance/fundraiser/anchor/programs/fundraiser/src/lib.rs b/finance/fundraiser/anchor/programs/fundraiser/src/lib.rs index 10bbe3c58..bd0c8c5c4 100644 --- a/finance/fundraiser/anchor/programs/fundraiser/src/lib.rs +++ b/finance/fundraiser/anchor/programs/fundraiser/src/lib.rs @@ -8,7 +8,7 @@ mod instructions; mod state; pub use constants::*; -use error::*; +pub use error::*; use instructions::*; #[program] diff --git a/finance/fundraiser/anchor/programs/fundraiser/src/state/fundraiser.rs b/finance/fundraiser/anchor/programs/fundraiser/src/state/fundraiser.rs index 12ba8bc72..34e818392 100644 --- a/finance/fundraiser/anchor/programs/fundraiser/src/state/fundraiser.rs +++ b/finance/fundraiser/anchor/programs/fundraiser/src/state/fundraiser.rs @@ -9,5 +9,13 @@ pub struct Fundraiser { pub current_amount: u64, pub time_started: i64, pub duration: u16, + /// Set by `check_contributions`. A claimed fundraiser accepts no more + /// contributions and no second claim, and stays open until every + /// contributor account written for it has been closed. + pub claimed: bool, + /// How many contributor accounts written for this fundraiser are still + /// open. `close_fundraiser` requires zero, so a new fundraiser at the + /// same address never starts with contributor accounts from an old one. + pub open_contributor_accounts: u32, pub bump: u8, } diff --git a/finance/fundraiser/anchor/programs/fundraiser/tests/test_fundraiser.rs b/finance/fundraiser/anchor/programs/fundraiser/tests/test_fundraiser.rs index 60d94dd35..f9fd00e0e 100644 --- a/finance/fundraiser/anchor/programs/fundraiser/tests/test_fundraiser.rs +++ b/finance/fundraiser/anchor/programs/fundraiser/tests/test_fundraiser.rs @@ -5,7 +5,7 @@ use { }, anchor_v2_testing::{Keypair, LiteSVM, Signer}, borsh::BorshDeserialize, - fundraiser::SECONDS_TO_DAYS, + fundraiser::{FundraiserError, SECONDS_TO_DAYS}, // LiteSVM's get_sysvar wants the host-side Clock, not pinocchio's. solana_clock::Clock, solana_kite::{ @@ -20,10 +20,14 @@ const MINT_DECIMALS: u8 = 6; const ONE_TOKEN: u64 = 1_000_000; /// Comfortably above the program's 3-major-unit minimum target. const AMOUNT_TO_RAISE: u64 = 30 * ONE_TOKEN; -/// The per-contributor cap is 10% of the target. -const MAX_CONTRIBUTION: u64 = AMOUNT_TO_RAISE / 10; +/// Three unequal contributions that together reach the target exactly. +const CONTRIBUTIONS_REACHING_TARGET: [u64; 3] = [12 * ONE_TOKEN, 10 * ONE_TOKEN, 8 * ONE_TOKEN]; +/// A contribution well short of the target on its own. +const CONTRIBUTION: u64 = 4 * ONE_TOKEN; const DURATION_DAYS: u16 = 7; -const CONTRIBUTOR_STARTING_BALANCE: u64 = 10 * ONE_TOKEN; +const CONTRIBUTOR_STARTING_BALANCE: u64 = 20 * ONE_TOKEN; +/// LiteSVM's fee for a transaction with one signature. +const TRANSACTION_FEE: u64 = 5_000; fn token_program_id() -> Address { "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" @@ -55,6 +59,8 @@ struct FundraiserState { current_amount: u64, _time_started: i64, duration: u16, + claimed: bool, + open_contributor_accounts: u32, _bump: u8, } @@ -286,6 +292,154 @@ fn build_close_fundraiser_instruction(setup: &FundraiserSetup, maker_ata: &Addre ) } +struct FundedContributor { + keypair: Keypair, + ata: Address, + contributor_account_pda: Address, +} + +/// Sends `contribute` for the given contributor and amount, signed by the +/// contributor. +fn contribute( + setup: &mut FundraiserSetup, + contributor: &FundedContributor, + amount: u64, +) -> Result<(), String> { + let contribute_instruction = build_contribute_instruction( + setup, + &contributor.keypair.pubkey(), + &contributor.ata, + &contributor.contributor_account_pda, + amount, + ); + send_transaction_from_instructions( + &mut setup.svm, + vec![contribute_instruction], + &[&contributor.keypair], + &contributor.keypair.pubkey(), + ) + .map(|_| ()) + .map_err(|error| format!("{error:?}")) +} + +fn new_contributor(setup: &mut FundraiserSetup) -> FundedContributor { + let (keypair, ata, contributor_account_pda) = create_funded_contributor(setup); + FundedContributor { + keypair, + ata, + contributor_account_pda, + } +} + +/// Creates three contributors whose contributions reach the target exactly. +fn fund_to_target(setup: &mut FundraiserSetup) -> Vec { + CONTRIBUTIONS_REACHING_TARGET + .iter() + .map(|amount| { + let contributor = new_contributor(setup); + contribute(setup, &contributor, *amount).unwrap(); + contributor + }) + .collect() +} + +/// Sends `refund` for `contributor`, paid for by `fee_payer`, who need not be the contributor. +fn refund( + setup: &mut FundraiserSetup, + fee_payer: &Keypair, + contributor: &FundedContributor, +) -> Result<(), String> { + let refund_instruction = build_refund_instruction( + setup, + &contributor.keypair.pubkey(), + &contributor.ata, + &contributor.contributor_account_pda, + ); + send_transaction_from_instructions( + &mut setup.svm, + vec![refund_instruction], + &[fee_payer], + &fee_payer.pubkey(), + ) + .map(|_| ()) + .map_err(|error| format!("{error:?}")) +} + +/// Sends `check_contributions`, signed by the maker. +fn claim(setup: &mut FundraiserSetup) -> Result { + let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); + let check_instruction = build_check_contributions_instruction(setup, &maker_ata); + let maker = setup.maker.insecure_clone(); + send_transaction_from_instructions( + &mut setup.svm, + vec![check_instruction], + &[&maker], + &maker.pubkey(), + ) + .map(|_| maker_ata) + .map_err(|error| format!("{error:?}")) +} + +/// Sends `close_contributor` for `contributor`, signed and paid for by +/// `fee_payer`. +fn close_contributor( + setup: &mut FundraiserSetup, + fee_payer: &Keypair, + contributor: &FundedContributor, +) -> Result<(), String> { + let close_instruction = build_close_contributor_instruction( + setup, + &contributor.keypair.pubkey(), + &contributor.contributor_account_pda, + ); + send_transaction_from_instructions( + &mut setup.svm, + vec![close_instruction], + &[fee_payer], + &fee_payer.pubkey(), + ) + .map(|_| ()) + .map_err(|error| format!("{error:?}")) +} + +/// Sends `close_fundraiser`, signed by the maker. +fn close_fundraiser(setup: &mut FundraiserSetup) -> Result<(), String> { + let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); + let close_instruction = build_close_fundraiser_instruction(setup, &maker_ata); + let maker = setup.maker.insecure_clone(); + send_transaction_from_instructions( + &mut setup.svm, + vec![close_instruction], + &[&maker], + &maker.pubkey(), + ) + .map(|_| ()) + .map_err(|error| format!("{error:?}")) +} + +fn lamports(setup: &FundraiserSetup, address: &Address) -> u64 { + setup + .svm + .get_account(address) + .map_or(0, |account| account.lamports) +} + +/// Anchor numbers a program's `#[error_code]` variants from 6000. +const ANCHOR_ERROR_CODE_OFFSET: u32 = 6000; + +/// Asserts that a transaction failed with the given program error. +fn assert_error(result: Result, expected_error: FundraiserError) { + let error = result.expect_err("transaction should have failed"); + let expected_code = format!( + "Custom({})", + ANCHOR_ERROR_CODE_OFFSET + expected_error as u32 + ); + assert!( + error.contains(&expected_code), + "expected {expected_code}, got: {error}" + ); +} + #[test] fn test_initialize_fundraiser() { let mut setup = full_setup(); @@ -296,6 +450,8 @@ fn test_initialize_fundraiser() { assert_eq!(fundraiser_state.amount_to_raise, AMOUNT_TO_RAISE); assert_eq!(fundraiser_state.current_amount, 0); assert_eq!(fundraiser_state.duration, DURATION_DAYS); + assert!(!fundraiser_state.claimed); + assert_eq!(fundraiser_state.open_contributor_accounts, 0); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), @@ -332,11 +488,9 @@ fn test_initialize_below_minimum_target_fails() { vec![initialize_instruction], &[&setup.maker], &setup.maker.pubkey(), - ); - assert!( - result.is_err(), - "Target below 3 major units must be rejected" - ); + ) + .map_err(|error| format!("{error:?}")); + assert_error(result, FundraiserError::InvalidAmount); assert!( setup.svm.get_account(&setup.fundraiser_pda).is_none(), "Fundraiser account must not exist after a failed initialize" @@ -347,76 +501,78 @@ fn test_initialize_below_minimum_target_fails() { fn test_contribute_inside_window_succeeds() { 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 contributor = new_contributor(&mut setup); // One day in: well inside the 7-day window. warp_days_forward(&mut setup.svm, 1); - - 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(); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), - MAX_CONTRIBUTION + CONTRIBUTION ); assert_eq!( - get_token_account_balance(&setup.svm, &contributor_ata).unwrap(), - CONTRIBUTOR_STARTING_BALANCE - MAX_CONTRIBUTION + get_token_account_balance(&setup.svm, &contributor.ata).unwrap(), + CONTRIBUTOR_STARTING_BALANCE - CONTRIBUTION ); let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); - assert_eq!(fundraiser_state.current_amount, MAX_CONTRIBUTION); + assert_eq!(fundraiser_state.current_amount, CONTRIBUTION); + assert_eq!(fundraiser_state.open_contributor_accounts, 1); - let contributor_state = read_contributor_state(&setup.svm, &contributor_account_pda); - assert_eq!(contributor_state.amount, MAX_CONTRIBUTION); + let contributor_state = + read_contributor_state(&setup.svm, &contributor.contributor_account_pda); + assert_eq!(contributor_state.amount, CONTRIBUTION); } #[test] -fn test_contribute_after_deadline_fails() { +fn test_contributions_accumulate_in_one_contributor_account() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - - // One day past the deadline. - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); + let first_contribution = 5 * ONE_TOKEN; + let second_contribution = 3 * ONE_TOKEN; + contribute(&mut setup, &contributor, first_contribution).unwrap(); + warp_days_forward(&mut setup.svm, 1); + contribute(&mut setup, &contributor, second_contribution).unwrap(); - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - ONE_TOKEN, + let contributor_state = + read_contributor_state(&setup.svm, &contributor.contributor_account_pda); + assert_eq!( + contributor_state.amount, + first_contribution + second_contribution ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), + + // A second contribution adds to the existing account rather than + // creating another, so the fundraiser still counts one. + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert_eq!( + fundraiser_state.current_amount, + first_contribution + second_contribution ); - assert!(result.is_err(), "Contributing after the deadline must fail"); + assert_eq!(fundraiser_state.open_contributor_accounts, 1); +} + +#[test] +fn test_contribute_after_deadline_fails() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); + + // The deadline falls exactly DURATION_DAYS after the start. + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); + assert_error( + contribute(&mut setup, &contributor, ONE_TOKEN), + FundraiserError::FundraiserEnded, + ); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), 0 ); assert_eq!( - get_token_account_balance(&setup.svm, &contributor_ata).unwrap(), + get_token_account_balance(&setup.svm, &contributor.ata).unwrap(), CONTRIBUTOR_STARTING_BALANCE ); } @@ -425,26 +581,11 @@ fn test_contribute_after_deadline_fails() { fn test_contribute_below_one_major_unit_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); - 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, - ONE_TOKEN - 1, - ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ); - assert!( - result.is_err(), - "Contributions below one major unit must fail" + assert_error( + contribute(&mut setup, &contributor, ONE_TOKEN - 1), + FundraiserError::ContributionTooSmall, ); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), @@ -456,40 +597,17 @@ fn test_contribute_below_one_major_unit_fails() { fn test_refund_before_deadline_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, ONE_TOKEN).unwrap(); - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); + // One day short of the deadline. + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 - 1); - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - ONE_TOKEN, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); - - // Still inside the window: refund must fail with FundraiserNotEnded. - let refund_instruction = build_refund_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![refund_instruction], - &[&contributor], - &contributor.pubkey(), + let fee_payer = contributor.keypair.insecure_clone(); + assert_error( + refund(&mut setup, &fee_payer, &contributor), + FundraiserError::FundraiserNotEnded, ); - assert!(result.is_err(), "Refunding before the deadline must fail"); - assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), ONE_TOKEN @@ -502,105 +620,74 @@ fn test_refund_before_deadline_fails() { fn test_refund_after_deadline_target_not_met_succeeds() { 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(); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); // Past the deadline, target not met: refund must succeed. - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); - let refund_instruction = build_refund_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![refund_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); + let fee_payer = contributor.keypair.insecure_clone(); + refund(&mut setup, &fee_payer, &contributor).unwrap(); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), 0 ); assert_eq!( - get_token_account_balance(&setup.svm, &contributor_ata).unwrap(), + get_token_account_balance(&setup.svm, &contributor.ata).unwrap(), CONTRIBUTOR_STARTING_BALANCE ); let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); assert_eq!(fundraiser_state.current_amount, 0); + assert_eq!(fundraiser_state.open_contributor_accounts, 0); assert!( - setup.svm.get_account(&contributor_account_pda).is_none(), + setup + .svm + .get_account(&contributor.contributor_account_pda) + .is_none(), "Contributor account must be closed after refund" ); } #[test] -fn test_refund_when_target_met_fails() { +fn test_anyone_can_refund_a_contributor() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); - // 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_ata, contributor_account_pda)); - } - - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); + // The maker sends the refund. The tokens and the rent still go to the + // contributor, who signs nothing. + let rent = lamports(&setup, &contributor.contributor_account_pda); + let contributor_lamports_before = lamports(&setup, &contributor.keypair.pubkey()); + let maker = setup.maker.insecure_clone(); + refund(&mut setup, &maker, &contributor).unwrap(); - let (contributor, contributor_ata, contributor_account_pda) = &contributors[0]; - let refund_instruction = build_refund_instruction( - &setup, - &contributor.pubkey(), - contributor_ata, - contributor_account_pda, + assert_eq!( + get_token_account_balance(&setup.svm, &contributor.ata).unwrap(), + CONTRIBUTOR_STARTING_BALANCE ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![refund_instruction], - &[contributor], - &contributor.pubkey(), + assert_eq!( + lamports(&setup, &contributor.keypair.pubkey()), + contributor_lamports_before + rent ); - assert!( - result.is_err(), - "Refunding must fail once the target has been met" +} + +#[test] +fn test_refund_when_target_met_fails() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributors = fund_to_target(&mut setup); + + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); + + let fee_payer = contributors[0].keypair.insecure_clone(); + assert_error( + refund(&mut setup, &fee_payer, &contributors[0]), + FundraiserError::TargetMet, ); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), @@ -609,195 +696,189 @@ fn test_refund_when_target_met_fails() { } #[test] -fn test_check_contributions_success_pays_maker_and_closes_vault() { +fn test_check_contributions_success_pays_maker_and_marks_claimed() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + fund_to_target(&mut setup); - // 10 contributors at the 10% cap reach the target exactly. - 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(); - } + let maker_ata = claim(&mut setup).unwrap(); assert_eq!( - get_token_account_balance(&setup.svm, &setup.vault).unwrap(), + get_token_account_balance(&setup.svm, &maker_ata).unwrap(), AMOUNT_TO_RAISE ); + assert_eq!( + get_token_account_balance(&setup.svm, &setup.vault).unwrap(), + 0 + ); - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let check_instruction = build_check_contributions_instruction(&setup, &maker_ata); - send_transaction_from_instructions( + // The fundraiser stays open, marked claimed, until every contributor + // account written for it is closed. + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert!(fundraiser_state.claimed); + assert_eq!( + fundraiser_state.open_contributor_accounts, + CONTRIBUTIONS_REACHING_TARGET.len() as u32 + ); +} + +#[test] +fn test_second_claim_fails() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + fund_to_target(&mut setup); + let maker_ata = claim(&mut setup).unwrap(); + + // A donation to the vault after the claim must not make a second claim + // possible. + mint_tokens_to_token_account( &mut setup.svm, - vec![check_instruction], - &[&setup.maker], - &setup.maker.pubkey(), + &setup.mint, + &setup.vault, + ONE_TOKEN, + &setup.payer, ) .unwrap(); + setup.svm.expire_blockhash(); + assert_error(claim(&mut setup), FundraiserError::FundraiserClaimed); assert_eq!( get_token_account_balance(&setup.svm, &maker_ata).unwrap(), AMOUNT_TO_RAISE ); - assert!( - setup.svm.get_account(&setup.vault).is_none(), - "Vault token account must be closed after a successful claim" - ); - assert!( - setup.svm.get_account(&setup.fundraiser_pda).is_none(), - "Fundraiser account must be closed after a successful claim" - ); } #[test] -fn test_contribute_above_cap_fails() { +fn test_contribute_after_claim_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + fund_to_target(&mut setup); + claim(&mut setup).unwrap(); - let (contributor, contributor_ata, contributor_account_pda) = - create_funded_contributor(&mut setup); - - // One minor unit over the 10% cap must fail with ContributionTooBig. - let contribute_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - MAX_CONTRIBUTION + 1, - ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), - ); - assert!( - result.is_err(), - "A single contribution above the 10% cap must fail" + // The deadline is still days away, but the vault has been paid out. + warp_days_forward(&mut setup.svm, 1); + let late_contributor = new_contributor(&mut setup); + assert_error( + contribute(&mut setup, &late_contributor, CONTRIBUTION), + FundraiserError::FundraiserClaimed, ); assert_eq!( - get_token_account_balance(&setup.svm, &setup.vault).unwrap(), - 0 + get_token_account_balance(&setup.svm, &late_contributor.ata).unwrap(), + CONTRIBUTOR_STARTING_BALANCE ); } #[test] -fn test_cumulative_contributions_above_cap_fail() { +fn test_check_contributions_ignores_direct_vault_donations() { 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); - - // Each call is under the cap on its own; the second pushes the - // cumulative total over it and must fail with - // MaximumContributionsReached. - let first_contribution = 2 * ONE_TOKEN; - let second_contribution = MAX_CONTRIBUTION - ONE_TOKEN; - - let first_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - first_contribution, - ); - send_transaction_from_instructions( + // Mint the full target straight into the vault, bypassing contribute. + // The state-tracked current_amount stays 0, so the claim must fail. + mint_tokens_to_token_account( &mut setup.svm, - vec![first_instruction], - &[&contributor], - &contributor.pubkey(), + &setup.mint, + &setup.vault, + AMOUNT_TO_RAISE, + &setup.payer, ) .unwrap(); - let second_instruction = build_contribute_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - second_contribution, - ); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![second_instruction], - &[&contributor], - &contributor.pubkey(), - ); + assert_error(claim(&mut setup), FundraiserError::TargetNotMet); + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert!(!fundraiser_state.claimed); +} + +#[test] +fn test_close_contributor_after_claim_returns_rent() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributors = fund_to_target(&mut setup); + claim(&mut setup).unwrap(); + + let contributor = &contributors[0]; + let rent = lamports(&setup, &contributor.contributor_account_pda); + let lamports_before = lamports(&setup, &contributor.keypair.pubkey()); + + let fee_payer = contributor.keypair.insecure_clone(); + close_contributor(&mut setup, &fee_payer, contributor).unwrap(); + assert!( - result.is_err(), - "Contributions that cumulatively exceed the 10% cap must fail" + setup + .svm + .get_account(&contributor.contributor_account_pda) + .is_none(), + "Contributor account must be closed" ); - + // The contributor paid the transaction fee out of the same balance, so + // the rent came back less that fee. assert_eq!( - get_token_account_balance(&setup.svm, &setup.vault).unwrap(), - first_contribution + lamports(&setup, &contributor.keypair.pubkey()), + lamports_before + rent - TRANSACTION_FEE + ); + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert_eq!( + fundraiser_state.open_contributor_accounts, + CONTRIBUTIONS_REACHING_TARGET.len() as u32 - 1 ); - let contributor_state = read_contributor_state(&setup.svm, &contributor_account_pda); - assert_eq!(contributor_state.amount, first_contribution); } #[test] -fn test_close_fundraiser_after_failed_raise_allows_a_new_raise() { +fn test_anyone_can_close_contributor_accounts_after_claim() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributors = fund_to_target(&mut setup); + claim(&mut setup).unwrap(); + + // The maker closes every contributor account; each rent deposit goes to + // its contributor, who signs nothing. + let maker = setup.maker.insecure_clone(); + for contributor in &contributors { + let rent = lamports(&setup, &contributor.contributor_account_pda); + let lamports_before = lamports(&setup, &contributor.keypair.pubkey()); + close_contributor(&mut setup, &maker, contributor).unwrap(); + assert_eq!( + lamports(&setup, &contributor.keypair.pubkey()), + lamports_before + rent + ); + } - 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(); + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert_eq!(fundraiser_state.open_contributor_accounts, 0); +} - // The raise fails; the contributor takes their refund. - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); - let refund_instruction = build_refund_instruction( - &setup, - &contributor.pubkey(), - &contributor_ata, - &contributor_account_pda, - ); - send_transaction_from_instructions( - &mut setup.svm, - vec![refund_instruction], - &[&contributor], - &contributor.pubkey(), - ) - .unwrap(); +#[test] +fn test_close_contributor_before_claim_fails() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); + + // The fundraiser is unclaimed, so the contribution can still be + // refunded: closing the account now would erase what the vault owes. + let fee_payer = contributor.keypair.insecure_clone(); + assert_error( + close_contributor(&mut setup, &fee_payer, &contributor), + FundraiserError::FundraiserNotClaimed, + ); + let contributor_state = + read_contributor_state(&setup.svm, &contributor.contributor_account_pda); + assert_eq!(contributor_state.amount, CONTRIBUTION); +} - // The maker retires the failed fundraiser. - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let close_instruction = build_close_fundraiser_instruction(&setup, &maker_ata); - send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[&setup.maker], - &setup.maker.pubkey(), - ) - .unwrap(); +#[test] +fn test_close_fundraiser_after_failed_raise_allows_a_new_raise() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); + // The raise fails; the contributor takes their refund. + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); + let fee_payer = contributor.keypair.insecure_clone(); + refund(&mut setup, &fee_payer, &contributor).unwrap(); + + close_fundraiser(&mut setup).unwrap(); assert!( setup.svm.get_account(&setup.vault).is_none(), "Vault token account must be closed with the fundraiser" @@ -827,17 +908,12 @@ fn test_close_fundraiser_before_deadline_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let close_instruction = build_close_fundraiser_instruction(&setup, &maker_ata); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[&setup.maker], - &setup.maker.pubkey(), - ); - assert!( - result.is_err(), - "Closing a fundraiser before its deadline must fail" + // One day short of the deadline. + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 - 1); + + assert_error( + close_fundraiser(&mut setup), + FundraiserError::FundraiserNotEnded, ); assert!( setup.svm.get_account(&setup.fundraiser_pda).is_some(), @@ -849,86 +925,33 @@ fn test_close_fundraiser_before_deadline_fails() { fn test_close_fundraiser_with_unrefunded_contributions_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(); + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); // Past the deadline but the contribution has not been refunded, so // closing would strand it in the vault. - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let close_instruction = build_close_fundraiser_instruction(&setup, &maker_ata); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[&setup.maker], - &setup.maker.pubkey(), - ); - assert!( - result.is_err(), - "Closing must fail while contributions remain unrefunded" + assert_error( + close_fundraiser(&mut setup), + FundraiserError::RefundsOutstanding, ); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), - MAX_CONTRIBUTION + CONTRIBUTION ); } #[test] -fn test_close_fundraiser_when_target_met_fails() { +fn test_close_fundraiser_when_target_met_but_unclaimed_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + fund_to_target(&mut setup); - // 10 contributors at the 10% cap reach the target exactly. - 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(); - } + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); - - // A successful raise exits through check_contributions, never close. - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let close_instruction = build_close_fundraiser_instruction(&setup, &maker_ata); - let result = send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[&setup.maker], - &setup.maker.pubkey(), - ); - assert!( - result.is_err(), - "Closing must fail when the target was met; the claim is the exit" - ); + // A raise that met its target closes only after the maker claims it. + assert_error(close_fundraiser(&mut setup), FundraiserError::TargetMet); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), AMOUNT_TO_RAISE @@ -936,200 +959,169 @@ fn test_close_fundraiser_when_target_met_fails() { } #[test] -fn test_close_fundraiser_sweeps_direct_donations_to_maker() { +fn test_close_fundraiser_with_open_contributor_accounts_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributors = fund_to_target(&mut setup); + claim(&mut setup).unwrap(); - // Tokens sent straight to the vault are outside the program's - // accounting; on close they go to the maker instead of being burned - // with the account. - let donation = 5 * ONE_TOKEN; - mint_tokens_to_token_account( - &mut setup.svm, - &setup.mint, - &setup.vault, - donation, - &setup.payer, - ) - .unwrap(); - - warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); - - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let close_instruction = build_close_fundraiser_instruction(&setup, &maker_ata); - send_transaction_from_instructions( - &mut setup.svm, - vec![close_instruction], - &[&setup.maker], - &setup.maker.pubkey(), - ) - .unwrap(); + // Close all but one contributor account. + let maker = setup.maker.insecure_clone(); + for contributor in &contributors[1..] { + close_contributor(&mut setup, &maker, contributor).unwrap(); + } - assert_eq!( - get_token_account_balance(&setup.svm, &maker_ata).unwrap(), - donation + assert_error( + close_fundraiser(&mut setup), + FundraiserError::ContributorAccountsOpen, ); - assert!(setup.svm.get_account(&setup.fundraiser_pda).is_none()); - assert!(setup.svm.get_account(&setup.vault).is_none()); + assert!(setup.svm.get_account(&setup.fundraiser_pda).is_some()); } #[test] -fn test_check_contributions_ignores_direct_vault_donations() { +fn test_reinitialize_with_open_contributor_accounts_fails() { let mut setup = full_setup(); initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + fund_to_target(&mut setup); + claim(&mut setup).unwrap(); - // Mint the full target straight into the vault, bypassing contribute. - // The state-tracked current_amount stays 0, so the claim must fail. - mint_tokens_to_token_account( - &mut setup.svm, - &setup.mint, - &setup.vault, - AMOUNT_TO_RAISE, - &setup.payer, - ) - .unwrap(); - - let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); - let check_instruction = build_check_contributions_instruction(&setup, &maker_ata); + // The claimed fundraiser account still exists, so a new fundraiser + // cannot be initialized at its address. + setup.svm.expire_blockhash(); + let initialize_instruction = Instruction::new_with_bytes( + setup.program_id, + &fundraiser::instruction::InitializeFundraiser { + amount: AMOUNT_TO_RAISE, + duration: DURATION_DAYS, + } + .data(), + fundraiser::accounts::InitializeFundraiserAccountConstraints { + maker: setup.maker.pubkey(), + mint_to_raise: setup.mint, + fundraiser: setup.fundraiser_pda, + vault: setup.vault, + system_program: system_program::ID, + token_program: token_program_id(), + associated_token_program: ata_program_id(), + } + .to_account_metas(None), + ); let result = send_transaction_from_instructions( &mut setup.svm, - vec![check_instruction], + vec![initialize_instruction], &[&setup.maker], &setup.maker.pubkey(), ); assert!( result.is_err(), - "Direct donations to the vault must not unlock the claim" - ); - assert!( - setup.svm.get_account(&setup.fundraiser_pda).is_some(), - "Fundraiser account must stay open after a failed claim" + "A new fundraiser must not start while the claimed one exists" ); + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert!(fundraiser_state.claimed); } #[test] -fn test_close_contributor_after_successful_claim_returns_rent() { +fn test_stale_contributor_account_cannot_refund_from_next_raise() { 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)); + // Raise one succeeds and the maker claims it. + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let first_raise_contributors = fund_to_target(&mut setup); + claim(&mut setup).unwrap(); + + // The maker closes every contributor account from raise one, then the + // fundraiser, and starts raise two at the same address. + let maker = setup.maker.insecure_clone(); + for contributor in &first_raise_contributors { + close_contributor(&mut setup, &maker, contributor).unwrap(); } + close_fundraiser(&mut setup).unwrap(); + setup.svm.expire_blockhash(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - 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" + // Raise two collects less than the target and fails. + let second_raise_contributors: Vec = (0..2) + .map(|_| { + let contributor = new_contributor(&mut setup); + contribute(&mut setup, &contributor, CONTRIBUTION).unwrap(); + contributor + }) + .collect(); + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); + + // A raise-one contributor tries to take a refund from raise two. Their + // contributor account was closed with raise one, so there is nothing to + // refund. + let stale_contributor = &first_raise_contributors[0]; + let fee_payer = stale_contributor.keypair.insecure_clone(); + assert!(refund(&mut setup, &fee_payer, stale_contributor).is_err()); + assert_eq!( + get_token_account_balance(&setup.svm, &setup.vault).unwrap(), + 2 * CONTRIBUTION ); - // 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; + // Every raise-two contributor is refunded in full. + for contributor in &second_raise_contributors { + let fee_payer = contributor.keypair.insecure_clone(); + refund(&mut setup, &fee_payer, contributor).unwrap(); + assert_eq!( + get_token_account_balance(&setup.svm, &contributor.ata).unwrap(), + CONTRIBUTOR_STARTING_BALANCE + ); + } + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert_eq!(fundraiser_state.current_amount, 0); + assert_eq!(fundraiser_state.open_contributor_accounts, 0); +} - 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(); +#[test] +fn test_close_fundraiser_after_claim_allows_a_new_raise() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let contributors = fund_to_target(&mut setup); + claim(&mut setup).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" - ); + let maker = setup.maker.insecure_clone(); + for contributor in &contributors { + close_contributor(&mut setup, &maker, contributor).unwrap(); + } + close_fundraiser(&mut setup).unwrap(); + assert!(setup.svm.get_account(&setup.fundraiser_pda).is_none()); + assert!(setup.svm.get_account(&setup.vault).is_none()); + + setup.svm.expire_blockhash(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert!(!fundraiser_state.claimed); + assert_eq!(fundraiser_state.current_amount, 0); } #[test] -fn test_close_contributor_while_fundraiser_open_fails() { +fn test_close_fundraiser_sweeps_direct_donations_to_maker() { 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( + // Tokens sent straight to the vault are outside the program's + // accounting; on close they go to the maker instead of being burned + // with the account. + let donation = 5 * ONE_TOKEN; + mint_tokens_to_token_account( &mut setup.svm, - vec![contribute_instruction], - &[&contributor], - &contributor.pubkey(), + &setup.mint, + &setup.vault, + donation, + &setup.payer, ) .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" + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64); + close_fundraiser(&mut setup).unwrap(); + + let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); + assert_eq!( + get_token_account_balance(&setup.svm, &maker_ata).unwrap(), + donation ); - let contributor_state = read_contributor_state(&setup.svm, &contributor_account_pda); - assert_eq!(contributor_state.amount, MAX_CONTRIBUTION); + assert!(setup.svm.get_account(&setup.fundraiser_pda).is_none()); + assert!(setup.svm.get_account(&setup.vault).is_none()); } diff --git a/finance/fundraiser/kani-proofs/README.md b/finance/fundraiser/kani-proofs/README.md index dd4ebba7e..ffa786926 100644 --- a/finance/fundraiser/kani-proofs/README.md +++ b/finance/fundraiser/kani-proofs/README.md @@ -14,20 +14,19 @@ one. The program collects contributions toward a goal; if the goal is not met by the deadline, every contributor reclaims their exact stake. Token movement is via SPL CPIs Kani cannot symbolically execute, but the accounting (`contribute`, -`refund`) is pure integer arithmetic, and the harnesses check it for every input in the +`refund`, `close_contributor`) is pure integer arithmetic, and the harnesses check it for every input in the declared ranges: -- `proof_contribution_cap_bounds`: The per-contributor cap never exceeds the goal, and the `cumulative <= cap` check keeps every contributor at or below it (and below the goal). +- `proof_open_contributor_accounts_counts_open_accounts`: `open_contributor_accounts` always equals the number of contributor accounts that exist, over every sequence of eight contributions, refunds and closes among three contributors. `close_fundraiser` requires it to be zero, so no contributor account carries over into the next raise at the same address. - `proof_current_amount_is_sum_of_contributions`: `current_amount` always equals the sum of the contributions added to it, no accounting drift. - `proof_refunds_sum_to_current_amount`: On a failed raise, refunds sum back to `current_amount`; no contributor reclaims more than they put in. -The cap harness checks nonlinear arithmetic (`goal · pct / scaler`) over -bounded inputs; the two accounting/refund harnesses are pure linear logic -and run at full `u64` width (bounded only in the number of contributors). The -whole suite finishes in under a second. +The counter harness explores every sequence of steps up to its bound; the two +accounting/refund harnesses are pure linear logic and run at full `u64` width +(bounded only in the number of contributors). Run weekly in CI (the `kani.yml` `verify` job), not on every push/PR, because -the nonlinear model checks are slow. A fast unit-test job runs per push/PR. +model checks are slow. A fast unit-test job runs per push/PR. ## Running diff --git a/finance/fundraiser/kani-proofs/src/lib.rs b/finance/fundraiser/kani-proofs/src/lib.rs index be1f9e2d5..26130e57e 100644 --- a/finance/fundraiser/kani-proofs/src/lib.rs +++ b/finance/fundraiser/kani-proofs/src/lib.rs @@ -10,58 +10,83 @@ //! The program collects contributions into a vault toward a goal; if the goal //! is not met by the deadline, every contributor reclaims their exact stake. //! Token movement is via SPL CPIs Kani cannot symbolically execute, but the -//! accounting (`contribute`, `refund`) is pure integer arithmetic. This crate -//! reproduces it faithfully and checks the per-contributor cap, the running- -//! total accounting, and refund conservation. +//! accounting (`contribute`, `refund`, `close_contributor`) is pure integer +//! arithmetic. This crate reproduces it faithfully and checks the contributor +//! account counter, the running-total accounting, and refund conservation. #![cfg_attr(kani, allow(dead_code))] -/// `contribute::MAX_CONTRIBUTION_PERCENTAGE` / `PERCENTAGE_SCALER`. The program -/// ships these as a percentage cap; the exact values do not matter to the check, -/// only that the cap is `goal * pct / scaler`. -pub const MAX_CONTRIBUTION_PERCENTAGE: u128 = 10; // 10% -pub const PERCENTAGE_SCALER: u128 = 100; - -/// `calculate_max_contribution`: the per-contributor cap is a fixed percentage -/// of the goal. -pub fn max_contribution(amount_to_raise: u64) -> Option { - ((amount_to_raise as u128) * MAX_CONTRIBUTION_PERCENTAGE / PERCENTAGE_SCALER) - .try_into() - .ok() +/// How many contributors the counter harness tracks. +pub const CONTRIBUTORS: usize = 3; + +/// One step of a fundraiser's life, as it affects contributor accounts. +#[derive(Clone, Copy)] +pub enum ContributorAccountStep { + /// `contribute` from this contributor: creates their account on the first + /// call and adds to it on later ones. + Contribute(usize), + /// `refund` or `close_contributor` for this contributor: both close the + /// account, and both fail if it does not exist. + Close(usize), +} + +/// Replays `contribute`, `refund` and `close_contributor`'s bookkeeping on +/// `open_contributor_accounts`. Returns `None` where the program would reject +/// the step, as it does when the account to close does not exist. +pub fn apply_step( + open_accounts: &mut [bool; CONTRIBUTORS], + open_contributor_accounts: u32, + step: ContributorAccountStep, +) -> Option { + match step { + ContributorAccountStep::Contribute(contributor) => { + if open_accounts[contributor] { + Some(open_contributor_accounts) + } else { + open_accounts[contributor] = true; + open_contributor_accounts.checked_add(1) + } + } + ContributorAccountStep::Close(contributor) => { + if !open_accounts[contributor] { + return None; + } + open_accounts[contributor] = false; + open_contributor_accounts.checked_sub(1) + } + } } // =========================================================================== -// 1. Per-contributor cap +// 1. Contributor account counter // =========================================================================== -/// The cap is never more than the goal itself, and `contribute`'s -/// `cumulative <= max_contribution` check keeps every contributor's running -/// total within it (so `checked_add` of a new contribution onto an at-cap -/// balance can only succeed below the cap). Captures the bound the on-chain -/// `MaximumContributionsReached` guard enforces. +/// `fundraiser.open_contributor_accounts` always equals the number of +/// contributor accounts that exist for the fundraiser, whatever order +/// contributions, refunds and closes arrive in. `close_fundraiser` requires +/// the counter to be zero, so this is what guarantees no contributor account +/// outlives its fundraiser and carries over into the next raise at the same +/// address. #[cfg(kani)] #[kani::proof] -#[kani::solver(cadical)] -fn proof_contribution_cap_bounds() { - let amount_to_raise: u64 = kani::any(); - let prior: u64 = kani::any(); // contributor's existing cumulative total - let amount: u64 = kani::any(); // new contribution - - kani::assume(amount_to_raise as u128 <= 4095); - - let cap = max_contribution(amount_to_raise).expect("computes"); - // The cap never exceeds the goal (10% <= 100%). - assert!(cap as u128 <= amount_to_raise as u128); - - // The on-chain check: a contribution is accepted only if the new cumulative - // stays within the cap. - kani::assume(prior <= cap); - if let Some(cumulative) = prior.checked_add(amount) { - if cumulative <= cap { - // Accepted contributions keep the contributor at or below the cap. - assert!(cumulative <= cap); - assert!(cumulative <= amount_to_raise); // ...and below the goal +#[kani::unwind(9)] +fn proof_open_contributor_accounts_counts_open_accounts() { + let mut open_accounts = [false; CONTRIBUTORS]; + let mut open_contributor_accounts: u32 = 0; + + for _ in 0..8 { + let contributor: usize = kani::any(); + kani::assume(contributor < CONTRIBUTORS); + let step = if kani::any() { + ContributorAccountStep::Contribute(contributor) + } else { + ContributorAccountStep::Close(contributor) + }; + if let Some(updated) = apply_step(&mut open_accounts, open_contributor_accounts, step) { + open_contributor_accounts = updated; } + let actually_open = open_accounts.iter().filter(|open| **open).count() as u32; + assert_eq!(open_contributor_accounts, actually_open); } } @@ -131,9 +156,24 @@ mod tests { use super::*; #[test] - fn cap_is_ten_percent() { - assert_eq!(max_contribution(1000).unwrap(), 100); - assert!(max_contribution(1000).unwrap() <= 1000); + fn counter_tracks_contributor_accounts() { + let mut open_accounts = [false; CONTRIBUTORS]; + let mut counter = 0u32; + for step in [ + ContributorAccountStep::Contribute(0), + ContributorAccountStep::Contribute(0), + ContributorAccountStep::Contribute(2), + ContributorAccountStep::Close(0), + ] { + counter = apply_step(&mut open_accounts, counter, step).unwrap(); + } + assert_eq!(counter, 1); + assert!(apply_step( + &mut open_accounts, + counter, + ContributorAccountStep::Close(1) + ) + .is_none()); } #[test] diff --git a/finance/fundraiser/quasar/CHANGELOG.md b/finance/fundraiser/quasar/CHANGELOG.md index bad0263b1..911c26f43 100644 --- a/finance/fundraiser/quasar/CHANGELOG.md +++ b/finance/fundraiser/quasar/CHANGELOG.md @@ -1,5 +1,37 @@ # 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, so the maker + could initialize a new fundraiser at the same address and `refund` would pay + a leftover account's old amount out of the new contributors' tokens. + `check_contributions` now sets a new `claimed` flag and leaves the Fundraiser + and vault open, and the Fundraiser counts `open_contributor_accounts`. + +### Added + +- `close_fundraiser` (discriminator 5): closes the vault and the Fundraiser + once no Contributor account is open (`ContributorAccountsOpen`), and on an + unclaimed fundraiser only after the deadline, with the target missed and + every contribution refunded (`FundraiserNotEnded`, `TargetMet`, the new + `RefundsOutstanding`). Any tokens left in the vault go to the maker. +- `FundraiserClaimed`: `contribute` and `check_contributions` refuse a claimed + fundraiser. + +### Changed + +- `close_contributor` requires the fundraiser to be claimed + (`FundraiserNotClaimed`, replacing `FundraiserStillOpen`). +- `refund` and `close_contributor` no longer require the contributor's + signature, so the maker can close every Contributor account. `refund` + checks that the destination token account belongs to the contributor. + `quasar build` reports P006 (instruction missing signer) for both; that is + intended. + ## [2026-09-28] ### Changed diff --git a/finance/fundraiser/quasar/README.md b/finance/fundraiser/quasar/README.md index 5bcd81dfc..0bced1c48 100644 --- a/finance/fundraiser/quasar/README.md +++ b/finance/fundraiser/quasar/README.md @@ -1,6 +1,6 @@ # Solana Fundraiser (Quasar) -Onchain crowdfunding on Solana toward a target amount in a chosen token, written with [Quasar](https://quasar-lang.com/docs). A **maker** opens a fundraiser with a target amount and a deadline; **contributors** deposit tokens into a program-controlled vault. If the target is met the maker withdraws everything; if the deadline passes without the target being met, each contributor reclaims exactly what they put in. +Onchain crowdfunding on Solana toward a target amount in a chosen token, written with [Quasar](https://quasar-lang.com/docs). A **maker** opens a fundraiser with a target amount and a deadline; **contributors** deposit tokens into a program-controlled vault. If the target is met the maker withdraws everything; if the deadline passes without the target being met, each contributor gets back exactly what they put in. Either way, once every contributor's record is closed the maker closes the fundraiser and can open another. This example was called **Token Fundraiser** (`finance/token-fundraiser`) until it was renamed: contributors receive no token, only a refund if the target is missed. @@ -8,18 +8,20 @@ See also: the [repository catalog](../../../README.md) and the [Anchor variant]( ## Major concepts -- The **Fundraiser** account is a PDA at `["fundraiser", maker]`. It stores the maker, the token's mint, the vault address, the target (`amount_to_raise`), the running total (`current_amount`), the Clock timestamp captured at creation (`time_started`), the window length in days (`duration`), and the PDA bump. Storing the vault address lets every later instruction bind the passed vault to this fundraiser with a `has_one(vault)` constraint. -- A **Contributor** account is a PDA at `["contributor", fundraiser, contributor]`. It records how much that signer has given to that fundraiser, plus its bump. The seeds bind the record to one (fundraiser, contributor) pair, so one contributor's record can never be spent by another signer or against another fundraiser. +- The **Fundraiser** account is a PDA at `["fundraiser", maker]`. It stores the maker, the token's mint, the vault address, the target (`amount_to_raise`), the running total (`current_amount`), the Clock timestamp captured at creation (`time_started`), the window length in days (`duration`), whether the maker has claimed it (`claimed`), how many Contributor accounts written for it are still open (`open_contributor_accounts`), and the PDA bump. Storing the vault address lets every later instruction bind the passed vault to this fundraiser with a `has_one(vault)` constraint. +- A **Contributor** account is a PDA at `["contributor", fundraiser, contributor]`. It records how much that contributor has given to that fundraiser, plus its bump. The seeds bind the record to one (fundraiser, contributor) pair, so one contributor's record can never be paid to anyone else or against another fundraiser. - The **vault** is a token account whose authority is the Fundraiser PDA. All deposits, the maker payout, and refunds flow through it, with the PDA signing outbound transfers via its seeds. +- **The Fundraiser outlives every Contributor account.** A Contributor PDA is derived from the Fundraiser's address, and the Fundraiser's address from the maker alone. If the Fundraiser could close while a Contributor account was open, the maker could initialize a new fundraiser at the same address and that Contributor account would count as a contribution to it, so `refund` would pay its old amount out of the new contributors' tokens. `open_contributor_accounts` counts them, and `close_fundraiser` requires zero. - The **fundraising window** runs from `time_started` for `duration` days. Contributions are allowed while `now < time_started + duration`; refunds are allowed once `now >= time_started + duration` and only if the target was not met. `now` is the Clock sysvar's unix timestamp. ## Lifecycle -- `initialize` (maker signs): rejects a zero target (`InvalidAmount`) or zero duration (`InvalidDuration`), creates the Fundraiser PDA and the vault, and records the current Clock time as `time_started`. -- `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. +- `initialize_fundraiser` (maker signs): rejects a zero target (`InvalidAmount`) or zero duration (`InvalidDuration`), creates the Fundraiser PDA and the vault, and records the current Clock time as `time_started`, with `claimed` false and `open_contributor_accounts` zero. +- `contribute` (contributor signs): rejects a zero amount, fails with `FundraiserClaimed` once the maker has claimed, and fails with `FundraiserEnded` after the deadline. Creates the contributor's Contributor PDA on first use (idempotent init, contributor pays the rent) and adds one to `open_contributor_accounts`, 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 `FundraiserClaimed` if already claimed and with `TargetNotMet` unless `current_amount >= amount_to_raise`. Sets `claimed` and transfers the whole vault balance to the maker's token account with the Fundraiser PDA signing. The Fundraiser and the empty vault stay open until every Contributor account is closed. +- `refund` (anyone may send it; the contributor does not sign): fails with `FundraiserNotEnded` before the deadline and with `TargetMet` if the fundraiser succeeded. Pays the contributor's recorded amount back from the vault to the contributor's own token account (checked against the contributor's address) with the PDA signing, subtracts it from `current_amount` and one from `open_contributor_accounts`, verifies the vault lost exactly that amount, and closes the Contributor account, returning its rent to the contributor. The tokens and rent can only reach the contributor, so the maker can refund everyone without waiting on them. +- `close_contributor` (anyone may send it; the contributor does not sign): fails with `FundraiserNotClaimed` until the maker has claimed, because before then the contribution can still be refunded. Closes the Contributor account, returning its rent to the contributor, and subtracts one from `open_contributor_accounts`. The Contributor account's seeds bind it to the fundraiser address, so no other fundraiser can be substituted. +- `close_fundraiser` (maker signs): for an unclaimed fundraiser, fails with `FundraiserNotEnded` before the deadline, `TargetMet` if the target was met, and `RefundsOutstanding` while `current_amount` is above zero. For any fundraiser, fails with `ContributorAccountsOpen` while `open_contributor_accounts` is above zero. Pays any tokens left in the vault (direct transfers outside `contribute`) to the maker's token account, then closes the vault and the Fundraiser account, returning their rent to the maker. Errors are defined in `src/error.rs` as a `#[error_code]` enum starting at code 6000. @@ -43,4 +45,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, 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. +The tests in `src/tests.rs` drive the real instruction handlers end to end (initialize_fundraiser, contribute, check_contributions, refund, close_contributor, close_fundraiser), 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, closing a contributor account before the claim, and closing the fundraiser early, with contributions unrefunded, or with Contributor accounts open. `stale_contributor_account_cannot_refund_from_next_raise` (see `src/tests.rs`) runs two raises at the same address and checks that a first-raise contributor cannot take a refund from the second. No local validator is needed. diff --git a/finance/fundraiser/quasar/src/error.rs b/finance/fundraiser/quasar/src/error.rs index c3c7d56ab..1e2762aac 100644 --- a/finance/fundraiser/quasar/src/error.rs +++ b/finance/fundraiser/quasar/src/error.rs @@ -22,7 +22,15 @@ pub enum FundraiserError { MathOverflow, /// A token balance after a transfer did not match the expected value. BalanceMismatch, - /// The fundraiser account still exists, so the contribution is live and - /// its contributor account closes through refund, not here. - FundraiserStillOpen, + /// The fundraiser has already been claimed. + FundraiserClaimed, + /// The fundraiser has not been claimed, so the contributor account closes + /// through refund. + FundraiserNotClaimed, + /// Contributor accounts for this fundraiser are still open, so it cannot + /// close yet. + ContributorAccountsOpen, + /// Contributions to an unclaimed fundraiser have not all been refunded, + /// so closing its vault would strand them. + RefundsOutstanding, } diff --git a/finance/fundraiser/quasar/src/instructions/check_contributions.rs b/finance/fundraiser/quasar/src/instructions/check_contributions.rs index deee38dcc..633fa628b 100644 --- a/finance/fundraiser/quasar/src/instructions/check_contributions.rs +++ b/finance/fundraiser/quasar/src/instructions/check_contributions.rs @@ -7,7 +7,6 @@ use { #[derive(Accounts)] pub struct CheckContributionsAccountConstraints { - #[account(mut)] pub maker: Signer, #[account( @@ -15,7 +14,6 @@ pub struct CheckContributionsAccountConstraints { has_one(maker), has_one(vault), has_one(mint_to_raise), - close(dest = maker), address = Fundraiser::seeds(maker.address()), )] pub fundraiser: Account, @@ -33,11 +31,27 @@ pub struct CheckContributionsAccountConstraints { pub token_program: Program, } +/// 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. #[inline(always)] pub fn handle_check_contributions( accounts: &mut CheckContributionsAccountConstraints, bumps: &CheckContributionsAccountConstraintsBumps, ) -> Result<(), ProgramError> { + require!( + !bool::from(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. let current_amount: u64 = accounts.fundraiser.current_amount.into(); let amount_to_raise: u64 = accounts.fundraiser.amount_to_raise.into(); require!( @@ -45,6 +59,9 @@ pub fn handle_check_contributions( FundraiserError::TargetNotMet ); + // Update state before the transfer CPI (checks-effects-interactions). + accounts.fundraiser.claimed = PodBool::from(true); + // Fundraiser PDA signer seeds: ["fundraiser", maker, bump]. let bump = [bumps.fundraiser]; let seeds = [ @@ -53,7 +70,7 @@ pub fn handle_check_contributions( Seed::from(bump.as_ref()), ]; - // Transfer all vault funds to the maker. + // Pay the whole vault (including any direct donations) to the maker. let vault_amount = accounts.vault.amount(); accounts .token_program @@ -67,17 +84,11 @@ pub fn handle_check_contributions( ) .invoke_signed(&seeds)?; - // Token conservation: the vault was fully drained. + // Token conservation: the vault was fully paid out. require!( accounts.vault.amount() == 0, FundraiserError::BalanceMismatch ); - // Close the vault token account, returning its rent to the maker. - accounts - .token_program - .close_account(&accounts.vault, &accounts.maker, &accounts.fundraiser) - .invoke_signed(&seeds)?; - Ok(()) } diff --git a/finance/fundraiser/quasar/src/instructions/close_contributor.rs b/finance/fundraiser/quasar/src/instructions/close_contributor.rs index 6612009e3..2e9ec8ca1 100644 --- a/finance/fundraiser/quasar/src/instructions/close_contributor.rs +++ b/finance/fundraiser/quasar/src/instructions/close_contributor.rs @@ -1,23 +1,24 @@ use { - crate::{error::FundraiserError, state::Contributor}, + crate::{ + error::FundraiserError, + state::{Contributor, Fundraiser}, + }, quasar_lang::prelude::*, }; #[derive(Accounts)] pub struct CloseContributorAccountConstraints { + /// 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, + pub contributor: SystemAccount, /// 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( - constraints(fundraiser.to_account_view().owner() != &crate::ID) - @ FundraiserError::FundraiserStillOpen - )] - pub fundraiser: UncheckedAccount, + /// fundraiser can be substituted. + #[account(mut)] + pub fundraiser: Account, #[account( mut, @@ -27,23 +28,29 @@ pub struct CloseContributorAccountConstraints { pub contributor_account: Account, } -/// Closes a contributor account once its fundraiser is gone, returning the -/// rent to the contributor. +/// Closes a contributor account once its fundraiser has been claimed, +/// 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 `constraints` above; the `close(dest = contributor)` constraint then -/// returns the rent. While the fundraiser exists the contribution is live, -/// and `refund` is the way to close it. +/// `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`. #[inline(always)] pub fn handle_close_contributor( - _accounts: &mut CloseContributorAccountConstraints, + accounts: &mut CloseContributorAccountConstraints, ) -> Result<(), ProgramError> { + require!( + bool::from(accounts.fundraiser.claimed), + FundraiserError::FundraiserNotClaimed + ); + + let open_contributor_accounts: u32 = accounts.fundraiser.open_contributor_accounts.into(); + accounts.fundraiser.open_contributor_accounts = PodU32::from( + open_contributor_accounts + .checked_sub(1) + .ok_or(FundraiserError::MathOverflow)?, + ); + Ok(()) } diff --git a/finance/fundraiser/quasar/src/instructions/close_fundraiser.rs b/finance/fundraiser/quasar/src/instructions/close_fundraiser.rs new file mode 100644 index 000000000..6f6604835 --- /dev/null +++ b/finance/fundraiser/quasar/src/instructions/close_fundraiser.rs @@ -0,0 +1,119 @@ +use quasar_lang::cpi::Seed; +use { + crate::{ + error::FundraiserError, + state::{fundraiser_deadline, Fundraiser}, + }, + quasar_lang::{prelude::*, sysvars::Sysvar as _}, + quasar_spl::prelude::*, +}; + +#[derive(Accounts)] +pub struct CloseFundraiserAccountConstraints { + #[account(mut)] + pub maker: Signer, + + #[account( + mut, + has_one(maker), + has_one(vault), + has_one(mint_to_raise), + close(dest = maker), + address = Fundraiser::seeds(maker.address()), + )] + pub fundraiser: Account, + + #[account(mut)] + pub vault: Account, + + #[account(mut)] + pub maker_ta: Account, + + // Bound to fundraiser.mint_to_raise by has_one above; carries the decimals + // that transfer_checked validates against the vault and maker_ta. + pub mint_to_raise: Account, + + pub token_program: Program, +} + +/// Closes a finished fundraiser and its vault so the maker can raise again. +/// +/// The fundraiser PDA is derived from the maker's address 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. +#[inline(always)] +pub fn handle_close_fundraiser( + accounts: &mut CloseFundraiserAccountConstraints, + bumps: &CloseFundraiserAccountConstraintsBumps, +) -> Result<(), ProgramError> { + if !bool::from(accounts.fundraiser.claimed) { + // Closing an unclaimed fundraiser is allowed only after it has ended + // (now >= start + duration). + let now: i64 = Clock::get()?.unix_timestamp.into(); + let deadline = fundraiser_deadline( + accounts.fundraiser.time_started.into(), + accounts.fundraiser.duration.into(), + )?; + require!(now >= deadline, FundraiserError::FundraiserNotEnded); + + // A raise that met its target closes after the maker claims it. + let current_amount: u64 = accounts.fundraiser.current_amount.into(); + let amount_to_raise: u64 = accounts.fundraiser.amount_to_raise.into(); + require!(current_amount < amount_to_raise, FundraiserError::TargetMet); + + // Closing the vault while contributions remain would strand the + // refunds, so every contributor must have been refunded first. + require!(current_amount == 0, FundraiserError::RefundsOutstanding); + } + + // A contributor account left open would be read as a contribution to the + // next fundraiser at this address. + let open_contributor_accounts: u32 = accounts.fundraiser.open_contributor_accounts.into(); + require!( + open_contributor_accounts == 0, + FundraiserError::ContributorAccountsOpen + ); + + // Fundraiser PDA signer seeds: ["fundraiser", maker, bump]. + let bump = [bumps.fundraiser]; + let seeds = [ + Seed::from(b"fundraiser" as &[u8]), + Seed::from(accounts.maker.address().as_ref()), + Seed::from(bump.as_ref()), + ]; + + // 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. + let vault_amount = accounts.vault.amount(); + if vault_amount > 0 { + accounts + .token_program + .transfer_checked( + &accounts.vault, + &accounts.mint_to_raise, + &accounts.maker_ta, + &accounts.fundraiser, + vault_amount, + accounts.mint_to_raise.decimals(), + ) + .invoke_signed(&seeds)?; + + // Token conservation: the vault was fully paid out. + require!( + accounts.vault.amount() == 0, + FundraiserError::BalanceMismatch + ); + } + + // Close the empty vault, returning its rent to the maker. The + // `close(dest = maker)` constraint then closes the fundraiser account. + accounts + .token_program + .close_account(&accounts.vault, &accounts.maker, &accounts.fundraiser) + .invoke_signed(&seeds)?; + + Ok(()) +} diff --git a/finance/fundraiser/quasar/src/instructions/contribute.rs b/finance/fundraiser/quasar/src/instructions/contribute.rs index 683cd06fd..6681cbab4 100644 --- a/finance/fundraiser/quasar/src/instructions/contribute.rs +++ b/finance/fundraiser/quasar/src/instructions/contribute.rs @@ -54,6 +54,13 @@ pub fn handle_contribute( ) -> Result<(), ProgramError> { require!(amount > 0, FundraiserError::InvalidAmount); + // 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!( + !bool::from(accounts.fundraiser.claimed), + FundraiserError::FundraiserClaimed + ); + // Contributions are allowed while now < start + duration. let now: i64 = Clock::get()?.unix_timestamp.into(); let deadline = fundraiser_deadline( @@ -76,7 +83,20 @@ pub fn handle_contribute( .checked_add(amount) .ok_or(FundraiserError::MathOverflow)?, ); - accounts.contributor_account.bump = bumps.contributor_account; + + // `init(idempotent)` creates the contributor account zeroed and reuses it + // on later contributions. Every contribution is nonzero, so a recorded + // amount of zero means the account was created by this instruction: save + // its bump and count it against the fundraiser. + if contributed_so_far == 0 { + accounts.contributor_account.bump = bumps.contributor_account; + let open_contributor_accounts: u32 = accounts.fundraiser.open_contributor_accounts.into(); + accounts.fundraiser.open_contributor_accounts = PodU32::from( + open_contributor_accounts + .checked_add(1) + .ok_or(FundraiserError::MathOverflow)?, + ); + } let vault_balance_before = accounts.vault.amount(); diff --git a/finance/fundraiser/quasar/src/instructions/initialize_fundraiser.rs b/finance/fundraiser/quasar/src/instructions/initialize_fundraiser.rs index 9ac241f0e..263b33224 100644 --- a/finance/fundraiser/quasar/src/instructions/initialize_fundraiser.rs +++ b/finance/fundraiser/quasar/src/instructions/initialize_fundraiser.rs @@ -53,6 +53,8 @@ pub fn handle_initialize_fundraiser( current_amount: 0, time_started, duration, + claimed: PodBool::from(false), + open_contributor_accounts: 0, bump, }); Ok(()) diff --git a/finance/fundraiser/quasar/src/instructions/mod.rs b/finance/fundraiser/quasar/src/instructions/mod.rs index ffb82e184..932e6663e 100644 --- a/finance/fundraiser/quasar/src/instructions/mod.rs +++ b/finance/fundraiser/quasar/src/instructions/mod.rs @@ -12,3 +12,6 @@ pub use refund::*; pub mod close_contributor; pub use close_contributor::*; + +pub mod close_fundraiser; +pub use close_fundraiser::*; diff --git a/finance/fundraiser/quasar/src/instructions/refund.rs b/finance/fundraiser/quasar/src/instructions/refund.rs index 465fa4b17..c548df182 100644 --- a/finance/fundraiser/quasar/src/instructions/refund.rs +++ b/finance/fundraiser/quasar/src/instructions/refund.rs @@ -10,8 +10,12 @@ use { #[derive(Accounts)] pub struct RefundAccountConstraints { + /// Not a signer: the tokens go to the contributor's token account and the + /// rent to the contributor, whoever sends the transaction. So a maker can + /// refund every contributor and close a failed fundraiser without waiting + /// on any of them. #[account(mut)] - pub contributor: Signer, + pub contributor: SystemAccount, pub maker: UncheckedAccount, @@ -31,7 +35,12 @@ pub struct RefundAccountConstraints { )] pub contributor_account: Account, - #[account(mut)] + // Bound to the contributor: since anyone may send a refund, the tokens + // must go to a token account the contributor owns, in the raised mint. + #[account( + mut, + token(mint = mint_to_raise, authority = contributor, token_program = token_program), + )] pub contributor_ta: Account, #[account(mut)] @@ -72,6 +81,12 @@ pub fn handle_refund( .ok_or(FundraiserError::MathOverflow)?, ); accounts.contributor_account.amount = PodU64::from(0); + let open_contributor_accounts: u32 = accounts.fundraiser.open_contributor_accounts.into(); + accounts.fundraiser.open_contributor_accounts = PodU32::from( + open_contributor_accounts + .checked_sub(1) + .ok_or(FundraiserError::MathOverflow)?, + ); // Fundraiser PDA signer seeds: ["fundraiser", maker, bump]. let bump = [bumps.fundraiser]; diff --git a/finance/fundraiser/quasar/src/lib.rs b/finance/fundraiser/quasar/src/lib.rs index 0a18f1697..f5106752b 100644 --- a/finance/fundraiser/quasar/src/lib.rs +++ b/finance/fundraiser/quasar/src/lib.rs @@ -14,6 +14,7 @@ declare_id!("Eoiuq1dXvHxh6dLx3wh9gj8kSAUpga11krTrbfF5XYsC"); /// Token crowdfunding program: a maker creates a fundraiser targeting a specific /// SPL token. Contributors deposit tokens into a vault. If the target is met, /// the maker withdraws everything. If not, contributors can reclaim their funds. +/// Once every contributor account is closed, the maker closes the fundraiser. #[program] mod quasar_fundraiser { use super::*; @@ -43,7 +44,9 @@ mod quasar_fundraiser { instructions::handle_contribute(&mut ctx.accounts, amount, &ctx.bumps) } - /// Maker withdraws all funds once the target is met. + /// Maker withdraws all funds once the target is met, marking the + /// fundraiser claimed. The fundraiser and the vault stay open until every + /// contributor account is closed. #[instruction(discriminator = 2)] pub fn check_contributions( ctx: Ctx, @@ -51,20 +54,28 @@ mod quasar_fundraiser { instructions::handle_check_contributions(&mut ctx.accounts, &ctx.bumps) } - /// Contributors reclaim their tokens after the deadline if the target - /// was not met. + /// Return a contributor's tokens after the deadline if the target was not + /// met. Anyone may send it; the tokens and the rent go to the contributor. #[instruction(discriminator = 3)] pub fn refund(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_refund(&mut ctx.accounts, &ctx.bumps) } - /// A contributor closes their contributor account once the fundraiser is - /// gone, taking back its rent. A successful raise closes the fundraiser - /// without touching the contributor accounts, so this is their exit. + /// Close a contributor account once its fundraiser has been claimed, + /// returning the rent to the contributor. Anyone may send it. #[instruction(discriminator = 4)] pub fn close_contributor( ctx: Ctx, ) -> Result<(), ProgramError> { instructions::handle_close_contributor(&mut ctx.accounts) } + + /// Maker closes a finished fundraiser and its vault once no contributor + /// account is open, so they can raise again at the same address. + #[instruction(discriminator = 5)] + pub fn close_fundraiser( + ctx: Ctx, + ) -> Result<(), ProgramError> { + instructions::handle_close_fundraiser(&mut ctx.accounts, &ctx.bumps) + } } diff --git a/finance/fundraiser/quasar/src/state.rs b/finance/fundraiser/quasar/src/state.rs index e3724cf08..a96f7d291 100644 --- a/finance/fundraiser/quasar/src/state.rs +++ b/finance/fundraiser/quasar/src/state.rs @@ -21,6 +21,14 @@ pub struct Fundraiser { pub time_started: i64, /// Fundraising window length in days, counted from `time_started`. pub duration: u16, + /// Set by `check_contributions`. A claimed fundraiser accepts no more + /// contributions and no second claim, and stays open until every + /// contributor account written for it has been closed. + pub claimed: PodBool, + /// How many contributor accounts written for this fundraiser are still + /// open. `close_fundraiser` requires zero, so a new fundraiser at the + /// same address never starts with contributor accounts from an old one. + pub open_contributor_accounts: u32, pub bump: u8, } diff --git a/finance/fundraiser/quasar/src/tests.rs b/finance/fundraiser/quasar/src/tests.rs index 43a5b4ce2..7d55d8fa0 100644 --- a/finance/fundraiser/quasar/src/tests.rs +++ b/finance/fundraiser/quasar/src/tests.rs @@ -1,12 +1,14 @@ //! quasar-test integration tests: create a fundraiser, contribute inside the -//! window, refund after a failed raise, and pay the maker after a successful -//! one — plus the deadline, target, and account-binding guard rails. +//! window, refund after a failed raise, pay the maker after a successful one, +//! close every contributor account and then the fundraiser, and raise again +//! at the same address — plus the deadline, target, claim, and +//! account-binding guard rails. use { crate::{ cpi::{ - CheckContributionsInstruction, CloseContributorInstruction, ContributeInstruction, - InitializeFundraiserInstruction, RefundInstruction, + CheckContributionsInstruction, CloseContributorInstruction, CloseFundraiserInstruction, + ContributeInstruction, InitializeFundraiserInstruction, RefundInstruction, }, error::FundraiserError, state::{Contributor, Fundraiser, SECONDS_PER_DAY}, @@ -28,6 +30,12 @@ const DEADLINE: i64 = START_TIME + DURATION_DAYS as i64 * SECONDS_PER_DAY; const CONTRIBUTOR_STARTING_BALANCE: u64 = 100_000; /// A contribution below the target, used by the refund-path tests. const PARTIAL_CONTRIBUTION: u64 = 500; +/// A second, different contribution below the target. +const SECOND_PARTIAL_CONTRIBUTION: u64 = 1_300; +/// Three unequal contributions that sum to exactly the target. +const CONTRIBUTIONS_REACHING_TARGET: [u64; 3] = [4_700, 3_100, 2_200]; +/// Tokens sent straight to the vault, outside `contribute`. +const DONATION: u64 = 750; // Deterministic addresses. const MAKER: Pubkey = Pubkey::new_from_array([1; 32]); @@ -40,14 +48,55 @@ const ATTACKER: Pubkey = Pubkey::new_from_array([7; 32]); const ATTACKER_TA: Pubkey = Pubkey::new_from_array([8; 32]); const DECOY_VAULT: Pubkey = Pubkey::new_from_array([9; 32]); +/// A contributor's wallet and their token account in the raised mint. +#[derive(Clone, Copy)] +struct ContributorKeys { + wallet: Pubkey, + token_account: Pubkey, +} + +const FIRST_CONTRIBUTOR: ContributorKeys = ContributorKeys { + wallet: CONTRIBUTOR, + token_account: CONTRIBUTOR_TA, +}; +const SECOND_CONTRIBUTOR: ContributorKeys = ContributorKeys { + wallet: Pubkey::new_from_array([10; 32]), + token_account: Pubkey::new_from_array([11; 32]), +}; +const THIRD_CONTRIBUTOR: ContributorKeys = ContributorKeys { + wallet: Pubkey::new_from_array([12; 32]), + token_account: Pubkey::new_from_array([13; 32]), +}; +/// Arrives after the claim. +const LATE_CONTRIBUTOR: ContributorKeys = ContributorKeys { + wallet: Pubkey::new_from_array([14; 32]), + token_account: Pubkey::new_from_array([15; 32]), +}; +/// Contributors to a second raise at the same fundraiser address. +const NEXT_RAISE_CONTRIBUTORS: [ContributorKeys; 2] = [ + ContributorKeys { + wallet: Pubkey::new_from_array([16; 32]), + token_account: Pubkey::new_from_array([17; 32]), + }, + ContributorKeys { + wallet: Pubkey::new_from_array([18; 32]), + token_account: Pubkey::new_from_array([19; 32]), + }, +]; +/// The contributors whose `CONTRIBUTIONS_REACHING_TARGET` fund a raise. +const TARGET_CONTRIBUTORS: [ContributorKeys; 3] = + [FIRST_CONTRIBUTOR, SECOND_CONTRIBUTOR, THIRD_CONTRIBUTOR]; + fn framework_error(error: QuasarError) -> ProgramError { ProgramError::Custom(error as u32) } -/// Register the maker, the mint, and warp to the fixed start time. +/// Register the maker, the maker's token account, the mint, and warp to the +/// fixed start time. fn base_world(test: &mut Test) { test.add(Wallet::new().at(MAKER)); test.add(Mint::new(MAKER).at(MINT).supply(1_000_000_000).decimals(9)); + test.add(TokenAccount::new(MINT, MAKER).at(MAKER_TA)); test.warp_to_timestamp(START_TIME); } @@ -61,38 +110,73 @@ fn initialize_fundraiser(test: &mut Test, amount_to_raise: u64, duration: u16) - }) } +/// Give a contributor a wallet and a funded token account. +fn add_contributor(test: &mut Test, contributor: ContributorKeys) { + test.add(Wallet::new().at(contributor.wallet)); + test.add( + TokenAccount::new(MINT, contributor.wallet) + .at(contributor.token_account) + .amount(CONTRIBUTOR_STARTING_BALANCE), + ); +} + /// A world with an initialized fundraiser and a funded contributor. fn initialized_world(test: &mut Test) -> Pubkey { base_world(test); initialize_fundraiser(test, TARGET_AMOUNT, DURATION_DAYS).succeeds(); - test.add(Wallet::new().at(CONTRIBUTOR)); - test.add( - TokenAccount::new(MINT, CONTRIBUTOR) - .at(CONTRIBUTOR_TA) - .amount(CONTRIBUTOR_STARTING_BALANCE), - ); + add_contributor(test, FIRST_CONTRIBUTOR); test.derive_pda(Fundraiser::seeds(&MAKER)) } -fn contribute(test: &mut Test, amount: u64) -> Outcome { +fn contributor_account(test: &Test, fundraiser: Pubkey, contributor: ContributorKeys) -> Pubkey { + test.derive_pda(Contributor::seeds(&fundraiser, &contributor.wallet)) +} + +fn contribute_from(test: &mut Test, contributor: ContributorKeys, amount: u64) -> Outcome { test.send(ContributeInstruction { - contributor: CONTRIBUTOR, + contributor: contributor.wallet, maker: MAKER, - contributor_ta: CONTRIBUTOR_TA, + contributor_ta: contributor.token_account, vault: VAULT, mint_to_raise: MINT, amount, }) } -fn refund(test: &mut Test) -> Outcome { - test.send(RefundInstruction { - contributor: CONTRIBUTOR, +fn contribute(test: &mut Test, amount: u64) -> Outcome { + contribute_from(test, FIRST_CONTRIBUTOR, amount) +} + +/// Three contributors whose contributions reach the target exactly. +fn fund_to_target(test: &mut Test) { + for (contributor, amount) in TARGET_CONTRIBUTORS + .iter() + .zip(CONTRIBUTIONS_REACHING_TARGET) + { + if contributor.wallet != CONTRIBUTOR { + add_contributor(test, *contributor); + } + contribute_from(test, *contributor, amount).succeeds(); + } +} + +fn refund_instruction(contributor: ContributorKeys) -> Instruction { + RefundInstruction { + contributor: contributor.wallet, maker: MAKER, - contributor_ta: CONTRIBUTOR_TA, + contributor_ta: contributor.token_account, vault: VAULT, mint_to_raise: MINT, - }) + } + .into() +} + +fn refund_for(test: &mut Test, contributor: ContributorKeys) -> Outcome { + test.send(refund_instruction(contributor)) +} + +fn refund(test: &mut Test) -> Outcome { + refund_for(test, FIRST_CONTRIBUTOR) } fn check_contributions(test: &mut Test) -> Outcome { @@ -104,13 +188,57 @@ fn check_contributions(test: &mut Test) -> Outcome { }) } -fn close_contributor(test: &mut Test, fundraiser: Pubkey) -> Outcome { - test.send(CloseContributorInstruction { - contributor: CONTRIBUTOR, +fn close_contributor_instruction(contributor: ContributorKeys, fundraiser: Pubkey) -> Instruction { + CloseContributorInstruction { + contributor: contributor.wallet, fundraiser, + } + .into() +} + +fn close_contributor_for( + test: &mut Test, + contributor: ContributorKeys, + fundraiser: Pubkey, +) -> Outcome { + test.send(close_contributor_instruction(contributor, fundraiser)) +} + +fn close_contributor(test: &mut Test, fundraiser: Pubkey) -> Outcome { + close_contributor_for(test, FIRST_CONTRIBUTOR, fundraiser) +} + +fn close_fundraiser(test: &mut Test) -> Outcome { + test.send(CloseFundraiserInstruction { + maker: MAKER, + vault: VAULT, + maker_ta: MAKER_TA, + mint_to_raise: MINT, }) } +/// Send tokens straight to the vault, bypassing `contribute`, by rewriting +/// the vault's balance. +fn donate_to_vault(test: &mut Test, fundraiser: Pubkey, amount: u64) { + let balance = test.tokens(VAULT); + test.add( + TokenAccount::new(MINT, fundraiser) + .at(VAULT) + .amount(balance + amount), + ); +} + +fn open_contributor_accounts(test: &Test, fundraiser: Pubkey) -> u32 { + u32::from( + test.read::(fundraiser) + .open_contributor_accounts, + ) +} + +fn is_claimed(test: &Test, fundraiser: Pubkey) -> bool { + bool::from(test.read::(fundraiser).claimed) +} + #[quasar_test] fn initialize_records_state_and_clock_time(test: &mut Test) { base_world(test); @@ -127,6 +255,8 @@ fn initialize_records_state_and_clock_time(test: &mut Test) { assert_eq!(u64::from(state.current_amount), 0); assert_eq!(i64::from(state.time_started), START_TIME); assert_eq!(u16::from(state.duration), DURATION_DAYS); + assert!(!bool::from(state.claimed)); + assert_eq!(u32::from(state.open_contributor_accounts), 0); assert_eq!(state.bump, expected_bump); } @@ -159,6 +289,7 @@ fn contribute_creates_contributor_account_and_moves_tokens(test: &mut Test) { u64::from(fundraiser_state.current_amount), PARTIAL_CONTRIBUTION ); + assert_eq!(u32::from(fundraiser_state.open_contributor_accounts), 1); let (contributor_account, expected_bump) = test.derive_pda_with_bump(Contributor::seeds(&fundraiser, &CONTRIBUTOR)); @@ -168,17 +299,18 @@ fn contribute_creates_contributor_account_and_moves_tokens(test: &mut Test) { } #[quasar_test] -fn contribute_accumulates_across_calls(test: &mut Test) { +fn contributions_accumulate_in_one_contributor_account(test: &mut Test) { let fundraiser = initialized_world(test); contribute(test, PARTIAL_CONTRIBUTION).succeeds(); - // Second contribution reuses the contributor account created by the first. - let expected_total = PARTIAL_CONTRIBUTION * 2; - contribute(test, PARTIAL_CONTRIBUTION) + // The second contribution reuses the contributor account created by the + // first, so the fundraiser still counts one open contributor account. + let expected_total = PARTIAL_CONTRIBUTION + SECOND_PARTIAL_CONTRIBUTION; + contribute(test, SECOND_PARTIAL_CONTRIBUTION) .succeeds() .has_tokens(VAULT, expected_total); - let contributor_account = test.derive_pda(Contributor::seeds(&fundraiser, &CONTRIBUTOR)); + let contributor_account = contributor_account(test, fundraiser, FIRST_CONTRIBUTOR); assert_eq!( u64::from(test.read::(contributor_account).amount), expected_total @@ -187,6 +319,7 @@ fn contribute_accumulates_across_calls(test: &mut Test) { u64::from(test.read::(fundraiser).current_amount), expected_total ); + assert_eq!(open_contributor_accounts(test, fundraiser), 1); } #[quasar_test] @@ -230,6 +363,28 @@ fn contribute_rejects_vault_not_bound_to_fundraiser(test: &mut Test) { .fails(framework_error(QuasarError::HasOneMismatch)); } +#[quasar_test] +fn contribute_after_claim_fails(test: &mut Test) { + let fundraiser = initialized_world(test); + fund_to_target(test); + check_contributions(test).succeeds(); + + // The deadline is still days away, but the vault has been paid out. + test.warp_to_timestamp(START_TIME + SECONDS_PER_DAY); + add_contributor(test, LATE_CONTRIBUTOR); + contribute_from(test, LATE_CONTRIBUTOR, PARTIAL_CONTRIBUTION) + .fails_with(FundraiserError::FundraiserClaimed); + + assert_eq!( + test.tokens(LATE_CONTRIBUTOR.token_account), + CONTRIBUTOR_STARTING_BALANCE + ); + assert_eq!( + open_contributor_accounts(test, fundraiser), + TARGET_CONTRIBUTORS.len() as u32 + ); +} + #[quasar_test] fn refund_returns_tokens_after_failed_fundraiser(test: &mut Test) { let fundraiser = initialized_world(test); @@ -237,7 +392,7 @@ fn refund_returns_tokens_after_failed_fundraiser(test: &mut Test) { test.warp_to_timestamp(DEADLINE); - let contributor_account = test.derive_pda(Contributor::seeds(&fundraiser, &CONTRIBUTOR)); + let contributor_account = contributor_account(test, fundraiser, FIRST_CONTRIBUTOR); refund(test) .succeeds() .has_tokens(VAULT, 0) @@ -245,9 +400,36 @@ fn refund_returns_tokens_after_failed_fundraiser(test: &mut Test) { // The contributor account was closed and its rent returned. .is_closed(contributor_account); + let fundraiser_state = test.read::(fundraiser); + assert_eq!(u64::from(fundraiser_state.current_amount), 0); + assert_eq!(u32::from(fundraiser_state.open_contributor_accounts), 0); +} + +#[quasar_test] +fn anyone_can_refund_a_contributor(test: &mut Test) { + let fundraiser = initialized_world(test); + contribute(test, SECOND_PARTIAL_CONTRIBUTION).succeeds(); + test.warp_to_timestamp(DEADLINE); + + // No account in the refund is a signer: whoever sends it, the tokens + // and the rent go to the contributor, who signs nothing. + let instruction = refund_instruction(FIRST_CONTRIBUTOR); + assert!( + instruction.accounts.iter().all(|meta| !meta.is_signer), + "refund must not require any signature" + ); + + let contributor_account = contributor_account(test, fundraiser, FIRST_CONTRIBUTOR); + let rent = test.lamports(contributor_account); + let contributor_lamports_before = test.lamports(CONTRIBUTOR); + + test.send(instruction) + .succeeds() + .has_tokens(CONTRIBUTOR_TA, CONTRIBUTOR_STARTING_BALANCE) + .is_closed(contributor_account); assert_eq!( - u64::from(test.read::(fundraiser).current_amount), - 0 + test.lamports(CONTRIBUTOR), + contributor_lamports_before + rent ); } @@ -263,10 +445,11 @@ fn refund_rejected_before_deadline(test: &mut Test) { #[quasar_test] fn refund_rejected_when_target_met(test: &mut Test) { initialized_world(test); - contribute(test, TARGET_AMOUNT).succeeds(); + fund_to_target(test); test.warp_to_timestamp(DEADLINE); refund(test).fails_with(FundraiserError::TargetMet); + assert_eq!(test.tokens(VAULT), TARGET_AMOUNT); } #[quasar_test] @@ -276,46 +459,55 @@ fn refund_rejects_another_contributors_account(test: &mut Test) { test.warp_to_timestamp(DEADLINE); - // The attacker signs as themselves but passes the victim's contributor - // record and their own token account, trying to drain the vault. test.add(Wallet::new().at(ATTACKER)); test.add(TokenAccount::new(MINT, ATTACKER).at(ATTACKER_TA)); - let mut instruction: Instruction = RefundInstruction { - contributor: CONTRIBUTOR, - maker: MAKER, - contributor_ta: ATTACKER_TA, - vault: VAULT, - mint_to_raise: MINT, - } - .into(); + // Refunds need no signature, so the attacker names the victim as the + // contributor but routes the tokens to their own token account. The + // destination must be owned by the contributor. + let mut instruction = refund_instruction(FIRST_CONTRIBUTOR); // Account indices follow the accounts-struct field order: - // 0 contributor (signer), 3 contributor_account, 4 contributor_ta. The - // builder derived contributor_account for the VICTIM; swap only the - // signer to the attacker. - instruction.accounts[0].pubkey = ATTACKER; + // 0 contributor, 3 contributor_account, 4 contributor_ta. + instruction.accounts[4].pubkey = ATTACKER_TA; + test.send(instruction) + .fails(ProgramError::InvalidAccountData); - // The contributor_account PDA check derives ["contributor", fundraiser, - // attacker], which does not match the victim's record. + // The attacker names themselves as the contributor, with their own token + // account, against the victim's contributor record. The record's PDA is + // derived from ["contributor", fundraiser, attacker], which does not + // match. + let mut instruction = refund_instruction(FIRST_CONTRIBUTOR); + instruction.accounts[0].pubkey = ATTACKER; + instruction.accounts[4].pubkey = ATTACKER_TA; test.send(instruction) .fails(framework_error(QuasarError::InvalidPda)); + // The vault still holds the victim's contribution. assert_eq!(test.tokens(VAULT), PARTIAL_CONTRIBUTION); + assert_eq!(test.tokens(ATTACKER_TA), 0); } #[quasar_test] -fn check_contributions_pays_maker_when_target_met(test: &mut Test) { +fn check_contributions_pays_maker_and_marks_claimed(test: &mut Test) { let fundraiser = initialized_world(test); - contribute(test, TARGET_AMOUNT).succeeds(); - - test.add(TokenAccount::new(MINT, MAKER).at(MAKER_TA)); + fund_to_target(test); check_contributions(test) .succeeds() .has_tokens(MAKER_TA, TARGET_AMOUNT) - // The vault and fundraiser accounts were closed. - .is_closed(VAULT) - .is_closed(fundraiser); + .has_tokens(VAULT, 0); + + // The fundraiser and the vault stay open, the fundraiser marked claimed, + // until every contributor account written for it is closed. + assert!( + test.account(VAULT).is_some(), + "the vault survives the claim" + ); + assert!(is_claimed(test, fundraiser)); + assert_eq!( + open_contributor_accounts(test, fundraiser), + TARGET_CONTRIBUTORS.len() as u32 + ); } #[quasar_test] @@ -323,21 +515,44 @@ fn check_contributions_rejected_below_target(test: &mut Test) { initialized_world(test); contribute(test, PARTIAL_CONTRIBUTION).succeeds(); - test.add(TokenAccount::new(MINT, MAKER).at(MAKER_TA)); check_contributions(test).fails_with(FundraiserError::TargetNotMet); } #[quasar_test] -fn close_contributor_returns_rent_after_successful_raise(test: &mut Test) { +fn check_contributions_ignores_direct_vault_donations(test: &mut Test) { let fundraiser = initialized_world(test); - contribute(test, TARGET_AMOUNT).succeeds(); - test.add(TokenAccount::new(MINT, MAKER).at(MAKER_TA)); - check_contributions(test).succeeds().is_closed(fundraiser); + // The full target sent straight to the vault leaves the state-tracked + // current_amount at 0, so the claim must fail. + donate_to_vault(test, fundraiser, TARGET_AMOUNT); + + check_contributions(test).fails_with(FundraiserError::TargetNotMet); + assert!(!is_claimed(test, fundraiser)); +} - // The claim closed the fundraiser and the vault, but the contributor - // account is still open with its rent inside. - let contributor_account = test.derive_pda(Contributor::seeds(&fundraiser, &CONTRIBUTOR)); +#[quasar_test] +fn second_claim_fails(test: &mut Test) { + let fundraiser = initialized_world(test); + fund_to_target(test); + check_contributions(test).succeeds(); + + // A donation to the vault after the claim must not make a second claim + // possible. + donate_to_vault(test, fundraiser, DONATION); + + check_contributions(test).fails_with(FundraiserError::FundraiserClaimed); + assert_eq!(test.tokens(MAKER_TA), TARGET_AMOUNT); + assert_eq!(test.tokens(VAULT), DONATION); +} + +#[quasar_test] +fn close_contributor_returns_rent_after_successful_raise(test: &mut Test) { + let fundraiser = initialized_world(test); + fund_to_target(test); + check_contributions(test).succeeds(); + + // The claim leaves the contributor account open with its rent inside. + let contributor_account = contributor_account(test, fundraiser, FIRST_CONTRIBUTOR); let rent = test.lamports(contributor_account); assert!(rent > 0, "the contributor account survives the claim"); let lamports_before = test.lamports(CONTRIBUTOR); @@ -350,20 +565,234 @@ fn close_contributor_returns_rent_after_successful_raise(test: &mut Test) { lamports_before + rent, "the contributor account's rent returns to the contributor" ); + assert_eq!( + open_contributor_accounts(test, fundraiser), + TARGET_CONTRIBUTORS.len() as u32 - 1 + ); } #[quasar_test] -fn close_contributor_rejected_while_fundraiser_exists(test: &mut Test) { +fn anyone_can_close_contributor_accounts_after_claim(test: &mut Test) { + let fundraiser = initialized_world(test); + fund_to_target(test); + check_contributions(test).succeeds(); + + // Whoever sends it, each rent deposit goes to its contributor, who signs + // nothing. + for contributor in TARGET_CONTRIBUTORS { + let instruction = close_contributor_instruction(contributor, fundraiser); + assert!( + instruction.accounts.iter().all(|meta| !meta.is_signer), + "close_contributor must not require any signature" + ); + + let contributor_account = contributor_account(test, fundraiser, contributor); + let rent = test.lamports(contributor_account); + let lamports_before = test.lamports(contributor.wallet); + test.send(instruction) + .succeeds() + .is_closed(contributor_account); + assert_eq!(test.lamports(contributor.wallet), lamports_before + rent); + } + + assert_eq!(open_contributor_accounts(test, fundraiser), 0); +} + +#[quasar_test] +fn close_contributor_before_claim_fails(test: &mut Test) { let fundraiser = initialized_world(test); contribute(test, PARTIAL_CONTRIBUTION).succeeds(); - // The fundraiser is live, so the contribution is live too: closing the - // record now would erase what the vault owes this contributor. - close_contributor(test, fundraiser).fails_with(FundraiserError::FundraiserStillOpen); + // The fundraiser is unclaimed, so the contribution can still be + // refunded: closing the record now would erase what the vault owes. + close_contributor(test, fundraiser).fails_with(FundraiserError::FundraiserNotClaimed); - let contributor_account = test.derive_pda(Contributor::seeds(&fundraiser, &CONTRIBUTOR)); + let contributor_account = contributor_account(test, fundraiser, FIRST_CONTRIBUTOR); assert_eq!( u64::from(test.read::(contributor_account).amount), PARTIAL_CONTRIBUTION ); + assert_eq!(open_contributor_accounts(test, fundraiser), 1); +} + +#[quasar_test] +fn close_fundraiser_after_failed_raise_allows_a_new_raise(test: &mut Test) { + let fundraiser = initialized_world(test); + contribute(test, PARTIAL_CONTRIBUTION).succeeds(); + + // The raise fails; the contributor takes their refund. + test.warp_to_timestamp(DEADLINE); + refund(test).succeeds(); + + close_fundraiser(test) + .succeeds() + .is_closed(VAULT) + .is_closed(fundraiser); + + // The same maker can now open a fresh fundraiser at the same address. + initialize_fundraiser(test, TARGET_AMOUNT, DURATION_DAYS).succeeds(); + let state = test.read::(fundraiser); + assert_eq!(u64::from(state.current_amount), 0); + assert_eq!(u64::from(state.amount_to_raise), TARGET_AMOUNT); + assert_eq!(i64::from(state.time_started), DEADLINE); + assert_eq!(test.tokens(VAULT), 0); +} + +#[quasar_test] +fn close_fundraiser_after_claim_allows_a_new_raise(test: &mut Test) { + let fundraiser = initialized_world(test); + fund_to_target(test); + check_contributions(test).succeeds(); + + for contributor in TARGET_CONTRIBUTORS { + close_contributor_for(test, contributor, fundraiser).succeeds(); + } + // A claimed fundraiser closes without waiting for its deadline. + close_fundraiser(test) + .succeeds() + .is_closed(VAULT) + .is_closed(fundraiser); + + initialize_fundraiser(test, TARGET_AMOUNT, DURATION_DAYS).succeeds(); + assert!(!is_claimed(test, fundraiser)); + assert_eq!( + u64::from(test.read::(fundraiser).current_amount), + 0 + ); +} + +#[quasar_test] +fn close_fundraiser_before_deadline_fails(test: &mut Test) { + let fundraiser = initialized_world(test); + + // One second short of the deadline. + test.warp_to_timestamp(DEADLINE - 1); + + close_fundraiser(test).fails_with(FundraiserError::FundraiserNotEnded); + assert!(test.account(fundraiser).is_some()); +} + +#[quasar_test] +fn close_fundraiser_with_unrefunded_contributions_fails(test: &mut Test) { + initialized_world(test); + contribute(test, PARTIAL_CONTRIBUTION).succeeds(); + + // Past the deadline but the contribution has not been refunded, so + // closing would strand it in the vault. + test.warp_to_timestamp(DEADLINE); + + close_fundraiser(test).fails_with(FundraiserError::RefundsOutstanding); + assert_eq!(test.tokens(VAULT), PARTIAL_CONTRIBUTION); +} + +#[quasar_test] +fn close_fundraiser_when_target_met_but_unclaimed_fails(test: &mut Test) { + initialized_world(test); + fund_to_target(test); + + test.warp_to_timestamp(DEADLINE); + + // A raise that met its target closes only after the maker claims it. + close_fundraiser(test).fails_with(FundraiserError::TargetMet); + assert_eq!(test.tokens(VAULT), TARGET_AMOUNT); +} + +#[quasar_test] +fn close_fundraiser_with_open_contributor_accounts_fails(test: &mut Test) { + let fundraiser = initialized_world(test); + fund_to_target(test); + check_contributions(test).succeeds(); + + // Close all but the first contributor account. + for contributor in &TARGET_CONTRIBUTORS[1..] { + close_contributor_for(test, *contributor, fundraiser).succeeds(); + } + + close_fundraiser(test).fails_with(FundraiserError::ContributorAccountsOpen); + assert!(test.account(fundraiser).is_some()); + assert_eq!(open_contributor_accounts(test, fundraiser), 1); +} + +#[quasar_test] +fn reinitialize_with_open_contributor_accounts_fails(test: &mut Test) { + let fundraiser = initialized_world(test); + fund_to_target(test); + check_contributions(test).succeeds(); + + // The claimed fundraiser account still exists, so a new fundraiser + // cannot be initialized at its address. + initialize_fundraiser(test, TARGET_AMOUNT, DURATION_DAYS) + .fails(ProgramError::AccountAlreadyInitialized); + assert!(is_claimed(test, fundraiser)); + assert_eq!( + open_contributor_accounts(test, fundraiser), + TARGET_CONTRIBUTORS.len() as u32 + ); +} + +#[quasar_test] +fn stale_contributor_account_cannot_refund_from_next_raise(test: &mut Test) { + let fundraiser = initialized_world(test); + + // Raise one succeeds and the maker claims it. + fund_to_target(test); + check_contributions(test).succeeds(); + + // The maker closes every contributor account from raise one, then the + // fundraiser, and starts raise two at the same address. + for contributor in TARGET_CONTRIBUTORS { + close_contributor_for(test, contributor, fundraiser).succeeds(); + } + close_fundraiser(test).succeeds(); + initialize_fundraiser(test, TARGET_AMOUNT, DURATION_DAYS).succeeds(); + + // Raise two collects less than the target and fails. + let next_raise_amounts = [PARTIAL_CONTRIBUTION, SECOND_PARTIAL_CONTRIBUTION]; + for (contributor, amount) in NEXT_RAISE_CONTRIBUTORS.iter().zip(next_raise_amounts) { + add_contributor(test, *contributor); + contribute_from(test, *contributor, amount).succeeds(); + } + let raise_two_total = PARTIAL_CONTRIBUTION + SECOND_PARTIAL_CONTRIBUTION; + assert_eq!(open_contributor_accounts(test, fundraiser), 2); + test.warp_to_timestamp(DEADLINE); + + // A raise-one contributor tries to take a refund from raise two. Their + // contributor account was closed with raise one, so the address is a + // system-owned empty account, not a contributor record: there is nothing + // to refund. + let stale_contributor = FIRST_CONTRIBUTOR; + let stale_balance = test.tokens(stale_contributor.token_account); + // The runtime reports the wrong-owner check as `IllegalOwner`, outside + // quasar-test's stable error set. + refund_for(test, stale_contributor).fails(ProgramError::Runtime("IllegalOwner".into())); + assert_eq!(test.tokens(VAULT), raise_two_total); + assert_eq!(test.tokens(stale_contributor.token_account), stale_balance); + + // Every raise-two contributor is refunded in full. + for contributor in NEXT_RAISE_CONTRIBUTORS { + refund_for(test, contributor) + .succeeds() + .has_tokens(contributor.token_account, CONTRIBUTOR_STARTING_BALANCE); + } + let state = test.read::(fundraiser); + assert_eq!(u64::from(state.current_amount), 0); + assert_eq!(u32::from(state.open_contributor_accounts), 0); + assert_eq!(test.tokens(VAULT), 0); +} + +#[quasar_test] +fn close_fundraiser_sweeps_direct_donations_to_maker(test: &mut Test) { + let fundraiser = initialized_world(test); + + // Tokens sent straight to the vault are outside the program's + // accounting; on close they go to the maker instead of being burned + // with the account. + donate_to_vault(test, fundraiser, DONATION); + + test.warp_to_timestamp(DEADLINE); + close_fundraiser(test) + .succeeds() + .has_tokens(MAKER_TA, DONATION) + .is_closed(VAULT) + .is_closed(fundraiser); }