Skip to content

Commit 7ae7235

Browse files
author
0xOpenClaw
committed
fix(escrow-native): refund offer rent to maker + correct balance assertion
1 parent 02751fe commit 7ae7235

File tree

3 files changed

+52
-11
lines changed

3 files changed

+52
-11
lines changed

tokens/escrow/native/program/src/instructions/take_offer.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ impl TakeOffer {
191191
);
192192
assert_eq!(
193193
maker_amount_b,
194-
taker_amount_a_before_transfer + offer.token_b_wanted_amount
194+
maker_amount_b_before_transfer + offer.token_b_wanted_amount
195195
);
196196

197197
let taker_amount_b = TokenAccount::unpack(&taker_token_account_b.data.borrow())?.amount;
@@ -216,11 +216,13 @@ impl TakeOffer {
216216
&[offer_signer_seeds],
217217
)?;
218218

219-
// Send the rent back to the payer
219+
// Refund the offer account rent to the maker (the account's logical owner).
220220
//
221+
// NOTE: `payer` is a free parameter for `TakeOffer` (used for ATA creation fees)
222+
// and must not be allowed to receive the offer PDA's rent.
221223
let lamports = offer_info.lamports();
222224
**offer_info.lamports.borrow_mut() -= lamports;
223-
**payer.lamports.borrow_mut() += lamports;
225+
**maker.lamports.borrow_mut() += lamports;
224226

225227
// Realloc the account to zero
226228
//

tokens/escrow/native/tests/instruction.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ export function buildTakeOffer(props: {
184184
{
185185
pubkey: props.maker,
186186
isSigner: false,
187-
isWritable: false,
187+
isWritable: true,
188188
},
189189
{
190190
pubkey: props.taker,

tokens/escrow/native/tests/test.ts

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, test } from 'node:test';
22
import { AccountLayout } from '@solana/spl-token';
3-
import { Transaction } from '@solana/web3.js';
3+
import { Keypair, SystemProgram, Transaction } from '@solana/web3.js';
44
import { assert } from 'chai';
55
import { start } from 'solana-bankrun';
66
import { OfferAccount } from './account';
@@ -15,6 +15,11 @@ describe('Escrow!', async () => {
1515
const client = context.banksClient;
1616
const payer = context.payer;
1717

18+
// Used to reproduce the "rent refund to arbitrary payer" bug: provide a
19+
// different signer as `payer` for TakeOffer.
20+
const attacker = Keypair.generate();
21+
let offerRentLamports = 0n;
22+
1823
console.log(`Program Address : ${values.programId}`);
1924
console.log(`Payer Address : ${payer.publicKey}`);
2025

@@ -57,6 +62,7 @@ describe('Escrow!', async () => {
5762
await client.processTransaction(tx);
5863

5964
const offerInfo = await client.getAccount(values.offer);
65+
offerRentLamports = BigInt(offerInfo.lamports);
6066
const offer = OfferAccount.fromBuffer(offerInfo.data).toData();
6167

6268
const vaultInfo = await client.getAccount(values.vault);
@@ -70,7 +76,34 @@ describe('Escrow!', async () => {
7076
assert(vaultTokenAccount.amount.toString() === values.amountA.toString(), 'unexpected amount A');
7177
});
7278

73-
test('Take Offer', async () => {
79+
test('Take Offer (rent refunded to maker, not arbitrary payer)', async () => {
80+
// Ensure system accounts exist with known starting balances.
81+
// (Bankrun doesn't materialize system accounts until they hold lamports.)
82+
const fundTx = new Transaction();
83+
fundTx.recentBlockhash = context.lastBlockhash;
84+
fundTx.feePayer = payer.publicKey;
85+
fundTx
86+
.add(
87+
SystemProgram.transfer({
88+
fromPubkey: payer.publicKey,
89+
toPubkey: attacker.publicKey,
90+
lamports: 5_000_000,
91+
}),
92+
)
93+
.add(
94+
SystemProgram.transfer({
95+
fromPubkey: payer.publicKey,
96+
toPubkey: values.maker.publicKey,
97+
lamports: 5_000_000,
98+
}),
99+
)
100+
.sign(payer);
101+
await client.processTransaction(fundTx);
102+
103+
const makerBefore = BigInt((await client.getAccount(values.maker.publicKey)).lamports);
104+
const attackerBefore = BigInt((await client.getAccount(attacker.publicKey)).lamports);
105+
106+
// Build TakeOffer with an arbitrary attacker-controlled payer.
74107
const ix = buildTakeOffer({
75108
maker: values.maker.publicKey,
76109
offer: values.offer,
@@ -81,17 +114,23 @@ describe('Escrow!', async () => {
81114
taker: values.taker.publicKey,
82115
taker_token_a: values.takerAccountA,
83116
taker_token_b: values.takerAccountB,
84-
payer: payer.publicKey,
117+
payer: attacker.publicKey,
85118
programId: values.programId,
86119
});
87120

88-
const blockhash = context.lastBlockhash;
89-
90121
const tx = new Transaction();
91-
tx.recentBlockhash = blockhash;
92-
tx.add(ix).sign(payer, values.taker);
122+
tx.recentBlockhash = context.lastBlockhash;
123+
tx.feePayer = payer.publicKey; // keep fees deterministic and away from attacker
124+
tx.add(ix).sign(payer, attacker, values.taker);
93125
await client.processTransaction(tx);
94126

127+
const makerAfter = BigInt((await client.getAccount(values.maker.publicKey)).lamports);
128+
const attackerAfter = BigInt((await client.getAccount(attacker.publicKey)).lamports);
129+
130+
// Security property: offer account rent must not be transferable to an arbitrary `payer`.
131+
assert(makerAfter - makerBefore === offerRentLamports, 'maker did not receive offer rent');
132+
assert(attackerAfter <= attackerBefore, 'attacker unexpectedly received offer rent');
133+
95134
const offerInfo = await client.getAccount(values.offer);
96135
assert(offerInfo === null, 'offer account not closed');
97136

0 commit comments

Comments
 (0)