Skip to content

Commit ae2e211

Browse files
committed
merkle-tree-token-claimer: create PDAs over a pre-funded address
Receipt and airdrop-state addresses are publicly derivable, and CreateAccount refuses to create over an account that already holds lamports. Anyone could therefore send a lamport to a receipt address and block that index's claim permanently. Top the account up, then Allocate and Assign — the same fallback Anchor's init performs.
1 parent 0b95b8a commit ae2e211

5 files changed

Lines changed: 77 additions & 20 deletions

File tree

tokens/merkle-tree-token-claimer/pinocchio/program/src/instructions/claim_airdrop.rs

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
use pinocchio::{
22
cpi::{Seed, Signer},
33
error::ProgramError,
4-
sysvars::{rent::Rent, Sysvar},
54
AccountView, Address, ProgramResult,
65
};
76
use pinocchio_associated_token_account::instructions::CreateIdempotent;
87
use pinocchio_log::log;
9-
use pinocchio_system::instructions::CreateAccount;
108
use pinocchio_token::instructions::TransferChecked;
119

1210
use crate::{
1311
error::ClaimError,
1412
merkle::compute_merkle_root,
1513
state::{AirdropState, ClaimReceipt, AIRDROP_STATE_SEED, CLAIM_RECEIPT_SEED, CLAIM_RECEIPT_SIZE, MINT_DECIMALS},
14+
util::create_pda_account,
1615
};
1716

1817
/// Pays out one leaf of the airdrop.
@@ -124,14 +123,7 @@ pub fn claim_airdrop(program_id: &Address, accounts: &mut [AccountView], data: &
124123
Seed::from(&receipt_bump_bytes),
125124
];
126125

127-
CreateAccount {
128-
from: signer,
129-
to: claim_receipt,
130-
lamports: Rent::get()?.try_minimum_balance(CLAIM_RECEIPT_SIZE)?,
131-
space: CLAIM_RECEIPT_SIZE as u64,
132-
owner: program_id,
133-
}
134-
.invoke_signed(&[Signer::from(&receipt_seeds)])?;
126+
create_pda_account(signer, claim_receipt, CLAIM_RECEIPT_SIZE, program_id, &receipt_seeds)?;
135127
}
136128

