From 0c08f8db23e02be612fb95aec5e01e01bf239d38 Mon Sep 17 00:00:00 2001 From: devtechedge Date: Sat, 12 Sep 2026 03:32:11 +0530 Subject: [PATCH 1/2] fundraiser: add teardown for failed campaigns A campaign that expires under target left the vault and fundraiser accounts behind with their rent unrecovered, and any tokens deposited directly into the vault stayed locked. Adds a maker-callable teardown instruction that runs once the duration has elapsed, requires every recorded contribution to be refunded first, sweeps the remaining vault balance to the maker, and closes the vault and fundraiser accounts. Fixes #725 --- .../anchor/programs/fundraiser/src/error.rs | 2 + .../fundraiser/src/instructions/mod.rs | 4 +- .../fundraiser/src/instructions/teardown.rs | 105 ++++++++++++++++++ .../anchor/programs/fundraiser/src/lib.rs | 6 + .../anchor/tests/litesvm.test.ts | 60 ++++++++++ 5 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/teardown.rs diff --git a/tokens/token-fundraiser/anchor/programs/fundraiser/src/error.rs b/tokens/token-fundraiser/anchor/programs/fundraiser/src/error.rs index 0c7ee948b..814101efc 100644 --- a/tokens/token-fundraiser/anchor/programs/fundraiser/src/error.rs +++ b/tokens/token-fundraiser/anchor/programs/fundraiser/src/error.rs @@ -16,6 +16,8 @@ pub enum FundraiserError { FundraiserNotEnded, #[msg("The fundraiser has ended")] FundraiserEnded, + #[msg("Contributions have not all been refunded yet")] + UnrefundedContributions, #[msg("Invalid total amount. i should be bigger than 3")] InvalidAmount } \ No newline at end of file diff --git a/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/mod.rs b/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/mod.rs index 40bece332..4668a1edb 100644 --- a/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/mod.rs +++ b/tokens/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 teardown; pub use initialize::*; pub use contribute::*; pub use checker::*; -pub use refund::*; \ No newline at end of file +pub use refund::*; +pub use teardown::*; \ No newline at end of file diff --git a/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/teardown.rs b/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/teardown.rs new file mode 100644 index 000000000..9f5608322 --- /dev/null +++ b/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/teardown.rs @@ -0,0 +1,105 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token::{ + close_account, + transfer, + CloseAccount, + Mint, + Token, + TokenAccount, + Transfer + } +}; + +use crate::{ + state::Fundraiser, + FundraiserError, + SECONDS_TO_DAYS +}; + +#[derive(Accounts)] +pub struct Teardown<'info> { + #[account(mut)] + pub maker: Signer<'info>, + pub mint_to_raise: Account<'info, Mint>, + #[account( + mut, + seeds = [b"fundraiser", maker.key().as_ref()], + bump = fundraiser.bump, + has_one = mint_to_raise, + close = maker, + )] + pub fundraiser: Account<'info, Fundraiser>, + #[account( + mut, + associated_token::mint = mint_to_raise, + associated_token::authority = fundraiser, + )] + pub vault: Account<'info, TokenAccount>, + #[account( + init_if_needed, + payer = maker, + associated_token::mint = mint_to_raise, + associated_token::authority = maker, + )] + pub maker_ata: Account<'info, TokenAccount>, + pub token_program: Program<'info, Token>, + pub system_program: Program<'info, System>, + pub associated_token_program: Program<'info, AssociatedToken>, +} + +impl<'info> Teardown<'info> { + pub fn teardown(&self) -> Result<()> { + + // A failed campaign can only be torn down once its duration has elapsed + let current_time = Clock::get()?.unix_timestamp; + + require!( + self.fundraiser.duration <= ((current_time - self.fundraiser.time_started) / SECONDS_TO_DAYS) as u16, + FundraiserError::FundraiserNotEnded + ); + + // Every recorded contribution must have been refunded first, so any + // balance left in the vault can only be stray direct deposits that + // belong to the maker + require!( + self.fundraiser.current_amount == 0, + FundraiserError::UnrefundedContributions + ); + + // Sweep the leftover vault balance to the maker + let cpi_program = self.token_program.key(); + + // Transfer the funds from the vault to the maker + let cpi_accounts = Transfer { + from: self.vault.to_account_info(), + to: self.maker_ata.to_account_info(), + authority: self.fundraiser.to_account_info(), + }; + + // Signer seeds to sign the CPI on behalf of the fundraiser account + let signer_seeds: [&[&[u8]]; 1] = [&[ + b"fundraiser".as_ref(), + self.maker.to_account_info().key.as_ref(), + &[self.fundraiser.bump], + ]]; + + // CPI context with signer since the fundraiser account is a PDA + let cpi_ctx = CpiContext::new_with_signer(cpi_program, cpi_accounts, &signer_seeds); + + // Transfer the funds from the vault to the maker + transfer(cpi_ctx, self.vault.amount)?; + + // Close the vault and recover its rent to the maker + let close_accounts = CloseAccount { + account: self.vault.to_account_info(), + destination: self.maker.to_account_info(), + authority: self.fundraiser.to_account_info(), + }; + + close_account(CpiContext::new_with_signer(cpi_program, close_accounts, &signer_seeds), None)?; + + Ok(()) + } +} diff --git a/tokens/token-fundraiser/anchor/programs/fundraiser/src/lib.rs b/tokens/token-fundraiser/anchor/programs/fundraiser/src/lib.rs index 12d34638f..fc1269d88 100644 --- a/tokens/token-fundraiser/anchor/programs/fundraiser/src/lib.rs +++ b/tokens/token-fundraiser/anchor/programs/fundraiser/src/lib.rs @@ -38,4 +38,10 @@ pub mod fundraiser { Ok(()) } + + pub fn teardown(ctx: Context) -> Result<()> { + ctx.accounts.teardown()?; + + Ok(()) + } } diff --git a/tokens/token-fundraiser/anchor/tests/litesvm.test.ts b/tokens/token-fundraiser/anchor/tests/litesvm.test.ts index 9c2a5994b..b8165f73e 100644 --- a/tokens/token-fundraiser/anchor/tests/litesvm.test.ts +++ b/tokens/token-fundraiser/anchor/tests/litesvm.test.ts @@ -4,6 +4,7 @@ import { createAssociatedTokenAccountInstruction, createInitializeMint2Instruction, createMintToInstruction, + createTransferInstruction, getAssociatedTokenAddressSync, MINT_SIZE, TOKEN_PROGRAM_ID, @@ -250,6 +251,30 @@ describe('fundraiser litesvm', () => { ); }); + // 2_000_000 is still recorded as un-refunded, so the maker cannot tear + // the campaign down yet even though the deadline has passed. + it('Teardown is rejected while contributions are outstanding', async () => { + const vault = getAssociatedTokenAddressSync(mint, fundraiser, true); + + await expectAnchorError( + program.methods + .teardown() + .accountsPartial({ + maker: maker.publicKey, + mintToRaise: mint, + fundraiser, + vault, + makerAta: makerATA, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: anchor.web3.SystemProgram.programId, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + }) + .signers([maker]) + .rpc(), + 'UnrefundedContributions', + ); + }); + it('Refund Contributions', async () => { // Runs after the deadline warp above, so refund's time check now passes. const vault = getAssociatedTokenAddressSync(mint, fundraiser, true); @@ -286,4 +311,39 @@ describe('fundraiser litesvm', () => { ); assert.isNull(client.getAccount(contributor), 'the Contributor account should be closed'); }); + + it('Teardown sweeps strays and closes the vault and fundraiser', async () => { + const vault = getAssociatedTokenAddressSync(mint, fundraiser, true); + + // A direct deposit into the vault that no contributor record tracks. + const stray = 1_234_567n; + client.expireBlockhash(); + const strayTx = new anchor.web3.Transaction().add( + createTransferInstruction(contributorATA, vault, provider.publicKey, stray), + ); + await provider.sendAndConfirm(strayTx); + assert.strictEqual(tokenBalance(vault), stray, 'stray deposit should sit in the vault'); + + client.expireBlockhash(); + + const tx = await program.methods + .teardown() + .accountsPartial({ + maker: maker.publicKey, + mintToRaise: mint, + fundraiser, + vault, + makerAta: makerATA, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: anchor.web3.SystemProgram.programId, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + }) + .signers([maker]) + .rpc(); + console.log('\nTore down failed fundraiser', tx); + + assert.strictEqual(tokenBalance(makerATA), stray, 'stray balance should be swept to the maker'); + assert.isNull(client.getAccount(vault), 'the vault should be closed'); + assert.isNull(client.getAccount(fundraiser), 'the fundraiser account should be closed'); + }); }); From 492982ec6b59f9d571b3a34711d4bb020669b91f Mon Sep 17 00:00:00 2001 From: devtechedge Date: Sat, 12 Sep 2026 03:52:01 +0530 Subject: [PATCH 2/2] fundraiser: fix close_account CPI call and cover the pre-deadline teardown guard --- .../fundraiser/src/instructions/teardown.rs | 2 +- .../anchor/tests/litesvm.test.ts | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/teardown.rs b/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/teardown.rs index 9f5608322..54986972e 100644 --- a/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/teardown.rs +++ b/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/teardown.rs @@ -98,7 +98,7 @@ impl<'info> Teardown<'info> { authority: self.fundraiser.to_account_info(), }; - close_account(CpiContext::new_with_signer(cpi_program, close_accounts, &signer_seeds), None)?; + close_account(CpiContext::new_with_signer(cpi_program, close_accounts, &signer_seeds))?; Ok(()) } diff --git a/tokens/token-fundraiser/anchor/tests/litesvm.test.ts b/tokens/token-fundraiser/anchor/tests/litesvm.test.ts index b8165f73e..3ef8e5380 100644 --- a/tokens/token-fundraiser/anchor/tests/litesvm.test.ts +++ b/tokens/token-fundraiser/anchor/tests/litesvm.test.ts @@ -196,6 +196,33 @@ describe('fundraiser litesvm', () => { assert.strictEqual(tokenBalance(vault), vaultBalanceBefore, 'rejected refund must not move any funds'); }); + // Teardown's time check fires before the un-refunded check, so this + // must run before the deadline warp below to isolate the guard itself. + it('Teardown is rejected while the fundraiser is still active', async () => { + const vault = getAssociatedTokenAddressSync(mint, fundraiser, true); + const vaultBalanceBefore = tokenBalance(vault); + + await expectAnchorError( + program.methods + .teardown() + .accountsPartial({ + maker: maker.publicKey, + mintToRaise: mint, + fundraiser, + vault, + makerAta: makerATA, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: anchor.web3.SystemProgram.programId, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + }) + .signers([maker]) + .rpc(), + 'FundraiserNotEnded', + ); + + assert.strictEqual(tokenBalance(vault), vaultBalanceBefore, 'rejected teardown must not move any funds'); + }); + it('Check contributions - Robustness Test', async () => { // Only 2_000_000 has been contributed against a 30_000_000 target. // Time-independent - checker.rs has no duration check.