diff --git a/CHANGELOG.md b/CHANGELOG.md index f5598738..ce59ddc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ 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-07-10] - Failed fundraisers can be retired + +### Added + +- `token-fundraiser` (Anchor): a `close_fundraiser` instruction handler. The Fundraiser PDA is derived from the maker's key alone, so a failed raise used to lock its maker out of ever raising again. The maker can now retire a failed fundraiser (after the deadline, target missed, all contributions refunded), sweeping any direct vault donations to themselves and recovering both rent deposits, then initialize a fresh fundraiser. New error variant `RefundsOutstanding`. +- `token-fundraiser` (Anchor): tests for both contribution caps (`test_contribute_above_cap_fails`, `test_cumulative_contributions_above_cap_fail`) and for every branch of the close path (before deadline, target met, refunds outstanding, donation sweep, and close-then-raise-again). + ## [2026-06-30] - Anchor 1.1.2 ### Changed diff --git a/finance/token-fundraiser/anchor/README.md b/finance/token-fundraiser/anchor/README.md index cdb82130..d160b7e4 100644 --- a/finance/token-fundraiser/anchor/README.md +++ b/finance/token-fundraiser/anchor/README.md @@ -1,6 +1,6 @@ # Token Fundraiser -Create a fundraiser that collects tokens. 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. +Create a fundraiser that collects tokens. 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. ## Architecture @@ -121,6 +121,18 @@ Lets a contributor reclaim their contribution after a failed fundraiser. Two che 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. +### `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: + +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). +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`. + ## 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: @@ -130,4 +142,4 @@ 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 also verifies that the claim pays the maker and closes the vault, that direct vault donations do not unlock the claim, and asserts 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, 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. diff --git a/finance/token-fundraiser/anchor/programs/fundraiser/src/error.rs b/finance/token-fundraiser/anchor/programs/fundraiser/src/error.rs index 9a13092f..31a74594 100644 --- a/finance/token-fundraiser/anchor/programs/fundraiser/src/error.rs +++ b/finance/token-fundraiser/anchor/programs/fundraiser/src/error.rs @@ -18,6 +18,8 @@ pub enum FundraiserError { FundraiserEnded, #[msg("The amount to raise is below the minimum of 3 major units")] InvalidAmount, + #[msg("Contributions have not all been refunded yet")] + RefundsOutstanding, #[msg("Arithmetic overflow")] MathOverflow, } diff --git a/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/close.rs b/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/close.rs new file mode 100644 index 00000000..c888cbc2 --- /dev/null +++ b/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/close.rs @@ -0,0 +1,131 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{ + close_account, transfer_checked, CloseAccount, Mint, TokenAccount, TokenInterface, + TransferChecked, + }, +}; + +use crate::{state::Fundraiser, FundraiserError, SECONDS_TO_DAYS}; + +#[derive(Accounts)] +pub struct CloseFundraiserAccountConstraints<'info> { + #[account(mut)] + pub maker: Signer<'info>, + + pub mint_to_raise: InterfaceAccount<'info, Mint>, + + #[account( + mut, + has_one = mint_to_raise, + seeds = [b"fundraiser".as_ref(), maker.key().as_ref()], + bump = fundraiser.bump, + close = maker, + )] + pub fundraiser: Account<'info, Fundraiser>, + + #[account( + mut, + associated_token::mint = mint_to_raise, + associated_token::authority = fundraiser, + associated_token::token_program = token_program, + )] + pub vault: InterfaceAccount<'info, TokenAccount>, + + #[account( + init_if_needed, + payer = maker, + associated_token::mint = mint_to_raise, + associated_token::authority = maker, + associated_token::token_program = token_program, + )] + pub maker_ata: InterfaceAccount<'info, TokenAccount>, + + pub token_program: Interface<'info, TokenInterface>, + + pub system_program: Program<'info, System>, + + pub associated_token_program: Program<'info, AssociatedToken>, +} + +/// Retires a failed fundraiser 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. +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 + ); + + // A successful fundraiser exits through check_contributions, which + // already closes these accounts. + 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 taken theirs first. + require!( + accounts.fundraiser.current_amount == 0, + FundraiserError::RefundsOutstanding + ); + + // The vault is owned by the fundraiser PDA, so both CPIs are signed with + // its seeds. + let signer_seeds: [&[&[u8]]; 1] = [&[ + b"fundraiser".as_ref(), + accounts.maker.to_account_info().key.as_ref(), + &[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. + if accounts.vault.amount > 0 { + let transfer_accounts = TransferChecked { + from: accounts.vault.to_account_info(), + mint: accounts.mint_to_raise.to_account_info(), + to: accounts.maker_ata.to_account_info(), + authority: accounts.fundraiser.to_account_info(), + }; + let transfer_context = CpiContext::new_with_signer( + accounts.token_program.key(), + transfer_accounts, + &signer_seeds, + ); + transfer_checked( + transfer_context, + accounts.vault.amount, + accounts.mint_to_raise.decimals, + )?; + } + + // Close the empty vault so its rent goes back to the maker. The + // fundraiser account itself is closed by its close = maker constraint. + 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/token-fundraiser/anchor/programs/fundraiser/src/instructions/mod.rs b/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/mod.rs index 40bece33..829e4846 100644 --- a/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/mod.rs +++ b/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/mod.rs @@ -2,8 +2,10 @@ pub mod initialize; pub mod contribute; pub mod checker; pub mod refund; +pub mod close; pub use initialize::*; pub use contribute::*; pub use checker::*; -pub use refund::*; \ No newline at end of file +pub use refund::*; +pub use close::*; \ No newline at end of file diff --git a/finance/token-fundraiser/anchor/programs/fundraiser/src/lib.rs b/finance/token-fundraiser/anchor/programs/fundraiser/src/lib.rs index 6da075ec..0ef1eb83 100644 --- a/finance/token-fundraiser/anchor/programs/fundraiser/src/lib.rs +++ b/finance/token-fundraiser/anchor/programs/fundraiser/src/lib.rs @@ -47,4 +47,10 @@ pub mod fundraiser { Ok(()) } + + pub fn close_fundraiser(mut context: Context) -> Result<()> { + handle_close_fundraiser(&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 679eb976..dcb5d95c 100644 --- a/finance/token-fundraiser/anchor/programs/fundraiser/tests/test_fundraiser.rs +++ b/finance/token-fundraiser/anchor/programs/fundraiser/tests/test_fundraiser.rs @@ -250,6 +250,24 @@ fn build_check_contributions_instruction( ) } +fn build_close_fundraiser_instruction(setup: &FundraiserSetup, maker_ata: &Pubkey) -> Instruction { + Instruction::new_with_bytes( + setup.program_id, + &fundraiser::instruction::CloseFundraiser {}.data(), + fundraiser::accounts::CloseFundraiserAccountConstraints { + maker: setup.maker.pubkey(), + mint_to_raise: setup.mint, + fundraiser: setup.fundraiser_pda, + vault: setup.vault, + maker_ata: *maker_ata, + token_program: token_program_id(), + system_program: system_program::id(), + associated_token_program: ata_program_id(), + } + .to_account_metas(None), + ) +} + #[test] fn test_initialize_fundraiser() { let mut setup = full_setup(); @@ -611,6 +629,305 @@ fn test_check_contributions_success_pays_maker_and_closes_vault() { ); } +#[test] +fn test_contribute_above_cap_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); + + // 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" + ); + assert_eq!(get_token_account_balance(&setup.svm, &setup.vault).unwrap(), 0); +} + +#[test] +fn test_cumulative_contributions_above_cap_fail() { + 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( + &mut setup.svm, + vec![first_instruction], + &[&contributor], + &contributor.pubkey(), + ) + .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!( + result.is_err(), + "Contributions that cumulatively exceed the 10% cap must fail" + ); + + assert_eq!( + get_token_account_balance(&setup.svm, &setup.vault).unwrap(), + first_contribution + ); + 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() { + 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 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(); + + // 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(); + + assert!( + setup.svm.get_account(&setup.vault).is_none(), + "Vault token account must be closed with the fundraiser" + ); + assert!( + setup.svm.get_account(&setup.fundraiser_pda).is_none(), + "Fundraiser account must be closed after a failed raise is retired" + ); + + // The same maker can now open a fresh fundraiser at the same PDA. The + // retry would otherwise be byte-identical to the first initialize (same + // accounts, data, and blockhash), which LiteSVM rejects as already + // processed. + 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_eq!(fundraiser_state.current_amount, 0); + assert_eq!(fundraiser_state.amount_to_raise, AMOUNT_TO_RAISE); + assert_eq!(get_token_account_balance(&setup.svm, &setup.vault).unwrap(), 0); +} + +#[test] +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" + ); + assert!( + setup.svm.get_account(&setup.fundraiser_pda).is_some(), + "Fundraiser account must stay open after a failed close" + ); +} + +#[test] +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(); + + // 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); + + 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_eq!( + get_token_account_balance(&setup.svm, &setup.vault).unwrap(), + MAX_CONTRIBUTION + ); +} + +#[test] +fn test_close_fundraiser_when_target_met_fails() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + + // 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 + 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" + ); + assert_eq!( + get_token_account_balance(&setup.svm, &setup.vault).unwrap(), + AMOUNT_TO_RAISE + ); +} + +#[test] +fn test_close_fundraiser_sweeps_direct_donations_to_maker() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + + // 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(); + + assert_eq!( + get_token_account_balance(&setup.svm, &maker_ata).unwrap(), + donation + ); + assert!(setup.svm.get_account(&setup.fundraiser_pda).is_none()); + assert!(setup.svm.get_account(&setup.vault).is_none()); +} + #[test] fn test_check_contributions_ignores_direct_vault_donations() { let mut setup = full_setup();