137129
CreateIdempotent {

tokens/merkle-tree-token-claimer/pinocchio/program/src/instructions/initialize_airdrop_data.rs

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use pinocchio::{
2-
cpi::{Seed, Signer},
2+
cpi::Seed,
33
error::ProgramError,
44
sysvars::{rent::Rent, Sysvar},
55
AccountView, Address, ProgramResult,
@@ -12,6 +12,7 @@ use pinocchio_token::instructions::{AuthorityType, InitializeMint2, MintTo, SetA
1212
use crate::{
1313
error::ClaimError,
1414
state::{AirdropState, AIRDROP_STATE_SEED, AIRDROP_STATE_SIZE, MINT_DECIMALS},
15+
util::create_pda_account,
1516
};
1617

1718
/// Size of a legacy SPL Token mint.
@@ -83,14 +84,7 @@ pub fn initialize_airdrop_data(program_id: &Address, accounts: &mut [AccountView
8384
let state_bump_bytes = [state_bump];
8485
let state_seeds =
8586
[Seed::from(AIRDROP_STATE_SEED), Seed::from(mint.address().as_ref()), Seed::from(&state_bump_bytes)];
86-
CreateAccount {
87-
from: authority,
88-
to: airdrop_state,
89-
lamports: rent.try_minimum_balance(AIRDROP_STATE_SIZE)?,
90-
space: AIRDROP_STATE_SIZE as u64,
91-
owner: program_id,
92-
}
93-
.invoke_signed(&[Signer::from(&state_seeds)])?;
87+
create_pda_account(authority, airdrop_state, AIRDROP_STATE_SIZE, program_id, &state_seeds)?;
9488

9589
AirdropState::from_bytes(&mut airdrop_state.try_borrow_mut()?)?.initialize(
9690
&merkle_root,

tokens/merkle-tree-token-claimer/pinocchio/program/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ pub mod instructions;
55
pub mod merkle;
66
pub mod processor;
77
pub mod state;
8+
pub mod util;
89

910
use pinocchio::{entrypoint, nostd_panic_handler};
1011

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
//! Shared account-creation helper.
2+
3+
use pinocchio::{
4+
cpi::{Seed, Signer},
5+
sysvars::{rent::Rent, Sysvar},
6+
AccountView, Address, ProgramResult,
7+
};
8+
use pinocchio_system::instructions::{Allocate, Assign, Transfer};
9+
10+
/// Creates `account` at a PDA, tolerating an address that already holds
11+
/// lamports.
12+
///
13+
/// `CreateAccount` fails outright if the target has a balance, and every PDA
14+
/// here has a publicly derivable address — so anyone could send one lamport to
15+
/// a receipt or state address and permanently block the instruction that was
16+
/// meant to create it. Topping the account up and then allocating and assigning
17+
/// it separately sidesteps that; it is the same fallback Anchor's `init`
18+
/// performs.
19+
pub fn create_pda_account(
20+
payer: &AccountView,
21+
account: &mut AccountView,
22+
space: usize,
23+
owner: &Address,
24+
seeds: &[Seed],
25+
) -> ProgramResult {
26+
let required = Rent::get()?.try_minimum_balance(space)?;
27+
let current = account.lamports();
28+
29+
if current < required {
30+
Transfer { from: payer, to: account, lamports: required - current }.invoke()?;
31+
}
32+
33+
let signer = [Signer::from(seeds)];
34+
Allocate { account, space: space as u64 }.invoke_signed(&signer)?;
35+
Assign { account, owner }.invoke_signed(&signer)?;
36+
37+
Ok(())
38+
}

tokens/merkle-tree-token-claimer/pinocchio/tests/test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,8 +325,40 @@ describe('Merkle Tree Token Claimer (Pinocchio)', () => {
325325
assert.deepEqual(Array.from(airdropStateData().merkleRoot), Array.from(tree.root), 'the root is unchanged');
326326
});
327327

328+
it('Pays a claim whose receipt address was pre-funded', async () => {
329+
// Receipt addresses are publicly derivable, so anyone can drop lamports
330+
// on one before the rightful claimant gets there. `CreateAccount`
331+
// refuses to create over an existing balance, so a bare create here
332+
// would let an attacker permanently block any index for a few lamports.
333+
const index = 1;
334+
const claimer = claimers[index];
335+
const receipt = await receiptAddress(BigInt(index));
336+
svm.setAccount({
337+
address: receipt,
338+
data: new Uint8Array(0),
339+
executable: false,
340+
lamports: lamports(1n),
341+
programAddress: SYSTEM_PROGRAM_ADDRESS,
342+
space: 0n,
343+
});
344+
345+
const ix = await claimIx(claimer, index, CLAIM_AMOUNTS[index], tree.proof(index));
346+
send(await tx([ix], claimer), 'claim over a pre-funded receipt');
347+
348+
const [claimerAta] = await findAssociatedTokenPda({
349+
owner: claimer.address,
350+
mint: mint.address,
351+
tokenProgram: TOKEN_PROGRAM_ADDRESS,
352+
});
353+
assert.equal(tokenAmount(claimerAta), CLAIM_AMOUNTS[index], 'the claim was still paid');
354+
355+
const receiptAccount = svm.getAccount(receipt);
356+
if (!receiptAccount?.exists) throw new Error('receipt not found');
357+
assert.equal(receiptAccount.programAddress, programId, 'the receipt ended up owned by the program');
358+
});
359+
328360
it('Pays the remaining claims', async () => {
329-
for (const index of [1, 2]) {
361+
for (const index of [2]) {
330362
const claimer = claimers[index];
331363
const ix = await claimIx(claimer, index, CLAIM_AMOUNTS[index], tree.proof(index));
332364
send(await tx([ix], claimer), `claim ${index}`);

0 commit comments

Comments
 (0)