From f2092229663939eb32da579d3c886ffbf874fdbc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 02:51:10 +0000 Subject: [PATCH] finance/token-fundraiser: let a contributor close their account after a successful raise A successful raise exits through check_contributions, which closes the vault and the fundraiser account but cannot reach the contributor accounts: there is one per contributor and the claim carries none of them. Their only other closer, refund, runs only on a failed raise, so every contributor to a successful raise held their rent in an account nothing could close. Add close_contributor to the Anchor v2 and Quasar ports. The contributor signs and passes the fundraiser address their account was written for; the account's seeds bind it to that address, and the one check is that the account there is no longer owned by the program, else the new FundraiserStillOpen error. A live fundraiser is program-owned and a closed one belongs to the system program again whatever lamports it holds, so a stray transfer to the closed address cannot block the close. The close constraint returns the rent. Two tests per port: the rent comes back after a claim, and the close is refused while the fundraiser exists. READMEs and changelogs follow. The Anchor v1 port is a frozen snapshot and does not change. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MFyue6rqsmrggYY6Zkcype --- CHANGELOG.md | 16 ++ finance/token-fundraiser/anchor/CHANGELOG.md | 13 ++ finance/token-fundraiser/anchor/README.md | 16 +- .../anchor/programs/fundraiser/src/error.rs | 2 + .../src/instructions/close_contributor.rs | 45 ++++++ .../fundraiser/src/instructions/mod.rs | 2 + .../anchor/programs/fundraiser/src/lib.rs | 8 + .../fundraiser/tests/test_fundraiser.rs | 144 ++++++++++++++++++ finance/token-fundraiser/quasar/CHANGELOG.md | 13 ++ finance/token-fundraiser/quasar/README.md | 3 +- finance/token-fundraiser/quasar/src/error.rs | 3 + .../src/instructions/close_contributor.rs | 49 ++++++ .../quasar/src/instructions/mod.rs | 3 + finance/token-fundraiser/quasar/src/lib.rs | 10 ++ finance/token-fundraiser/quasar/src/tests.rs | 52 ++++++- 15 files changed, 374 insertions(+), 5 deletions(-) create mode 100644 finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/close_contributor.rs create mode 100644 finance/token-fundraiser/quasar/src/instructions/close_contributor.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index bf1d421a0..2097ad368 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to this repository are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [2026-09-14] - Token fundraiser contributors can close their accounts + +A successful raise exits through `check_contributions`, which closes the vault +and the fundraiser account but cannot reach the contributor accounts: there is +one per contributor and the claim carries none of them. Their only other +closer, `refund`, runs only on a failed raise, so every contributor to a +successful raise held their rent in an account nothing could close. + +- The token fundraiser gains `close_contributor` in its Anchor v2 and Quasar + ports: a contributor closes their own contributor account once the + fundraiser is gone, and the rent comes back. The one check is that the + passed fundraiser account is not owned by the program, else the new + `FundraiserStillOpen` error, so a live contribution still closes only + through `refund`. Tests, READMEs, and changelogs follow. The Anchor v1 port + is a frozen snapshot and does not change. + ## [2026-09-10] - Market and pool accounts own their vaults Five finance examples kept a dataless "authority" PDA beside their state diff --git a/finance/token-fundraiser/anchor/CHANGELOG.md b/finance/token-fundraiser/anchor/CHANGELOG.md index 4f1163881..459869746 100644 --- a/finance/token-fundraiser/anchor/CHANGELOG.md +++ b/finance/token-fundraiser/anchor/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 2026-09-14 + +### Added + +- `close_contributor`: a contributor closes their Contributor account once the + fundraiser is gone, taking back its rent. A successful raise closed the vault + and the Fundraiser account but left every Contributor account open, and + `refund`, their only other closer, runs only on a failed raise, so the rent + was stuck. The handler's one check is that the passed fundraiser account is + not owned by this program, else the new `FundraiserStillOpen` error. Two + tests cover the rent returning after a claim and the refusal while the + fundraiser exists. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/finance/token-fundraiser/anchor/README.md b/finance/token-fundraiser/anchor/README.md index f3a0c0521..91a81f7b6 100644 --- a/finance/token-fundraiser/anchor/README.md +++ b/finance/token-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; if it is not reached by the deadline, contributors can refund, and once refunds are complete the maker can retire the fundraiser and open a new one. +Onchain crowdfunding on Solana: a program that collects tokens toward a target amount, like Kickstarter without a payment processor. A **maker** creates a fundraiser [account](https://solana.com/docs/terminology#account), specifies the [mint](https://solana.com/docs/terminology#token-mint) they want to receive, the target amount, and a duration in days. **Contributors** contribute while the window is open. If the target is reached, the maker claims the funds and each contributor closes their own record to take back its rent; if it is not reached by the deadline, contributors can refund, and once refunds are complete the maker can retire the fundraiser and open a new one. ## Architecture @@ -140,6 +140,14 @@ Retires a failed fundraiser so the maker can raise again. The Fundraiser PDA is Anything still in the vault at this point is a direct donation outside the program's accounting; the handler sweeps it to `maker_ata` with `transfer_checked` rather than burning it, then closes the vault with `close_account` (both CPIs signed with the Fundraiser PDA's seeds). The Fundraiser state account is closed via `close = maker`. +### `close_contributor` + +[`programs/fundraiser/src/instructions/close_contributor.rs`](programs/fundraiser/src/instructions/close_contributor.rs), account constraints `CloseContributorAccountConstraints`. + +Lets a contributor close their Contributor account once the fundraiser is gone, taking back its rent. A successful raise exits through `check_contributions`, which closes the vault and the Fundraiser account but cannot reach the Contributor accounts: there is one per contributor and the claim carries none of them. Their other closer, `refund`, runs only on a failed raise, so without this handler every contributor to a successful raise would hold their rent in an account nothing could close. + +One check: the `fundraiser` account passed in is not owned by this program, else `FundraiserStillOpen`. A live fundraiser is program-owned; a closed one belongs to the system program again, whatever lamports it holds. The Contributor account's seeds bind it to that fundraiser address, so no other fundraiser can be substituted. The account is closed via `close = contributor`. + ## Testing The tests are Rust integration tests using [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) and [solana-kite](https://crates.io/crates/solana-kite), in [`programs/fundraiser/tests/test_fundraiser.rs`](programs/fundraiser/tests/test_fundraiser.rs). They load the compiled program with `include_bytes!`, so build the program first and rebuild after every program change: @@ -149,7 +157,7 @@ cargo build-sbf cargo test ``` -The suite uses a nonzero duration and warps the LiteSVM `Clock` sysvar to exercise both sides of every deadline: contributing inside the window succeeds, contributing after the deadline fails, refunding before the deadline fails, and refunding after the deadline succeeds when the target was not met. It exercises both contribution caps (a single contribution over the 10% cap, and contributions that cumulatively exceed it), and verifies that the claim pays the maker and closes the vault, that direct vault donations do not unlock the claim, and that `close_fundraiser` retires a failed raise (only after the deadline, only when the target was missed, only once refunds are complete, sweeping direct donations to the maker) and lets the same maker initialize a fresh fundraiser. Assertions check token balances and decoded account state rather than just transaction success. +The suite uses a nonzero duration and warps the LiteSVM `Clock` sysvar to exercise both sides of every deadline: contributing inside the window succeeds, contributing after the deadline fails, refunding before the deadline fails, and refunding after the deadline succeeds when the target was not met. It exercises both contribution caps (a single contribution over the 10% cap, and contributions that cumulatively exceed it), and verifies that the claim pays the maker and closes the vault, that direct vault donations do not unlock the claim, that `close_fundraiser` retires a failed raise (only after the deadline, only when the target was missed, only once refunds are complete, sweeping direct donations to the maker) and lets the same maker initialize a fresh fundraiser, and that `close_contributor` returns a contributor's rent after a successful claim and is refused while the fundraiser exists. Assertions check token balances and decoded account state rather than just transaction success. ## FAQ @@ -157,6 +165,10 @@ The suite uses a nonzero duration and warps the LiteSVM `Clock` sysvar to exerci A maker opens a fundraiser with `initialize_fundraiser`, naming the token, target amount, and duration. Contributors deposit with `contribute` while the window is open, and the funds sit in a program-controlled vault that neither side can raid. When the target is reached, the maker claims the raise with `check_contributions`, which pays out the vault and closes the fundraiser. +### What happens to the contributor accounts after a successful raise? + +The claim closes the vault and the Fundraiser account, but each Contributor account stays open with its rent inside. Its owner calls `close_contributor`, which checks that the fundraiser is gone and returns the rent. + ### What happens if the fundraiser misses its target? Contributors call `refund` after the deadline to reclaim exactly what they put in. Once refunds are complete, the maker calls `close_fundraiser` to retire the failed raise and can then open a new one. diff --git a/finance/token-fundraiser/anchor/programs/fundraiser/src/error.rs b/finance/token-fundraiser/anchor/programs/fundraiser/src/error.rs index 31a745947..06e94d8a1 100644 --- a/finance/token-fundraiser/anchor/programs/fundraiser/src/error.rs +++ b/finance/token-fundraiser/anchor/programs/fundraiser/src/error.rs @@ -22,4 +22,6 @@ pub enum FundraiserError { RefundsOutstanding, #[msg("Arithmetic overflow")] MathOverflow, + #[msg("The fundraiser still exists, so the contributor account closes through refund")] + FundraiserStillOpen, } diff --git a/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/close_contributor.rs b/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/close_contributor.rs new file mode 100644 index 000000000..944f3fb35 --- /dev/null +++ b/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/close_contributor.rs @@ -0,0 +1,45 @@ +use anchor_lang::prelude::*; + +use crate::{state::Contributor, FundraiserError}; + +#[derive(Accounts)] +pub struct CloseContributorAccountConstraints { + #[account(mut)] + pub contributor: Signer, + + /// CHECK: the fundraiser this contributor account was written for. The + /// contributor account's seeds bind it to this address, so no other + /// fundraiser can be substituted. The constraint requires the account to + /// be gone: a live fundraiser is owned by this program, and a closed one + /// belongs to the system program again, whatever lamports it holds. + #[account( + constraint = !fundraiser.account().owned_by(&crate::ID) @ FundraiserError::FundraiserStillOpen, + )] + pub fundraiser: UncheckedAccount, + + #[account( + mut, + seeds = [b"contributor", fundraiser.address().as_ref(), contributor.address().as_ref()], + bump = contributor_account.bump, + close = contributor, + )] + pub contributor_account: BorshAccount, +} + +/// Closes a contributor account once its fundraiser is gone, returning the +/// rent to the contributor. +/// +/// A successful raise exits through `check_contributions`, which closes the +/// vault and the fundraiser but cannot reach the contributor accounts: there +/// is one per contributor and the claim carries none of them. Their other +/// closer, `refund`, runs only on a failed raise. Without this handler every +/// contributor to a successful raise would hold their rent in an account +/// nothing could close. +/// +/// The one check is that the fundraiser account no longer exists, which is +/// the `constraint` above; the `close = contributor` constraint then returns +/// the rent. While the fundraiser exists the contribution is live, and +/// `refund` is the way to close it. +pub fn handle_close_contributor(_accounts: &mut CloseContributorAccountConstraints) -> Result<()> { + Ok(()) +} diff --git a/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/mod.rs b/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/mod.rs index b192e20f4..b188ee75d 100644 --- a/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/mod.rs +++ b/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/mod.rs @@ -1,11 +1,13 @@ pub mod checker; pub mod close; +pub mod close_contributor; pub mod contribute; pub mod initialize_fundraiser; pub mod refund; pub use checker::*; pub use close::*; +pub use close_contributor::*; pub use contribute::*; pub use initialize_fundraiser::*; pub use refund::*; diff --git a/finance/token-fundraiser/anchor/programs/fundraiser/src/lib.rs b/finance/token-fundraiser/anchor/programs/fundraiser/src/lib.rs index d30ece2bf..10bbe3c58 100644 --- a/finance/token-fundraiser/anchor/programs/fundraiser/src/lib.rs +++ b/finance/token-fundraiser/anchor/programs/fundraiser/src/lib.rs @@ -55,4 +55,12 @@ pub mod fundraiser { Ok(()) } + + pub fn close_contributor( + context: &mut Context, + ) -> Result<()> { + handle_close_contributor(&mut context.accounts)?; + + Ok(()) + } } diff --git a/finance/token-fundraiser/anchor/programs/fundraiser/tests/test_fundraiser.rs b/finance/token-fundraiser/anchor/programs/fundraiser/tests/test_fundraiser.rs index 0bfa52d28..60d94dd35 100644 --- a/finance/token-fundraiser/anchor/programs/fundraiser/tests/test_fundraiser.rs +++ b/finance/token-fundraiser/anchor/programs/fundraiser/tests/test_fundraiser.rs @@ -251,6 +251,23 @@ fn build_check_contributions_instruction( ) } +fn build_close_contributor_instruction( + setup: &FundraiserSetup, + contributor: &Address, + contributor_account_pda: &Address, +) -> Instruction { + Instruction::new_with_bytes( + setup.program_id, + &fundraiser::instruction::CloseContributor {}.data(), + fundraiser::accounts::CloseContributorAccountConstraints { + contributor: *contributor, + fundraiser: setup.fundraiser_pda, + contributor_account: *contributor_account_pda, + } + .to_account_metas(None), + ) +} + fn build_close_fundraiser_instruction(setup: &FundraiserSetup, maker_ata: &Address) -> Instruction { Instruction::new_with_bytes( setup.program_id, @@ -989,3 +1006,130 @@ fn test_check_contributions_ignores_direct_vault_donations() { "Fundraiser account must stay open after a failed claim" ); } + +#[test] +fn test_close_contributor_after_successful_claim_returns_rent() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + + // 10 contributors at the 10% cap reach the target exactly. + let mut contributors = Vec::new(); + for _ in 0..10 { + let (contributor, contributor_ata, contributor_account_pda) = + create_funded_contributor(&mut setup); + let contribute_instruction = build_contribute_instruction( + &setup, + &contributor.pubkey(), + &contributor_ata, + &contributor_account_pda, + MAX_CONTRIBUTION, + ); + send_transaction_from_instructions( + &mut setup.svm, + vec![contribute_instruction], + &[&contributor], + &contributor.pubkey(), + ) + .unwrap(); + contributors.push((contributor, contributor_account_pda)); + } + + let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); + let check_instruction = build_check_contributions_instruction(&setup, &maker_ata); + send_transaction_from_instructions( + &mut setup.svm, + vec![check_instruction], + &[&setup.maker], + &setup.maker.pubkey(), + ) + .unwrap(); + assert!( + setup.svm.get_account(&setup.fundraiser_pda).is_none(), + "Fundraiser account must be closed after a successful claim" + ); + + // The claim closed the fundraiser and the vault, but every contributor + // account is still open with its rent inside. + let (contributor, contributor_account_pda) = &contributors[0]; + let rent = setup + .svm + .get_account(contributor_account_pda) + .expect("Contributor account survives the claim") + .lamports; + let lamports_before = setup + .svm + .get_account(&contributor.pubkey()) + .unwrap() + .lamports; + + let close_instruction = + build_close_contributor_instruction(&setup, &contributor.pubkey(), contributor_account_pda); + send_transaction_from_instructions( + &mut setup.svm, + vec![close_instruction], + &[contributor], + &contributor.pubkey(), + ) + .unwrap(); + + assert!( + setup.svm.get_account(contributor_account_pda).is_none(), + "Contributor account must be closed" + ); + let lamports_after = setup + .svm + .get_account(&contributor.pubkey()) + .unwrap() + .lamports; + // The contributor paid the transaction fee out of the same balance, so + // the rent came back less that fee. + let fee = 5_000; + assert_eq!( + lamports_after, + lamports_before + rent - fee, + "The contributor account's rent must return to the contributor" + ); +} + +#[test] +fn test_close_contributor_while_fundraiser_open_fails() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + + let (contributor, contributor_ata, contributor_account_pda) = + create_funded_contributor(&mut setup); + let contribute_instruction = build_contribute_instruction( + &setup, + &contributor.pubkey(), + &contributor_ata, + &contributor_account_pda, + MAX_CONTRIBUTION, + ); + send_transaction_from_instructions( + &mut setup.svm, + vec![contribute_instruction], + &[&contributor], + &contributor.pubkey(), + ) + .unwrap(); + + // The fundraiser is live, so the contribution is live too: closing the + // record now would erase what the vault owes this contributor. + let close_instruction = build_close_contributor_instruction( + &setup, + &contributor.pubkey(), + &contributor_account_pda, + ); + let result = send_transaction_from_instructions( + &mut setup.svm, + vec![close_instruction], + &[&contributor], + &contributor.pubkey(), + ); + assert!( + result.is_err(), + "Closing a contributor account must fail while its fundraiser exists" + ); + let contributor_state = read_contributor_state(&setup.svm, &contributor_account_pda); + assert_eq!(contributor_state.amount, MAX_CONTRIBUTION); +} diff --git a/finance/token-fundraiser/quasar/CHANGELOG.md b/finance/token-fundraiser/quasar/CHANGELOG.md index 5f3768602..2b8e2761e 100644 --- a/finance/token-fundraiser/quasar/CHANGELOG.md +++ b/finance/token-fundraiser/quasar/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [2026-09-14] + +### Added + +- `close_contributor` (discriminator 4): a contributor closes their Contributor + account once the fundraiser is gone, taking back its rent. A successful raise + closed the vault and the Fundraiser account but left every Contributor + account open, and `refund`, their only other closer, runs only on a failed + raise, so the rent was stuck. The handler's one check is that the passed + fundraiser account is not owned by this program, else the new + `FundraiserStillOpen` error. Two tests cover the rent returning after a claim + and the refusal while the fundraiser exists. + ## [2026-07-22] ### Changed diff --git a/finance/token-fundraiser/quasar/README.md b/finance/token-fundraiser/quasar/README.md index 3b9511267..631596bd1 100644 --- a/finance/token-fundraiser/quasar/README.md +++ b/finance/token-fundraiser/quasar/README.md @@ -17,6 +17,7 @@ See also: the [repository catalog](../../../README.md) and the [Anchor variant]( - `contribute` (contributor signs): rejects a zero amount and, after the deadline, fails with `FundraiserEnded`. Creates the contributor's Contributor PDA on first use (idempotent init, contributor pays the rent), adds the amount to both `current_amount` and the contributor's record with checked arithmetic, transfers tokens from the contributor's token account into the vault, then verifies the vault gained exactly the contributed amount (`BalanceMismatch` otherwise). - `check_contributions` (maker signs): fails with `TargetNotMet` unless `current_amount >= amount_to_raise`. Transfers the whole vault balance to the maker's token account with the Fundraiser PDA signing, then closes the vault and the Fundraiser account, returning their rent to the maker. - `refund` (contributor signs): fails with `FundraiserNotEnded` before the deadline and with `TargetMet` if the fundraiser succeeded. Pays the contributor's recorded amount back from the vault with the PDA signing, subtracts it from `current_amount`, verifies the vault lost exactly that amount, and closes the Contributor account back to the contributor. +- `close_contributor` (contributor signs): fails with `FundraiserStillOpen` while the passed fundraiser account is still owned by this program. A successful raise closes the vault and the Fundraiser account but leaves every Contributor account open, and `refund` runs only on a failed raise, so this is how a contributor to a successful raise takes back their rent. The Contributor account's seeds bind it to the fundraiser address, so no other fundraiser can be substituted; the account is closed back to the contributor. Errors are defined in `src/error.rs` as a `#[error_code]` enum starting at code 6000. @@ -40,4 +41,4 @@ In-process tests via **Quasar SVM** (`quasar-svm` in `Quasar.toml`): quasar test ``` -The tests in `src/tests.rs` drive the real instruction handlers end to end (initialize, contribute, check_contributions, refund), assert vault and contributor token balances plus account state after every step, and use `QuasarSvm::warp_to_timestamp` to test both sides of the deadline. They also cover the rejection paths: contributing after the deadline, refunding early or after a successful raise, paying out below target, passing a vault not bound to the fundraiser, and refunding against another contributor's record. No local validator is needed. +The tests in `src/tests.rs` drive the real instruction handlers end to end (initialize, contribute, check_contributions, refund, close_contributor), assert vault and contributor token balances plus account state after every step, and use `QuasarSvm::warp_to_timestamp` to test both sides of the deadline. They also cover the rejection paths: contributing after the deadline, refunding early or after a successful raise, paying out below target, passing a vault not bound to the fundraiser, refunding against another contributor's record, and closing a contributor account while its fundraiser still exists. No local validator is needed. diff --git a/finance/token-fundraiser/quasar/src/error.rs b/finance/token-fundraiser/quasar/src/error.rs index 1599b5bab..c3c7d56ab 100644 --- a/finance/token-fundraiser/quasar/src/error.rs +++ b/finance/token-fundraiser/quasar/src/error.rs @@ -22,4 +22,7 @@ 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, } diff --git a/finance/token-fundraiser/quasar/src/instructions/close_contributor.rs b/finance/token-fundraiser/quasar/src/instructions/close_contributor.rs new file mode 100644 index 000000000..6612009e3 --- /dev/null +++ b/finance/token-fundraiser/quasar/src/instructions/close_contributor.rs @@ -0,0 +1,49 @@ +use { + crate::{error::FundraiserError, state::Contributor}, + quasar_lang::prelude::*, +}; + +#[derive(Accounts)] +pub struct CloseContributorAccountConstraints { + #[account(mut)] + pub contributor: Signer, + + /// 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, + + #[account( + mut, + close(dest = contributor), + address = Contributor::seeds(fundraiser.address(), contributor.address()), + )] + pub contributor_account: Account, +} + +/// Closes a contributor account once its fundraiser is gone, returning the +/// rent to the contributor. +/// +/// A successful raise exits through `check_contributions`, which closes the +/// vault and the fundraiser but cannot reach the contributor accounts: there +/// is one per contributor and the claim carries none of them. Their other +/// closer, `refund`, runs only on a failed raise. Without this handler every +/// contributor to a successful raise would hold their rent in an account +/// nothing could close. +/// +/// The one check is that the fundraiser account no longer exists, which is +/// the `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. +#[inline(always)] +pub fn handle_close_contributor( + _accounts: &mut CloseContributorAccountConstraints, +) -> Result<(), ProgramError> { + Ok(()) +} diff --git a/finance/token-fundraiser/quasar/src/instructions/mod.rs b/finance/token-fundraiser/quasar/src/instructions/mod.rs index 1567cbd71..ffb82e184 100644 --- a/finance/token-fundraiser/quasar/src/instructions/mod.rs +++ b/finance/token-fundraiser/quasar/src/instructions/mod.rs @@ -9,3 +9,6 @@ pub use check_contributions::*; pub mod refund; pub use refund::*; + +pub mod close_contributor; +pub use close_contributor::*; diff --git a/finance/token-fundraiser/quasar/src/lib.rs b/finance/token-fundraiser/quasar/src/lib.rs index 40e027967..e63f41879 100644 --- a/finance/token-fundraiser/quasar/src/lib.rs +++ b/finance/token-fundraiser/quasar/src/lib.rs @@ -57,4 +57,14 @@ mod quasar_token_fundraiser { 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. + #[instruction(discriminator = 4)] + pub fn close_contributor( + ctx: Ctx, + ) -> Result<(), ProgramError> { + instructions::handle_close_contributor(&mut ctx.accounts) + } } diff --git a/finance/token-fundraiser/quasar/src/tests.rs b/finance/token-fundraiser/quasar/src/tests.rs index 4d85db60b..43a5b4ce2 100644 --- a/finance/token-fundraiser/quasar/src/tests.rs +++ b/finance/token-fundraiser/quasar/src/tests.rs @@ -5,8 +5,8 @@ use { crate::{ cpi::{ - CheckContributionsInstruction, ContributeInstruction, InitializeFundraiserInstruction, - RefundInstruction, + CheckContributionsInstruction, CloseContributorInstruction, ContributeInstruction, + InitializeFundraiserInstruction, RefundInstruction, }, error::FundraiserError, state::{Contributor, Fundraiser, SECONDS_PER_DAY}, @@ -104,6 +104,13 @@ fn check_contributions(test: &mut Test) -> Outcome { }) } +fn close_contributor(test: &mut Test, fundraiser: Pubkey) -> Outcome { + test.send(CloseContributorInstruction { + contributor: CONTRIBUTOR, + fundraiser, + }) +} + #[quasar_test] fn initialize_records_state_and_clock_time(test: &mut Test) { base_world(test); @@ -319,3 +326,44 @@ fn check_contributions_rejected_below_target(test: &mut Test) { 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) { + 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 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)); + let rent = test.lamports(contributor_account); + assert!(rent > 0, "the contributor account survives the claim"); + let lamports_before = test.lamports(CONTRIBUTOR); + + close_contributor(test, fundraiser) + .succeeds() + .is_closed(contributor_account); + assert_eq!( + test.lamports(CONTRIBUTOR), + lamports_before + rent, + "the contributor account's rent returns to the contributor" + ); +} + +#[quasar_test] +fn close_contributor_rejected_while_fundraiser_exists(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); + + let contributor_account = test.derive_pda(Contributor::seeds(&fundraiser, &CONTRIBUTOR)); + assert_eq!( + u64::from(test.read::(contributor_account).amount), + PARTIAL_CONTRIBUTION + ); +}