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..54986972e --- /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))?; + + 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..3ef8e5380 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, @@ -195,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. @@ -250,6 +278,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 +338,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'); + }); });