Skip to content

Commit d4842cf

Browse files
authored
security: fix inverted fundraiser deadline logic in token-fundraiser (#674)
* security: fix inverted fundraiser deadline logic in token-fundraiser contribute.rs and refund.rs both gated their time check on the wrong side of the comparison. contribute() required duration <= elapsed_days to pass - contributions only succeeded *after* the fundraiser's duration had already elapsed, and stayed open forever after that. refund() required duration >= elapsed_days - refunds only succeeded *before* the deadline. Net effect: with any realistic nonzero duration, contribute() and refund() are live in mutually-exclusive, backwards time windows. Nobody can contribute while the fundraiser claims to be running; once contributions start working (only possible post-deadline), refund is permanently blocked. If the target isn't met, funds sit in the vault with no instruction able to move them - check_contributions requires the target met, refund now requires "not yet past deadline" which is false by construction. A fund-lock, not a theft, but a complete break of the contract's core guarantee. Confirmed against the example's own README, whose prose states the correct intent while the code implemented the opposite - and whose refund code snippet already had the correct form, meaning refund.rs itself had drifted from the documented behavior, not the reverse. Fixed with a two-character flip per file: contribute.rs now requires duration > elapsed (window still open); refund.rs now requires duration <= elapsed (window has closed), matching the README exactly. Also fixed the one inverted README snippet (contribute section; the refund section was already correct) and the misleading comments above both checks. Both test suites previously called initialize(..., 0) - duration=0 made elapsed_days >= 0 trivially satisfy both inverted checks at once, completely masking the bug. Updated both to a realistic duration=1 and added adversarial coverage: tests/litesvm.test.ts (which can warp its own clock) now proves contribute() correctly rejects at the exact deadline boundary and refund() correctly succeeds once past it, with balance/account-closure assertions on the happy path. tests/fundraiser.ts (real validator, no way to fast-forward its clock) proves refund() is correctly rejected while still active. Verified the new tests actually fail against the pre-fix code (multiple independent failure signals, including a direct "succeeded when it should have rejected" repro at the boundary) and pass after. Out of scope, called out for follow-up: a possibly-negative elapsed value getting cast to u16 (wraps on a backwards clock - the fix widens nothing, kept as a 2-line diff instead of adding saturating_sub hardening); refund.rs/checker.rs gating on the vault's live token balance rather than the tracked current_amount, letting anyone grief the target check by transferring tokens into the vault directly; and check_contributions closing the Fundraiser PDA while Contributor PDAs may still be open, stranding their rent. All separate findings from this time-gate inversion. * fix(#674): address dev-jodee review - shared test util, trim comments 1. Extracted the duplicated expectAnchorError helper from both test files into a shared tests/utils.ts. 2. Trimmed the explanatory comments across both test files - several ran 5-13 lines to explain a single design decision. Kept the one load-bearing sentence per comment, cut the rest.
1 parent 4120f25 commit d4842cf

6 files changed

Lines changed: 161 additions & 80 deletions

File tree

tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/contribute.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,10 @@ impl<'info> Contribute<'info> {
6868
FundraiserError::ContributionTooBig
6969
);
7070

71-
// Check if the fundraising duration has been reached
71+
// Check that the fundraising duration has not elapsed yet
7272
let current_time = Clock::get()?.unix_timestamp;
7373
require!(
74-
self.fundraiser.duration <= ((current_time - self.fundraiser.time_started) / SECONDS_TO_DAYS) as u16,
74+
self.fundraiser.duration > ((current_time - self.fundraiser.time_started) / SECONDS_TO_DAYS) as u16,
7575
crate::FundraiserError::FundraiserEnded
7676
);
7777

tokens/token-fundraiser/anchor/programs/fundraiser/src/instructions/refund.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,11 @@ pub struct Refund<'info> {
5454
impl<'info> Refund<'info> {
5555
pub fn refund(&mut self) -> Result<()> {
5656

57-
// Check if the fundraising duration has been reached
57+
// Check that the fundraising duration has elapsed
5858
let current_time = Clock::get()?.unix_timestamp;
59-
59+
6060
require!(
61-
self.fundraiser.duration >= ((current_time - self.fundraiser.time_started) / SECONDS_TO_DAYS) as u16,
61+
self.fundraiser.duration <= ((current_time - self.fundraiser.time_started) / SECONDS_TO_DAYS) as u16,
6262
crate::FundraiserError::FundraiserNotEnded
6363
);
6464

tokens/token-fundraiser/anchor/readme.MD

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,10 +212,10 @@ impl<'info> Contribute<'info> {
212212
FundraiserError::MaximumContributionsReached
213213
);
214214

215-
// Check if the fundraising duration has been reached
215+
// Check that the fundraising duration has not elapsed yet
216216
let current_time = Clock::get()?.unix_timestamp;
217217
require!(
218-
self.fundraiser.duration <= ((current_time - self.fundraiser.time_started) / SECONDS_TO_DAYS) as u8,
218+
self.fundraiser.duration > ((current_time - self.fundraiser.time_started) / SECONDS_TO_DAYS) as u8,
219219
crate::FundraiserError::FundraisingEnded
220220
);
221221

tokens/token-fundraiser/anchor/tests/fundraiser.ts

Lines changed: 45 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import {
1010
TOKEN_PROGRAM_ID,
1111
} from '@solana/spl-token';
1212
import BN from 'bn.js';
13+
import { assert } from 'chai';
1314
import type { Fundraiser } from '../target/types/fundraiser';
15+
import { expectAnchorError } from './utils';
1416

1517
describe('fundraiser', () => {
1618
// Configure the client to use the local cluster.
@@ -78,8 +80,10 @@ describe('fundraiser', () => {
7880
it('Initialize Fundaraiser', async () => {
7981
const vault = getAssociatedTokenAddressSync(mint, fundraiser, true);
8082

83+
// duration=1 day. No clock-warping here (real validator) - the
84+
// post-deadline happy path is covered in litesvm.test.ts instead.
8185
const tx = await program.methods
82-
.initialize(new BN(30000000), 0)
86+
.initialize(new BN(30000000), 1)
8387
.accountsPartial({
8488
maker: maker.publicKey,
8589
fundraiser,
@@ -145,10 +149,12 @@ describe('fundraiser', () => {
145149
});
146150

147151
it('Contribute to Fundraiser - Robustness Test', async () => {
148-
try {
149-
const vault = getAssociatedTokenAddressSync(mint, fundraiser, true);
152+
// Per-contributor cap is 10% of target (3_000_000); contributor
153+
// already holds 2_000_000, so this pushes past the cap.
154+
const vault = getAssociatedTokenAddressSync(mint, fundraiser, true);
150155

151-
const tx = await program.methods
156+
await expectAnchorError(
157+
program.methods
152158
.contribute(new BN(2000000))
153159
.accountsPartial({
154160
contributor: provider.publicKey,
@@ -159,22 +165,17 @@ describe('fundraiser', () => {
159165
tokenProgram: TOKEN_PROGRAM_ID,
160166
})
161167
.rpc()
162-
.then(confirm);
163-
164-
console.log('\nContributed to fundraiser', tx);
165-
console.log('Your transaction signature', tx);
166-
console.log('Vault balance', (await provider.connection.getTokenAccountBalance(vault)).value.amount);
167-
} catch (error) {
168-
console.log('\nError contributing to fundraiser');
169-
console.log(error.msg);
170-
}
168+
.then(confirm),
169+
'MaximumContributionsReached',
170+
);
171171
});
172172

173173
it('Check contributions - Robustness Test', async () => {
174-
try {
175-
const vault = getAssociatedTokenAddressSync(mint, fundraiser, true);
174+
// Only 2_000_000 has been contributed against a 30_000_000 target.
175+
const vault = getAssociatedTokenAddressSync(mint, fundraiser, true);
176176

177-
const tx = await program.methods
177+
await expectAnchorError(
178+
program.methods
178179
.checkContributions()
179180
.accountsPartial({
180181
maker: maker.publicKey,
@@ -186,41 +187,38 @@ describe('fundraiser', () => {
186187
})
187188
.signers([maker])
188189
.rpc()
189-
.then(confirm);
190-
191-
console.log('\nChecked contributions');
192-
console.log('Your transaction signature', tx);
193-
console.log('Vault balance', (await provider.connection.getTokenAccountBalance(vault)).value.amount);
194-
} catch (error) {
195-
console.log('\nError checking contributions');
196-
console.log(error.msg);
197-
}
190+
.then(confirm),
191+
'TargetNotMet',
192+
);
198193
});
199194

200-
it('Refund Contributions', async () => {
195+
// Can't clock-warp on a real validator, so this only proves refund is
196+
// rejected while active - see litesvm.test.ts for the post-deadline
197+
// happy path.
198+
it('Refund is rejected while the fundraiser is still active', async () => {
201199
const vault = getAssociatedTokenAddressSync(mint, fundraiser, true);
200+
const vaultBalanceBefore = (await provider.connection.getTokenAccountBalance(vault)).value.amount;
202201

203-
const contributorAccount = await program.account.contributor.fetch(contributor);
204-
console.log('\nContributor balance', contributorAccount.amount.toString());
205-
206-
const tx = await program.methods
207-
.refund()
208-
.accountsPartial({
209-
contributor: provider.publicKey,
210-
maker: maker.publicKey,
211-
mintToRaise: mint,
212-
fundraiser,
213-
contributorAccount: contributor,
214-
contributorAta: contributorATA,
215-
vault,
216-
tokenProgram: TOKEN_PROGRAM_ID,
217-
systemProgram: anchor.web3.SystemProgram.programId,
218-
})
219-
.rpc()
220-
.then(confirm);
202+
await expectAnchorError(
203+
program.methods
204+
.refund()
205+
.accountsPartial({
206+
contributor: provider.publicKey,
207+
maker: maker.publicKey,
208+
mintToRaise: mint,
209+
fundraiser,
210+
contributorAccount: contributor,
211+
contributorAta: contributorATA,
212+
vault,
213+
tokenProgram: TOKEN_PROGRAM_ID,
214+
systemProgram: anchor.web3.SystemProgram.programId,
215+
})
216+
.rpc()
217+
.then(confirm),
218+
'FundraiserNotEnded',
219+
);
221220

222-
console.log('\nRefunded contributions', tx);
223-
console.log('Your transaction signature', tx);
224-
console.log('Vault balance', (await provider.connection.getTokenAccountBalance(vault)).value.amount);
221+
const vaultBalanceAfter = (await provider.connection.getTokenAccountBalance(vault)).value.amount;
222+
assert.strictEqual(vaultBalanceAfter, vaultBalanceBefore, 'rejected refund must not move any funds');
225223
});
226224
});

tokens/token-fundraiser/anchor/tests/litesvm.test.ts

Lines changed: 95 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@ import {
1212
import { PublicKey } from '@solana/web3.js';
1313
import { LiteSVMProvider } from 'anchor-litesvm';
1414
import BN from 'bn.js';
15+
import { assert } from 'chai';
1516
import { LiteSVM } from 'litesvm';
1617
import IDL from '../target/idl/fundraiser.json';
1718
import type { Fundraiser } from '../target/types/fundraiser';
19+
import { expectAnchorError } from './utils';
1820

1921
const PROGRAM_ID = new PublicKey(IDL.address);
22+
const SECONDS_PER_DAY = 86400n;
2023

2124
describe('fundraiser litesvm', () => {
2225
const client = new LiteSVM();
@@ -78,8 +81,10 @@ describe('fundraiser litesvm', () => {
7881
it('Initialize Fundaraiser', async () => {
7982
const vault = getAssociatedTokenAddressSync(mint, fundraiser, true);
8083

84+
// duration=1 day. This suite can warp its own clock, so it covers
85+
// the full lifecycle (see the tests below).
8186
const tx = await program.methods
82-
.initialize(new BN(30000000), 0)
87+
.initialize(new BN(30000000), 1)
8388
.accountsPartial({
8489
maker: maker.publicKey,
8590
fundraiser,
@@ -143,10 +148,12 @@ describe('fundraiser litesvm', () => {
143148
});
144149

145150
it('Contribute to Fundraiser - Robustness Test', async () => {
146-
try {
147-
const vault = getAssociatedTokenAddressSync(mint, fundraiser, true);
151+
// Per-contributor cap is 3_000_000 (10% of target); contributor
152+
// holds 2_000_000 already. Must run before the deadline warp below.
153+
const vault = getAssociatedTokenAddressSync(mint, fundraiser, true);
148154

149-
const tx = await program.methods
155+
await expectAnchorError(
156+
program.methods
150157
.contribute(new BN(2000000))
151158
.accountsPartial({
152159
contributor: provider.publicKey,
@@ -156,22 +163,46 @@ describe('fundraiser litesvm', () => {
156163
vault,
157164
tokenProgram: TOKEN_PROGRAM_ID,
158165
})
159-
.rpc();
160-
161-
console.log('\nContributed to fundraiser', tx);
162-
console.log('Your transaction signature', tx);
163-
console.log('Vault balance', tokenBalance(vault).toString());
164-
} catch (error) {
165-
console.log('\nError contributing to fundraiser');
166-
console.log(error.msg);
167-
}
166+
.rpc(),
167+
'MaximumContributionsReached',
168+
);
169+
});
170+
171+
// Pre-fix this fails with AccountNotInitialized, not a wrongly-
172+
// succeeding refund - contribute() was broken from its first call, so
173+
// no Contributor account ever formed. Same bug, different symptom.
174+
it('Refund is rejected while the fundraiser is still active', async () => {
175+
const vault = getAssociatedTokenAddressSync(mint, fundraiser, true);
176+
const vaultBalanceBefore = tokenBalance(vault);
177+
178+
await expectAnchorError(
179+
program.methods
180+
.refund()
181+
.accountsPartial({
182+
contributor: provider.publicKey,
183+
maker: maker.publicKey,
184+
mintToRaise: mint,
185+
fundraiser,
186+
contributorAccount: contributor,
187+
contributorAta: contributorATA,
188+
vault,
189+
tokenProgram: TOKEN_PROGRAM_ID,
190+
systemProgram: anchor.web3.SystemProgram.programId,
191+
})
192+
.rpc(),
193+
'FundraiserNotEnded',
194+
);
195+
196+
assert.strictEqual(tokenBalance(vault), vaultBalanceBefore, 'rejected refund must not move any funds');
168197
});
169198

170199
it('Check contributions - Robustness Test', async () => {
171-
try {
172-
const vault = getAssociatedTokenAddressSync(mint, fundraiser, true);
200+
// Only 2_000_000 has been contributed against a 30_000_000 target.
201+
// Time-independent - checker.rs has no duration check.
202+
const vault = getAssociatedTokenAddressSync(mint, fundraiser, true);
173203

174-
const tx = await program.methods
204+
await expectAnchorError(
205+
program.methods
175206
.checkContributions()
176207
.accountsPartial({
177208
maker: maker.publicKey,
@@ -182,23 +213,54 @@ describe('fundraiser litesvm', () => {
182213
tokenProgram: TOKEN_PROGRAM_ID,
183214
})
184215
.signers([maker])
185-
.rpc();
186-
187-
console.log('\nChecked contributions');
188-
console.log('Your transaction signature', tx);
189-
console.log('Vault balance', tokenBalance(vault).toString());
190-
} catch (error) {
191-
console.log('\nError checking contributions');
192-
console.log(error.msg);
193-
}
216+
.rpc(),
217+
'TargetNotMet',
218+
);
219+
});
220+
221+
// Warps to the exact deadline boundary (not past it) to pin the
222+
// off-by-one directly: contribute must reject exactly here.
223+
it('Fundraiser closes to contributions once the duration has elapsed', async () => {
224+
const fundraiserAccount = await program.account.fundraiser.fetch(fundraiser);
225+
const deadline =
226+
BigInt(fundraiserAccount.timeStarted.toString()) + BigInt(fundraiserAccount.duration) * SECONDS_PER_DAY;
227+
228+
const clock = client.getClock();
229+
clock.unixTimestamp = deadline;
230+
client.setClock(clock);
231+
// Avoids a duplicate-transaction rejection from an earlier identical call.
232+
client.expireBlockhash();
233+
234+
const vault = getAssociatedTokenAddressSync(mint, fundraiser, true);
235+
236+
// 1_000_000 keeps the per-contributor cap check passing, so only
237+
// the time check can reject this - proving it's the deadline.
238+
await expectAnchorError(
239+
program.methods
240+
.contribute(new BN(1000000))
241+
.accountsPartial({
242+
contributor: provider.publicKey,
243+
fundraiser,
244+
contributorAccount: contributor,
245+
contributorAta: contributorATA,
246+
vault,
247+
tokenProgram: TOKEN_PROGRAM_ID,
248+
})
249+
.rpc(),
250+
'FundraiserEnded',
251+
);
194252
});
195253

196254
it('Refund Contributions', async () => {
255+
// Runs after the deadline warp above, so refund's time check now passes.
197256
const vault = getAssociatedTokenAddressSync(mint, fundraiser, true);
198257

199258
const contributorAccount = await program.account.contributor.fetch(contributor);
200259
console.log('\nContributor balance', contributorAccount.amount.toString());
201260

261+
// Same duplicate-transaction hazard as above.
262+
client.expireBlockhash();
263+
202264
const tx = await program.methods
203265
.refund()
204266
.accountsPartial({
@@ -216,6 +278,13 @@ describe('fundraiser litesvm', () => {
216278

217279
console.log('\nRefunded contributions', tx);
218280
console.log('Your transaction signature', tx);
219-
console.log('Vault balance', tokenBalance(vault).toString());
281+
282+
assert.strictEqual(tokenBalance(vault), 0n, 'vault should be fully drained back to the contributor');
283+
assert.strictEqual(
284+
tokenBalance(contributorATA),
285+
10_000_000n,
286+
"contributor's full original balance should be restored",
287+
);
288+
assert.isNull(client.getAccount(contributor), 'the Contributor account should be closed');
220289
});
221290
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { assert } from 'chai';
2+
3+
// Asserts `promise` rejects with the given Anchor custom error code, not
4+
// just "something failed".
5+
export const expectAnchorError = async (promise: Promise<unknown>, code: string) => {
6+
let caught: any;
7+
try {
8+
await promise;
9+
} catch (error) {
10+
caught = error;
11+
}
12+
assert.isDefined(caught, `expected the transaction to fail with ${code}`);
13+
assert.strictEqual(caught?.error?.errorCode?.code, code, `expected ${code}, got: ${caught}`);
14+
};

0 commit comments

Comments
 (0)