Skip to content

Commit f90ef59

Browse files
committed
token-fundraiser: derive the contributor PDA bump on-chain
contribute took the contributor bump from instruction data and fed it to create_program_address. Several bumps can yield a valid address for the same seeds, so a contributor could open extra, non-canonical records for themselves and be metered against the per-contributor cap separately on each. Those records were also unrefundable, since refund only ever derives the canonical address. The bump is now derived on-chain with find_program_address and the supplied byte is gone from the instruction data entirely, so there is no longer a caller-controlled input to the derivation. This matches the Anchor version, whose `seeds = [...], bump` constraint likewise derives canonically rather than accepting a bump from the client. Adds a test that a contributor_account which is not the canonical PDA is refused before any record is created.
1 parent c76d716 commit f90ef59

2 files changed

Lines changed: 53 additions & 18 deletions

File tree

tokens/token-fundraiser/pinocchio/program/src/instructions/contribute.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use crate::{
2727
/// 6. `[]` token program
2828
/// 7. `[]` system program
2929
///
30-
/// Instruction data: `[amount: u64 (LE), contributor_bump: u8]`
30+
/// Instruction data: `[amount: u64 (LE)]`
3131
pub fn contribute(program_id: &Address, accounts: &mut [AccountView], data: &[u8]) -> ProgramResult {
3232
let [contributor, mint_to_raise, fundraiser, contributor_account, contributor_ata, vault, _token_program, _system_program] =
3333
accounts
@@ -40,7 +40,6 @@ pub fn contribute(program_id: &Address, accounts: &mut [AccountView], data: &[u8
4040
}
4141

4242
let amount = read_u64(data, 0)?;
43-
let contributor_bump = *data.get(8).ok_or(ProgramError::InvalidInstructionData)?;
4443

4544
let mut state = Fundraiser::deserialize(&fundraiser.try_borrow()?)?;
4645

@@ -76,18 +75,23 @@ pub fn contribute(program_id: &Address, accounts: &mut [AccountView], data: &[u8
7675

7776
// Bind the contributor record to this signer before trusting it, whether it
7877
// already exists or is about to be created.
78+
//
79+
// The bump is derived here rather than taken from the caller: several bumps
80+
// can yield a valid address for the same seeds, so accepting one would let a
81+
// contributor open a second, non-canonical record and be metered against the
82+
// cap separately on each. `refund` only ever derives the canonical address,
83+
// so those extra records would also be unrefundable.
84+
let (contributor_pda, contributor_bump) = Address::find_program_address(
85+
&[Contributor::SEED_PREFIX, fundraiser.address().as_ref(), contributor.address().as_ref()],
86+
program_id,
87+
);
7988
let bump_bytes = [contributor_bump];
8089
let seeds = [
8190
Seed::from(Contributor::SEED_PREFIX),
8291
Seed::from(fundraiser.address().as_ref()),
8392
Seed::from(contributor.address().as_ref()),
8493
Seed::from(&bump_bytes),
8594
];
86-
let contributor_pda = Address::create_program_address(
87-
&[Contributor::SEED_PREFIX, fundraiser.address().as_ref(), contributor.address().as_ref(), &bump_bytes],
88-
program_id,
89-
)
90-
.map_err(|_| ProgramError::InvalidSeeds)?;
9195
if contributor_account.address() != &contributor_pda {
9296
return Err(ProgramError::InvalidSeeds);
9397
}

tokens/token-fundraiser/pinocchio/tests/test.ts

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,6 @@ describe('Token Fundraiser (Pinocchio)', () => {
176176
contributorAccount: Address,
177177
contributorAta: Address,
178178
vault: Address,
179-
bump: number,
180179
amount: bigint,
181180
) {
182181
return {
@@ -191,7 +190,8 @@ describe('Token Fundraiser (Pinocchio)', () => {
191190
{ address: TOKEN_PROGRAM_ADDRESS, role: AccountRole.READONLY },
192191
{ address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY },
193192
],
194-
data: concatBytes(Uint8Array.of(1), u64(amount), Uint8Array.of(bump)),
193+
// The contributor PDA bump is derived on-chain, not supplied here.
194+
data: concatBytes(Uint8Array.of(1), u64(amount)),
195195
};
196196
}
197197

@@ -261,7 +261,7 @@ describe('Token Fundraiser (Pinocchio)', () => {
261261
const contributor = await generateKeyPairSigner();
262262
svm.airdrop(contributor.address, lamports(1_000_000_000n));
263263
const contributorAta = await fundAta(maker, maker, contributor.address, mint.address, 10n);
264-
const [contributorAccount, cbump] = await contributorPda(fundraiser, contributor.address);
264+
const [contributorAccount] = await contributorPda(fundraiser, contributor.address);
265265

266266
send(
267267
await tx(contributor, [
@@ -272,7 +272,6 @@ describe('Token Fundraiser (Pinocchio)', () => {
272272
contributorAccount,
273273
contributorAta,
274274
vault,
275-
cbump,
276275
10n,
277276
),
278277
]),
@@ -323,7 +322,7 @@ describe('Token Fundraiser (Pinocchio)', () => {
323322
const contributor = await generateKeyPairSigner();
324323
svm.airdrop(contributor.address, lamports(1_000_000_000n));
325324
const contributorAta = await fundAta(maker, maker, contributor.address, mint.address, 10n);
326-
const [contributorAccount, cbump] = await contributorPda(fundraiser, contributor.address);
325+
const [contributorAccount] = await contributorPda(fundraiser, contributor.address);
327326
send(
328327
await tx(contributor, [
329328
contributeIx(
@@ -333,7 +332,6 @@ describe('Token Fundraiser (Pinocchio)', () => {
333332
contributorAccount,
334333
contributorAta,
335334
vault,
336-
cbump,
337335
10n,
338336
),
339337
]),
@@ -373,10 +371,10 @@ describe('Token Fundraiser (Pinocchio)', () => {
373371
const victim = await generateKeyPairSigner();
374372
svm.airdrop(victim.address, lamports(1_000_000_000n));
375373
const victimAta = await fundAta(maker, maker, victim.address, mint.address, 1n);
376-
const [victimAccount, victimBump] = await contributorPda(fundraiser, victim.address);
374+
const [victimAccount] = await contributorPda(fundraiser, victim.address);
377375
send(
378376
await tx(victim, [
379-
contributeIx(victim, mint.address, fundraiser, victimAccount, victimAta, vault, victimBump, 1n),
377+
contributeIx(victim, mint.address, fundraiser, victimAccount, victimAta, vault, 1n),
380378
]),
381379
'victim contribute',
382380
);
@@ -387,7 +385,7 @@ describe('Token Fundraiser (Pinocchio)', () => {
387385
const attackerAta = await fundAta(maker, maker, attacker.address, mint.address, 1n);
388386
sendExpectingFailure(
389387
await tx(attacker, [
390-
contributeIx(attacker, mint.address, fundraiser, victimAccount, attackerAta, vault, victimBump, 1n),
388+
contributeIx(attacker, mint.address, fundraiser, victimAccount, attackerAta, vault, 1n),
391389
]),
392390
'contribution into a substituted record',
393391
);
@@ -396,6 +394,40 @@ describe('Token Fundraiser (Pinocchio)', () => {
396394
assert.equal(tokenAmount(vault), 1n, 'vault only holds the recorded contribution');
397395
});
398396

397+
it('Rejects a contributor record that is not the canonical PDA', async () => {
398+
const maker = await generateKeyPairSigner();
399+
svm.airdrop(maker.address, lamports(1_000_000_000n));
400+
const mint = await createMint(maker, 0, maker.address);
401+
402+
const [fundraiser, bump] = await fundraiserPda(maker.address);
403+
const [vault] = await findAssociatedTokenPda({
404+
owner: fundraiser,
405+
mint: mint.address,
406+
tokenProgram: TOKEN_PROGRAM_ADDRESS,
407+
});
408+
send(await tx(maker, [initializeIx(maker, mint.address, fundraiser, vault, bump, 100n, 30)]), 'initialize');
409+
410+
// The record address is derived on-chain, so a contributor cannot open a
411+
// second record for themselves at any other address — including one
412+
// derived from a non-canonical bump. A record that is not the canonical
413+
// PDA is refused before it can be created, which also keeps every record
414+
// reachable by `refund` (which only ever derives the canonical address).
415+
const contributor = await generateKeyPairSigner();
416+
svm.airdrop(contributor.address, lamports(1_000_000_000n));
417+
const contributorAta = await fundAta(maker, maker, contributor.address, mint.address, 5n);
418+
const notTheCanonicalPda = (await generateKeyPairSigner()).address;
419+
420+
sendExpectingFailure(
421+
await tx(contributor, [
422+
contributeIx(contributor, mint.address, fundraiser, notTheCanonicalPda, contributorAta, vault, 5n),
423+
]),
424+
'contribution into a non-canonical record',
425+
);
426+
427+
assert.equal(tokenAmount(contributorAta), 5n, 'contributor kept their tokens');
428+
assert.isNotOk(svm.getAccount(notTheCanonicalPda)?.exists, 'no second record was created');
429+
});
430+
399431
it('Ignores unrecorded direct transfers into the vault', async () => {
400432
const maker = await generateKeyPairSigner();
401433
svm.airdrop(maker.address, lamports(1_000_000_000n));
@@ -414,7 +446,7 @@ describe('Token Fundraiser (Pinocchio)', () => {
414446
const contributor = await generateKeyPairSigner();
415447
svm.airdrop(contributor.address, lamports(1_000_000_000n));
416448
const contributorAta = await fundAta(maker, maker, contributor.address, mint.address, 10n);
417-
const [contributorAccount, cbump] = await contributorPda(fundraiser, contributor.address);
449+
const [contributorAccount] = await contributorPda(fundraiser, contributor.address);
418450
send(
419451
await tx(contributor, [
420452
contributeIx(
@@ -424,7 +456,6 @@ describe('Token Fundraiser (Pinocchio)', () => {
424456
contributorAccount,
425457
contributorAta,
426458
vault,
427-
cbump,
428459
10n,
429460
),
430461
]),

0 commit comments

Comments
 (0)