diff --git a/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/contribute.rs b/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/contribute.rs index 1415cce52..ce8e07787 100644 --- a/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/contribute.rs +++ b/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/contribute.rs @@ -68,10 +68,11 @@ impl<'info> Contribute<'info> { FundraiserError::ContributionTooBig ); - // Check if the fundraising duration has been reached + // Contributions are only accepted while the campaign is still open, + // i.e. before `duration` days have elapsed since it started. let current_time = Clock::get()?.unix_timestamp; require!( - self.fundraiser.duration <= ((current_time - self.fundraiser.time_started) / SECONDS_TO_DAYS) as u16, + (((current_time - self.fundraiser.time_started) / SECONDS_TO_DAYS) as u16) < self.fundraiser.duration, crate::FundraiserError::FundraiserEnded ); diff --git a/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/refund.rs b/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/refund.rs index 86c4c9028..9b1b6dc9b 100644 --- a/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/refund.rs +++ b/tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/refund.rs @@ -54,11 +54,12 @@ pub struct Refund<'info> { impl<'info> Refund<'info> { pub fn refund(&mut self) -> Result<()> { - // Check if the fundraising duration has been reached + // Refunds are only allowed once the campaign has ended, i.e. after + // `duration` days have elapsed since it started. let current_time = Clock::get()?.unix_timestamp; - + require!( - self.fundraiser.duration >= ((current_time - self.fundraiser.time_started) / SECONDS_TO_DAYS) as u16, + (((current_time - self.fundraiser.time_started) / SECONDS_TO_DAYS) as u16) >= self.fundraiser.duration, crate::FundraiserError::FundraiserNotEnded ); diff --git a/tokens/token-fundraiser/anchor/tests/bankrun.test.ts b/tokens/token-fundraiser/anchor/tests/bankrun.test.ts index ce11539aa..eaf1fac85 100644 --- a/tokens/token-fundraiser/anchor/tests/bankrun.test.ts +++ b/tokens/token-fundraiser/anchor/tests/bankrun.test.ts @@ -1,3 +1,4 @@ +import assert from "node:assert"; import { describe, it } from "node:test"; import * as anchor from "@anchor-lang/core"; import { @@ -11,7 +12,7 @@ import { import { PublicKey } from "@solana/web3.js"; import { BankrunProvider } from "anchor-bankrun"; import BN from "bn.js"; -import { startAnchor } from "solana-bankrun"; +import { Clock, startAnchor } from "solana-bankrun"; import IDL from "../target/idl/fundraiser.json"; import type { Fundraiser } from "../target/types/fundraiser"; @@ -51,6 +52,23 @@ describe("fundraiser bankrun", async () => { return signature; }; + // Pin the bankrun clock to a deterministic point in the campaign so the + // day-based time gates are exercised regardless of the runtime's default + // clock behaviour: `days` days after the fundraiser's recorded start time. + const setElapsedDays = async (days: number) => { + const state = await program.account.fundraiser.fetch(fundraiser); + const clock = await context.banksClient.getClock(); + context.setClock( + new Clock( + clock.slot, + clock.epochStartTimestamp, + clock.epoch, + clock.leaderScheduleEpoch, + BigInt(state.timeStarted.toString()) + BigInt(days * 24 * 60 * 60), + ), + ); + }; + it("Test Preparation", async () => { const airdrop = await provider.connection .requestAirdrop(maker.publicKey, 1 * anchor.web3.LAMPORTS_PER_SOL) @@ -82,7 +100,7 @@ describe("fundraiser bankrun", async () => { const vault = getAssociatedTokenAddressSync(mint, fundraiser, true); const tx = await program.methods - .initialize(new BN(30000000), 0) + .initialize(new BN(30000000), 5) .accountsPartial({ maker: maker.publicKey, fundraiser, @@ -98,6 +116,9 @@ describe("fundraiser bankrun", async () => { console.log("\nInitialized fundraiser Account"); console.log("Your transaction signature", tx); + + // Place "now" one day into the 5-day campaign so contributions are open. + await setElapsedDays(1); }); it("Contribute to Fundraiser", async () => { @@ -200,11 +221,44 @@ describe("fundraiser bankrun", async () => { } }); - it("Refund Contributions", async () => { + it("Refund is rejected while the campaign is still open", async () => { + // One day into the 5-day campaign, so a refund must be rejected until it + // ends. (The successful-refund case advances the clock past `duration`.) + await setElapsedDays(1); const vault = getAssociatedTokenAddressSync(mint, fundraiser, true); - const contributorAccount = await program.account.contributor.fetch(contributor); - console.log("\nContributor balance", contributorAccount.amount.toString()); + let rejected = false; + try { + await program.methods + .refund() + .accountsPartial({ + contributor: provider.publicKey, + maker: maker.publicKey, + mintToRaise: mint, + fundraiser, + contributorAccount: contributor, + contributorAta: contributorATA, + vault, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .rpc() + .then(confirm); + } catch (error) { + rejected = true; + assert.ok(String(error).includes("FundraiserNotEnded"), `expected a FundraiserNotEnded error, got: ${error}`); + } + + assert.ok(rejected, "refund should be rejected while the campaign is still open"); + }); + + it("Refunds the contributor after the campaign ends", async () => { + const vault = getAssociatedTokenAddressSync(mint, fundraiser, true); + + // Advance the clock past the 5-day campaign window so refunds are allowed. + await setElapsedDays(6); + + const before = BigInt((await provider.connection.getTokenAccountBalance(contributorATA)).value.amount); const tx = await program.methods .refund() @@ -223,7 +277,23 @@ describe("fundraiser bankrun", async () => { .then(confirm); console.log("\nRefunded contributions", tx); - console.log("Your transaction signature", tx); - console.log("Vault balance", (await provider.connection.getTokenAccountBalance(vault)).value.amount); + + // The 2_000_000 contributed is returned to the contributor. + const after = BigInt((await provider.connection.getTokenAccountBalance(contributorATA)).value.amount); + assert.equal(after - before, BigInt(2_000_000), "contributor should be refunded their contribution"); + + // The vault is emptied and the contributor account is closed. + assert.equal( + (await provider.connection.getTokenAccountBalance(vault)).value.amount, + "0", + "vault should be empty after the refund", + ); + let closed = false; + try { + await program.account.contributor.fetch(contributor); + } catch { + closed = true; + } + assert.ok(closed, "contributor account should be closed after the refund"); }); });