Skip to content

Commit 6ede82e

Browse files
authored
Merge pull request #723 from ar-io/feat/adr0029-epoch-rent-receipt
feat(solana): route epoch rent to the creator (ADR-0029)
2 parents 397e456 + 8fbead0 commit 6ede82e

6 files changed

Lines changed: 272 additions & 7 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@
114114
"typescript": "^5.1.6"
115115
},
116116
"dependencies": {
117-
"@ar.io/solana-contracts": "1.1.0",
117+
"@ar.io/solana-contracts": "1.2.0",
118118
"@noble/hashes": "^1.8.0",
119119
"@solana-program/address-lookup-table": "^0.11.0",
120120
"@solana-program/compute-budget": "^0.15.0",

src/solana/constants.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,17 @@ export const WITHDRAWAL_SEED = Buffer.from('withdrawal');
7777
export const WITHDRAWAL_COUNTER_SEED = Buffer.from('withdrawal_counter');
7878
export const ALLOWLIST_SEED = Buffer.from('allowlist');
7979
export const EPOCH_SEED = Buffer.from('epoch');
80+
/**
81+
* Seed for the `EpochRentReceipt` PDA (ADR-0029). The receipt records which
82+
* account funded an Epoch's rent so `close_epoch` can refund the creator
83+
* rather than whoever wins the race to sign the close.
84+
*
85+
* Derived by hand rather than with a generated helper: the account is reached
86+
* only through `remaining_accounts` and `admin_close_orphaned_epoch_rent_receipt`,
87+
* so the IDL carries no seed metadata for it and Codama emits no
88+
* `findEpochRentReceiptPda`.
89+
*/
90+
export const EPOCH_RENT_RECEIPT_SEED = Buffer.from('epoch_rent_receipt');
8091
export const EPOCH_SETTINGS_SEED = Buffer.from('epoch_settings');
8192
export const OBSERVATION_SEED = Buffer.from('observation');
8293
export const REDELEGATION_SEED = Buffer.from('redelegation');
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* Unit tests for the ADR-0029 epoch-rent-receipt client surface:
3+
*
4+
* - `getEpochRentReceiptPDA` — the hand-rolled PDA derivation. Codama emits
5+
* no finder for `EpochRentReceipt` (the account is reached only through
6+
* `remaining_accounts` and `admin_close_orphaned_epoch_rent_receipt`, so
7+
* the IDL carries no seed metadata), which makes this derivation the one
8+
* place a typo would silently produce a wrong-but-valid address.
9+
* - `buildCloseEpochRentAccounts` — the `remaining_accounts` tail for
10+
* `close_epoch`.
11+
*
12+
* Mirrors `programs/ario-gar/src/instructions/epoch.rs`:
13+
* create_epoch: `ctx.remaining_accounts.first()` is the receipt; when absent
14+
* the epoch is created with `has_rent_receipt = 0`.
15+
* close_epoch: branches on `epoch.has_rent_receipt != 0` — NOT on what the
16+
* caller passed — then requires
17+
* `remaining_accounts = [receipt, creator]`, both writable.
18+
*/
19+
import { strict as assert } from 'node:assert';
20+
import { describe, it } from 'node:test';
21+
22+
import { AccountRole, type Address, getAddressDecoder } from '@solana/kit';
23+
24+
import { buildCloseEpochRentAccounts } from './io-writeable.js';
25+
import { getEpochPDA, getEpochRentReceiptPDA } from './pda.js';
26+
27+
const dec = getAddressDecoder();
28+
function pk(tag: number): Address {
29+
const u = new Uint8Array(32);
30+
u[0] = tag & 0xff;
31+
u[31] = 0x2a;
32+
return dec.decode(u);
33+
}
34+
const GAR = 'ARioGarProgramXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' as Address;
35+
const RECEIPT = pk(7);
36+
const CREATOR = pk(8);
37+
38+
describe('getEpochRentReceiptPDA', () => {
39+
it('is deterministic for a given index', async () => {
40+
const [a] = await getEpochRentReceiptPDA(42, GAR);
41+
const [b] = await getEpochRentReceiptPDA(42, GAR);
42+
assert.equal(a, b);
43+
});
44+
45+
it('differs per epoch index', async () => {
46+
const [a] = await getEpochRentReceiptPDA(42, GAR);
47+
const [b] = await getEpochRentReceiptPDA(43, GAR);
48+
assert.notEqual(a, b);
49+
});
50+
51+
it('accepts number and bigint identically (u64 LE seed)', async () => {
52+
const [a] = await getEpochRentReceiptPDA(780, GAR);
53+
const [b] = await getEpochRentReceiptPDA(780n, GAR);
54+
assert.equal(a, b);
55+
});
56+
57+
it('is NOT the Epoch PDA for the same index (distinct seed prefix)', async () => {
58+
const [receipt] = await getEpochRentReceiptPDA(780, GAR);
59+
const [epoch] = await getEpochPDA(780, GAR);
60+
assert.notEqual(receipt, epoch);
61+
});
62+
63+
it('is program-scoped', async () => {
64+
const [a] = await getEpochRentReceiptPDA(1, GAR);
65+
const [b] = await getEpochRentReceiptPDA(1, pk(123));
66+
assert.notEqual(a, b);
67+
});
68+
});
69+
70+
describe('buildCloseEpochRentAccounts', () => {
71+
it('returns no extra accounts for a pre-ADR-0029 epoch (flag clear)', () => {
72+
// This is what keeps un-upgraded crankers working through the transition.
73+
assert.deepEqual(buildCloseEpochRentAccounts(0, RECEIPT, null, 100), []);
74+
});
75+
76+
it('ignores a creator that happens to be known when the flag is clear', () => {
77+
// The program refunds `payer` in this branch; passing extras would be wrong.
78+
assert.deepEqual(buildCloseEpochRentAccounts(0, RECEIPT, CREATOR, 100), []);
79+
});
80+
81+
it('returns [receipt, creator] in that exact order when the flag is set', () => {
82+
const got = buildCloseEpochRentAccounts(1, RECEIPT, CREATOR, 100);
83+
assert.equal(got.length, 2);
84+
assert.equal(
85+
got[0].address,
86+
RECEIPT,
87+
'receipt must be remaining_accounts[0]',
88+
);
89+
assert.equal(
90+
got[1].address,
91+
CREATOR,
92+
'creator must be remaining_accounts[1]',
93+
);
94+
});
95+
96+
it('marks both accounts writable — the program drains both', () => {
97+
const got = buildCloseEpochRentAccounts(1, RECEIPT, CREATOR, 100);
98+
assert.equal(got[0].role, AccountRole.WRITABLE);
99+
assert.equal(got[1].role, AccountRole.WRITABLE);
100+
});
101+
102+
it('treats any non-zero flag byte as set, matching `has_rent_receipt != 0`', () => {
103+
for (const flag of [1, 2, 255]) {
104+
assert.equal(
105+
buildCloseEpochRentAccounts(flag, RECEIPT, CREATOR, 100).length,
106+
2,
107+
`flag ${flag} should take the receipted branch`,
108+
);
109+
}
110+
});
111+
112+
it('throws a diagnosable error when the flag is set but the receipt is gone', () => {
113+
assert.throws(
114+
() => buildCloseEpochRentAccounts(1, RECEIPT, null, 4242),
115+
/Epoch 4242 .*flagged.*rent receipt.*MissingEpochRentReceipt/s,
116+
);
117+
});
118+
});

src/solana/io-writeable.ts

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ import {
174174
} from '@ar.io/solana-contracts/gar';
175175
import {
176176
Protocol,
177+
fetchMaybeEpoch,
178+
fetchMaybeEpochRentReceipt,
177179
getAdminSetRewardRatiosInstructionAsync,
178180
getAllowDelegateInstructionAsync,
179181
getCancelWithdrawalInstruction,
@@ -224,6 +226,7 @@ import {
224226
getDelegationPDA,
225227
getDemandFactorPDA,
226228
getEpochPDA,
229+
getEpochRentReceiptPDA,
227230
getEpochSettingsPDA,
228231
getGarSettingsPDA,
229232
getGatewayPDA,
@@ -291,6 +294,52 @@ function withRemainingAccounts<I extends Instruction>(
291294
return { ...ix, accounts } as I;
292295
}
293296

297+
/**
298+
* Build the `remaining_accounts` tail that `close_epoch` requires (ADR-0029).
299+
*
300+
* Mirrors `programs/ario-gar/src/instructions/epoch.rs::close_epoch`, which
301+
* branches on `Epoch.has_rent_receipt` — **program-controlled state, never
302+
* "did the caller pass a receipt?"**. That distinction is the whole security
303+
* property: if the branch keyed off account presence, a scavenger would simply
304+
* omit the receipt to fall through to the legacy `close = payer` path and
305+
* pocket rent the epoch's creator paid for.
306+
*
307+
* - flag clear (every pre-ADR-0029 epoch) → no extra accounts; the program
308+
* refunds `payer` exactly as before, which is what lets un-upgraded crankers
309+
* keep closing old epochs during the ~8-day transition window.
310+
* - flag set → exactly two accounts, in this order, both writable because both
311+
* are drained: `[receipt, creator]`. The program rejects a missing or
312+
* read-only entry with `MissingEpochRentReceipt`, a wrong-PDA or
313+
* foreign-owned receipt with `InvalidEpochRentReceipt`, and a creator that
314+
* does not match `receipt.creator` with `WrongEpochCreator`.
315+
*
316+
* @param hasRentReceipt `Epoch.hasRentReceipt`. Any non-zero value counts — the
317+
* byte was padding before ADR-0029 and the program itself treats it as
318+
* boolean (`epoch.has_rent_receipt != 0`).
319+
* @param creator `EpochRentReceipt.creator`, or `null` if no receipt account
320+
* exists at the derived address.
321+
*/
322+
export function buildCloseEpochRentAccounts(
323+
hasRentReceipt: number,
324+
receiptPda: Address,
325+
creator: Address | null,
326+
epochIndex: number,
327+
): AccountMeta[] {
328+
if (hasRentReceipt === 0) return [];
329+
if (creator === null) {
330+
throw new Error(
331+
`Epoch ${epochIndex} is flagged as having a rent receipt but none exists ` +
332+
`at ${receiptPda}. close_epoch would fail with MissingEpochRentReceipt; ` +
333+
`an authority can clear the orphan with ` +
334+
`admin_close_orphaned_epoch_rent_receipt.`,
335+
);
336+
}
337+
return [
338+
{ address: receiptPda, role: AccountRole.WRITABLE },
339+
{ address: creator, role: AccountRole.WRITABLE },
340+
];
341+
}
342+
294343
/**
295344
* Pick the swapped-gateway operator that `finalize_gone` needs as a writable
296345
* `remaining_accounts[0]`.
@@ -4117,7 +4166,27 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
41174166
{ programAddress: this.garProgram },
41184167
);
41194168

4120-
const sig = await this.sendTransaction([ix], 1_000_000);
4169+
// ADR-0029: record who funded the Epoch's rent so `close_epoch` can refund
4170+
// the creator instead of whoever wins the race to sign the close.
4171+
//
4172+
// The receipt rides as a trailing `remaining_accounts` entry, NOT a declared
4173+
// account, so `create_epoch`'s IDL account list is unchanged and crankers
4174+
// running an older client keep working — they simply omit it and the epoch
4175+
// is created with `has_rent_receipt = 0`, taking the legacy refund path.
4176+
// On-chain: `create_epoch` reads `ctx.remaining_accounts.first()`.
4177+
const [receiptPda] = await getEpochRentReceiptPDA(
4178+
epochIndex,
4179+
this.garProgram,
4180+
);
4181+
4182+
const sig = await this.sendTransaction(
4183+
[
4184+
withRemainingAccounts(ix, [
4185+
{ address: receiptPda, role: AccountRole.WRITABLE },
4186+
]),
4187+
],
4188+
1_000_000,
4189+
);
41214190
return { id: sig };
41224191
}
41234192

@@ -4286,7 +4355,51 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
42864355
{ programAddress: this.garProgram },
42874356
);
42884357

4289-
const sig = await this.sendTransaction([ix]);
4358+
// ADR-0029: `close_epoch` decides where the rent goes by reading
4359+
// `Epoch.hasRentReceipt` — program-controlled state, deliberately NOT
4360+
// "did the caller pass a receipt?" (that would let a scavenger omit the
4361+
// receipt to force the legacy `close = payer` branch and pocket the rent).
4362+
//
4363+
// So mirror the program: read the flag, and only when it is set append the
4364+
// two accounts the receipted branch requires, in this exact order:
4365+
// remaining_accounts[0] = the EpochRentReceipt PDA (writable — closed)
4366+
// remaining_accounts[1] = receipt.creator (writable — paid)
4367+
// Both are mandatory in that branch; omitting either is
4368+
// `MissingEpochRentReceipt`. Epochs created before this upgrade have the
4369+
// flag clear and need no extra accounts, which is what keeps the ~8-day
4370+
// mixed-version transition window working in both directions.
4371+
const [epochPda] = await getEpochPDA(params.epochIndex, this.garProgram);
4372+
const epochAccount = await fetchMaybeEpoch(this.rpc, epochPda, {
4373+
commitment: this.commitment,
4374+
});
4375+
const hasRentReceipt = epochAccount.exists
4376+
? epochAccount.data.hasRentReceipt
4377+
: 0;
4378+
4379+
const [receiptPda] = await getEpochRentReceiptPDA(
4380+
params.epochIndex,
4381+
this.garProgram,
4382+
);
4383+
let creator: Address | null = null;
4384+
if (hasRentReceipt !== 0) {
4385+
const receipt = await fetchMaybeEpochRentReceipt(this.rpc, receiptPda, {
4386+
commitment: this.commitment,
4387+
});
4388+
creator = receipt.exists ? receipt.data.creator : null;
4389+
}
4390+
4391+
const remainingAccounts = buildCloseEpochRentAccounts(
4392+
hasRentReceipt,
4393+
receiptPda,
4394+
creator,
4395+
params.epochIndex,
4396+
);
4397+
4398+
const sig = await this.sendTransaction([
4399+
remainingAccounts.length > 0
4400+
? withRemainingAccounts(ix, remainingAccounts)
4401+
: ix,
4402+
]);
42904403
return { id: sig };
42914404
}
42924405

src/solana/pda.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
BALANCE_SEED,
5252
DELEGATION_SEED,
5353
DEMAND_FACTOR_SEED,
54+
EPOCH_RENT_RECEIPT_SEED,
5455
EPOCH_SEED,
5556
EPOCH_SETTINGS_SEED,
5657
ESCROW_ANT_SEED,
@@ -262,6 +263,28 @@ export async function getEpochPDA(
262263
});
263264
}
264265

266+
/**
267+
* `EpochRentReceipt` PDA for an epoch (ADR-0029) — seeds
268+
* `["epoch_rent_receipt", u64_le(epochIndex)]`.
269+
*
270+
* `create_epoch` initialises this as a trailing `remaining_accounts` entry and
271+
* sets `Epoch.hasRentReceipt`; `close_epoch` then refunds the Epoch's rent to
272+
* the recorded `creator` instead of the closing signer. Hand-rolled because the
273+
* account never appears in a declared `Accounts` struct for those two
274+
* instructions, so the IDL has no seeds for it and Codama generates no finder.
275+
*/
276+
export async function getEpochRentReceiptPDA(
277+
epochIndex: number | bigint,
278+
programId: Address = ARIO_GAR_PROGRAM_ID,
279+
): Promise<Pda> {
280+
const indexBuf = Buffer.alloc(8);
281+
indexBuf.writeBigUInt64LE(BigInt(epochIndex));
282+
return getProgramDerivedAddress({
283+
programAddress: programId,
284+
seeds: [EPOCH_RENT_RECEIPT_SEED, indexBuf],
285+
});
286+
}
287+
265288
export async function getEpochSettingsPDA(
266289
programId: Address = ARIO_GAR_PROGRAM_ID,
267290
): Promise<Pda> {

yarn.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@
3030
resolved "https://registry.yarnpkg.com/@actions/io/-/io-3.0.2.tgz#6f89b27a159d109836d983efa283997c23b92284"
3131
integrity sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==
3232

33-
"@ar.io/solana-contracts@1.1.0":
34-
version "1.1.0"
35-
resolved "https://registry.yarnpkg.com/@ar.io/solana-contracts/-/solana-contracts-1.1.0.tgz#900784dad9b0867ae76678dc177d346692f5e61f"
36-
integrity sha512-qX8ttp5GoZYMMNNgVzGMdBmaym3g1XL3Y7GyApbbXo4e4SSgt3DXMKl6PCISij5snhkec21LAhHxzHuLyoOe8w==
33+
"@ar.io/solana-contracts@1.2.0":
34+
version "1.2.0"
35+
resolved "https://registry.yarnpkg.com/@ar.io/solana-contracts/-/solana-contracts-1.2.0.tgz#3e56cdbce36c295001572a105fe689690cb77236"
36+
integrity sha512-g/zqlYNK3geFzjaiYhicFniGsBu2T3MDM+Gh7IuOmQXV1HSPakib3oP1p0wWeJ0ho1SipoPvW+KncUaM9KVQHA==
3737
dependencies:
3838
"@noble/hashes" "^1.5.0"
3939
bs58 "^6.0.0"

0 commit comments

Comments
 (0)