Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
pub use refund::*;
pub use teardown::*;
Original file line number Diff line number Diff line change
@@ -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(())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,10 @@ pub mod fundraiser {

Ok(())
}

pub fn teardown(ctx: Context<Teardown>) -> Result<()> {
ctx.accounts.teardown()?;

Ok(())
}
}
87 changes: 87 additions & 0 deletions tokens/token-fundraiser/anchor/tests/litesvm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
createAssociatedTokenAccountInstruction,
createInitializeMint2Instruction,
createMintToInstruction,
createTransferInstruction,
getAssociatedTokenAddressSync,
MINT_SIZE,
TOKEN_PROGRAM_ID,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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',
);
});
Comment on lines +283 to +303

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Expiry guard remains untested

The teardown tests run only after the shared clock has reached the deadline, so none directly exercises the new FundraiserNotEnded guard. Add a pre-deadline teardown attempt; otherwise, removing or weakening that guard could go unnoticed because the existing rejection test would still fail on UnrefundedContributions.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


it('Refund Contributions', async () => {
// Runs after the deadline warp above, so refund's time check now passes.
const vault = getAssociatedTokenAddressSync(mint, fundraiser, true);
Expand Down Expand Up @@ -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');
});
});