@@ -174,6 +174,8 @@ import {
174174} from '@ar.io/solana-contracts/gar' ;
175175import {
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
0 commit comments