@ar.io/sdk 3.24.0 → 4.0.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/README.md +757 -589
  2. package/lib/esm/cli/cli.js +188 -152
  3. package/lib/esm/cli/commands/antCommands.js +23 -58
  4. package/lib/esm/cli/commands/arnsPurchaseCommands.js +48 -30
  5. package/lib/esm/cli/commands/escrowCommands.js +227 -0
  6. package/lib/esm/cli/commands/gatewayWriteCommands.js +140 -23
  7. package/lib/esm/cli/commands/pruneCommands.js +154 -0
  8. package/lib/esm/cli/commands/readCommands.js +22 -3
  9. package/lib/esm/cli/commands/transfer.js +6 -6
  10. package/lib/esm/cli/options.js +124 -58
  11. package/lib/esm/cli/utils.js +303 -175
  12. package/lib/esm/common/ant-registry.js +17 -143
  13. package/lib/esm/common/ant.js +44 -1167
  14. package/lib/esm/common/faucet.js +17 -6
  15. package/lib/esm/common/index.js +0 -4
  16. package/lib/esm/common/io.js +25 -1412
  17. package/lib/esm/constants.js +13 -19
  18. package/lib/esm/solana/ant-readable.js +724 -0
  19. package/lib/esm/solana/ant-registry-readable.js +133 -0
  20. package/lib/esm/solana/ant-registry-writeable.js +472 -0
  21. package/lib/esm/solana/ant-writeable.js +384 -0
  22. package/lib/esm/solana/ata.js +70 -0
  23. package/lib/esm/solana/canonical-message.js +128 -0
  24. package/lib/esm/solana/clusters.js +111 -0
  25. package/lib/esm/solana/constants.js +146 -0
  26. package/lib/esm/solana/delegation-math.js +112 -0
  27. package/lib/esm/solana/deserialize.js +711 -0
  28. package/lib/esm/solana/escrow.js +839 -0
  29. package/lib/{cjs/utils/json.js → esm/solana/events.js} +15 -10
  30. package/lib/esm/solana/funding-plan.js +699 -0
  31. package/lib/esm/solana/index.js +126 -0
  32. package/lib/esm/solana/instruction.js +39 -0
  33. package/lib/esm/solana/io-readable.js +2182 -0
  34. package/lib/esm/solana/io-writeable.js +3196 -0
  35. package/lib/esm/solana/json-rpc.js +90 -0
  36. package/lib/esm/solana/metadata.js +81 -0
  37. package/lib/esm/solana/mpl-core.js +192 -0
  38. package/lib/esm/solana/pda.js +332 -0
  39. package/lib/esm/solana/predict-prescribed-observers.js +110 -0
  40. package/lib/esm/solana/retry.js +117 -0
  41. package/lib/esm/solana/rpc-circuit-breaker.js +258 -0
  42. package/lib/esm/solana/send.js +372 -0
  43. package/lib/esm/solana/spawn-ant.js +224 -0
  44. package/lib/esm/solana/types.js +1 -0
  45. package/lib/esm/types/ant.js +27 -15
  46. package/lib/esm/types/io.js +8 -11
  47. package/lib/esm/utils/ant.js +0 -63
  48. package/lib/esm/utils/index.js +0 -3
  49. package/lib/esm/version.js +1 -1
  50. package/lib/types/cli/commands/antCommands.d.ts +5 -13
  51. package/lib/types/cli/commands/arnsPurchaseCommands.d.ts +33 -7
  52. package/lib/types/cli/commands/escrowCommands.d.ts +68 -0
  53. package/lib/types/cli/commands/gatewayWriteCommands.d.ts +12 -11
  54. package/lib/types/cli/commands/pruneCommands.d.ts +31 -0
  55. package/lib/types/cli/commands/readCommands.d.ts +27 -22
  56. package/lib/types/cli/commands/transfer.d.ts +9 -9
  57. package/lib/types/cli/options.d.ts +76 -21
  58. package/lib/types/cli/types.d.ts +11 -13
  59. package/lib/types/cli/utils.d.ts +71 -31
  60. package/lib/types/common/ant-registry.d.ts +49 -47
  61. package/lib/types/common/ant.d.ts +54 -539
  62. package/lib/types/common/faucet.d.ts +20 -8
  63. package/lib/types/common/index.d.ts +0 -3
  64. package/lib/types/common/io.d.ts +66 -258
  65. package/lib/types/constants.d.ts +11 -18
  66. package/lib/types/solana/ant-readable.d.ts +180 -0
  67. package/lib/types/solana/ant-registry-readable.d.ts +105 -0
  68. package/lib/types/solana/ant-registry-writeable.d.ts +249 -0
  69. package/lib/types/solana/ant-writeable.d.ts +177 -0
  70. package/lib/types/solana/ata.d.ts +44 -0
  71. package/lib/types/solana/canonical-message.d.ts +121 -0
  72. package/lib/types/solana/clusters.d.ts +109 -0
  73. package/lib/types/solana/constants.d.ts +119 -0
  74. package/lib/types/solana/delegation-math.d.ts +45 -0
  75. package/lib/types/solana/deserialize.d.ts +262 -0
  76. package/lib/types/solana/escrow.d.ts +480 -0
  77. package/lib/types/solana/events.d.ts +38 -0
  78. package/lib/types/solana/funding-plan.d.ts +225 -0
  79. package/lib/types/solana/index.d.ts +87 -0
  80. package/lib/types/solana/instruction.d.ts +39 -0
  81. package/lib/types/solana/io-readable.d.ts +499 -0
  82. package/lib/types/solana/io-writeable.d.ts +893 -0
  83. package/lib/types/solana/json-rpc.d.ts +47 -0
  84. package/lib/types/solana/metadata.d.ts +84 -0
  85. package/lib/types/solana/mpl-core.d.ts +120 -0
  86. package/lib/types/solana/pda.d.ts +95 -0
  87. package/lib/types/solana/predict-prescribed-observers.d.ts +28 -0
  88. package/lib/types/solana/retry.d.ts +62 -0
  89. package/lib/types/solana/rpc-circuit-breaker.d.ts +78 -0
  90. package/lib/types/solana/send.d.ts +94 -0
  91. package/lib/types/solana/spawn-ant.d.ts +145 -0
  92. package/lib/types/solana/types.d.ts +82 -0
  93. package/lib/types/types/ant-registry.d.ts +43 -4
  94. package/lib/types/types/ant.d.ts +114 -96
  95. package/lib/types/types/common.d.ts +18 -74
  96. package/lib/types/types/faucet.d.ts +2 -2
  97. package/lib/types/types/io.d.ts +244 -158
  98. package/lib/types/types/token.d.ts +0 -12
  99. package/lib/types/utils/ant.d.ts +1 -12
  100. package/lib/types/utils/index.d.ts +0 -3
  101. package/lib/types/version.d.ts +1 -1
  102. package/package.json +36 -33
  103. package/lib/cjs/cli/cli.js +0 -822
  104. package/lib/cjs/cli/commands/antCommands.js +0 -113
  105. package/lib/cjs/cli/commands/arnsPurchaseCommands.js +0 -212
  106. package/lib/cjs/cli/commands/gatewayWriteCommands.js +0 -210
  107. package/lib/cjs/cli/commands/readCommands.js +0 -215
  108. package/lib/cjs/cli/commands/transfer.js +0 -159
  109. package/lib/cjs/cli/options.js +0 -470
  110. package/lib/cjs/cli/types.js +0 -2
  111. package/lib/cjs/cli/utils.js +0 -639
  112. package/lib/cjs/common/ant-registry.js +0 -155
  113. package/lib/cjs/common/ant-versions.js +0 -93
  114. package/lib/cjs/common/ant.js +0 -1182
  115. package/lib/cjs/common/arweave.js +0 -27
  116. package/lib/cjs/common/contracts/ao-process.js +0 -224
  117. package/lib/cjs/common/error.js +0 -64
  118. package/lib/cjs/common/faucet.js +0 -150
  119. package/lib/cjs/common/hyperbeam/hb.js +0 -173
  120. package/lib/cjs/common/index.js +0 -42
  121. package/lib/cjs/common/io.js +0 -1423
  122. package/lib/cjs/common/logger.js +0 -83
  123. package/lib/cjs/common/loggers/winston.js +0 -68
  124. package/lib/cjs/common/marketplace.js +0 -731
  125. package/lib/cjs/common/turbo.js +0 -223
  126. package/lib/cjs/constants.js +0 -41
  127. package/lib/cjs/node/index.js +0 -39
  128. package/lib/cjs/package.json +0 -1
  129. package/lib/cjs/types/ant-registry.js +0 -2
  130. package/lib/cjs/types/ant.js +0 -168
  131. package/lib/cjs/types/common.js +0 -2
  132. package/lib/cjs/types/faucet.js +0 -2
  133. package/lib/cjs/types/index.js +0 -37
  134. package/lib/cjs/types/io.js +0 -51
  135. package/lib/cjs/types/token.js +0 -116
  136. package/lib/cjs/utils/ant.js +0 -108
  137. package/lib/cjs/utils/ao.js +0 -432
  138. package/lib/cjs/utils/arweave.js +0 -285
  139. package/lib/cjs/utils/base64.js +0 -62
  140. package/lib/cjs/utils/hash.js +0 -56
  141. package/lib/cjs/utils/index.js +0 -38
  142. package/lib/cjs/utils/processes.js +0 -173
  143. package/lib/cjs/utils/random.js +0 -30
  144. package/lib/cjs/utils/schema.js +0 -15
  145. package/lib/cjs/utils/url.js +0 -37
  146. package/lib/cjs/version.js +0 -20
  147. package/lib/cjs/web/index.js +0 -41
  148. package/lib/esm/common/ant-versions.js +0 -87
  149. package/lib/esm/common/arweave.js +0 -21
  150. package/lib/esm/common/contracts/ao-process.js +0 -220
  151. package/lib/esm/common/hyperbeam/hb.js +0 -169
  152. package/lib/esm/common/marketplace.js +0 -724
  153. package/lib/esm/common/turbo.js +0 -215
  154. package/lib/esm/node/index.js +0 -20
  155. package/lib/esm/utils/ao.js +0 -420
  156. package/lib/esm/utils/arweave.js +0 -271
  157. package/lib/esm/utils/processes.js +0 -167
  158. package/lib/esm/web/index.js +0 -20
  159. package/lib/types/common/ant-versions.d.ts +0 -39
  160. package/lib/types/common/arweave.d.ts +0 -17
  161. package/lib/types/common/contracts/ao-process.d.ts +0 -47
  162. package/lib/types/common/hyperbeam/hb.d.ts +0 -88
  163. package/lib/types/common/marketplace.d.ts +0 -568
  164. package/lib/types/common/turbo.d.ts +0 -61
  165. package/lib/types/node/index.d.ts +0 -20
  166. package/lib/types/utils/ao.d.ts +0 -80
  167. package/lib/types/utils/arweave.d.ts +0 -79
  168. package/lib/types/utils/processes.d.ts +0 -39
  169. package/lib/types/web/index.d.ts +0 -20
@@ -0,0 +1,3196 @@
1
+ /**
2
+ * Copyright (C) 2022-2024 Permanent Data Solutions, Inc.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+ /**
17
+ * Solana implementation of ARIOWrite interface.
18
+ *
19
+ * Extends SolanaARIOReadable with write operations that build and send
20
+ * Solana transactions via Codama-generated instruction builders.
21
+ *
22
+ * All instruction encoding (discriminators, account ordering, Borsh codecs,
23
+ * default value resolution for token/system programs) is delegated to the
24
+ * generated builders in `./generated/{core,gar,arns}/instructions/`. The
25
+ * builders are derived from the on-chain IDL and stay in sync via codegen.
26
+ *
27
+ * This file's job is just to:
28
+ * 1. Translate the AO-style SDK params into the builder's input shape.
29
+ * 2. Pre-derive the PDAs that the *Async builders can't infer (the ones
30
+ * whose seeds depend on runtime state — e.g. the next withdrawal id
31
+ * from the on-chain counter, or the buyer's ATA from a runtime mint).
32
+ * 3. Append remaining_accounts (gateway PDAs, name registry) for the
33
+ * epoch crank instructions, since Codama doesn't generate a typed
34
+ * surface for them.
35
+ */
36
+ import { AccountRole, address, appendTransactionMessageInstructions, compileTransaction, createTransactionMessage, fetchEncodedAccount, getAddressDecoder, getBase64EncodedWireTransaction, pipe, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, } from '@solana/kit';
37
+ import { CostIntent, PurchaseType, getBuyNameFromDelegationInstructionAsync, getBuyNameFromFundingPlanInstructionAsync, getBuyNameFromOperatorStakeInstructionAsync, getBuyNameFromWithdrawalInstructionAsync, getBuyNameInstructionAsync, getBuyReturnedNameFromDelegationInstructionAsync, getBuyReturnedNameFromFundingPlanInstructionAsync, getBuyReturnedNameFromOperatorStakeInstructionAsync, getBuyReturnedNameFromWithdrawalInstructionAsync, getBuyReturnedNameInstructionAsync, getExtendLeaseFromDelegationInstructionAsync, getExtendLeaseFromFundingPlanInstructionAsync, getExtendLeaseFromOperatorStakeInstructionAsync, getExtendLeaseFromWithdrawalInstructionAsync, getExtendLeaseInstructionAsync, getGetTokenCostInstructionAsync, getIncreaseUndernameLimitFromDelegationInstructionAsync, getIncreaseUndernameLimitFromFundingPlanInstructionAsync, getIncreaseUndernameLimitFromOperatorStakeInstructionAsync, getIncreaseUndernameLimitFromWithdrawalInstructionAsync, getIncreaseUndernameLimitInstructionAsync, getMigrateArnsRecordInstruction, getPruneExpiredNamesInstructionAsync, getPruneExpiredReservationInstruction, getPruneNameToReturnedInstructionAsync, getPruneReturnedNamesInstructionAsync, getReassignNameInstructionAsync, getReleaseNameInstructionAsync, getUpdateDemandFactorInstruction, getUpgradeNameFromDelegationInstructionAsync, getUpgradeNameFromFundingPlanInstructionAsync, getUpgradeNameFromOperatorStakeInstructionAsync, getUpgradeNameFromWithdrawalInstructionAsync, getUpgradeNameInstructionAsync, } from '@ar.io/solana-contracts/arns';
38
+ import { FundingSourceKind as GeneratedFundingSourceKindEnum } from '@ar.io/solana-contracts/gar';
39
+ import { buildCreateAtaIdempotentIx, getAssociatedTokenAddressKit, } from './ata.js';
40
+ import { deserializeArnsRecord, deserializeDemandFactor, deserializeEpochSettingsFull, deserializePrimaryName, } from './deserialize.js';
41
+ import { buildFundingPlan as buildFundingPlanCore, buildFundingPlanRemainingAccounts, computeResidueIndexes, predictResidueVaults, } from './funding-plan.js';
42
+ /** Maps the SDK's user-facing FundingSourceKind string union to the
43
+ * Codama-generated enum used by the on-chain ix payload. */
44
+ function toGeneratedFundingSourceSpec(s) {
45
+ const kindMap = {
46
+ balance: GeneratedFundingSourceKindEnum.Balance,
47
+ delegation: GeneratedFundingSourceKindEnum.Delegation,
48
+ operatorStake: GeneratedFundingSourceKindEnum.OperatorStake,
49
+ withdrawal: GeneratedFundingSourceKindEnum.Withdrawal,
50
+ };
51
+ return { kind: kindMap[s.kind], amount: s.amount };
52
+ }
53
+ import { getSyncAttributesInstruction } from '@ar.io/solana-contracts/ant';
54
+ import { getApprovePrimaryNameInstructionAsync, getCloseExpiredRequestInstruction, getCreateVaultInstructionAsync, getExtendVaultInstructionAsync, getIncreaseVaultInstructionAsync, getReleaseVaultInstructionAsync, getRemovePrimaryNameInstructionAsync, getRequestAndSetPrimaryNameFromFundingPlanInstructionAsync, getRequestAndSetPrimaryNameInstructionAsync, getRequestPrimaryNameFromFundingPlanInstructionAsync, getRequestPrimaryNameInstructionAsync, getRevokeVaultInstructionAsync, getVaultedTransferInstructionAsync, } from '@ar.io/solana-contracts/core';
55
+ import { getDelegationDecoder, getGatewayDecoder, } from '@ar.io/solana-contracts/gar';
56
+ import { Protocol, getAllowDelegateInstructionAsync, getCancelWithdrawalInstruction, getClaimDelegateFromDisabledGatewayInstructionAsync, getClaimDelegateFromLeavingGatewayInstructionAsync, getClaimWithdrawalInstructionAsync, getCloseDrainedWithdrawalInstruction, getCloseEmptyDelegationInstruction, getCloseEpochInstructionAsync, getCloseObservationInstructionAsync, getCompoundDelegationRewardsInstruction, getCreateEpochInstructionAsync, getDecreaseDelegateStakeInstructionAsync, getDecreaseOperatorStakeInstructionAsync, getDelegateStakeInstructionAsync, getDisallowDelegateInstructionAsync, getDistributeEpochInstructionAsync, getFinalizeGoneInstructionAsync, getIncreaseOperatorStakeInstructionAsync, getInstantWithdrawalInstructionAsync, getJoinNetworkInstructionAsync, getLeaveNetworkInstructionAsync, getPrescribeEpochInstructionAsync, getPruneGatewayInstructionAsync, getRedelegateStakeInstructionAsync, getSaveObservationsInstructionAsync, getSetAllowlistEnabledInstructionAsync, getTallyWeightsInstructionAsync, getUpdateGatewaySettingsInstructionAsync, getUpdateObserverAddressInstructionAsync, } from '@ar.io/solana-contracts/gar';
57
+ import { getTransferCheckedInstruction } from '@solana-program/token';
58
+ import { ARIO_ANT_PROGRAM_ID, TOKEN_DECIMALS } from './constants.js';
59
+ import { SolanaARIOReadable } from './io-readable.js';
60
+ import { getAntConfigPDA, getAntRecordPDA, getArioConfigPDA, getArnsRecordPDA, getArnsRegistryPDA, getArnsSettingsPDA, getDelegationPDA, getDemandFactorPDA, getEpochPDA, getEpochSettingsPDA, getGarSettingsPDA, getGatewayPDA, getGatewayRegistryPDA, getObservationPDA, getObserverLookupPDA, getPrimaryNamePDA, getPrimaryNameRequestPDA, getPrimaryNameReversePDA, getReservedNamePDA, getReturnedNamePDA, getVaultPDA, getWithdrawalCounterPDA, getWithdrawalPDA, hashName, } from './pda.js';
61
+ import { predictPrescribedObservers, } from './predict-prescribed-observers.js';
62
+ import { reclaimLookupTablesForSigner, sendAndConfirm, sendWithEphemeralLookupTable, } from './send.js';
63
+ const addressDecoder = getAddressDecoder();
64
+ /** Resolve mARIOToken | number to a plain number */
65
+ function toAmount(qty) {
66
+ if (typeof qty === 'number')
67
+ return qty;
68
+ return qty.valueOf();
69
+ }
70
+ /**
71
+ * Append additional `AccountMeta`s to a Codama-generated instruction.
72
+ *
73
+ * The generated `getXInstruction[Async]` builders return frozen objects with
74
+ * a typed, fixed `accounts` tuple. The Solana program accepts extra
75
+ * `remaining_accounts` for epoch crank ops (gateway PDAs, name registry) and
76
+ * for primary-name authorization (arnsRecord, demandFactor, antRecord — see
77
+ * `_buildPrimaryNameValidationAccounts`), but
78
+ * Codama has no typed surface for them — so we splice them in here.
79
+ */
80
+ function withRemainingAccounts(ix, remaining) {
81
+ const accounts = [
82
+ ...(ix.accounts ?? []),
83
+ ...remaining,
84
+ ];
85
+ return { ...ix, accounts };
86
+ }
87
+ /**
88
+ * Pick the swapped-gateway operator that `finalize_gone` needs as a writable
89
+ * `remaining_accounts[0]`.
90
+ *
91
+ * `finalize_gone` reclaims a gateway's slot from the compact GatewayRegistry by
92
+ * moving the LAST active slot into it and rewriting that swapped gateway's
93
+ * stored `registry_index`. When the finalized gateway is NOT already the last
94
+ * slot, the on-chain handler requires the swapped Gateway PDA (writable) at
95
+ * `remaining_accounts[0]`; when it IS the last slot, no swap occurs and no
96
+ * extra account is needed. See
97
+ * `programs/ario-gar/src/instructions/gateway.rs::finalize_gone`.
98
+ *
99
+ * `registryAddresses` MUST be the active registry operator addresses in slot
100
+ * order (`getRegistryGatewayAddresses()` — length === on-chain
101
+ * `registry.count`), so `registryAddresses[length - 1]` is exactly the
102
+ * `registry.gateways[count - 1].address` the on-chain swap reads.
103
+ *
104
+ * @returns the swapped gateway's operator address, or `null` when the finalized
105
+ * gateway already occupies the last slot.
106
+ * @throws if `registryIndex` is outside the active registry count (mirrors the
107
+ * on-chain `index < registry.count` guard, surfacing a stale index early).
108
+ */
109
+ export function selectFinalizeGoneSwapOperator(registryIndex, registryAddresses) {
110
+ if (registryIndex < 0 || registryIndex >= registryAddresses.length) {
111
+ throw new Error(`finalizeGone: registry index ${registryIndex} is outside the active ` +
112
+ `registry count ${registryAddresses.length}`);
113
+ }
114
+ const lastIndex = registryAddresses.length - 1;
115
+ return registryIndex === lastIndex ? null : registryAddresses[lastIndex];
116
+ }
117
+ /**
118
+ * Split a primary name into its undername + base parts using the same rule
119
+ * as the on-chain `splitn(2, '_')` in `programs/ario-core/src/instructions/primary_name.rs`:
120
+ * everything before the first '_' is the undername, the rest is the base.
121
+ *
122
+ * Exposed as a top-level helper so it can be unit-tested without spinning up
123
+ * an `SolanaARIOWriteable`. Lowercases the input to match contract behavior.
124
+ */
125
+ export function splitPrimaryName(name) {
126
+ const lower = name.toLowerCase();
127
+ const ix = lower.indexOf('_');
128
+ if (ix === -1) {
129
+ return { isUndername: false, baseName: lower, undername: null };
130
+ }
131
+ return {
132
+ isUndername: true,
133
+ baseName: lower.slice(ix + 1),
134
+ undername: lower.slice(0, ix),
135
+ };
136
+ }
137
+ /**
138
+ * Solana-backed read-write client for the AR.IO protocol.
139
+ *
140
+ * Usage:
141
+ * ```ts
142
+ * import {
143
+ * createSolanaRpc,
144
+ * createSolanaRpcSubscriptions,
145
+ * generateKeyPairSigner,
146
+ * } from '@solana/kit';
147
+ * import { SolanaARIOWriteable } from '@ar.io/sdk/solana';
148
+ *
149
+ * const rpc = createSolanaRpc('https://api.mainnet-beta.solana.com');
150
+ * const rpcSubscriptions = createSolanaRpcSubscriptions('wss://api.mainnet-beta.solana.com');
151
+ * const signer = await generateKeyPairSigner();
152
+ * const ario = new SolanaARIOWriteable({ rpc, rpcSubscriptions, signer });
153
+ *
154
+ * await ario.transfer({ target: 'RecipientPubkey...', qty: 100_000_000 });
155
+ * ```
156
+ */
157
+ // =========================================================================
158
+ // save_observations encoding helpers
159
+ // =========================================================================
160
+ // Extracted as pure functions so the bitmap-pack + base64url-decode logic
161
+ // can be unit-tested without standing up the rpc/signer plumbing of the
162
+ // SolanaARIOWriteable class. The on-chain ABI:
163
+ // - gateway_results: [u8; 375] bit i = 1 (pass) / 0 (fail) for the
164
+ // gateway at registry index i.
165
+ // - gateway_count: u16 must equal epoch.active_gateway_count.
166
+ // - report_tx_id: [u8; 32] raw 32-byte Arweave hash (base64url
167
+ // decoded from its 43-char string form).
168
+ /** Build the gateway_results bitmap for save_observations.
169
+ * All bits start as 1 (pass) for the first `registryAddresses.length`
170
+ * positions; positions named in `failedGateways` get cleared to 0; all
171
+ * positions beyond `registryAddresses.length` are 0. */
172
+ export function buildObservationBitmap(registryAddresses, failedGateways) {
173
+ const buf = Buffer.alloc(375, 0xff);
174
+ const failedSet = new Set(failedGateways);
175
+ for (let i = 0; i < registryAddresses.length; i++) {
176
+ if (failedSet.has(registryAddresses[i])) {
177
+ buf[Math.floor(i / 8)] &= ~(1 << (i % 8));
178
+ }
179
+ }
180
+ // Clear bits beyond the active gateway count so the bitmap is exactly
181
+ // the prescribed shape (1s only at indices < gatewayCount that passed).
182
+ for (let i = registryAddresses.length; i < 3000; i++) {
183
+ buf[Math.floor(i / 8)] &= ~(1 << (i % 8));
184
+ }
185
+ return buf;
186
+ }
187
+ /** Encode an Arweave TX ID into the on-chain `[u8; 32]` slot.
188
+ *
189
+ * An Arweave TX ID **is** a 32-byte SHA-256 hash; the 43-char base64url
190
+ * string is just its presentation encoding. We decode here so the
191
+ * on-chain bytes are the raw hash — lossless and trivially reversible
192
+ * via base64url-encode on the consumer side. Without this, on-chain
193
+ * bytes alone couldn't be used to look up the original report bundle
194
+ * on permaweb (the whole point of recording the txid for auditability).
195
+ *
196
+ * Empty / undefined input → 32 zero bytes ("no permaweb archive
197
+ * configured for this submission" — the report still lives off-chain
198
+ * in the observer's local sinks but isn't anchored on Arweave).
199
+ *
200
+ * Throws on malformed input: the base64url string must be exactly 43
201
+ * chars and decode to 32 bytes. Strict validation here is desirable —
202
+ * silently truncating or accepting bad input would erode the
203
+ * auditability that the field exists for.
204
+ */
205
+ export function encodeReportTxId(reportTxId) {
206
+ const out = Buffer.alloc(32);
207
+ if (reportTxId === undefined || reportTxId === '') {
208
+ return out;
209
+ }
210
+ // base64url → base64. The 43-char Arweave form has no padding; add it
211
+ // back so Node's `Buffer.from(_, 'base64')` accepts the input.
212
+ const padded = reportTxId
213
+ .replace(/-/g, '+')
214
+ .replace(/_/g, '/')
215
+ .padEnd(Math.ceil(reportTxId.length / 4) * 4, '=');
216
+ // Reject non-base64url chars up front — `Buffer.from` silently
217
+ // tolerates them, which would mask typos.
218
+ if (!/^[A-Za-z0-9+/=]+$/.test(padded)) {
219
+ throw new Error(`reportTxId contains non-base64url characters: "${reportTxId}". ` +
220
+ `Expected a 43-char Arweave TX ID using A-Z, a-z, 0-9, -, _.`);
221
+ }
222
+ const decoded = Buffer.from(padded, 'base64');
223
+ if (decoded.length !== 32) {
224
+ throw new Error(`reportTxId must be a 43-char base64url Arweave TX ID decoding to 32 bytes; ` +
225
+ `got ${reportTxId.length} chars decoding to ${decoded.length} bytes.`);
226
+ }
227
+ decoded.copy(out);
228
+ return out;
229
+ }
230
+ /**
231
+ * Max `compound_delegation_rewards` instructions packed into one batched tx.
232
+ * Each carries a gateway + delegation + delegator account; 12 stays well within
233
+ * the per-tx account/1232-byte budget even when none share a gateway.
234
+ */
235
+ const MAX_COMPOUND_BATCH = 12;
236
+ /** Observation PDAs closed per tx before close_epoch (each ix carries Epoch +
237
+ * Observation + payer + system accounts — keep well under the tx account cap). */
238
+ const MAX_CLOSE_OBSERVATION_BATCH = 8;
239
+ /** Demand-factor period length (seconds) — mirrors `PERIOD_LENGTH_SECONDS` in ario-arns. */
240
+ const DEMAND_FACTOR_PERIOD_SECONDS = 86_400;
241
+ /**
242
+ * Detect the GAR `InvalidGatewayAccount` error by Anchor error name/message
243
+ * (walking the cause chain + `context.logs`), NOT by numeric code — codes are
244
+ * `6000 + enum-index` and shift across program versions, but the name and
245
+ * message are stable. `prescribe_epoch` raises this when a supplied observer
246
+ * Gateway PDA is missing/spoofed (e.g. a predicted observer left the registry
247
+ * between prediction and tx landing).
248
+ */
249
+ export function isInvalidGatewayAccountError(error) {
250
+ const parts = [];
251
+ let cur = error;
252
+ for (let i = 0; cur != null && i < 8; i++) {
253
+ const e = cur;
254
+ if (e.message)
255
+ parts.push(e.message);
256
+ if (Array.isArray(e.context?.logs))
257
+ parts.push(e.context.logs.join('\n'));
258
+ cur = e.cause;
259
+ }
260
+ const text = parts.join('\n');
261
+ return (text.includes('InvalidGatewayAccount') ||
262
+ text.includes('Invalid gateway account'));
263
+ }
264
+ export class SolanaARIOWriteable extends SolanaARIOReadable {
265
+ signer;
266
+ rpcSubscriptions;
267
+ constructor(config) {
268
+ super(config);
269
+ this.signer = config.signer;
270
+ this.rpcSubscriptions = config.rpcSubscriptions;
271
+ }
272
+ /** The signer's on-chain address. */
273
+ get signerAddress() {
274
+ return this.signer.address;
275
+ }
276
+ async sendTransaction(instructions, computeUnitLimit = 400_000) {
277
+ return sendAndConfirm({
278
+ rpc: this.rpc,
279
+ rpcSubscriptions: this.rpcSubscriptions,
280
+ signer: this.signer,
281
+ instructions,
282
+ commitment: this.commitment,
283
+ computeUnitLimit,
284
+ });
285
+ }
286
+ /** Helper to get the ARIO mint and treasury from ArioConfig */
287
+ async getCoreConfig() {
288
+ const [configPda] = await getArioConfigPDA(this.coreProgram);
289
+ const account = await fetchEncodedAccount(this.rpc, configPda, {
290
+ commitment: this.commitment,
291
+ });
292
+ if (!account.exists)
293
+ throw new Error('ArioConfig not found');
294
+ const data = Buffer.from(account.data);
295
+ // ArioConfig: [8 disc][32 authority][32 mint][32 arns_program][32 treasury]
296
+ const mint = addressDecoder.decode(data.subarray(40, 72));
297
+ const treasury = addressDecoder.decode(data.subarray(104, 136));
298
+ return { mint, treasury };
299
+ }
300
+ async getMint() {
301
+ return (await this.getCoreConfig()).mint;
302
+ }
303
+ /** Helper to get ArNS config fields (mint and treasury) */
304
+ async getArnsConfig() {
305
+ const [settingsPda] = await getArnsSettingsPDA(this.arnsProgram);
306
+ const account = await fetchEncodedAccount(this.rpc, settingsPda, {
307
+ commitment: this.commitment,
308
+ });
309
+ if (!account.exists)
310
+ throw new Error('ArnsConfig not found');
311
+ const data = Buffer.from(account.data);
312
+ // ArnsConfig layout: [8 disc][32 authority][32 mint][32 treasury]...
313
+ const mint = addressDecoder.decode(data.subarray(40, 72));
314
+ const treasury = addressDecoder.decode(data.subarray(72, 104));
315
+ return { mint, treasury };
316
+ }
317
+ /** Helper to get GAR config fields (mint, stake pool, protocol pool) */
318
+ async getGarConfig() {
319
+ const [settingsPda] = await getGarSettingsPDA(this.garProgram);
320
+ const account = await fetchEncodedAccount(this.rpc, settingsPda, {
321
+ commitment: this.commitment,
322
+ });
323
+ if (!account.exists)
324
+ throw new Error('GarSettings not found');
325
+ const data = Buffer.from(account.data);
326
+ // GarSettings: [8 disc][32 authority][32 mint][8+8+8+8+8+8=48 u64s][4 u32][1 bool]
327
+ // [32 migration_authority][32 stake_token_account][32 protocol_token_account][1 bump]
328
+ const mint = addressDecoder.decode(data.subarray(40, 72));
329
+ const stakeTokenAccount = addressDecoder.decode(data.subarray(157, 189));
330
+ const protocolTokenAccount = addressDecoder.decode(data.subarray(189, 221));
331
+ return { mint, stakeTokenAccount, protocolTokenAccount };
332
+ }
333
+ // =========================================
334
+ // Codama default-PDA injection helpers
335
+ // =========================================
336
+ //
337
+ // Codama's auto-generated `getXInstructionAsync` builders fall back to
338
+ // calling `find<Account>Pda()` (no args) when a "defaultable" account is
339
+ // omitted from the input. Those `find*Pda` helpers default to the
340
+ // **placeholder** program addresses baked into the generated client
341
+ // (`ARioArnsProgXXX...`, `ArioCoreProgXXX...`, etc.), *not* the env- or
342
+ // constructor-overridden program ID we actually deploy at. The result is a
343
+ // PDA derived against the wrong program id, which on-chain shows up as
344
+ // Anchor `AccountNotInitialized` (#3012) for `config` / `demand_factor` /
345
+ // `name_registry` / `settings` / etc.
346
+ //
347
+ // The wrappers below pre-derive each program's defaultable PDAs against
348
+ // the **real** program id and merge them into the input so codama never
349
+ // touches its placeholder defaults. Caller-provided values still win
350
+ // because spread order is `(...defaults, ...input)`.
351
+ /**
352
+ * Inject ARNS default PDAs (config, demandFactor, nameRegistry).
353
+ *
354
+ * Extra fields not consumed by a given builder are harmlessly ignored
355
+ * (codama only reads the named keys from `input`).
356
+ */
357
+ async withArnsDefaults(input) {
358
+ const [config] = await getArnsSettingsPDA(this.arnsProgram);
359
+ const [demandFactor] = await getDemandFactorPDA(this.arnsProgram);
360
+ const [nameRegistry] = await getArnsRegistryPDA(this.arnsProgram);
361
+ return { config, demandFactor, nameRegistry, ...input };
362
+ }
363
+ /**
364
+ * If the on-chain ArnsRecord for `name` hasn't been migrated to the
365
+ * current schema (name_hash at offset 8 doesn't match the expected
366
+ * hash), return a `migrate_arns_record` instruction that must be
367
+ * prepended to any operation referencing the record with PDA seed
368
+ * verification.
369
+ *
370
+ * Returns an empty array when the record is already up-to-date or
371
+ * doesn't exist.
372
+ */
373
+ async _buildMigrateArnsRecordIxIfNeeded(name) {
374
+ const [arnsRecordPda] = await getArnsRecordPDA(name, this.arnsProgram);
375
+ const account = await fetchEncodedAccount(this.rpc, arnsRecordPda, {
376
+ commitment: this.commitment,
377
+ });
378
+ if (!account.exists)
379
+ return [];
380
+ const data = Buffer.from(account.data);
381
+ const expectedHash = hashName(name);
382
+ const storedHash = data.subarray(8, 40);
383
+ if (storedHash.equals(expectedHash))
384
+ return [];
385
+ return [
386
+ getMigrateArnsRecordInstruction({
387
+ record: arnsRecordPda,
388
+ payer: this.signer,
389
+ }, { programAddress: this.arnsProgram }),
390
+ ];
391
+ }
392
+ /** Inject ARIO core default PDAs (config). */
393
+ async withCoreDefaults(input) {
394
+ const [config] = await getArioConfigPDA(this.coreProgram);
395
+ return { config, ...input };
396
+ }
397
+ /** Inject GAR default PDAs (settings, epochSettings, registry). */
398
+ async withGarDefaults(input) {
399
+ const [settings] = await getGarSettingsPDA(this.garProgram);
400
+ const [epochSettings] = await getEpochSettingsPDA(this.garProgram);
401
+ const [registry] = await getGatewayRegistryPDA(this.garProgram);
402
+ return { settings, epochSettings, registry, ...input };
403
+ }
404
+ /** Read WithdrawalCounter's next_id (returns 0n if not yet created) */
405
+ async getNextWithdrawalId(owner) {
406
+ const [counterPda] = await getWithdrawalCounterPDA(owner, this.garProgram);
407
+ const account = await fetchEncodedAccount(this.rpc, counterPda, {
408
+ commitment: this.commitment,
409
+ });
410
+ if (!account.exists)
411
+ return 0n;
412
+ // WithdrawalCounter: [8 disc][32 owner][8 next_id]
413
+ return Buffer.from(account.data).readBigUInt64LE(40);
414
+ }
415
+ // =========================================
416
+ // Token operations (ario-core)
417
+ // =========================================
418
+ async transfer(params, _options) {
419
+ const amount = toAmount(params.qty);
420
+ const recipient = address(params.target);
421
+ const mint = await this.getMint();
422
+ const fromATA = await getAssociatedTokenAddressKit(mint, this.signer.address);
423
+ const toATA = await getAssociatedTokenAddressKit(mint, recipient);
424
+ // SPL `transferChecked` requires the recipient ATA to exist; bundle
425
+ // an idempotent ATA-create so fresh recipients just work. Same
426
+ // pattern as `vaultedTransfer` below.
427
+ const createToAtaIx = buildCreateAtaIdempotentIx(this.signer.address, toATA, recipient, mint);
428
+ // Standard SPL Token `transferChecked`. The custom `ario-core::transfer`
429
+ // ix is deprecated — it added no protocol-level accounting, just wrapped
430
+ // this same CPI plus a `TransferEvent` emission that no major Solana
431
+ // indexer needs (Helius, Solscan, etc. all track SPL transfers natively).
432
+ // See `docs/REMOVE_CUSTOM_TRANSFER_PLAN.md` in `ar-io/solana-ar-io`.
433
+ // `transferChecked` (vs `transfer`) validates the mint + decimals
434
+ // on-chain, preventing cross-mint mistakes.
435
+ const ix = getTransferCheckedInstruction({
436
+ source: fromATA,
437
+ mint,
438
+ destination: toATA,
439
+ authority: this.signer,
440
+ amount,
441
+ decimals: TOKEN_DECIMALS,
442
+ });
443
+ const sig = await this.sendTransaction([createToAtaIx, ix]);
444
+ return { id: sig };
445
+ }
446
+ async vaultedTransfer(params, _options) {
447
+ const amount = toAmount(params.quantity);
448
+ const lockSeconds = Math.floor(params.lockLengthMs / 1000);
449
+ const recipient = address(params.recipient);
450
+ const mint = await this.getMint();
451
+ // Vault PDA depends on the recipient's *current* vault counter id, which
452
+ // the codegen builder can't infer — derive it manually.
453
+ const nextId = await this.getNextVaultId(recipient);
454
+ const [vaultPda] = await getVaultPDA(recipient, nextId, this.coreProgram);
455
+ const senderATA = await getAssociatedTokenAddressKit(mint, this.signer.address);
456
+ const vaultATA = await getAssociatedTokenAddressKit(mint, vaultPda, true);
457
+ const ix = await getVaultedTransferInstructionAsync(await this.withCoreDefaults({
458
+ vault: vaultPda,
459
+ senderTokenAccount: senderATA,
460
+ vaultTokenAccount: vaultATA,
461
+ recipient,
462
+ sender: this.signer,
463
+ amount,
464
+ lockDurationSeconds: lockSeconds,
465
+ revocable: params.revokable ?? false,
466
+ }), { programAddress: this.coreProgram });
467
+ // The on-chain CreateVault / VaultedTransfer constraint is
468
+ // `Account<TokenAccount>` (NOT `init`) — Anchor expects the vault ATA to
469
+ // already exist. Bundle an idempotent CreateAssociatedTokenAccount in
470
+ // the same tx so the caller doesn't need a separate setup step.
471
+ const createVaultAtaIx = buildCreateAtaIdempotentIx(this.signer.address, vaultATA, vaultPda, mint);
472
+ const sig = await this.sendTransaction([createVaultAtaIx, ix]);
473
+ return { id: sig };
474
+ }
475
+ async createVault(params, _options) {
476
+ const amount = toAmount(params.quantity);
477
+ const lockSeconds = Math.floor(params.lockLengthMs / 1000);
478
+ const mint = await this.getMint();
479
+ const nextId = await this.getNextVaultId(this.signer.address);
480
+ const [vaultPda] = await getVaultPDA(this.signer.address, nextId, this.coreProgram);
481
+ const ownerATA = await getAssociatedTokenAddressKit(mint, this.signer.address);
482
+ const vaultATA = await getAssociatedTokenAddressKit(mint, vaultPda, true);
483
+ const ix = await getCreateVaultInstructionAsync(await this.withCoreDefaults({
484
+ vault: vaultPda,
485
+ ownerTokenAccount: ownerATA,
486
+ vaultTokenAccount: vaultATA,
487
+ owner: this.signer,
488
+ amount,
489
+ lockDurationSeconds: lockSeconds,
490
+ }), { programAddress: this.coreProgram });
491
+ // See note in vaultedTransfer above — vault ATA is not init'd by the
492
+ // on-chain handler; bundle the create.
493
+ const createVaultAtaIx = buildCreateAtaIdempotentIx(this.signer.address, vaultATA, vaultPda, mint);
494
+ const sig = await this.sendTransaction([createVaultAtaIx, ix]);
495
+ return { id: sig };
496
+ }
497
+ /** Read VaultCounter's next_id (returns 0n if not yet created). */
498
+ async getNextVaultId(owner) {
499
+ // VaultCounter PDA derivation lives in pda.ts as getVaultCounterPDA.
500
+ const { getVaultCounterPDA } = await import('./pda.js');
501
+ const [counterPda] = await getVaultCounterPDA(owner, this.coreProgram);
502
+ const account = await fetchEncodedAccount(this.rpc, counterPda, {
503
+ commitment: this.commitment,
504
+ });
505
+ if (!account.exists)
506
+ return 0n;
507
+ return Buffer.from(account.data).readBigUInt64LE(40);
508
+ }
509
+ async extendVault(params, _options) {
510
+ const additionalSeconds = Math.floor(params.extendLengthMs / 1000);
511
+ const [vaultPda] = await getVaultPDA(this.signer.address, BigInt(params.vaultId), this.coreProgram);
512
+ const ix = await getExtendVaultInstructionAsync(await this.withCoreDefaults({
513
+ vault: vaultPda,
514
+ owner: this.signer,
515
+ additionalSeconds,
516
+ }), { programAddress: this.coreProgram });
517
+ const sig = await this.sendTransaction([ix]);
518
+ return { id: sig };
519
+ }
520
+ async increaseVault(params, _options) {
521
+ const amount = toAmount(params.quantity);
522
+ const mint = await this.getMint();
523
+ const [vaultPda] = await getVaultPDA(this.signer.address, BigInt(params.vaultId), this.coreProgram);
524
+ const ownerATA = await getAssociatedTokenAddressKit(mint, this.signer.address);
525
+ const vaultATA = await getAssociatedTokenAddressKit(mint, vaultPda, true);
526
+ const ix = await getIncreaseVaultInstructionAsync(await this.withCoreDefaults({
527
+ vault: vaultPda,
528
+ ownerTokenAccount: ownerATA,
529
+ vaultTokenAccount: vaultATA,
530
+ owner: this.signer,
531
+ amount,
532
+ }), { programAddress: this.coreProgram });
533
+ const sig = await this.sendTransaction([ix]);
534
+ return { id: sig };
535
+ }
536
+ async revokeVault(params, _options) {
537
+ const recipient = address(params.recipient);
538
+ const mint = await this.getMint();
539
+ const [vaultPda] = await getVaultPDA(recipient, BigInt(params.vaultId), this.coreProgram);
540
+ const vaultATA = await getAssociatedTokenAddressKit(mint, vaultPda, true);
541
+ const controllerATA = await getAssociatedTokenAddressKit(mint, this.signer.address);
542
+ const ix = await getRevokeVaultInstructionAsync(await this.withCoreDefaults({
543
+ vault: vaultPda,
544
+ vaultTokenAccount: vaultATA,
545
+ controllerTokenAccount: controllerATA,
546
+ controller: this.signer,
547
+ }), { programAddress: this.coreProgram });
548
+ const sig = await this.sendTransaction([ix]);
549
+ return { id: sig };
550
+ }
551
+ // =========================================
552
+ // Gateway operations (ario-gar)
553
+ // =========================================
554
+ async joinNetwork(params, _options) {
555
+ const garConfig = await this.getGarConfig();
556
+ const operatorATA = await getAssociatedTokenAddressKit(garConfig.mint, this.signer.address);
557
+ const observerAddress = params.observerAddress
558
+ ? address(params.observerAddress)
559
+ : this.signer.address;
560
+ const [observerLookupPda] = await getObserverLookupPDA(observerAddress, this.garProgram);
561
+ const ix = await getJoinNetworkInstructionAsync(await this.withGarDefaults({
562
+ operatorTokenAccount: operatorATA,
563
+ stakeTokenAccount: garConfig.stakeTokenAccount,
564
+ observerLookup: observerLookupPda,
565
+ operator: this.signer,
566
+ operatorStake: BigInt(params.operatorStake),
567
+ label: params.label ?? '',
568
+ fqdn: params.fqdn ?? '',
569
+ port: params.port ?? 443,
570
+ protocol: Protocol.Https,
571
+ properties: params.properties ?? null,
572
+ note: params.note ?? null,
573
+ allowDelegatedStaking: params.allowDelegatedStaking === true ||
574
+ params.allowDelegatedStaking === 'allowlist',
575
+ delegateRewardShareRatio: params.delegateRewardShareRatio ?? 0,
576
+ minDelegateStake: params.minDelegatedStake !== undefined
577
+ ? BigInt(params.minDelegatedStake)
578
+ : null,
579
+ observerAddress,
580
+ }), { programAddress: this.garProgram });
581
+ const sig = await this.sendTransaction([ix], 1_000_000);
582
+ return { id: sig };
583
+ }
584
+ async leaveNetwork(_options) {
585
+ // BD-102: leave_network may produce 1 or 2 Withdrawal PDAs. The
586
+ // protected exit vault uses `next_id`; the optional excess vault
587
+ // uses `next_id + 1`. The SDK always derives both PDAs and passes
588
+ // them to the codama-emitted builder — the contract's
589
+ // `Option<UncheckedAccount>` excess slot is consumed only when the
590
+ // post-stake excess is positive. Passing it unconditionally keeps
591
+ // the SDK side stateless (no need to fetch gateway.operator_stake +
592
+ // settings.min_operator_stake to decide).
593
+ const nextId = await this.getNextWithdrawalId(this.signer.address);
594
+ const [exitVaultPda] = await getWithdrawalPDA(this.signer.address, nextId, this.garProgram);
595
+ const [excessVaultPda] = await getWithdrawalPDA(this.signer.address, nextId + 1n, this.garProgram);
596
+ const ix = await getLeaveNetworkInstructionAsync(await this.withGarDefaults({
597
+ withdrawal: exitVaultPda,
598
+ excessWithdrawal: excessVaultPda,
599
+ operator: this.signer,
600
+ }), { programAddress: this.garProgram });
601
+ const sig = await this.sendTransaction([ix], 1_000_000);
602
+ return { id: sig };
603
+ }
604
+ async updateGatewaySettings(params, _options) {
605
+ const ixs = [];
606
+ // Settings fields (label, fqdn, port, etc.) — only emit when at least one
607
+ // non-observer field is provided so we don't send a no-op instruction.
608
+ const { observerAddress: _observer, ...settingsFields } = params;
609
+ if (Object.keys(settingsFields).length > 0) {
610
+ const settingsIx = await getUpdateGatewaySettingsInstructionAsync(await this.withGarDefaults({
611
+ operator: this.signer,
612
+ label: params.label ?? null,
613
+ fqdn: params.fqdn ?? null,
614
+ port: params.port ?? null,
615
+ // Codama exposes `protocol` as Option<Protocol>. We only ever updated
616
+ // the URL parts above, so leave protocol untouched (None).
617
+ protocol: null,
618
+ properties: params.properties ?? null,
619
+ note: params.note ?? null,
620
+ allowDelegatedStaking: typeof params.allowDelegatedStaking === 'boolean'
621
+ ? params.allowDelegatedStaking
622
+ : null,
623
+ delegateRewardShareRatio: params.delegateRewardShareRatio ?? null,
624
+ minDelegateStake: params.minDelegatedStake !== undefined
625
+ ? BigInt(params.minDelegatedStake)
626
+ : null,
627
+ }), { programAddress: this.garProgram });
628
+ ixs.push(settingsIx);
629
+ }
630
+ // Observer address update — uses a separate on-chain instruction that
631
+ // swaps the observer lookup PDA from old → new.
632
+ if (params.observerAddress !== undefined) {
633
+ const newObserver = address(params.observerAddress);
634
+ const gateway = await this.getGateway({
635
+ address: this.signer.address,
636
+ });
637
+ const oldObserver = address(gateway.observerAddress);
638
+ const [oldObserverLookupPda] = await getObserverLookupPDA(oldObserver, this.garProgram);
639
+ const [newObserverLookupPda] = await getObserverLookupPDA(newObserver, this.garProgram);
640
+ const observerIx = await getUpdateObserverAddressInstructionAsync(await this.withGarDefaults({
641
+ operator: this.signer,
642
+ oldObserverLookup: oldObserverLookupPda,
643
+ newObserverLookup: newObserverLookupPda,
644
+ newObserver,
645
+ }), { programAddress: this.garProgram });
646
+ ixs.push(observerIx);
647
+ }
648
+ const sig = await this.sendTransaction(ixs, 1_000_000);
649
+ return { id: sig };
650
+ }
651
+ async increaseOperatorStake(params, _options) {
652
+ const amount = toAmount(params.increaseQty);
653
+ const garConfig = await this.getGarConfig();
654
+ const operatorATA = await getAssociatedTokenAddressKit(garConfig.mint, this.signer.address);
655
+ const ix = await getIncreaseOperatorStakeInstructionAsync(await this.withGarDefaults({
656
+ operatorTokenAccount: operatorATA,
657
+ stakeTokenAccount: garConfig.stakeTokenAccount,
658
+ operator: this.signer,
659
+ amount,
660
+ }), { programAddress: this.garProgram });
661
+ const sig = await this.sendTransaction([ix], 1_000_000);
662
+ return { id: sig };
663
+ }
664
+ async decreaseOperatorStake(params, _options) {
665
+ const amount = toAmount(params.decreaseQty);
666
+ const nextId = await this.getNextWithdrawalId(this.signer.address);
667
+ const [withdrawalPda] = await getWithdrawalPDA(this.signer.address, nextId, this.garProgram);
668
+ const ix = await getDecreaseOperatorStakeInstructionAsync(await this.withGarDefaults({
669
+ withdrawal: withdrawalPda,
670
+ operator: this.signer,
671
+ amount,
672
+ }), { programAddress: this.garProgram });
673
+ const sig = await this.sendTransaction([ix], 1_000_000);
674
+ return { id: sig };
675
+ }
676
+ async delegateStake(params, _options) {
677
+ const amount = toAmount(params.stakeQty);
678
+ const target = address(params.target);
679
+ const garConfig = await this.getGarConfig();
680
+ const [gatewayPda] = await getGatewayPDA(target, this.garProgram);
681
+ const [delegationPda] = await getDelegationPDA(target, this.signer.address, this.garProgram);
682
+ const delegatorATA = await getAssociatedTokenAddressKit(garConfig.mint, this.signer.address);
683
+ const ix = await getDelegateStakeInstructionAsync(await this.withGarDefaults({
684
+ gateway: gatewayPda,
685
+ delegation: delegationPda,
686
+ delegatorTokenAccount: delegatorATA,
687
+ stakeTokenAccount: garConfig.stakeTokenAccount,
688
+ delegator: this.signer,
689
+ amount,
690
+ }), { programAddress: this.garProgram });
691
+ const sig = await this.sendTransaction([ix], 1_000_000);
692
+ return { id: sig };
693
+ }
694
+ async decreaseDelegateStake(params, _options) {
695
+ const amount = toAmount(params.decreaseQty);
696
+ const target = address(params.target);
697
+ const [gatewayPda] = await getGatewayPDA(target, this.garProgram);
698
+ const [delegationPda] = await getDelegationPDA(target, this.signer.address, this.garProgram);
699
+ const nextId = await this.getNextWithdrawalId(this.signer.address);
700
+ const [withdrawalPda] = await getWithdrawalPDA(this.signer.address, nextId, this.garProgram);
701
+ const ix = await getDecreaseDelegateStakeInstructionAsync(await this.withGarDefaults({
702
+ gateway: gatewayPda,
703
+ delegation: delegationPda,
704
+ withdrawal: withdrawalPda,
705
+ delegator: this.signer,
706
+ amount,
707
+ }), { programAddress: this.garProgram });
708
+ const sig = await this.sendTransaction([ix], 1_000_000);
709
+ if (!params.instant) {
710
+ return { id: sig };
711
+ }
712
+ // Instant boolean on decrease delegated stake (vs protected exit vault) is not currently supported by the contract — Instead we call InstantWithdrawal after creating the withdrawal, which achieves the same effect but requires two transactions. The protected exit vault is still created in the first transaction, but will be empty and can be ignored when instant = true.
713
+ await this.instantWithdrawal({ vaultId: nextId.toString() }, _options);
714
+ return { id: sig };
715
+ }
716
+ async instantWithdrawal(params, _options) {
717
+ const garConfig = await this.getGarConfig();
718
+ const [withdrawalPda] = await getWithdrawalPDA(this.signer.address, BigInt(params.vaultId), this.garProgram);
719
+ const ownerATA = await getAssociatedTokenAddressKit(garConfig.mint, this.signer.address);
720
+ const ix = await getInstantWithdrawalInstructionAsync(await this.withGarDefaults({
721
+ withdrawal: withdrawalPda,
722
+ stakeTokenAccount: garConfig.stakeTokenAccount,
723
+ ownerTokenAccount: ownerATA,
724
+ protocolTokenAccount: garConfig.protocolTokenAccount,
725
+ owner: this.signer,
726
+ }), { programAddress: this.garProgram });
727
+ const sig = await this.sendTransaction([ix], 1_000_000);
728
+ return { id: sig };
729
+ }
730
+ async cancelWithdrawal(params, _options) {
731
+ const gateway = params.gatewayAddress
732
+ ? address(params.gatewayAddress)
733
+ : this.signer.address;
734
+ const [gatewayPda] = await getGatewayPDA(gateway, this.garProgram);
735
+ const [withdrawalPda] = await getWithdrawalPDA(this.signer.address, BigInt(params.vaultId), this.garProgram);
736
+ const isDelegate = gateway !== this.signer.address;
737
+ const delegation = isDelegate
738
+ ? (await getDelegationPDA(gateway, this.signer.address, this.garProgram))[0]
739
+ : undefined;
740
+ const [settingsPda] = await getGarSettingsPDA(this.garProgram);
741
+ const ix = getCancelWithdrawalInstruction({
742
+ settings: settingsPda,
743
+ gateway: gatewayPda,
744
+ withdrawal: withdrawalPda,
745
+ delegation,
746
+ owner: this.signer,
747
+ }, { programAddress: this.garProgram });
748
+ const sig = await this.sendTransaction([ix], 1_000_000);
749
+ return { id: sig };
750
+ }
751
+ async saveObservations(params, _options) {
752
+ let epochIndex;
753
+ if (params.epochIndex !== undefined) {
754
+ epochIndex = params.epochIndex;
755
+ }
756
+ else {
757
+ const [settingsPda] = await getEpochSettingsPDA(this.garProgram);
758
+ const settingsAccount = await fetchEncodedAccount(this.rpc, settingsPda, {
759
+ commitment: this.commitment,
760
+ });
761
+ if (!settingsAccount.exists)
762
+ throw new Error('EpochSettings not found');
763
+ const settings = deserializeEpochSettingsFull(Buffer.from(settingsAccount.data));
764
+ epochIndex = settings.currentEpochIndex;
765
+ }
766
+ // Build the [u8; 375] gateway_results bitfield. On-chain convention:
767
+ // bit set (1) = passed, bit clear (0) = failed.
768
+ let resultsBuf;
769
+ let gatewayCount;
770
+ if (params.gatewayResults) {
771
+ resultsBuf = Buffer.alloc(375);
772
+ resultsBuf.set(params.gatewayResults.subarray(0, 375));
773
+ gatewayCount = params.gatewayCount ?? 0;
774
+ }
775
+ else {
776
+ const registryAddresses = await this.getRegistryGatewayAddresses();
777
+ gatewayCount = registryAddresses.length;
778
+ resultsBuf = buildObservationBitmap(registryAddresses, params.failedGateways);
779
+ }
780
+ const reportTxId = encodeReportTxId(params.reportTxId);
781
+ const ix = await getSaveObservationsInstructionAsync({
782
+ observer: this.signer,
783
+ epochIndex: BigInt(epochIndex),
784
+ gatewayResults: new Uint8Array(resultsBuf),
785
+ gatewayCount,
786
+ reportTxId: new Uint8Array(reportTxId),
787
+ }, { programAddress: this.garProgram });
788
+ const sig = await this.sendTransaction([ix], 1_000_000);
789
+ return { id: sig };
790
+ }
791
+ async redelegateStake(params, _options) {
792
+ const amount = toAmount(params.stakeQty);
793
+ const source = address(params.source);
794
+ const target = address(params.target);
795
+ const garConfig = await this.getGarConfig();
796
+ const [sourceGatewayPda] = await getGatewayPDA(source, this.garProgram);
797
+ const [targetGatewayPda] = await getGatewayPDA(target, this.garProgram);
798
+ const [sourceDelegationPda] = await getDelegationPDA(source, this.signer.address, this.garProgram);
799
+ const [targetDelegationPda] = await getDelegationPDA(target, this.signer.address, this.garProgram);
800
+ const delegatorATA = await getAssociatedTokenAddressKit(garConfig.mint, this.signer.address);
801
+ const ix = await getRedelegateStakeInstructionAsync(await this.withGarDefaults({
802
+ sourceGateway: sourceGatewayPda,
803
+ targetGateway: targetGatewayPda,
804
+ sourceDelegation: sourceDelegationPda,
805
+ targetDelegation: targetDelegationPda,
806
+ delegatorTokenAccount: delegatorATA,
807
+ stakeTokenAccount: garConfig.stakeTokenAccount,
808
+ protocolTokenAccount: garConfig.protocolTokenAccount,
809
+ delegator: this.signer,
810
+ amount,
811
+ }), { programAddress: this.garProgram });
812
+ const sig = await this.sendTransaction([ix], 1_000_000);
813
+ return { id: sig };
814
+ }
815
+ // =========================================
816
+ // ArNS operations (ario-arns)
817
+ // =========================================
818
+ async buyRecord(params, _options) {
819
+ const arnsConfig = await this.getArnsConfig();
820
+ const buyerATA = await getAssociatedTokenAddressKit(arnsConfig.mint, this.signer.address);
821
+ const antPubkey = address(params.processId ?? '11111111111111111111111111111111');
822
+ const [arnsRecord] = await getArnsRecordPDA(params.name, this.arnsProgram);
823
+ const [reservedNameCheck] = await getReservedNamePDA(params.name, this.arnsProgram);
824
+ const [returnedNameCheck] = await getReturnedNamePDA(params.name, this.arnsProgram);
825
+ const buyNameParams = {
826
+ name: params.name,
827
+ purchaseType: params.type === 'permabuy' ? PurchaseType.Permabuy : PurchaseType.Lease,
828
+ years: params.years ?? 1,
829
+ ant: antPubkey,
830
+ };
831
+ // Phase 4 of FUND_FROM_PLAN.md: dispatch on params.fundFrom. The pre-Phase-4
832
+ // path always fell through to the balance-funded `buyName` ix even when
833
+ // CLI-set `--fund-from stakes`; we now route to the corresponding on-chain
834
+ // wrapper for each mode.
835
+ let ix;
836
+ if (params.fundFrom === 'stakes' && params.gatewayAddress) {
837
+ const gatewayAddr = address(params.gatewayAddress);
838
+ const garConfig = await this.getGarConfig();
839
+ const [garSettings] = await getGarSettingsPDA(this.garProgram);
840
+ const [gatewayPda] = await getGatewayPDA(gatewayAddr, this.garProgram);
841
+ const baseShared = {
842
+ config: await this.arnsConfigPda(),
843
+ demandFactor: await this.demandFactorPda(),
844
+ arnsRecord,
845
+ nameRegistry: await this.nameRegistryPda(),
846
+ reservedNameCheck,
847
+ returnedNameCheck,
848
+ garSettings,
849
+ gateway: gatewayPda,
850
+ stakeTokenAccount: garConfig.stakeTokenAccount,
851
+ protocolTokenAccount: arnsConfig.treasury,
852
+ buyer: this.signer,
853
+ garProgram: this.garProgram,
854
+ params: buyNameParams,
855
+ };
856
+ if (params.fundAsOperator) {
857
+ ix = await getBuyNameFromOperatorStakeInstructionAsync(baseShared, {
858
+ programAddress: this.arnsProgram,
859
+ });
860
+ }
861
+ else {
862
+ const [delegationPda] = await getDelegationPDA(gatewayAddr, this.signer.address, this.garProgram);
863
+ ix = await getBuyNameFromDelegationInstructionAsync({ ...baseShared, delegation: delegationPda }, { programAddress: this.arnsProgram });
864
+ }
865
+ }
866
+ else if (params.fundFrom === 'withdrawal' &&
867
+ params.withdrawalId !== undefined) {
868
+ const garConfig = await this.getGarConfig();
869
+ const [garSettings] = await getGarSettingsPDA(this.garProgram);
870
+ const [withdrawalPda] = await getWithdrawalPDA(this.signer.address, params.withdrawalId, this.garProgram);
871
+ ix = await getBuyNameFromWithdrawalInstructionAsync({
872
+ config: await this.arnsConfigPda(),
873
+ demandFactor: await this.demandFactorPda(),
874
+ arnsRecord,
875
+ nameRegistry: await this.nameRegistryPda(),
876
+ reservedNameCheck,
877
+ returnedNameCheck,
878
+ garSettings,
879
+ withdrawal: withdrawalPda,
880
+ stakeTokenAccount: garConfig.stakeTokenAccount,
881
+ protocolTokenAccount: arnsConfig.treasury,
882
+ buyer: this.signer,
883
+ garProgram: this.garProgram,
884
+ params: buyNameParams,
885
+ }, { programAddress: this.arnsProgram });
886
+ }
887
+ else if (params.fundFrom === 'plan' ||
888
+ params.fundFrom === 'any' ||
889
+ params.fundFrom === 'stakes' ||
890
+ params.fundFrom === 'withdrawal') {
891
+ // 'stakes'/'withdrawal' WITHOUT an explicit gatewayAddress/withdrawalId
892
+ // land here (the single-source branches above handle the explicit case).
893
+ // Route through the funding planner, which constrains sources to the
894
+ // chosen mode — it never silently spends liquid balance and auto-splits
895
+ // across the caller's delegations/vaults. (Previously these fell through
896
+ // to the balance path below, silently draining liquid ARIO.)
897
+ ix = await this._buildBuyNameFromFundingPlanIx({
898
+ params,
899
+ antPubkey,
900
+ arnsRecord,
901
+ reservedNameCheck,
902
+ returnedNameCheck,
903
+ buyNameParams,
904
+ arnsConfig,
905
+ });
906
+ }
907
+ else if (!params.fundFrom ||
908
+ params.fundFrom === 'balance' ||
909
+ params.fundFrom === 'turbo') {
910
+ // Direct balance-funded buy.
911
+ ix = await getBuyNameInstructionAsync(await this.withArnsDefaults({
912
+ arnsRecord,
913
+ buyerTokenAccount: buyerATA,
914
+ protocolTokenAccount: arnsConfig.treasury,
915
+ reservedNameCheck,
916
+ returnedNameCheck,
917
+ buyer: this.signer,
918
+ params: buyNameParams,
919
+ }), { programAddress: this.arnsProgram });
920
+ }
921
+ else {
922
+ throw new Error(`unsupported fundFrom mode '${params.fundFrom}' for buyRecord`);
923
+ }
924
+ // Sprint 4 / ADR-016: bundle `ant.sync_attributes` IFF the buyer
925
+ // owns the ANT (preserves BD-096 — non-holder buys defer the trait
926
+ // sync to a later `syncAttributes()` call by the actual owner).
927
+ // Pass `antPubkey` as assetOverride: the ArnsRecord PDA is CREATED
928
+ // by the buy_name ix, so it doesn't exist on-chain at SDK build
929
+ // time — the helper would 404 if it tried to read it.
930
+ const syncIx = await this._buildSyncAttributesIxIfOwner(params.name, antPubkey);
931
+ const sig = await this.sendTransaction(syncIx ? [ix, syncIx] : [ix]);
932
+ return { id: sig };
933
+ }
934
+ /**
935
+ * Resolve a `FundingPlan` for a fee-paying ArNS ix. When `params.sources`
936
+ * is set, use it verbatim (caller-supplied plan); otherwise discover the
937
+ * user's sources and build a plan via the Lua-faithful planner.
938
+ *
939
+ * Throws `InsufficientFundingError` (as a thrown Error with the structured
940
+ * payload as `cause`) when no plan covers `amountNeeded`.
941
+ */
942
+ async _resolveFundingPlan(params, amountNeeded) {
943
+ // `'plan'` is the explicit "I'll supply my own sources, skip discovery"
944
+ // mode (per FUNDING_MODES.md). Fail loudly if a caller picks `'plan'`
945
+ // without sources — pre-2026-05 the SDK silently fell through to
946
+ // discovery, making `'plan'` a synonym for `'any'`. The new semantic
947
+ // matches the doc: `'any'` discovers, `'plan'` uses what you give it.
948
+ if (params.fundFrom === 'plan' && !params.sources?.length) {
949
+ throw new Error("fundFrom: 'plan' requires explicit `sources`. Pass them via " +
950
+ "params.sources, or use fundFrom: 'any' to let the SDK discover " +
951
+ 'and plan automatically.');
952
+ }
953
+ if (params.sources?.length) {
954
+ // Caller supplied an explicit plan; build the FundingPlan envelope
955
+ // around it without source discovery. Each Delegation/OperatorStake
956
+ // source MUST carry an explicit `gateway` field so the executor knows
957
+ // which gateway PDA to slot in. Earlier single-gateway flows used
958
+ // `params.gatewayAddress` as a fallback for the first stake source —
959
+ // we preserve that for back-compat in the explicit-plan path.
960
+ const hasBalance = params.sources.some((s) => s.kind === 'balance');
961
+ const fallbackGateway = params.gatewayAddress
962
+ ? address(params.gatewayAddress)
963
+ : undefined;
964
+ const gatewayPerSource = params.sources.map((s) => {
965
+ if (s.kind !== 'delegation' && s.kind !== 'operatorStake')
966
+ return undefined;
967
+ const explicit = s.gateway;
968
+ if (explicit)
969
+ return address(explicit);
970
+ if (fallbackGateway)
971
+ return fallbackGateway;
972
+ throw new Error('sources includes delegation/operatorStake but no gateway is set on that source and gatewayAddress is unset');
973
+ });
974
+ // Auto-detect residue: for each Delegation source, fetch the live
975
+ // Delegation + Gateway PDAs and compute the post-drain. If it lands
976
+ // in (0, min_delegation_amount), that source will trigger an on-
977
+ // chain residue auto-vault and we must reserve a residue PDA slot.
978
+ const residueDelegationIndexes = await this._detectResidueIndexes(params.sources, gatewayPerSource);
979
+ return {
980
+ sources: params.sources.map((s) => ({
981
+ kind: s.kind,
982
+ amount: s.amount,
983
+ ...(s.withdrawalId !== undefined
984
+ ? { withdrawalId: s.withdrawalId }
985
+ : {}),
986
+ })),
987
+ gatewayPerSource,
988
+ residueDelegationIndexes,
989
+ hasBalanceSource: hasBalance,
990
+ };
991
+ }
992
+ // No explicit sources: discover + plan.
993
+ this.logger.debug(`[funding] discovering sources for ${this.signer.address}`, {
994
+ fundFrom: params.fundFrom,
995
+ amountNeeded: amountNeeded.toString(),
996
+ });
997
+ const arnsConfig = await this.getArnsConfig();
998
+ const { discoverFundingSources } = await import('./funding-plan.js');
999
+ const sources = await discoverFundingSources(this.rpc, this.signer.address, {
1000
+ arioMint: arnsConfig.mint,
1001
+ garProgram: this.garProgram,
1002
+ });
1003
+ this.logger.debug(`[funding] discovered ${sources.length} source(s)`, {
1004
+ sources: sources.map((s) => {
1005
+ if (s.kind === 'balance')
1006
+ return { kind: s.kind, available: s.available.toString() };
1007
+ if (s.kind === 'delegation')
1008
+ return {
1009
+ kind: s.kind,
1010
+ gateway: s.gateway,
1011
+ available: s.available.toString(),
1012
+ minDelegationAmount: s.minDelegationAmount.toString(),
1013
+ };
1014
+ if (s.kind === 'operatorStake')
1015
+ return {
1016
+ kind: s.kind,
1017
+ gateway: s.gateway,
1018
+ available: s.available.toString(),
1019
+ };
1020
+ return {
1021
+ kind: s.kind,
1022
+ withdrawalId: s.withdrawalId.toString(),
1023
+ gateway: s.gateway,
1024
+ available: s.available.toString(),
1025
+ availableAt: s.availableAt.toString(),
1026
+ };
1027
+ }),
1028
+ });
1029
+ const plan = buildFundingPlanCore(sources, amountNeeded, {
1030
+ fundFrom: params.fundFrom,
1031
+ preferGateway: params.gatewayAddress
1032
+ ? address(params.gatewayAddress)
1033
+ : undefined,
1034
+ fundAsOperator: params.fundAsOperator,
1035
+ });
1036
+ if ('kind' in plan) {
1037
+ this.logger.debug(`[funding] plan failed: ${plan.message}`);
1038
+ const err = new Error(plan.message);
1039
+ err.cause = plan;
1040
+ throw err;
1041
+ }
1042
+ this.logger.debug(`[funding] built plan with ${plan.sources.length} source(s)`, {
1043
+ sources: plan.sources.map((s, i) => ({
1044
+ kind: s.kind,
1045
+ amount: s.amount.toString(),
1046
+ gateway: plan.gatewayPerSource[i] ?? null,
1047
+ })),
1048
+ residueDelegationIndexes: plan.residueDelegationIndexes,
1049
+ hasBalanceSource: plan.hasBalanceSource,
1050
+ });
1051
+ return plan;
1052
+ }
1053
+ /**
1054
+ * Build a `buy_name_from_funding_plan` ix using the funding-plan module.
1055
+ * Resolves per-source PDAs (Delegation, Withdrawal) and the residue-vault
1056
+ * PDA prediction in one shot.
1057
+ */
1058
+ async _buildBuyNameFromFundingPlanIx(args) {
1059
+ const garConfig = await this.getGarConfig();
1060
+ const [garSettings] = await getGarSettingsPDA(this.garProgram);
1061
+ const buyerATA = await getAssociatedTokenAddressKit(args.arnsConfig.mint, this.signer.address);
1062
+ const cost = await this._simulateTokenCost({
1063
+ intent: CostIntent.BuyName,
1064
+ name: args.buyNameParams.name,
1065
+ years: args.buyNameParams.years,
1066
+ purchaseType: args.buyNameParams.purchaseType,
1067
+ });
1068
+ const plan = await this._resolveFundingPlan(args.params, cost);
1069
+ const { remainingAccounts, withdrawalCounter, residueVaultCount } = await this._materializeFundingPlan(args.params, plan);
1070
+ return await getBuyNameFromFundingPlanInstructionAsync({
1071
+ config: await this.arnsConfigPda(),
1072
+ demandFactor: await this.demandFactorPda(),
1073
+ arnsRecord: args.arnsRecord,
1074
+ nameRegistry: await this.nameRegistryPda(),
1075
+ reservedNameCheck: args.reservedNameCheck,
1076
+ returnedNameCheck: args.returnedNameCheck,
1077
+ garSettings,
1078
+ stakeTokenAccount: garConfig.stakeTokenAccount,
1079
+ protocolTokenAccount: args.arnsConfig.treasury,
1080
+ payerTokenAccount: plan.hasBalanceSource ? buyerATA : undefined,
1081
+ buyer: this.signer,
1082
+ withdrawalCounter,
1083
+ garProgram: this.garProgram,
1084
+ params: args.buyNameParams,
1085
+ sources: plan.sources.map(toGeneratedFundingSourceSpec),
1086
+ discountAccountCount: 0,
1087
+ residueVaultCount,
1088
+ }, {
1089
+ programAddress: this.arnsProgram,
1090
+ }).then((ix) => remainingAccounts.length > 0
1091
+ ? withRemainingAccounts(ix, remainingAccounts)
1092
+ : ix);
1093
+ }
1094
+ /**
1095
+ * For an explicit caller-supplied plan, detect which Delegation sources
1096
+ * will trigger an on-chain residue auto-vault. Reads each (Delegation,
1097
+ * Gateway) pair in parallel; computes post-drain; flags `(0, min)`.
1098
+ *
1099
+ * Hard-fails on RPC error or missing PDA — silently skipping would let
1100
+ * the on-chain handler reject the tx with `MissingResidueVault` and
1101
+ * burn fees. The error message points at remediation.
1102
+ */
1103
+ async _detectResidueIndexes(sources, gatewayPerSource) {
1104
+ const delegationIndexes = sources
1105
+ .map((s, i) => ({ s, i }))
1106
+ .filter(({ s }) => s.kind === 'delegation');
1107
+ if (delegationIndexes.length === 0)
1108
+ return [];
1109
+ const owner = this.signer.address;
1110
+ const reads = await Promise.all(delegationIndexes.map(async ({ i }) => {
1111
+ const gateway = gatewayPerSource[i];
1112
+ if (!gateway) {
1113
+ throw new Error(`funding plan source #${i} is a Delegation but gatewayPerSource[${i}] is undefined; set source.gateway or params.gatewayAddress`);
1114
+ }
1115
+ const [delegationPda] = await getDelegationPDA(gateway, owner, this.garProgram);
1116
+ const [gatewayPda] = await getGatewayPDA(gateway, this.garProgram);
1117
+ const [delAcct, gwAcct] = await Promise.all([
1118
+ fetchEncodedAccount(this.rpc, delegationPda, {
1119
+ commitment: this.commitment,
1120
+ }),
1121
+ fetchEncodedAccount(this.rpc, gatewayPda, {
1122
+ commitment: this.commitment,
1123
+ }),
1124
+ ]);
1125
+ if (!delAcct.exists) {
1126
+ throw new Error(`residue auto-detect failed: Delegation PDA ${delegationPda} not found for source #${i} (gateway ${gateway}); ensure the delegation exists or pass an explicit funding plan with residue hints`);
1127
+ }
1128
+ if (!gwAcct.exists) {
1129
+ throw new Error(`residue auto-detect failed: Gateway PDA ${gatewayPda} not found for source #${i}`);
1130
+ }
1131
+ return { i, delAcct, gwAcct };
1132
+ }));
1133
+ const delegationDecoder = getDelegationDecoder();
1134
+ const gatewayDecoder = getGatewayDecoder();
1135
+ const states = new Array(sources.length).fill(undefined);
1136
+ for (const { i, delAcct, gwAcct } of reads) {
1137
+ const delegation = delegationDecoder.decode(delAcct.data);
1138
+ const gateway = gatewayDecoder.decode(gwAcct.data);
1139
+ states[i] = {
1140
+ delegationAmount: delegation.amount,
1141
+ minDelegationAmount: gateway.settings.minDelegationAmount,
1142
+ };
1143
+ }
1144
+ return computeResidueIndexes(sources, states);
1145
+ }
1146
+ /**
1147
+ * Materialize a `FundingPlan` into the on-chain ix's per-source remaining
1148
+ * accounts, residue-vault PDAs, and the withdrawal_counter slot. Shared
1149
+ * across all 5 ArNS funding-plan ix dispatches and the 2 ario-core
1150
+ * primary-name funding-plan dispatches.
1151
+ *
1152
+ * Throws when the plan has Withdrawal sources whose ids cannot be
1153
+ * resolved from either `spec.withdrawalId` (preferred) or
1154
+ * `params.withdrawalId` (single-withdrawal back-compat).
1155
+ */
1156
+ async _materializeFundingPlan(params, plan) {
1157
+ // Resolve withdrawalIds: prefer per-source `spec.withdrawalId` (the
1158
+ // multi-withdrawal canonical path — discoverFundingSources sets it
1159
+ // and explicit caller plans can too); fall back to params.withdrawalId
1160
+ // for single-withdrawal CLI back-compat.
1161
+ const withdrawalSpecs = (params.sources ?? plan.sources).filter((s) => s.kind === 'withdrawal');
1162
+ const withdrawalIds = withdrawalSpecs.map((s, idx) => {
1163
+ const specId = s.withdrawalId;
1164
+ if (specId !== undefined)
1165
+ return BigInt(specId);
1166
+ if (params.withdrawalId !== undefined && idx === 0) {
1167
+ return BigInt(params.withdrawalId);
1168
+ }
1169
+ throw new Error(`funding plan Withdrawal source #${idx} has no withdrawalId; set it on the source spec or pass params.withdrawalId for single-withdrawal plans`);
1170
+ });
1171
+ const { residueVaults, withdrawalCounter } = await predictResidueVaults(this.rpc, this.signer.address, plan, { garProgram: this.garProgram });
1172
+ const remainingAccounts = await buildFundingPlanRemainingAccounts(plan, this.signer.address, { withdrawalIds, residueVaults, garProgram: this.garProgram });
1173
+ return {
1174
+ remainingAccounts: remainingAccounts,
1175
+ withdrawalCounter,
1176
+ residueVaultCount: residueVaults.length,
1177
+ };
1178
+ }
1179
+ /**
1180
+ * Simulate the on-chain `get_token_cost` instruction and return the exact
1181
+ * cost as a `bigint` (mARIO). This guarantees byte-exact agreement with the
1182
+ * on-chain pricing math, avoiding integer-division rounding divergences
1183
+ * that plagued the previous client-side reimplementation.
1184
+ *
1185
+ * The program writes a LE-u64 cost into the transaction's return data;
1186
+ * we parse it from the simulation result.
1187
+ */
1188
+ async _simulateTokenCost(params) {
1189
+ const demandFactorAddr = await this.demandFactorPda();
1190
+ this.logger.debug(`[funding] simulating token cost`, {
1191
+ intent: params.intent,
1192
+ name: params.name,
1193
+ years: params.years,
1194
+ quantity: params.quantity,
1195
+ purchaseType: params.purchaseType,
1196
+ arnsProgram: this.arnsProgram,
1197
+ demandFactor: demandFactorAddr,
1198
+ });
1199
+ // Roll the demand factor to the current period BEFORE pricing. The
1200
+ // `GetTokenCost` view reads the STORED demand factor, which is stale until a
1201
+ // crank (or a buy/extend) rolls it — but the `*FromFundingPlan` handlers
1202
+ // roll it inline before computing the cost. Without this, at a period
1203
+ // rollover the plan is sized to the old factor while the program charges
1204
+ // the new one → FundingPlanAmountMismatch (#6066). `update_demand_factor`
1205
+ // is permissionless + idempotent (no-op within the same period), and the
1206
+ // write is local to this simulation; the subsequent `GetTokenCost` in the
1207
+ // same tx sees the rolled value.
1208
+ const updateDfIx = getUpdateDemandFactorInstruction({ demandFactor: demandFactorAddr, payer: this.signer }, { programAddress: this.arnsProgram });
1209
+ const ix = await getGetTokenCostInstructionAsync({
1210
+ demandFactor: demandFactorAddr,
1211
+ payer: this.signer,
1212
+ intent: params.intent,
1213
+ name: params.name,
1214
+ years: params.years ?? null,
1215
+ quantity: params.quantity ?? null,
1216
+ purchaseType: params.purchaseType ?? null,
1217
+ }, { programAddress: this.arnsProgram });
1218
+ const { value: latestBlockhash } = await this.rpc
1219
+ .getLatestBlockhash()
1220
+ .send();
1221
+ const message = pipe(createTransactionMessage({ version: 0 }), (m) => setTransactionMessageFeePayerSigner(this.signer, m), (m) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m), (m) => appendTransactionMessageInstructions([updateDfIx, ix], m));
1222
+ const compiled = compileTransaction(message);
1223
+ const wire = getBase64EncodedWireTransaction(compiled);
1224
+ const sim = await this.rpc
1225
+ .simulateTransaction(wire, {
1226
+ sigVerify: false,
1227
+ replaceRecentBlockhash: true,
1228
+ encoding: 'base64',
1229
+ })
1230
+ .send();
1231
+ if (sim.value.err) {
1232
+ throw new Error(`get_token_cost simulation failed (arnsProgram=${this.arnsProgram}, demandFactor=${demandFactorAddr}): ${JSON.stringify(sim.value.err)}` +
1233
+ (sim.value.logs ? '\n' + sim.value.logs.join('\n') : ''));
1234
+ }
1235
+ const returnData = sim.value.returnData;
1236
+ if (!returnData?.data?.[0]) {
1237
+ throw new Error('get_token_cost simulation returned no data; expected a u64 cost');
1238
+ }
1239
+ const buf = Buffer.from(returnData.data[0], 'base64');
1240
+ if (buf.length < 8) {
1241
+ throw new Error(`get_token_cost return data too short: ${buf.length} bytes (expected 8)`);
1242
+ }
1243
+ const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
1244
+ const cost = dv.getBigUint64(0, true);
1245
+ this.logger.debug(`[funding] simulated token cost: ${cost} mARIO`, {
1246
+ name: params.name,
1247
+ });
1248
+ return cost;
1249
+ }
1250
+ async arnsConfigPda() {
1251
+ // The ArNS config (`Settings`) PDA is seeded with `arns_config` — NOT
1252
+ // the `ario_config` seed used by the ario-core `Config` PDA. Earlier
1253
+ // revisions of this helper called `getArioConfigPDA(this.arnsProgram)`
1254
+ // which composes the wrong seed against the right program; the
1255
+ // resulting PDA points at an account that never exists, and every
1256
+ // fund-from-stakes / withdrawal / funding-plan path simulated as
1257
+ // AccountNotInitialized (#3012) on the `config` slot.
1258
+ const [pda] = await getArnsSettingsPDA(this.arnsProgram);
1259
+ return pda;
1260
+ }
1261
+ async demandFactorPda() {
1262
+ const [pda] = await getDemandFactorPDA(this.arnsProgram);
1263
+ return pda;
1264
+ }
1265
+ async nameRegistryPda() {
1266
+ const [pda] = await getArnsRegistryPDA(this.arnsProgram);
1267
+ return pda;
1268
+ }
1269
+ async upgradeRecord(params, _options) {
1270
+ const migrateIxs = await this._buildMigrateArnsRecordIxIfNeeded(params.name);
1271
+ const ix = await this._buildManageStakeIx({
1272
+ params,
1273
+ operation: 'upgrade',
1274
+ });
1275
+ const syncIx = await this._buildSyncAttributesIxIfOwner(params.name);
1276
+ const sig = await this.sendTransaction(syncIx ? [...migrateIxs, ix, syncIx] : [...migrateIxs, ix]);
1277
+ return { id: sig };
1278
+ }
1279
+ async syncAttributes(params, _options) {
1280
+ // Public method — caller is asking explicitly to sync. Build the ix
1281
+ // unconditionally; if they don't actually own the ANT, the on-chain
1282
+ // handler returns NotNftHolder. (The bundle path uses
1283
+ // `_buildSyncAttributesIxIfOwner`, which skips when not the owner so
1284
+ // the wrapping arns ix can still succeed for non-holder management
1285
+ // — see BD-095.)
1286
+ const migrateIxs = await this._buildMigrateArnsRecordIxIfNeeded(params.name);
1287
+ const ix = await this._buildSyncAttributesIxUnconditional(params.name);
1288
+ const sig = await this.sendTransaction([...migrateIxs, ix]);
1289
+ return { id: sig };
1290
+ }
1291
+ /**
1292
+ * Build a `sync_attributes` instruction for `name` IFF the signer is
1293
+ * the current MPL Core asset owner. Returns `null` otherwise.
1294
+ *
1295
+ * Sprint 4 / ADR-016: bundle helper for `buyRecord`, `upgradeRecord`,
1296
+ * `increaseUndernameLimit`, `reassignName`, and `buyReturnedName`.
1297
+ * Returning `null` lets the caller send the arns ix alone — preserves
1298
+ * BD-095 (non-holder ArNS lease management) and BD-096 (deferred
1299
+ * trait sync for non-holder buyers). The actual ANT owner reconciles
1300
+ * state later via the public `syncAttributes()`.
1301
+ *
1302
+ * `extendLease` is NOT a caller of this helper — extend_lease changes
1303
+ * only `end_timestamp`, which isn't mirrored in any Attributes-plugin
1304
+ * trait (BD-095 last row). `releaseName` is also excluded — release_name
1305
+ * closes the ArnsRecord PDA, so a follow-up `sync_attributes` would
1306
+ * fail the PDA-existence check.
1307
+ *
1308
+ * `assetOverride` MUST be set when the bundling ix mutates
1309
+ * `record.ant` mid-tx (i.e. `reassign_name`). The on-chain record
1310
+ * still points at the OLD asset at SDK build time, so without the
1311
+ * override the bundled sync would target the wrong asset and fail
1312
+ * the post-reassign `record.ant == asset.key()` check. The owner
1313
+ * check runs against the supplied asset (= new ANT for reassign),
1314
+ * matching the pre-reshape "new owner reconciles later" semantic.
1315
+ */
1316
+ async _buildSyncAttributesIxIfOwner(name, assetOverride) {
1317
+ let asset;
1318
+ if (assetOverride !== undefined) {
1319
+ asset = assetOverride;
1320
+ }
1321
+ else {
1322
+ const record = await this.getArNSRecord({ name });
1323
+ asset = address(record.processId);
1324
+ }
1325
+ const { fetchMplCoreOwner } = await import('./mpl-core.js');
1326
+ const owner = await fetchMplCoreOwner(this.rpc, asset, {
1327
+ commitment: this.commitment,
1328
+ });
1329
+ if (owner === null || owner !== this.signer.address) {
1330
+ return null;
1331
+ }
1332
+ return this._buildSyncAttributesIxFor(name, asset);
1333
+ }
1334
+ /**
1335
+ * Build a `sync_attributes` instruction unconditionally (no owner
1336
+ * check). Used by the public `syncAttributes()` method, where the
1337
+ * caller is asking explicitly — if they aren't the owner the chain
1338
+ * returns NotNftHolder.
1339
+ */
1340
+ async _buildSyncAttributesIxUnconditional(name) {
1341
+ const record = await this.getArNSRecord({ name });
1342
+ return this._buildSyncAttributesIxFor(name, address(record.processId));
1343
+ }
1344
+ /** Pure builder; no RPC. Used by both gated + unconditional helpers. */
1345
+ async _buildSyncAttributesIxFor(name, asset) {
1346
+ const [arnsRecord] = await getArnsRecordPDA(name, this.arnsProgram);
1347
+ return getSyncAttributesInstruction({
1348
+ asset,
1349
+ payer: this.signer,
1350
+ authority: this.signer,
1351
+ arnsRecord,
1352
+ name,
1353
+ }, { programAddress: this.antProgram });
1354
+ }
1355
+ async extendLease(params, _options) {
1356
+ const migrateIxs = await this._buildMigrateArnsRecordIxIfNeeded(params.name);
1357
+ const ix = await this._buildManageStakeIx({
1358
+ params,
1359
+ operation: 'extend',
1360
+ years: params.years,
1361
+ });
1362
+ // BD-095: extend_lease changes only `end_timestamp`, which isn't
1363
+ // mirrored in any Metaplex Attributes plugin trait. No bundle.
1364
+ const sig = await this.sendTransaction([...migrateIxs, ix]);
1365
+ return { id: sig };
1366
+ }
1367
+ async increaseUndernameLimit(params, _options) {
1368
+ const migrateIxs = await this._buildMigrateArnsRecordIxIfNeeded(params.name);
1369
+ const ix = await this._buildManageStakeIx({
1370
+ params,
1371
+ operation: 'increaseUndername',
1372
+ quantity: params.increaseCount,
1373
+ });
1374
+ const syncIx = await this._buildSyncAttributesIxIfOwner(params.name);
1375
+ const sig = await this.sendTransaction(syncIx ? [...migrateIxs, ix, syncIx] : [...migrateIxs, ix]);
1376
+ return { id: sig };
1377
+ }
1378
+ /**
1379
+ * Shared dispatch helper for the 3 manage variants (upgrade / extend /
1380
+ * increaseUndername). They share the same account shape per the
1381
+ * `manage_from_delegation_accounts!` / `manage_from_operator_stake_accounts!`
1382
+ * macros in `programs/ario-arns/src/instructions/manage_from_stake.rs`.
1383
+ * Only the operation-specific extra args differ (years for extend,
1384
+ * quantity for increaseUndername).
1385
+ */
1386
+ async _buildManageStakeIx(args) {
1387
+ const arnsConfig = await this.getArnsConfig();
1388
+ const callerATA = await getAssociatedTokenAddressKit(arnsConfig.mint, this.signer.address);
1389
+ const [arnsRecord] = await getArnsRecordPDA(args.params.name, this.arnsProgram);
1390
+ // Balance / undefined → original direct-transfer ix (matches pre-Phase-4).
1391
+ if (!args.params.fundFrom ||
1392
+ args.params.fundFrom === 'balance' ||
1393
+ args.params.fundFrom === 'turbo') {
1394
+ const baseAccounts = await this.withArnsDefaults({
1395
+ arnsRecord,
1396
+ callerTokenAccount: callerATA,
1397
+ protocolTokenAccount: arnsConfig.treasury,
1398
+ caller: this.signer,
1399
+ });
1400
+ if (args.operation === 'upgrade') {
1401
+ return getUpgradeNameInstructionAsync(baseAccounts, {
1402
+ programAddress: this.arnsProgram,
1403
+ });
1404
+ }
1405
+ if (args.operation === 'extend') {
1406
+ return getExtendLeaseInstructionAsync({ ...baseAccounts, years: args.years }, { programAddress: this.arnsProgram });
1407
+ }
1408
+ return getIncreaseUndernameLimitInstructionAsync({ ...baseAccounts, quantity: args.quantity }, { programAddress: this.arnsProgram });
1409
+ }
1410
+ // Stake / withdrawal / funding-plan paths share an account skeleton.
1411
+ const garConfig = await this.getGarConfig();
1412
+ const [garSettings] = await getGarSettingsPDA(this.garProgram);
1413
+ const sharedManageBase = {
1414
+ config: await this.arnsConfigPda(),
1415
+ demandFactor: await this.demandFactorPda(),
1416
+ arnsRecord,
1417
+ garSettings,
1418
+ stakeTokenAccount: garConfig.stakeTokenAccount,
1419
+ protocolTokenAccount: arnsConfig.treasury,
1420
+ caller: this.signer,
1421
+ garProgram: this.garProgram,
1422
+ };
1423
+ if (args.params.fundFrom === 'stakes' && args.params.gatewayAddress) {
1424
+ const gatewayAddr = address(args.params.gatewayAddress);
1425
+ const [gatewayPda] = await getGatewayPDA(gatewayAddr, this.garProgram);
1426
+ const stakeBase = { ...sharedManageBase, gateway: gatewayPda };
1427
+ if (args.params.fundAsOperator) {
1428
+ if (args.operation === 'upgrade')
1429
+ return getUpgradeNameFromOperatorStakeInstructionAsync(stakeBase, {
1430
+ programAddress: this.arnsProgram,
1431
+ });
1432
+ if (args.operation === 'extend')
1433
+ return getExtendLeaseFromOperatorStakeInstructionAsync({ ...stakeBase, years: args.years }, { programAddress: this.arnsProgram });
1434
+ return getIncreaseUndernameLimitFromOperatorStakeInstructionAsync({ ...stakeBase, quantity: args.quantity }, { programAddress: this.arnsProgram });
1435
+ }
1436
+ const [delegationPda] = await getDelegationPDA(gatewayAddr, this.signer.address, this.garProgram);
1437
+ const delBase = { ...stakeBase, delegation: delegationPda };
1438
+ if (args.operation === 'upgrade')
1439
+ return getUpgradeNameFromDelegationInstructionAsync(delBase, {
1440
+ programAddress: this.arnsProgram,
1441
+ });
1442
+ if (args.operation === 'extend')
1443
+ return getExtendLeaseFromDelegationInstructionAsync({ ...delBase, years: args.years }, { programAddress: this.arnsProgram });
1444
+ return getIncreaseUndernameLimitFromDelegationInstructionAsync({ ...delBase, quantity: args.quantity }, { programAddress: this.arnsProgram });
1445
+ }
1446
+ if (args.params.fundFrom === 'withdrawal' &&
1447
+ args.params.withdrawalId !== undefined) {
1448
+ const [withdrawalPda] = await getWithdrawalPDA(this.signer.address, args.params.withdrawalId, this.garProgram);
1449
+ const wBase = { ...sharedManageBase, withdrawal: withdrawalPda };
1450
+ if (args.operation === 'upgrade')
1451
+ return getUpgradeNameFromWithdrawalInstructionAsync(wBase, {
1452
+ programAddress: this.arnsProgram,
1453
+ });
1454
+ if (args.operation === 'extend')
1455
+ return getExtendLeaseFromWithdrawalInstructionAsync({ ...wBase, years: args.years }, { programAddress: this.arnsProgram });
1456
+ return getIncreaseUndernameLimitFromWithdrawalInstructionAsync({ ...wBase, quantity: args.quantity }, { programAddress: this.arnsProgram });
1457
+ }
1458
+ if (args.params.fundFrom === 'plan' ||
1459
+ args.params.fundFrom === 'any' ||
1460
+ args.params.fundFrom === 'stakes' ||
1461
+ args.params.fundFrom === 'withdrawal') {
1462
+ // 'stakes'/'withdrawal' without an explicit gatewayAddress/withdrawalId
1463
+ // route here (the single-source branches above handle the explicit
1464
+ // case). The planner constrains sources to the chosen mode and never
1465
+ // spends liquid balance.
1466
+ // Cost estimation for manage variants: each operation has its own
1467
+ // pricing path. Keep it pragmatic — let the planner build the plan
1468
+ // around the user's desired total (caller can pass explicit sources
1469
+ // to bypass cost estimation entirely).
1470
+ const intentMap = {
1471
+ upgrade: CostIntent.UpgradeName,
1472
+ extend: CostIntent.ExtendLease,
1473
+ increaseUndername: CostIntent.IncreaseUndernameLimit,
1474
+ };
1475
+ const cost = await this._simulateTokenCost({
1476
+ intent: intentMap[args.operation],
1477
+ name: args.params.name,
1478
+ years: args.years,
1479
+ quantity: args.quantity,
1480
+ });
1481
+ const plan = await this._resolveFundingPlan(args.params, cost);
1482
+ const buyerATA = await getAssociatedTokenAddressKit(arnsConfig.mint, this.signer.address);
1483
+ const { remainingAccounts, withdrawalCounter, residueVaultCount } = await this._materializeFundingPlan(args.params, plan);
1484
+ const fpBase = {
1485
+ config: await this.arnsConfigPda(),
1486
+ demandFactor: await this.demandFactorPda(),
1487
+ arnsRecord,
1488
+ garSettings,
1489
+ stakeTokenAccount: garConfig.stakeTokenAccount,
1490
+ protocolTokenAccount: arnsConfig.treasury,
1491
+ payerTokenAccount: plan.hasBalanceSource ? buyerATA : undefined,
1492
+ caller: this.signer,
1493
+ withdrawalCounter,
1494
+ garProgram: this.garProgram,
1495
+ sources: plan.sources.map(toGeneratedFundingSourceSpec),
1496
+ discountAccountCount: 0,
1497
+ residueVaultCount,
1498
+ };
1499
+ let ix;
1500
+ if (args.operation === 'upgrade')
1501
+ ix = await getUpgradeNameFromFundingPlanInstructionAsync(fpBase, {
1502
+ programAddress: this.arnsProgram,
1503
+ });
1504
+ else if (args.operation === 'extend')
1505
+ ix = await getExtendLeaseFromFundingPlanInstructionAsync({ ...fpBase, years: args.years }, { programAddress: this.arnsProgram });
1506
+ else
1507
+ ix = await getIncreaseUndernameLimitFromFundingPlanInstructionAsync({ ...fpBase, quantity: args.quantity }, { programAddress: this.arnsProgram });
1508
+ return remainingAccounts.length > 0
1509
+ ? withRemainingAccounts(ix, remainingAccounts)
1510
+ : ix;
1511
+ }
1512
+ throw new Error(`unsupported fundFrom mode '${args.params.fundFrom}' for ${args.operation}`);
1513
+ }
1514
+ // =========================================
1515
+ // Primary name operations (ario-core)
1516
+ // =========================================
1517
+ /**
1518
+ * If the signer already has a primary name set, build the instruction(s)
1519
+ * needed to remove it so they can be prepended to a request/set tx —
1520
+ * enabling single-tx "change primary name" flows.
1521
+ *
1522
+ * Returns an empty array when the signer has no existing primary name.
1523
+ *
1524
+ * Throws when the signer has a legacy primary-name state (forward
1525
+ * `PrimaryName` PDA exists but its paired `PrimaryNameReverse` PDA does
1526
+ * NOT). Both `remove_primary_name` AND `request_and_set_primary_name`
1527
+ * require the reverse PDA on-chain — the latter rejects with
1528
+ * `MustRemoveExistingPrimaryName` (0x1786, code 6022) any time a
1529
+ * forward record already exists for the signer, regardless of reverse
1530
+ * state. Silently skipping the remove would queue a tx guaranteed to
1531
+ * fail with that opaque error. Surfacing it at the client with a clear
1532
+ * remediation pointer is the only safe behavior.
1533
+ *
1534
+ * The legacy state should not exist on any cluster post-snapshot/import
1535
+ * PR #159 (which emits PrimaryNameReverse in lockstep with PrimaryName)
1536
+ * — it's a relic of pre-#159 imports. Operators on affected clusters
1537
+ * must run `yarn workspace @ar-io/migration-import backfill:primary-name-reverse`
1538
+ * (in the solana-ar-io repo) before this method can succeed.
1539
+ */
1540
+ async _buildRemoveExistingPrimaryNameIxs() {
1541
+ const [primaryNamePda] = await getPrimaryNamePDA(this.signer.address, this.coreProgram);
1542
+ const account = await fetchEncodedAccount(this.rpc, primaryNamePda, {
1543
+ commitment: this.commitment,
1544
+ });
1545
+ if (!account.exists)
1546
+ return [];
1547
+ const { name: oldName } = deserializePrimaryName(Buffer.from(account.data));
1548
+ const [primaryNameReversePda] = await getPrimaryNameReversePDA(oldName, this.coreProgram);
1549
+ const reverseAccount = await fetchEncodedAccount(this.rpc, primaryNameReversePda, { commitment: this.commitment });
1550
+ if (!reverseAccount.exists) {
1551
+ // Fail fast with an actionable message. See method docstring for
1552
+ // why request_and_set would reject this regardless.
1553
+ throw new Error(`Cannot change primary name: signer "${this.signer.address}" has a ` +
1554
+ `legacy PrimaryName ("${oldName}") with no paired PrimaryNameReverse PDA ` +
1555
+ `(${primaryNameReversePda}). The on-chain remove_primary_name and ` +
1556
+ `request_and_set_primary_name ixs both require the reverse PDA — ` +
1557
+ `request_and_set will reject with MustRemoveExistingPrimaryName (code 6022). ` +
1558
+ `Run \`yarn workspace @ar-io/migration-import backfill:primary-name-reverse\` ` +
1559
+ `against this cluster's ario-core program to materialize the missing reverse ` +
1560
+ `PDA, then retry.`);
1561
+ }
1562
+ return [
1563
+ await getRemovePrimaryNameInstructionAsync({
1564
+ primaryName: primaryNamePda,
1565
+ primaryNameReverse: primaryNameReversePda,
1566
+ owner: this.signer,
1567
+ reverseLookupHash: hashName(oldName),
1568
+ }, { programAddress: this.coreProgram }),
1569
+ ];
1570
+ }
1571
+ /**
1572
+ * Build the `remaining_accounts` slice + the `antProgramId` arg the
1573
+ * four ario-core primary-name instructions consume. Sprint 2/5
1574
+ * reshape (ADR-016): ario-core no longer reads MPL Core asset bytes.
1575
+ * Authorization is "caller is the effective AntRecord owner for this
1576
+ * name", resolved from the `AntRecord` + `AntConfig` PDAs (freshness-
1577
+ * gated against `AntConfig.last_known_owner` — see ario-core BD-097 /
1578
+ * BD-109). Both are program-PDA-pinned lookups.
1579
+ *
1580
+ * Layouts the on-chain handlers expect:
1581
+ * request_primary_name: [arnsRecord, demandFactor]
1582
+ * request_and_set_primary_name: [arnsRecord, demandFactor, antRecord, antConfig]
1583
+ * approve_primary_name: [arnsRecord, antRecord, antConfig]
1584
+ * remove_primary_name_for_base_name: [arnsRecord, antRecord(@), antConfig]
1585
+ *
1586
+ * `antRecord` keys off the undername part for undernames (e.g.
1587
+ * "blog_arweave" → AntRecord at "blog") or the canonical "@" sentinel
1588
+ * for base names. `removeForBaseName` is special — it always uses "@"
1589
+ * regardless of whether the primary name being removed is an undername,
1590
+ * since the *base* name owner is the one revoking it.
1591
+ *
1592
+ * `antProgram` honors ADR-016 / BD-100 pluggability: the asset's
1593
+ * `ANT Program` Attributes-plugin trait selects which program owns the
1594
+ * AntRecord PDA. Absent / unparseable → canonical fallback. The detected
1595
+ * trait is untrusted asset/RPC data, so it is honored only when it matches
1596
+ * the canonical program or this client's explicitly-configured
1597
+ * `this.antProgram`; any other value falls back to the configured program
1598
+ * (see the SECURITY note in the body). Both the PDA derivation here and the
1599
+ * `ant_program_id` arg the caller passes to the on-chain ix MUST agree (the
1600
+ * handler re-derives and rejects mismatches).
1601
+ */
1602
+ async _buildPrimaryNameValidationAccounts(name, variant) {
1603
+ const { isUndername, baseName, undername } = splitPrimaryName(name);
1604
+ const [arnsRecordPda] = await getArnsRecordPDA(baseName, this.arnsProgram);
1605
+ const remaining = [
1606
+ { address: arnsRecordPda, role: AccountRole.READONLY },
1607
+ ];
1608
+ // Fee-charging variants need the demand factor for fee scaling.
1609
+ if (variant === 'request' || variant === 'requestAndSet') {
1610
+ const [demandFactorPda] = await getDemandFactorPDA(this.arnsProgram);
1611
+ remaining.push({ address: demandFactorPda, role: AccountRole.READONLY });
1612
+ }
1613
+ // ANT-auth variants need the AntRecord PDA. We have to read the
1614
+ // ArnsRecord first to recover the ANT mint, then read the asset's
1615
+ // `ANT Program` trait (ADR-016 / BD-100) to pick the right program
1616
+ // for the AntRecord PDA derivation.
1617
+ let antProgram = this.antProgram;
1618
+ if (variant !== 'request') {
1619
+ const arnsAccount = await fetchEncodedAccount(this.rpc, arnsRecordPda, {
1620
+ commitment: this.commitment,
1621
+ });
1622
+ if (!arnsAccount.exists) {
1623
+ throw new Error(`ArNS record not found for base name: ${baseName}`);
1624
+ }
1625
+ const arnsRecord = deserializeArnsRecord(Buffer.from(arnsAccount.data));
1626
+ const antMint = address(arnsRecord.processId);
1627
+ const { fetchAntProgramFromAsset } = await import('./mpl-core.js');
1628
+ const detected = await fetchAntProgramFromAsset(this.rpc, antMint, {
1629
+ commitment: this.commitment,
1630
+ });
1631
+ // SECURITY (BD-100 / SDK ANT-program auth finding): the asset's
1632
+ // `ANT Program` trait is untrusted asset/RPC data. Only honor a detected
1633
+ // value when it matches the canonical program or the program this client
1634
+ // was explicitly configured with (`this.antProgram`); otherwise fall back
1635
+ // to the configured program so a spoofed trait can't redirect the
1636
+ // AntRecord PDA derivation. This path only derives a READONLY validation
1637
+ // account (the instruction itself targets the canonical core program), so
1638
+ // the fallback is silent rather than a throw — but the gate keeps an
1639
+ // attacker from steering PDA derivation. Heterogeneous BYO-ANT primary
1640
+ // names (an asset on a non-configured program) await the contract-side
1641
+ // resolution of the `ant_program == ario_ant::ID` pin; see the
1642
+ // accompanying security note.
1643
+ antProgram =
1644
+ detected !== null &&
1645
+ (detected === ARIO_ANT_PROGRAM_ID || detected === this.antProgram)
1646
+ ? detected
1647
+ : this.antProgram;
1648
+ // removeForBaseName always uses the "@" undername (the base-name
1649
+ // owner's record). The other ANT-auth variants use the undername
1650
+ // part if the primary name is an undername.
1651
+ const antUndername = variant === 'removeForBaseName' || !isUndername || undername === null
1652
+ ? '@'
1653
+ : undername;
1654
+ const [antRecordPda] = await getAntRecordPDA(antMint, antUndername, antProgram);
1655
+ remaining.push({ address: antRecordPda, role: AccountRole.READONLY });
1656
+ // AntConfig PDA (ANT-level owner snapshot). ario-core reads
1657
+ // `AntConfig.last_known_owner` as the implicit-owner source and to
1658
+ // freshness-gate the per-record `AntRecord.owner` delegate (BD-097 /
1659
+ // BD-109). Derived under the SAME resolved `antProgram` as the
1660
+ // AntRecord above. Required trailing account for all three ANT-auth
1661
+ // variants (and the funding-plan variant, whose validation_account_count
1662
+ // is derived from this array's length downstream).
1663
+ const [antConfigPda] = await getAntConfigPDA(antMint, antProgram);
1664
+ remaining.push({ address: antConfigPda, role: AccountRole.READONLY });
1665
+ }
1666
+ return { remaining, antProgram };
1667
+ }
1668
+ async requestPrimaryName(params, _options) {
1669
+ // If the caller already has a primary name, prepend remove ixs so
1670
+ // the on-chain handler doesn't reject with MustRemoveExistingPrimaryName.
1671
+ const removeIxs = await this._buildRemoveExistingPrimaryNameIxs();
1672
+ const { baseName } = splitPrimaryName(params.name);
1673
+ const migrateIxs = await this._buildMigrateArnsRecordIxIfNeeded(baseName);
1674
+ const coreConfig = await this.getCoreConfig();
1675
+ const signerATA = await getAssociatedTokenAddressKit(coreConfig.mint, this.signer.address);
1676
+ const { remaining } = await this._buildPrimaryNameValidationAccounts(params.name, 'request');
1677
+ // Phase 4 of FUND_FROM_PLAN.md: dispatch primary-name funding via the
1678
+ // funding-plan ix when caller asks for stakes/withdrawal/plan/any. The
1679
+ // direct-transfer ix stays the path for fundFrom='balance' / undefined.
1680
+ let ix;
1681
+ if (!params.fundFrom ||
1682
+ params.fundFrom === 'balance' ||
1683
+ params.fundFrom === 'turbo') {
1684
+ ix = withRemainingAccounts(await getRequestPrimaryNameInstructionAsync(await this.withCoreDefaults({
1685
+ initiatorTokenAccount: signerATA,
1686
+ protocolTokenAccount: coreConfig.treasury,
1687
+ initiator: this.signer,
1688
+ name: params.name,
1689
+ }), { programAddress: this.coreProgram }), remaining);
1690
+ }
1691
+ else {
1692
+ ix = await this._buildPrimaryNameFromFundingPlanIx({
1693
+ params,
1694
+ coreConfig,
1695
+ validationAccounts: remaining,
1696
+ operation: 'request',
1697
+ });
1698
+ }
1699
+ const sig = await this.sendTransaction([...removeIxs, ...migrateIxs, ix]);
1700
+ return { id: sig };
1701
+ }
1702
+ async setPrimaryName(params, _options) {
1703
+ // setPrimaryName routes to the on-chain `request_and_set_primary_name`
1704
+ // path — the auto-approve flow when the caller owns the AntRecord
1705
+ // for the matching name (undername part, or "@" for base names).
1706
+ // If the caller already has a primary name, prepend remove ixs so
1707
+ // the "change" is atomic in a single transaction.
1708
+ const removeIxs = await this._buildRemoveExistingPrimaryNameIxs();
1709
+ const { baseName } = splitPrimaryName(params.name);
1710
+ const migrateIxs = await this._buildMigrateArnsRecordIxIfNeeded(baseName);
1711
+ const coreConfig = await this.getCoreConfig();
1712
+ const signerATA = await getAssociatedTokenAddressKit(coreConfig.mint, this.signer.address);
1713
+ const { remaining, antProgram } = await this._buildPrimaryNameValidationAccounts(params.name, 'requestAndSet');
1714
+ let ix;
1715
+ if (!params.fundFrom ||
1716
+ params.fundFrom === 'balance' ||
1717
+ params.fundFrom === 'turbo') {
1718
+ ix = withRemainingAccounts(await getRequestAndSetPrimaryNameInstructionAsync(await this.withCoreDefaults({
1719
+ initiatorTokenAccount: signerATA,
1720
+ protocolTokenAccount: coreConfig.treasury,
1721
+ initiator: this.signer,
1722
+ name: params.name,
1723
+ reverseLookupHash: hashName(params.name),
1724
+ antProgramId: antProgram,
1725
+ }), { programAddress: this.coreProgram }), remaining);
1726
+ }
1727
+ else {
1728
+ ix = await this._buildPrimaryNameFromFundingPlanIx({
1729
+ params,
1730
+ coreConfig,
1731
+ validationAccounts: remaining,
1732
+ operation: 'requestAndSet',
1733
+ antProgramId: antProgram,
1734
+ });
1735
+ }
1736
+ const sig = await this.sendTransaction([...removeIxs, ...migrateIxs, ix]);
1737
+ return { id: sig };
1738
+ }
1739
+ /**
1740
+ * Build a `request_primary_name_from_funding_plan` or
1741
+ * `request_and_set_primary_name_from_funding_plan` ix. Forwards both:
1742
+ * - validation accounts (ArnsRecord + DemandFactor [+ ant_asset
1743
+ * [+ AntRecord]]) — passed via remaining_accounts at indices
1744
+ * [0..validation_account_count)
1745
+ * - per-source PDAs from the funding plan — passed at indices
1746
+ * [validation_account_count..)
1747
+ *
1748
+ * The on-chain handler (programs/ario-core/src/instructions/primary_name.rs)
1749
+ * splits remaining_accounts at `validation_account_count` and forwards the
1750
+ * funding-source slice to ario-gar's pay_from_funding_plan via CPI.
1751
+ */
1752
+ async _buildPrimaryNameFromFundingPlanIx(args) {
1753
+ const fee = await this._simulateTokenCost({
1754
+ intent: CostIntent.PrimaryNameRequest,
1755
+ name: args.params.name,
1756
+ });
1757
+ const plan = await this._resolveFundingPlan(args.params, fee);
1758
+ const garConfig = await this.getGarConfig();
1759
+ const [garSettings] = await getGarSettingsPDA(this.garProgram);
1760
+ const initiatorATA = await getAssociatedTokenAddressKit(args.coreConfig.mint, this.signer.address);
1761
+ const { remainingAccounts: fundingRemaining, withdrawalCounter, residueVaultCount, } = await this._materializeFundingPlan(args.params, plan);
1762
+ const allRemaining = [
1763
+ ...args.validationAccounts,
1764
+ ...fundingRemaining,
1765
+ ];
1766
+ const validationAccountCount = args.validationAccounts.length;
1767
+ const baseFp = {
1768
+ config: await this._coreConfigPda(),
1769
+ garSettings,
1770
+ stakeTokenAccount: garConfig.stakeTokenAccount,
1771
+ protocolTokenAccount: args.coreConfig.treasury,
1772
+ payerTokenAccount: plan.hasBalanceSource ? initiatorATA : undefined,
1773
+ initiator: this.signer,
1774
+ withdrawalCounter,
1775
+ garProgram: this.garProgram,
1776
+ sources: plan.sources.map(toGeneratedFundingSourceSpec),
1777
+ validationAccountCount,
1778
+ residueVaultCount,
1779
+ };
1780
+ let ix;
1781
+ if (args.operation === 'request') {
1782
+ const [requestPda] = await getPrimaryNameRequestPDA(this.signer.address, this.coreProgram);
1783
+ ix = await getRequestPrimaryNameFromFundingPlanInstructionAsync({ ...baseFp, request: requestPda, name: args.params.name }, { programAddress: this.coreProgram });
1784
+ }
1785
+ else {
1786
+ const [primaryNamePda] = await getPrimaryNamePDA(this.signer.address, this.coreProgram);
1787
+ const [primaryNameReversePda] = await getPrimaryNameReversePDA(args.params.name, this.coreProgram);
1788
+ ix = await getRequestAndSetPrimaryNameFromFundingPlanInstructionAsync({
1789
+ ...baseFp,
1790
+ primaryName: primaryNamePda,
1791
+ primaryNameReverse: primaryNameReversePda,
1792
+ name: args.params.name,
1793
+ reverseLookupHash: hashName(args.params.name),
1794
+ antProgramId: args.antProgramId ?? this.antProgram,
1795
+ }, { programAddress: this.coreProgram });
1796
+ }
1797
+ return allRemaining.length > 0
1798
+ ? withRemainingAccounts(ix, allRemaining)
1799
+ : ix;
1800
+ }
1801
+ async _coreConfigPda() {
1802
+ const [pda] = await getArioConfigPDA(this.coreProgram);
1803
+ return pda;
1804
+ }
1805
+ /**
1806
+ * Approve a previously-created primary name request. The signer must be
1807
+ * the AntRecord.owner for the requested name (undername part for
1808
+ * undernames, "@" for base names) — Sprint 2 / ADR-016 reshape.
1809
+ *
1810
+ * Mirrors the on-chain `approve_primary_name` instruction
1811
+ * (`programs/ario-core/src/instructions/primary_name.rs`).
1812
+ * remaining_accounts: [arns_record(base), ant_record(undername | @), ant_config].
1813
+ */
1814
+ async approvePrimaryName(params, _options) {
1815
+ const { baseName } = splitPrimaryName(params.name);
1816
+ const migrateIxs = await this._buildMigrateArnsRecordIxIfNeeded(baseName);
1817
+ const [requestPda] = await getPrimaryNameRequestPDA(params.initiator, this.coreProgram);
1818
+ const [primaryNamePda] = await getPrimaryNamePDA(params.initiator, this.coreProgram);
1819
+ const [primaryNameReversePda] = await getPrimaryNameReversePDA(params.name, this.coreProgram);
1820
+ const { remaining, antProgram } = await this._buildPrimaryNameValidationAccounts(params.name, 'approve');
1821
+ // withCoreDefaults injects `config` as the ArioConfig PDA derived against
1822
+ // *this.coreProgram*. Without it, Codama's `findConfigPda()` fallback
1823
+ // resolves to the source-pinned `ARioCoreProgramXXXX...` placeholder
1824
+ // program id (declare_id! literal pre-anchor-keys-sync), which on any
1825
+ // non-mainnet deployment produces an unallocated PDA → Anchor #3012
1826
+ // AccountNotInitialized on `config`. requestPrimaryName /
1827
+ // requestAndSetPrimaryName already use withCoreDefaults; this was the
1828
+ // last setPrimaryName-family entrypoint missing it.
1829
+ const ix = withRemainingAccounts(await getApprovePrimaryNameInstructionAsync(await this.withCoreDefaults({
1830
+ request: requestPda,
1831
+ initiator: params.initiator,
1832
+ primaryName: primaryNamePda,
1833
+ primaryNameReverse: primaryNameReversePda,
1834
+ nameOwner: this.signer,
1835
+ reverseLookupHash: hashName(params.name),
1836
+ antProgramId: antProgram,
1837
+ }), { programAddress: this.coreProgram }), remaining);
1838
+ const sig = await this.sendTransaction([...migrateIxs, ix]);
1839
+ return { id: sig };
1840
+ }
1841
+ // =========================================
1842
+ // Vault release (ario-core)
1843
+ // =========================================
1844
+ /** Release tokens from an unlocked vault back to the owner. */
1845
+ async releaseVault(params, _options) {
1846
+ const mint = await this.getMint();
1847
+ const [vaultPda] = await getVaultPDA(this.signer.address, BigInt(params.vaultId), this.coreProgram);
1848
+ const vaultATA = await getAssociatedTokenAddressKit(mint, vaultPda, true);
1849
+ const ownerATA = await getAssociatedTokenAddressKit(mint, this.signer.address);
1850
+ const ix = await getReleaseVaultInstructionAsync(await this.withCoreDefaults({
1851
+ vault: vaultPda,
1852
+ vaultTokenAccount: vaultATA,
1853
+ ownerTokenAccount: ownerATA,
1854
+ owner: this.signer,
1855
+ }), { programAddress: this.coreProgram });
1856
+ const sig = await this.sendTransaction([ix]);
1857
+ return { id: sig };
1858
+ }
1859
+ // =========================================
1860
+ // Close expired primary name request (ario-core)
1861
+ // =========================================
1862
+ /** Close an expired primary name request (permissionless — anyone can call). */
1863
+ async closeExpiredRequest(params, _options) {
1864
+ const initiatorPubkey = address(params.initiator);
1865
+ const [requestPda] = await getPrimaryNameRequestPDA(initiatorPubkey, this.coreProgram);
1866
+ const ix = getCloseExpiredRequestInstruction({
1867
+ request: requestPda,
1868
+ initiator: initiatorPubkey,
1869
+ payer: this.signer,
1870
+ }, { programAddress: this.coreProgram });
1871
+ const sig = await this.sendTransaction([ix]);
1872
+ return { id: sig };
1873
+ }
1874
+ // =========================================
1875
+ // Withdrawal claim (ario-gar)
1876
+ // =========================================
1877
+ /** Claim tokens from a completed withdrawal (after lock period). */
1878
+ async claimWithdrawal(params, _options) {
1879
+ const garConfig = await this.getGarConfig();
1880
+ const [withdrawalPda] = await getWithdrawalPDA(this.signer.address, BigInt(params.withdrawalId), this.garProgram);
1881
+ const ownerATA = await getAssociatedTokenAddressKit(garConfig.mint, this.signer.address);
1882
+ const ix = await getClaimWithdrawalInstructionAsync(await this.withGarDefaults({
1883
+ withdrawal: withdrawalPda,
1884
+ stakeTokenAccount: garConfig.stakeTokenAccount,
1885
+ ownerTokenAccount: ownerATA,
1886
+ owner: this.signer,
1887
+ }), { programAddress: this.garProgram });
1888
+ const sig = await this.sendTransaction([ix], 1_000_000);
1889
+ return { id: sig };
1890
+ }
1891
+ // =========================================
1892
+ // Claim delegation from leaving gateway (ario-gar)
1893
+ // =========================================
1894
+ /** Claim delegated stake from a gateway that is leaving the network. */
1895
+ async claimDelegateFromLeavingGateway(params, _options) {
1896
+ const gateway = address(params.gatewayAddress);
1897
+ const [gatewayPda] = await getGatewayPDA(gateway, this.garProgram);
1898
+ const [delegationPda] = await getDelegationPDA(gateway, this.signer.address, this.garProgram);
1899
+ const nextId = await this.getNextWithdrawalId(this.signer.address);
1900
+ const [withdrawalPda] = await getWithdrawalPDA(this.signer.address, nextId, this.garProgram);
1901
+ // The on-chain handler is permissionless since `af38a40` (delegator +
1902
+ // payer split — anyone can crank). The IDL exposes both fields:
1903
+ // `delegator: Address` (no signature, just the seeds-derivation key)
1904
+ // and `payer: Signer` (covers rent on the init_if_needed withdrawal
1905
+ // counter + the new withdrawal account). The SDK's primary self-
1906
+ // claim path passes the signer for both roles; cranker callers can
1907
+ // pass distinct addresses by adding a `payer?` param to this method
1908
+ // (out of scope here — feature gap; tracked in
1909
+ // docs/E2E_TEST_COVERAGE_PLAN.md Phase 3.3).
1910
+ const ix = await getClaimDelegateFromLeavingGatewayInstructionAsync({
1911
+ gateway: gatewayPda,
1912
+ delegation: delegationPda,
1913
+ withdrawal: withdrawalPda,
1914
+ delegator: this.signer.address,
1915
+ payer: this.signer,
1916
+ }, { programAddress: this.garProgram });
1917
+ const sig = await this.sendTransaction([ix], 1_000_000);
1918
+ return { id: sig };
1919
+ }
1920
+ // =========================================
1921
+ // Claim delegation from gateway with delegation DISABLED (ario-gar, Fix #6)
1922
+ // =========================================
1923
+ /**
1924
+ * Claim a delegate's stake out of a gateway that has DISABLED delegation
1925
+ * (`allow_delegated_staking == false`), moving it into the delegate's own
1926
+ * withdrawal vault (WP §6.3 / Fix #6). This is the disabled-gateway analog of
1927
+ * {@link claimDelegateFromLeavingGateway}: the on-chain instruction is
1928
+ * permissionless, so a cranker can sweep delegates out (the operator cannot
1929
+ * re-enable delegation until `total_delegated_stake == 0` and the cooldown
1930
+ * elapses). The withdrawal-counter and withdrawal PDAs are seeded by the
1931
+ * DELEGATOR, so a cranker must pass that delegate's `delegatorAddress`.
1932
+ *
1933
+ * @param params.gatewayAddress The gateway whose delegation was disabled.
1934
+ * @param params.delegatorAddress The delegate to claim for. Defaults to the
1935
+ * signer (self-claim). Pass another address to crank on a delegate's behalf;
1936
+ * the signer covers rent (`payer`) but stake still routes to the delegate's
1937
+ * own vault (the delegator key is bound by the delegation PDA seeds).
1938
+ */
1939
+ async claimDelegateFromDisabledGateway(params, _options) {
1940
+ const gateway = address(params.gatewayAddress);
1941
+ const delegator = params.delegatorAddress
1942
+ ? address(params.delegatorAddress)
1943
+ : this.signer.address;
1944
+ const [gatewayPda] = await getGatewayPDA(gateway, this.garProgram);
1945
+ const [delegationPda] = await getDelegationPDA(gateway, delegator, this.garProgram);
1946
+ // Withdrawal counter + vault are PDA-seeded by the delegator, not the payer.
1947
+ const nextId = await this.getNextWithdrawalId(delegator);
1948
+ const [withdrawalPda] = await getWithdrawalPDA(delegator, nextId, this.garProgram);
1949
+ const ix = await getClaimDelegateFromDisabledGatewayInstructionAsync({
1950
+ gateway: gatewayPda,
1951
+ delegation: delegationPda,
1952
+ withdrawal: withdrawalPda,
1953
+ // `delegator` is an unsigned seeds-derivation key; `payer` (the signer)
1954
+ // covers rent on the init_if_needed counter + the new withdrawal.
1955
+ delegator,
1956
+ payer: this.signer,
1957
+ }, { programAddress: this.garProgram });
1958
+ const sig = await this.sendTransaction([ix], 1_000_000);
1959
+ return { id: sig };
1960
+ }
1961
+ // =========================================
1962
+ // Delegation allowlist (ario-gar)
1963
+ // =========================================
1964
+ /** Add an address to the gateway's delegation allowlist. */
1965
+ async allowDelegate(params, _options) {
1966
+ const ix = await getAllowDelegateInstructionAsync({
1967
+ delegate: address(params.delegate),
1968
+ operator: this.signer,
1969
+ }, { programAddress: this.garProgram });
1970
+ const sig = await this.sendTransaction([ix], 1_000_000);
1971
+ return { id: sig };
1972
+ }
1973
+ /** Remove an address from the gateway's delegation allowlist. */
1974
+ async disallowDelegate(params, _options) {
1975
+ const ix = await getDisallowDelegateInstructionAsync({
1976
+ delegate: address(params.delegate),
1977
+ operator: this.signer,
1978
+ }, { programAddress: this.garProgram });
1979
+ const sig = await this.sendTransaction([ix], 1_000_000);
1980
+ return { id: sig };
1981
+ }
1982
+ /** Enable or disable the delegation allowlist for the gateway. */
1983
+ async setAllowlistEnabled(params, _options) {
1984
+ const ix = await getSetAllowlistEnabledInstructionAsync(await this.withGarDefaults({
1985
+ operator: this.signer,
1986
+ enabled: params.enabled,
1987
+ }), { programAddress: this.garProgram });
1988
+ const sig = await this.sendTransaction([ix], 1_000_000);
1989
+ return { id: sig };
1990
+ }
1991
+ // =========================================
1992
+ // Buy returned name (ario-arns)
1993
+ // =========================================
1994
+ /**
1995
+ * Buy a name from the returned name auction (Dutch auction with premium).
1996
+ *
1997
+ * Phase 4: now dispatches on `params.fundFrom`. Note that for
1998
+ * `buyReturnedName`, only the protocol share funds from the chosen source;
1999
+ * the initiator share is always a direct buyer-ATA → initiator-ATA SPL
2000
+ * transfer (matches the on-chain `_from_*` variant behavior).
2001
+ */
2002
+ async buyReturnedName(params, _options) {
2003
+ const arnsConfig = await this.getArnsConfig();
2004
+ const buyerATA = await getAssociatedTokenAddressKit(arnsConfig.mint, this.signer.address);
2005
+ const antPubkey = address(params.processId);
2006
+ // Read the ReturnedName to find the original initiator (gets the premium).
2007
+ const [returnedNamePda] = await getReturnedNamePDA(params.name, this.arnsProgram);
2008
+ const returnedNameAccount = await fetchEncodedAccount(this.rpc, returnedNamePda, { commitment: this.commitment });
2009
+ if (!returnedNameAccount.exists) {
2010
+ throw new Error(`Returned name not found: ${params.name}`);
2011
+ }
2012
+ const returnedNameData = Buffer.from(returnedNameAccount.data);
2013
+ const nameLen = returnedNameData.readUInt32LE(8);
2014
+ const initiatorOffset = 8 + 4 + nameLen + 32;
2015
+ const initiator = addressDecoder.decode(returnedNameData.subarray(initiatorOffset, initiatorOffset + 32));
2016
+ const initiatorATA = await getAssociatedTokenAddressKit(arnsConfig.mint, initiator);
2017
+ const [arnsRecord] = await getArnsRecordPDA(params.name, this.arnsProgram);
2018
+ const buyParams = {
2019
+ name: params.name,
2020
+ purchaseType: params.type === 'permabuy' ? PurchaseType.Permabuy : PurchaseType.Lease,
2021
+ years: params.years ?? 1,
2022
+ ant: antPubkey,
2023
+ };
2024
+ // Returned-name price is a per-slot-decaying Dutch auction, so the
2025
+ // multi-source funding plan (which pre-commits exact source amounts) can't
2026
+ // match the execution-time cost → FundingPlanAmountMismatch (#6066). Prefer
2027
+ // a single-source stake path: it carries no amount, so the program computes
2028
+ // and draws the live cost itself. When the caller asked to fund from
2029
+ // stakes/withdrawal/any without naming a specific gateway/vault,
2030
+ // auto-resolve a single source with enough stake to cover the
2031
+ // (premium-inclusive) cost.
2032
+ let resolvedGateway = params.gatewayAddress;
2033
+ let resolvedFundAsOperator = params.fundAsOperator ?? false;
2034
+ let resolvedWithdrawalId = params.withdrawalId;
2035
+ const wantsStake = params.fundFrom === 'stakes' ||
2036
+ params.fundFrom === 'withdrawal' ||
2037
+ params.fundFrom === 'any';
2038
+ if (wantsStake &&
2039
+ resolvedGateway === undefined &&
2040
+ resolvedWithdrawalId === undefined &&
2041
+ !params.sources?.length) {
2042
+ const picked = await this._autoPickReturnedNameStakeSource(params);
2043
+ if (picked?.kind === 'delegation') {
2044
+ resolvedGateway = picked.gateway;
2045
+ resolvedFundAsOperator = false;
2046
+ }
2047
+ else if (picked?.kind === 'operatorStake') {
2048
+ resolvedGateway = picked.gateway;
2049
+ resolvedFundAsOperator = true;
2050
+ }
2051
+ else if (picked?.kind === 'withdrawal') {
2052
+ resolvedWithdrawalId = picked.withdrawalId;
2053
+ }
2054
+ else if (params.fundFrom !== 'any') {
2055
+ // 'stakes'/'withdrawal' explicitly requested but nothing covers it.
2056
+ throw new Error(`buyReturnedName: no ${params.fundFrom === 'withdrawal'
2057
+ ? 'matured withdrawal vault'
2058
+ : 'delegation/operator stake'} large enough to fund '${params.name}' was found for ` +
2059
+ `${this.signer.address}. Fund from balance, or add stake first.`);
2060
+ }
2061
+ // 'any' with nothing found → falls through to the balance path.
2062
+ }
2063
+ let ix;
2064
+ const useBalance = !params.fundFrom ||
2065
+ params.fundFrom === 'balance' ||
2066
+ params.fundFrom === 'turbo' ||
2067
+ (params.fundFrom === 'any' &&
2068
+ resolvedGateway === undefined &&
2069
+ resolvedWithdrawalId === undefined &&
2070
+ !params.sources?.length);
2071
+ if (useBalance) {
2072
+ ix = await getBuyReturnedNameInstructionAsync(await this.withArnsDefaults({
2073
+ arnsRecord,
2074
+ returnedName: returnedNamePda,
2075
+ buyerTokenAccount: buyerATA,
2076
+ protocolTokenAccount: arnsConfig.treasury,
2077
+ initiatorTokenAccount: initiatorATA,
2078
+ buyer: this.signer,
2079
+ params: buyParams,
2080
+ }), { programAddress: this.arnsProgram });
2081
+ }
2082
+ else {
2083
+ const garConfig = await this.getGarConfig();
2084
+ const [garSettings] = await getGarSettingsPDA(this.garProgram);
2085
+ const sharedReturnedBase = {
2086
+ config: await this.arnsConfigPda(),
2087
+ demandFactor: await this.demandFactorPda(),
2088
+ returnedName: returnedNamePda,
2089
+ arnsRecord,
2090
+ nameRegistry: await this.nameRegistryPda(),
2091
+ buyerTokenAccount: buyerATA,
2092
+ initiatorTokenAccount: initiatorATA,
2093
+ garSettings,
2094
+ stakeTokenAccount: garConfig.stakeTokenAccount,
2095
+ protocolTokenAccount: arnsConfig.treasury,
2096
+ buyer: this.signer,
2097
+ garProgram: this.garProgram,
2098
+ params: buyParams,
2099
+ };
2100
+ if (resolvedGateway !== undefined) {
2101
+ const gatewayAddr = address(resolvedGateway);
2102
+ const [gatewayPda] = await getGatewayPDA(gatewayAddr, this.garProgram);
2103
+ if (resolvedFundAsOperator) {
2104
+ ix = await getBuyReturnedNameFromOperatorStakeInstructionAsync({ ...sharedReturnedBase, gateway: gatewayPda }, { programAddress: this.arnsProgram });
2105
+ }
2106
+ else {
2107
+ const [delegationPda] = await getDelegationPDA(gatewayAddr, this.signer.address, this.garProgram);
2108
+ ix = await getBuyReturnedNameFromDelegationInstructionAsync({
2109
+ ...sharedReturnedBase,
2110
+ gateway: gatewayPda,
2111
+ delegation: delegationPda,
2112
+ }, { programAddress: this.arnsProgram });
2113
+ }
2114
+ }
2115
+ else if (resolvedWithdrawalId !== undefined) {
2116
+ const [withdrawalPda] = await getWithdrawalPDA(this.signer.address, resolvedWithdrawalId, this.garProgram);
2117
+ ix = await getBuyReturnedNameFromWithdrawalInstructionAsync({ ...sharedReturnedBase, withdrawal: withdrawalPda }, { programAddress: this.arnsProgram });
2118
+ }
2119
+ else if (params.fundFrom === 'plan' && params.sources?.length) {
2120
+ // Explicit caller-supplied plan only: the caller owns the source
2121
+ // amounts and accepts the decay risk (the price moves per slot, so a
2122
+ // stale plan trips FundingPlanAmountMismatch). We do NOT auto-discover
2123
+ // a multi-source plan for returned names — see the single-source note
2124
+ // above.
2125
+ const cost = await this._simulateTokenCost({
2126
+ intent: CostIntent.BuyName,
2127
+ name: params.name,
2128
+ years: buyParams.years,
2129
+ purchaseType: buyParams.purchaseType,
2130
+ });
2131
+ const plan = await this._resolveFundingPlan(params, cost);
2132
+ const { remainingAccounts, withdrawalCounter, residueVaultCount } = await this._materializeFundingPlan(params, plan);
2133
+ ix = await getBuyReturnedNameFromFundingPlanInstructionAsync({
2134
+ ...sharedReturnedBase,
2135
+ payerTokenAccount: plan.hasBalanceSource ? buyerATA : undefined,
2136
+ withdrawalCounter,
2137
+ sources: plan.sources.map(toGeneratedFundingSourceSpec),
2138
+ discountAccountCount: 0,
2139
+ residueVaultCount,
2140
+ }, { programAddress: this.arnsProgram });
2141
+ if (remainingAccounts.length > 0)
2142
+ ix = withRemainingAccounts(ix, remainingAccounts);
2143
+ }
2144
+ else {
2145
+ throw new Error(`unsupported fundFrom mode '${params.fundFrom}' for buyReturnedName`);
2146
+ }
2147
+ }
2148
+ // The on-chain `buy_returned_name*` handlers take `initiator_token_account`
2149
+ // and `buyer_token_account` as `Account<TokenAccount>` (NOT `init`), so
2150
+ // Anchor requires both ATAs to already exist or fails with
2151
+ // AccountNotInitialized (#3012). The original initiator may never have held
2152
+ // ARIO, and the premium always settles from the buyer's liquid ATA — bundle
2153
+ // idempotent ATA creates so the buy succeeds without a separate setup step
2154
+ // (mirrors the vault-ATA handling above). Idempotent: ~1500 CU each, no-op
2155
+ // when the account already exists.
2156
+ const createInitiatorAtaIx = buildCreateAtaIdempotentIx(this.signer.address, initiatorATA, initiator, arnsConfig.mint);
2157
+ const createBuyerAtaIx = buildCreateAtaIdempotentIx(this.signer.address, buyerATA, this.signer.address, arnsConfig.mint);
2158
+ // Sprint 4 / ADR-016: bundle ant.sync_attributes after the buy so the
2159
+ // Attributes plugin reflects the new record holder. assetOverride =
2160
+ // antPubkey because the ArnsRecord PDA is created by buy_returned_name
2161
+ // and doesn't exist on-chain at SDK build time.
2162
+ const syncIx = await this._buildSyncAttributesIxIfOwner(params.name, antPubkey);
2163
+ const sig = await this.sendTransaction([
2164
+ createBuyerAtaIx,
2165
+ createInitiatorAtaIx,
2166
+ ix,
2167
+ ...(syncIx ? [syncIx] : []),
2168
+ ]);
2169
+ return { id: sig };
2170
+ }
2171
+ /**
2172
+ * Pick a single stake-derived funding source that can cover a returned-name
2173
+ * purchase, for the single-source `buy_returned_name_from_*` paths.
2174
+ *
2175
+ * Returned-name prices decay per slot, so the multi-source funding plan
2176
+ * (which pre-commits exact amounts) can't match the execution-time cost. The
2177
+ * single-source paths carry no amount — the program draws the live cost — so
2178
+ * we only need to pick ONE source with enough stake. We size the pick against
2179
+ * the premium-inclusive estimate (an upper bound, since the price only falls
2180
+ * from now) and choose the largest matching source. Returns `null` when no
2181
+ * single source covers the estimate.
2182
+ */
2183
+ async _autoPickReturnedNameStakeSource(params) {
2184
+ const estimate = BigInt(Math.ceil(await this.getTokenCost({
2185
+ intent: 'Buy-Name',
2186
+ name: params.name,
2187
+ type: params.type,
2188
+ years: params.years ?? 1,
2189
+ })));
2190
+ const arnsConfig = await this.getArnsConfig();
2191
+ const { discoverFundingSources } = await import('./funding-plan.js');
2192
+ const sources = await discoverFundingSources(this.rpc, this.signer.address, { arioMint: arnsConfig.mint, garProgram: this.garProgram });
2193
+ // 'withdrawal' mode → matured withdrawal vaults only; otherwise prefer
2194
+ // operator stake when the caller asked, else a delegation.
2195
+ const wantKind = params.fundFrom === 'withdrawal'
2196
+ ? 'withdrawal'
2197
+ : params.fundAsOperator === true
2198
+ ? 'operatorStake'
2199
+ : 'delegation';
2200
+ const candidates = sources
2201
+ .filter((s) => s.kind === wantKind && s.available >= estimate)
2202
+ .sort((a, b) => b.available > a.available ? 1 : b.available < a.available ? -1 : 0);
2203
+ return candidates[0] ?? null;
2204
+ }
2205
+ // =========================================
2206
+ // Name management (ario-arns)
2207
+ // =========================================
2208
+ /** Reassign an ArNS name to a different ANT. */
2209
+ async reassignName(params, _options) {
2210
+ const migrateIxs = await this._buildMigrateArnsRecordIxIfNeeded(params.name);
2211
+ const newAnt = address(params.processId);
2212
+ const [arnsRecord] = await getArnsRecordPDA(params.name, this.arnsProgram);
2213
+ const record = await this.getArNSRecord({ name: params.name });
2214
+ const antAsset = address(record.processId);
2215
+ const ix = await getReassignNameInstructionAsync(await this.withArnsDefaults({
2216
+ arnsRecord,
2217
+ antAsset,
2218
+ caller: this.signer,
2219
+ newAnt,
2220
+ }), { programAddress: this.arnsProgram });
2221
+ // Post-reassign the record points at `newAnt`. The bundled
2222
+ // `sync_attributes` MUST target `newAnt` — without the override, the
2223
+ // helper would read the on-chain record at SDK build time (still
2224
+ // pointing at the OLD asset), build a sync ix for the OLD asset, and
2225
+ // fail the post-reassign `record.ant == asset.key()` check. The
2226
+ // owner-check inside _buildSyncAttributesIxIfOwner runs against
2227
+ // `newAnt`, so the bundle fires only when the reassign caller is also
2228
+ // the new ANT's holder; otherwise the ix is sent alone and the new
2229
+ // owner runs `syncAttributes()` later (BD-095/096).
2230
+ const syncIx = await this._buildSyncAttributesIxIfOwner(params.name, newAnt);
2231
+ const reassignWithMetas = withRemainingAccounts(ix, [
2232
+ { address: newAnt, role: AccountRole.READONLY },
2233
+ ]);
2234
+ const sig = await this.sendTransaction(syncIx
2235
+ ? [...migrateIxs, reassignWithMetas, syncIx]
2236
+ : [...migrateIxs, reassignWithMetas]);
2237
+ return { id: sig };
2238
+ }
2239
+ /** Release a permabuy name back to the registry (creates a returned name auction). */
2240
+ async releaseName(params, _options) {
2241
+ const migrateIxs = await this._buildMigrateArnsRecordIxIfNeeded(params.name);
2242
+ const [returnedNamePda] = await getReturnedNamePDA(params.name, this.arnsProgram);
2243
+ const [arnsRecord] = await getArnsRecordPDA(params.name, this.arnsProgram);
2244
+ const record = await this.getArNSRecord({ name: params.name });
2245
+ const antAsset = address(record.processId);
2246
+ const ix = await getReleaseNameInstructionAsync(await this.withArnsDefaults({
2247
+ arnsRecord,
2248
+ returnedName: returnedNamePda,
2249
+ antAsset,
2250
+ caller: this.signer,
2251
+ }), { programAddress: this.arnsProgram });
2252
+ // Note: no sync_attributes bundle here — release_name closes the
2253
+ // ArnsRecord PDA, so a follow-up sync would fail PDA validation. The
2254
+ // asset's stale traits remain pointing at the released name; off-chain
2255
+ // resolvers should treat ArnsRecord as the source of truth and ignore
2256
+ // a "ArNS Name" trait that no longer resolves.
2257
+ const sig = await this.sendTransaction([...migrateIxs, ix]);
2258
+ return { id: sig };
2259
+ }
2260
+ // =========================================
2261
+ // Lazy-state crank steps — materialize accumulator/period state.
2262
+ // Both are permissionless + idempotent (safe to crank on a schedule).
2263
+ // =========================================
2264
+ /**
2265
+ * Roll the demand factor forward to the current period. Permissionless and
2266
+ * idempotent — a no-op within the same period. Pricing already rolls the
2267
+ * factor inline on every buy/extend, so this only refreshes the STORED
2268
+ * factor that `getDemandFactor` and between-buy price previews read; a
2269
+ * periodic crank (~once per 24h `PERIOD_LENGTH_SECONDS`) keeps it current.
2270
+ */
2271
+ async updateDemandFactor(_options) {
2272
+ const [demandFactorPda] = await getDemandFactorPDA(this.arnsProgram);
2273
+ const ix = getUpdateDemandFactorInstruction({ demandFactor: demandFactorPda, payer: this.signer }, { programAddress: this.arnsProgram });
2274
+ const sig = await this.sendTransaction([ix]);
2275
+ return { id: sig };
2276
+ }
2277
+ /**
2278
+ * Materialize a single delegate's pending rewards into their delegated
2279
+ * stake by settling the gateway's reward-per-share accumulator.
2280
+ * Permissionless — there is no signer beyond the fee payer; `delegator` is
2281
+ * only a PDA-derivation seed. Rewards always accrue correctly in the
2282
+ * accumulator regardless of this call; compounding makes the on-chain
2283
+ * `delegatedStake` reflect them (and earn compound interest in the next
2284
+ * epoch's weighting). Idempotent — a no-op once already settled.
2285
+ */
2286
+ async compoundDelegationRewards(params, _options) {
2287
+ const ix = await this.buildCompoundDelegationRewardsInstruction(params);
2288
+ const sig = await this.sendTransaction([ix]);
2289
+ return { id: sig };
2290
+ }
2291
+ /**
2292
+ * Compound many delegates' rewards in a SINGLE transaction — one
2293
+ * `compound_delegation_rewards` instruction per entry. Idempotent and
2294
+ * permissionless, so partial batches are safe to retry. Keep each batch
2295
+ * within the per-tx account/CU budget; grouping entries that share a gateway
2296
+ * lowers the unique-account count (the gateway account is reused across
2297
+ * instructions). Typical cranker usage: enumerate with
2298
+ * `SolanaARIOReadable.getDelegationsToCompound`, chunk, then call this.
2299
+ */
2300
+ async compoundDelegationRewardsBatch(delegations, _options) {
2301
+ if (delegations.length === 0) {
2302
+ throw new Error('compoundDelegationRewardsBatch: delegations list is empty');
2303
+ }
2304
+ const ixs = await Promise.all(delegations.map((d) => this.buildCompoundDelegationRewardsInstruction(d)));
2305
+ const sig = await this.sendTransaction(ixs, 1_400_000);
2306
+ return { id: sig };
2307
+ }
2308
+ /**
2309
+ * Build a single `compound_delegation_rewards` instruction (shared by the
2310
+ * single + batch methods). PDAs are derived under the configured gar program
2311
+ * so the program-id override always targets the right cluster.
2312
+ */
2313
+ async buildCompoundDelegationRewardsInstruction(params) {
2314
+ const gateway = address(params.gateway);
2315
+ const delegator = address(params.delegator);
2316
+ const [gatewayPda] = await getGatewayPDA(gateway, this.garProgram);
2317
+ const [delegationPda] = await getDelegationPDA(gateway, delegator, this.garProgram);
2318
+ return getCompoundDelegationRewardsInstruction({ gateway: gatewayPda, delegation: delegationPda, delegator }, { programAddress: this.garProgram });
2319
+ }
2320
+ // =========================================
2321
+ // Epoch cranking (ario-gar) — permissionless
2322
+ // =========================================
2323
+ /**
2324
+ * Create a new epoch. Permissionless — anyone can call when the next
2325
+ * epoch's start time has arrived.
2326
+ */
2327
+ async createEpoch(_options) {
2328
+ const garConfig = await this.getGarConfig();
2329
+ const [epochSettingsPda] = await getEpochSettingsPDA(this.garProgram);
2330
+ const settingsAccount = await fetchEncodedAccount(this.rpc, epochSettingsPda, { commitment: this.commitment });
2331
+ if (!settingsAccount.exists)
2332
+ throw new Error('EpochSettings not found');
2333
+ const settings = deserializeEpochSettingsFull(Buffer.from(settingsAccount.data));
2334
+ const epochIndex = settings.currentEpochIndex;
2335
+ const [epochPda] = await getEpochPDA(epochIndex, this.garProgram);
2336
+ const ix = await getCreateEpochInstructionAsync(await this.withGarDefaults({
2337
+ epoch: epochPda,
2338
+ protocolTokenAccount: garConfig.protocolTokenAccount,
2339
+ payer: this.signer,
2340
+ }), { programAddress: this.garProgram });
2341
+ const sig = await this.sendTransaction([ix], 1_000_000);
2342
+ return { id: sig };
2343
+ }
2344
+ /**
2345
+ * Tally weights for a batch of gateways. Permissionless — call repeatedly
2346
+ * until all gateways are processed. Pass gateway PDAs as
2347
+ * `gatewayAccounts`; they're appended as `remaining_accounts`.
2348
+ */
2349
+ async tallyWeights(params, _options) {
2350
+ const ix = await getTallyWeightsInstructionAsync(await this.withGarDefaults({
2351
+ payer: this.signer,
2352
+ epochIndex: BigInt(params.epochIndex),
2353
+ }), { programAddress: this.garProgram });
2354
+ const remaining = params.gatewayAccounts.map((address) => ({
2355
+ address,
2356
+ role: AccountRole.WRITABLE,
2357
+ }));
2358
+ const sig = await this.sendTransaction([withRemainingAccounts(ix, remaining)], 1_000_000);
2359
+ return { id: sig };
2360
+ }
2361
+ /**
2362
+ * Prescribe observers and names for an epoch. Permissionless — call after
2363
+ * weights are tallied.
2364
+ *
2365
+ * `gatewayAccounts` MUST be the Gateway PDAs of the SELECTED observers only
2366
+ * — at most `epoch_settings.prescribed_observer_count` (≤50), NOT the whole
2367
+ * registry. The selection is computed on-chain; mirror it off-chain with
2368
+ * {@link predictPrescribedObservers} / {@link getPredictedObserverPDAs} to
2369
+ * learn the set. Passing every registry gateway (e.g. via
2370
+ * {@link getAllRegistryGatewayPDAs}) hits Solana's `MAX_TX_ACCOUNT_LOCKS = 64`
2371
+ * on large registries and the tx fails at pre-flight.
2372
+ *
2373
+ * The selected PDAs are appended as `remaining_accounts`, followed by the
2374
+ * optional `nameRegistryAccount` (must be LAST) which enables the name
2375
+ * prescription leg.
2376
+ *
2377
+ * If a selected gateway leaves between prediction and tx landing, the tx
2378
+ * fails with `InvalidGatewayAccount` — retry once with a fresh prediction.
2379
+ */
2380
+ async prescribeEpoch(params, _options) {
2381
+ const ix = await getPrescribeEpochInstructionAsync(await this.withGarDefaults({
2382
+ payer: this.signer,
2383
+ epochIndex: BigInt(params.epochIndex),
2384
+ }), { programAddress: this.garProgram });
2385
+ const remaining = params.gatewayAccounts.map((address) => ({
2386
+ address,
2387
+ role: AccountRole.READONLY,
2388
+ }));
2389
+ if (params.nameRegistryAccount) {
2390
+ remaining.push({
2391
+ address: params.nameRegistryAccount,
2392
+ role: AccountRole.READONLY,
2393
+ });
2394
+ }
2395
+ const fullIx = withRemainingAccounts(ix, remaining);
2396
+ // A prescribe tx with the selected observer set (~50 PDAs) exceeds Solana's
2397
+ // 1232-byte limit once there are more than ~24 remaining accounts, so route
2398
+ // those through an ephemeral Address Lookup Table (create → extend →
2399
+ // compressed v0 tx). Small sets (sparse testnets) take the cheaper inline
2400
+ // path. `prescribe_epoch` searches `remaining_accounts` by PDA, so serving
2401
+ // them via the ALT (which preserves instruction account order) is
2402
+ // transparent — incl. NameRegistry staying last. Validated on staging
2403
+ // (667 gateways, 50 observers): 428k CU, name prescription intact.
2404
+ if (remaining.length > 24) {
2405
+ const id = await sendWithEphemeralLookupTable({
2406
+ rpc: this.rpc,
2407
+ rpcSubscriptions: this.rpcSubscriptions,
2408
+ signer: this.signer,
2409
+ instruction: fullIx,
2410
+ lookupAddresses: remaining.map((a) => a.address),
2411
+ commitment: this.commitment,
2412
+ computeUnitLimit: 1_000_000,
2413
+ });
2414
+ return { id };
2415
+ }
2416
+ const sig = await this.sendTransaction([fullIx], 1_000_000);
2417
+ return { id: sig };
2418
+ }
2419
+ /**
2420
+ * Distribute rewards for a completed epoch in batches. Permissionless —
2421
+ * call after epoch ends. Gateway PDAs appended as `remaining_accounts`.
2422
+ */
2423
+ async distributeEpoch(params, _options) {
2424
+ const garConfig = await this.getGarConfig();
2425
+ // ario_gar::distribute_epoch CPIs into ario_core::release_treasury_to_recipient
2426
+ // (signed by the ArioConfig PDA — the canonical treasury authority). The
2427
+ // generated builder expects `arioConfig` + `arioCoreProgram` accounts at
2428
+ // positions 6+7 (post-PR-19 in ar-io-solana-contracts). Pin both to the
2429
+ // configured core program so devnet/testnet deployments don't fall back
2430
+ // to the bundled mainnet default.
2431
+ const [arioConfig] = await getArioConfigPDA(this.coreProgram);
2432
+ const ix = await getDistributeEpochInstructionAsync(await this.withGarDefaults({
2433
+ protocolTokenAccount: garConfig.protocolTokenAccount,
2434
+ stakeTokenAccount: garConfig.stakeTokenAccount,
2435
+ arioConfig,
2436
+ arioCoreProgram: this.coreProgram,
2437
+ payer: this.signer,
2438
+ epochIndex: BigInt(params.epochIndex),
2439
+ }), { programAddress: this.garProgram });
2440
+ const remaining = params.gatewayAccounts.map((address) => ({
2441
+ address,
2442
+ role: AccountRole.WRITABLE,
2443
+ }));
2444
+ const sig = await this.sendTransaction([withRemainingAccounts(ix, remaining)], 1_000_000);
2445
+ return { id: sig };
2446
+ }
2447
+ /**
2448
+ * Close an old epoch account and reclaim rent. Permissionless — call after
2449
+ * the epoch is distributed and at least 7 epochs have passed.
2450
+ */
2451
+ async closeEpoch(params, _options) {
2452
+ const ix = await getCloseEpochInstructionAsync(await this.withGarDefaults({
2453
+ payer: this.signer,
2454
+ epochIndex: BigInt(params.epochIndex),
2455
+ }), { programAddress: this.garProgram });
2456
+ const sig = await this.sendTransaction([ix]);
2457
+ return { id: sig };
2458
+ }
2459
+ // =========================================
2460
+ // Cranker helpers
2461
+ // =========================================
2462
+ /**
2463
+ * Get gateway PDAs for a batch starting at registryIndex.
2464
+ * Reads the GatewayRegistry and derives PDAs for the next `batchSize`
2465
+ * active gateways.
2466
+ */
2467
+ async getRegistryGatewayPDAs(startIndex, batchSize) {
2468
+ const [registryPda] = await getGatewayRegistryPDA(this.garProgram);
2469
+ const registryAccount = await fetchEncodedAccount(this.rpc, registryPda, {
2470
+ commitment: this.commitment,
2471
+ });
2472
+ if (!registryAccount.exists)
2473
+ return [];
2474
+ const registryData = Buffer.from(registryAccount.data);
2475
+ const count = registryData.readUInt32LE(40); // 8 disc + 32 authority
2476
+ const slotsOffset = 48; // 8 + 32 + 4 + 4
2477
+ // GatewaySlot: address(32) + composite_weight(8) + start_timestamp(8)
2478
+ // + status(1) + _padding(7) = 56 bytes.
2479
+ const SLOT_STRIDE = 56;
2480
+ const pdas = [];
2481
+ const end = Math.min(startIndex + batchSize, count);
2482
+ const zero = '11111111111111111111111111111111';
2483
+ for (let i = startIndex; i < end && i < 3000; i++) {
2484
+ const slotOffset = slotsOffset + i * SLOT_STRIDE;
2485
+ const addr = addressDecoder.decode(registryData.subarray(slotOffset, slotOffset + 32));
2486
+ if (addr === zero)
2487
+ continue;
2488
+ const [gatewayPda] = await getGatewayPDA(addr, this.garProgram);
2489
+ pdas.push(gatewayPda);
2490
+ }
2491
+ return pdas;
2492
+ }
2493
+ /** Get ALL active gateway PDAs from the registry. */
2494
+ async getAllRegistryGatewayPDAs() {
2495
+ const [registryPda] = await getGatewayRegistryPDA(this.garProgram);
2496
+ const registryAccount = await fetchEncodedAccount(this.rpc, registryPda, {
2497
+ commitment: this.commitment,
2498
+ });
2499
+ if (!registryAccount.exists)
2500
+ return [];
2501
+ const registryData = Buffer.from(registryAccount.data);
2502
+ const count = registryData.readUInt32LE(40);
2503
+ const slotsOffset = 48;
2504
+ const SLOT_STRIDE = 56;
2505
+ const pdas = [];
2506
+ const zero = '11111111111111111111111111111111';
2507
+ for (let i = 0; i < count && i < 3000; i++) {
2508
+ const slotOffset = slotsOffset + i * SLOT_STRIDE;
2509
+ const addr = addressDecoder.decode(registryData.subarray(slotOffset, slotOffset + 32));
2510
+ if (addr === zero)
2511
+ continue;
2512
+ const [gatewayPda] = await getGatewayPDA(addr, this.garProgram);
2513
+ pdas.push(gatewayPda);
2514
+ }
2515
+ return pdas;
2516
+ }
2517
+ /**
2518
+ * Predict the Gateway PDAs that `prescribe_epoch` will select as observers
2519
+ * for `epochIndex`, mirroring the on-chain weighted-roulette selection.
2520
+ *
2521
+ * Returns at most `epoch_settings.prescribed_observer_count` (≤50) PDAs
2522
+ * regardless of registry size — the set to pass as `gatewayAccounts` to
2523
+ * {@link prescribeEpoch}. This is the size-safe replacement for
2524
+ * {@link getAllRegistryGatewayPDAs} on the prescribe path (which oversupplies
2525
+ * and trips `MAX_TX_ACCOUNT_LOCKS = 64` on large registries).
2526
+ *
2527
+ * Reads three accounts (epoch, registry, epoch settings) at the configured
2528
+ * commitment so the prediction reflects live registry weights. If a selected
2529
+ * gateway races out before the tx lands, `prescribeEpoch` throws
2530
+ * `InvalidGatewayAccount` — re-call this and retry once.
2531
+ */
2532
+ async getPredictedObserverPDAs(epochIndex) {
2533
+ // --- Epoch: hashchain (frozen entropy) + active_gateway_count (walk bound) ---
2534
+ const [epochPda] = await getEpochPDA(epochIndex, this.garProgram);
2535
+ const epochAccount = await fetchEncodedAccount(this.rpc, epochPda, {
2536
+ commitment: this.commitment,
2537
+ });
2538
+ if (!epochAccount.exists)
2539
+ throw new Error(`Epoch ${epochIndex} not found`);
2540
+ const epochData = Buffer.from(epochAccount.data);
2541
+ // After the 8-byte discriminator (see fetchEpochRawFields): 9×u64 = 72
2542
+ // bytes, then hashchain[32], then active_gateway_count(u32).
2543
+ const EPOCH_BASE = 8;
2544
+ const hashchain = epochData.subarray(EPOCH_BASE + 72, EPOCH_BASE + 72 + 32);
2545
+ const activeGatewayCount = epochData.readUInt32LE(EPOCH_BASE + 104);
2546
+ // --- Registry: slots[0..activeGatewayCount] (address + composite_weight) ---
2547
+ const [registryPda] = await getGatewayRegistryPDA(this.garProgram);
2548
+ const registryAccount = await fetchEncodedAccount(this.rpc, registryPda, {
2549
+ commitment: this.commitment,
2550
+ });
2551
+ if (!registryAccount.exists)
2552
+ throw new Error('GatewayRegistry not found');
2553
+ const registryData = Buffer.from(registryAccount.data);
2554
+ const registryCount = registryData.readUInt32LE(40); // 8 disc + 32 authority
2555
+ const SLOTS_OFFSET = 48; // 8 + 32 + 4 count + 4 pad
2556
+ const SLOT_STRIDE = 56; // address(32)+weight(8)+start_ts(8)+status(1)+pad(7)
2557
+ // Walk exactly the on-chain prefix. The roulette uses
2558
+ // registry.gateways[0..epoch.active_gateway_count]; include zero-weight
2559
+ // slots so the cumulative walk and weight sum match byte-for-byte.
2560
+ const walkCount = Math.min(activeGatewayCount, registryCount, 3000);
2561
+ const slots = [];
2562
+ for (let i = 0; i < walkCount; i++) {
2563
+ const slotOffset = SLOTS_OFFSET + i * SLOT_STRIDE;
2564
+ slots.push({
2565
+ address: addressDecoder.decode(registryData.subarray(slotOffset, slotOffset + 32)),
2566
+ compositeWeight: registryData.readBigUInt64LE(slotOffset + 32),
2567
+ });
2568
+ }
2569
+ // --- Epoch settings: prescribed_observer_count ---
2570
+ const [epochSettingsPda] = await getEpochSettingsPDA(this.garProgram);
2571
+ const settingsAccount = await fetchEncodedAccount(this.rpc, epochSettingsPda, {
2572
+ commitment: this.commitment,
2573
+ });
2574
+ if (!settingsAccount.exists)
2575
+ throw new Error('EpochSettings not found');
2576
+ const settings = deserializeEpochSettingsFull(Buffer.from(settingsAccount.data));
2577
+ // --- Predict selected operators, then derive their Gateway PDAs ---
2578
+ const operators = predictPrescribedObservers(hashchain, slots, settings.prescribedObserverCount);
2579
+ const pdas = [];
2580
+ for (const operator of operators) {
2581
+ const [gatewayPda] = await getGatewayPDA(operator, this.garProgram);
2582
+ pdas.push(gatewayPda);
2583
+ }
2584
+ return pdas;
2585
+ }
2586
+ /**
2587
+ * Reclaim rent from the ephemeral Address Lookup Tables this signer created
2588
+ * for `prescribe_epoch` (see {@link sendWithEphemeralLookupTable}). Each
2589
+ * prescribe leaves a single-use table allocated (~0.0126 SOL); reclaiming
2590
+ * needs a deactivate → ~513-slot cooldown → close sequence, so it can't run
2591
+ * inline. Call this from a throttled/permissionless cleanup pass (cranker /
2592
+ * observer) to deactivate active tables and close cooled-down ones, refunding
2593
+ * the rent to the signer.
2594
+ *
2595
+ * Discovery reads the signer's transaction history (RPC-portable; the ALT
2596
+ * program can't be enumerated via `getProgramAccounts`). The GAR + ArNS
2597
+ * program IDs are passed as the entry-ownership fingerprint so only genuine
2598
+ * prescribe tables are touched. Best-effort: at most `maxTables` submissions
2599
+ * per call, scanning at most `scanLimit` recent signatures.
2600
+ */
2601
+ async reclaimLookupTableRent(opts) {
2602
+ return reclaimLookupTablesForSigner({
2603
+ rpc: this.rpc,
2604
+ rpcSubscriptions: this.rpcSubscriptions,
2605
+ signer: this.signer,
2606
+ allowedEntryOwners: [this.garProgram, this.arnsProgram],
2607
+ commitment: this.commitment,
2608
+ maxTables: opts?.maxTables,
2609
+ scanLimit: opts?.scanLimit,
2610
+ });
2611
+ }
2612
+ /** Read and deserialize the full EpochSettings account. */
2613
+ async getEpochSettingsFull() {
2614
+ const [esPda] = await getEpochSettingsPDA(this.garProgram);
2615
+ const account = await fetchEncodedAccount(this.rpc, esPda, {
2616
+ commitment: this.commitment,
2617
+ });
2618
+ if (!account.exists)
2619
+ throw new Error('EpochSettings not found');
2620
+ return deserializeEpochSettingsFull(Buffer.from(account.data));
2621
+ }
2622
+ /**
2623
+ * Submit `prescribe_epoch` using the off-chain-predicted observer set, with a
2624
+ * single re-predict-and-retry on `InvalidGatewayAccount` (covers a gateway
2625
+ * leaving the registry between the prediction read and the tx landing).
2626
+ */
2627
+ async prescribeWithPrediction(epochIndex, nameRegistryAccount) {
2628
+ const submit = async () => this.prescribeEpoch({
2629
+ epochIndex,
2630
+ gatewayAccounts: await this.getPredictedObserverPDAs(epochIndex),
2631
+ nameRegistryAccount,
2632
+ });
2633
+ try {
2634
+ return await submit();
2635
+ }
2636
+ catch (err) {
2637
+ if (!isInvalidGatewayAccountError(err))
2638
+ throw err;
2639
+ return submit();
2640
+ }
2641
+ }
2642
+ /**
2643
+ * Advance the epoch lifecycle by ONE on-chain action and return what it did.
2644
+ *
2645
+ * Stateless and idempotent: it reads `EpochSettings` + the current `Epoch`,
2646
+ * determines the single next required step
2647
+ * (`create` → `tally` → `prescribe` → `distribute` → `close`), submits it,
2648
+ * and returns a {@link CrankEpochStepResult}. Call it repeatedly on your own
2649
+ * schedule — it owns *which* on-chain action is correct and *which accounts*
2650
+ * it needs; you own scheduling, logging, error classification, and any
2651
+ * permissionless cleanup.
2652
+ *
2653
+ * Crucially, the `prescribe` leg uses {@link getPredictedObserverPDAs} (only
2654
+ * the ~`prescribed_observer_count` selected Gateway PDAs), so it never trips
2655
+ * `MAX_TX_ACCOUNT_LOCKS = 64` on large registries — and it re-predicts and
2656
+ * retries once on `InvalidGatewayAccount`.
2657
+ *
2658
+ * Errors propagate to the caller (classify/retry as you see fit); the only
2659
+ * internally-handled error is the prescribe `InvalidGatewayAccount` retry.
2660
+ */
2661
+ async crankEpochStep(opts = {}) {
2662
+ // tally_weights / distribute_epoch append the batch's Gateway PDAs as
2663
+ // remaining_accounts. distribute also CPIs into ario-core (treasury
2664
+ // release) so it carries 10 named accounts; with ~18+ gateway PDAs on top
2665
+ // the tx exceeds Solana's 1232-byte limit. Cap the lifecycle batch at 18 so
2666
+ // an oversized caller `batchSize` can't produce an unsendable tx (verified:
2667
+ // 30 gateways → 1527B; 18 → ~1050B). prescribe is the exception — it needs
2668
+ // ALL selected observers in one tx, so it uses an ALT instead (see
2669
+ // prescribeEpoch).
2670
+ const MAX_LIFECYCLE_BATCH = 18;
2671
+ const batchSize = Math.min(opts.batchSize ?? MAX_LIFECYCLE_BATCH, MAX_LIFECYCLE_BATCH);
2672
+ const enableClose = opts.enableClose ?? true;
2673
+ const retention = opts.epochRetention ?? 7;
2674
+ const enableCompound = opts.enableCompound ?? true;
2675
+ const compoundMinPending = opts.compoundMinPendingRewards ?? 0;
2676
+ const enableDemandFactorRoll = opts.enableDemandFactorRoll ?? true;
2677
+ const enablePrune = opts.enablePrune ?? true;
2678
+ const now = opts.now ?? Math.floor(Date.now() / 1000);
2679
+ const settings = await this.getEpochSettingsFull();
2680
+ if (!settings.enabled)
2681
+ return { action: 'idle', reason: 'epochs_disabled' };
2682
+ const currentIndex = settings.currentEpochIndex;
2683
+ // currentIndex is the NEXT epoch to create; the live one is currentIndex-1.
2684
+ const targetEpochIndex = currentIndex > 0 ? currentIndex - 1 : 0;
2685
+ const nextEpochStart = settings.genesisTimestamp + currentIndex * settings.epochDuration;
2686
+ const epoch = await this.getEpochRaw(targetEpochIndex);
2687
+ // Cold start: the live epoch (targetEpochIndex) doesn't exist yet. Two cases,
2688
+ // handled identically — `create_epoch` always creates epoch[currentIndex] and
2689
+ // then increments the counter, so the NEXT crank finds it live at currentIndex-1:
2690
+ // 1. Genesis: currentIndex === 0 → create epoch 0.
2691
+ // 2. AO→Solana continuity cold start: `admin_set_current_epoch_index` jumped
2692
+ // currentIndex to e.g. 454 with NO prior epochs on-chain (the AO-side
2693
+ // epochs were never created on Solana). targetEpochIndex (453) points at
2694
+ // an epoch that will never exist, so the old `currentIndex === 0`-only
2695
+ // bootstrap deadlocked here ('waiting_for_epoch' forever). Create
2696
+ // epoch[currentIndex] (454) directly — its start was re-anchored to ≈now.
2697
+ if (!epoch) {
2698
+ if (now < nextEpochStart)
2699
+ return {
2700
+ action: 'idle',
2701
+ reason: currentIndex === 0 ? 'waiting_for_genesis' : 'waiting_for_epoch',
2702
+ };
2703
+ const { id } = await this.createEpoch();
2704
+ return { action: 'create', epochIndex: currentIndex, txId: id };
2705
+ }
2706
+ // Tally (batched). activeGatewayCount===0 still needs one tx to flip the flag.
2707
+ if (epoch.weightsTallied === 0) {
2708
+ const gatewayAccounts = epoch.activeGatewayCount > 0
2709
+ ? await this.getRegistryGatewayPDAs(epoch.tallyIndex, batchSize)
2710
+ : [];
2711
+ const { id } = await this.tallyWeights({
2712
+ epochIndex: targetEpochIndex,
2713
+ gatewayAccounts,
2714
+ });
2715
+ return {
2716
+ action: 'tally',
2717
+ epochIndex: targetEpochIndex,
2718
+ txId: id,
2719
+ progress: { index: epoch.tallyIndex, total: epoch.activeGatewayCount },
2720
+ };
2721
+ }
2722
+ // Prescribe (predicted observers only — the size-safe path).
2723
+ if (epoch.prescriptionsDone === 0) {
2724
+ const nameRegistryAccount = opts.nameRegistryAccount === null
2725
+ ? undefined
2726
+ : (opts.nameRegistryAccount ??
2727
+ (await getArnsRegistryPDA(this.arnsProgram))[0]);
2728
+ const { id } = await this.prescribeWithPrediction(targetEpochIndex, nameRegistryAccount);
2729
+ return { action: 'prescribe', epochIndex: targetEpochIndex, txId: id };
2730
+ }
2731
+ // Observations happen while the epoch is live. This is the dominant idle
2732
+ // window — fold returned-name pruning in here so it isn't starved on
2733
+ // clusters whose epochs park here (e.g. imported gateways that can't
2734
+ // observe → distribution never advances → the post-distribution tail below
2735
+ // is never reached).
2736
+ if (now < epoch.endTimestamp) {
2737
+ if (enablePrune) {
2738
+ const pruned = await this.maybePruneReturnedNamesStep(opts, now);
2739
+ if (pruned)
2740
+ return pruned;
2741
+ }
2742
+ return { action: 'idle', reason: 'waiting_for_observations' };
2743
+ }
2744
+ // Distribute (batched).
2745
+ if (epoch.rewardsDistributed === 0) {
2746
+ const gatewayAccounts = epoch.activeGatewayCount > 0
2747
+ ? await this.getRegistryGatewayPDAs(epoch.distributionIndex, batchSize)
2748
+ : [];
2749
+ const { id } = await this.distributeEpoch({
2750
+ epochIndex: targetEpochIndex,
2751
+ gatewayAccounts,
2752
+ });
2753
+ return {
2754
+ action: 'distribute',
2755
+ epochIndex: targetEpochIndex,
2756
+ txId: id,
2757
+ progress: {
2758
+ index: epoch.distributionIndex,
2759
+ total: epoch.activeGatewayCount,
2760
+ },
2761
+ };
2762
+ }
2763
+ // Close a fully-distributed epoch past retention (GAR-006).
2764
+ //
2765
+ // CRITICAL: this is cleanup of OLD epochs and must NEVER block creation of
2766
+ // NEW ones. `close_epoch` reverts with EpochObservationsNotClosed until the
2767
+ // epoch's Observation PDAs are closed (observations_closed ==
2768
+ // observations_submitted), so we close those FIRST. The whole branch is
2769
+ // wrapped so any failure falls through to create-next instead of throwing
2770
+ // out of crankEpochStep — otherwise a single un-closeable epoch wedges epoch
2771
+ // progression network-wide (every operator's crank hits the same wall).
2772
+ if (enableClose && targetEpochIndex >= retention) {
2773
+ const closeTarget = targetEpochIndex - retention;
2774
+ try {
2775
+ const old = await this.getEpochRaw(closeTarget);
2776
+ if (old && old.rewardsDistributed === 1) {
2777
+ if (old.observationsSubmitted > old.observationsClosed) {
2778
+ // Open observations remain — close a batch before close_epoch.
2779
+ const observers = await this.getEpochObservers(closeTarget);
2780
+ if (observers.length > 0) {
2781
+ const batch = observers.slice(0, MAX_CLOSE_OBSERVATION_BATCH);
2782
+ const { id } = await this.closeObservations({
2783
+ epochIndex: closeTarget,
2784
+ observers: batch,
2785
+ });
2786
+ return {
2787
+ action: 'close_observation',
2788
+ epochIndex: closeTarget,
2789
+ txId: id,
2790
+ progress: { index: batch.length, total: observers.length },
2791
+ };
2792
+ }
2793
+ // Counter says open but no Observation PDA exists (orphaned counter):
2794
+ // can't close it, so don't attempt close_epoch (it would revert) and
2795
+ // don't wedge — fall through to create-next.
2796
+ }
2797
+ else {
2798
+ const { id } = await this.closeEpoch({ epochIndex: closeTarget });
2799
+ return { action: 'close', epochIndex: closeTarget, txId: id };
2800
+ }
2801
+ }
2802
+ }
2803
+ catch {
2804
+ // Best-effort cleanup — never block epoch creation. Retried next tick.
2805
+ }
2806
+ }
2807
+ // Lazy-state maintenance — lower urgency than the lifecycle, reached only
2808
+ // once the live epoch is fully distributed (rewardsDistributed === 1 here).
2809
+ // Compound FIRST so delegated stake reflects the just-distributed rewards
2810
+ // before the next epoch's tally weights it; then roll the demand factor if
2811
+ // its period elapsed. Both are permissionless + idempotent, and run BEFORE
2812
+ // creating the next epoch so the compounded stake is in place for its tally.
2813
+ if (enableCompound) {
2814
+ const compounded = await this.maybeCompoundStep(compoundMinPending);
2815
+ if (compounded)
2816
+ return compounded;
2817
+ }
2818
+ if (enableDemandFactorRoll) {
2819
+ const rolled = await this.maybeRollDemandFactorStep(now);
2820
+ if (rolled)
2821
+ return rolled;
2822
+ }
2823
+ if (enablePrune) {
2824
+ const pruned = await this.maybePruneReturnedNamesStep(opts, now);
2825
+ if (pruned)
2826
+ return pruned;
2827
+ }
2828
+ // Current epoch fully processed — create the next once its start arrives.
2829
+ if (now >= nextEpochStart) {
2830
+ const { id } = await this.createEpoch();
2831
+ return { action: 'create', epochIndex: currentIndex, txId: id };
2832
+ }
2833
+ return { action: 'idle', reason: 'epoch_complete' };
2834
+ }
2835
+ /**
2836
+ * One compound batch over delegations with pending rewards (≤
2837
+ * {@link MAX_COMPOUND_BATCH} per tx), or `null` when none are due. Settling
2838
+ * is idempotent, so this converges over a few crank steps then no-ops until
2839
+ * the next epoch's distribution advances the accumulator again.
2840
+ */
2841
+ async maybeCompoundStep(minPendingRewards) {
2842
+ const pending = await this.getDelegationsToCompound({ minPendingRewards });
2843
+ if (pending.length === 0)
2844
+ return null;
2845
+ const batch = pending.slice(0, MAX_COMPOUND_BATCH).map((p) => ({
2846
+ gateway: p.gatewayAddress,
2847
+ delegator: p.delegatorAddress,
2848
+ }));
2849
+ const { id } = await this.compoundDelegationRewardsBatch(batch);
2850
+ return {
2851
+ action: 'compound',
2852
+ txId: id,
2853
+ progress: { index: batch.length, total: pending.length },
2854
+ };
2855
+ }
2856
+ /**
2857
+ * Roll the demand factor forward if its (wall-clock) period elapsed since the
2858
+ * last stored roll, else `null`. Mirrors the on-chain period math; the roll
2859
+ * itself is idempotent.
2860
+ */
2861
+ async maybeRollDemandFactorStep(now) {
2862
+ const state = await this.getDemandFactorPeriodState();
2863
+ if (!state)
2864
+ return null;
2865
+ const elapsed = now - state.periodZeroStartTimestamp;
2866
+ const periodForNow = elapsed < 0 ? 1 : Math.floor(elapsed / DEMAND_FACTOR_PERIOD_SECONDS) + 1;
2867
+ if (periodForNow <= state.currentPeriod)
2868
+ return null; // same period — no-op
2869
+ const { id } = await this.updateDemandFactor();
2870
+ return { action: 'update_demand_factor', txId: id };
2871
+ }
2872
+ /** Wall-clock (ms) of the last returned-name prune scan; throttles the
2873
+ * getProgramAccounts scan below the crank poll rate. */
2874
+ lastReturnedNamePruneScanMs = 0;
2875
+ /**
2876
+ * One prune batch over ReturnedName PDAs whose 14-day auction window has
2877
+ * elapsed (≤ {@link CrankEpochStepOptions.pruneBatchSize} per tx), or `null`
2878
+ * when none are due / the scan is throttled. Scans the PDAs directly — it does
2879
+ * NOT gate on `config.next_returned_names_prune_timestamp`, which is never set
2880
+ * for imported returned names and would strand them. The contract re-checks
2881
+ * each account's window, so a slightly-skewed client clock is safe.
2882
+ */
2883
+ async maybePruneReturnedNamesStep(opts, now) {
2884
+ const scanInterval = opts.pruneScanIntervalMs ?? 60_000;
2885
+ const wallNow = Date.now();
2886
+ if (wallNow - this.lastReturnedNamePruneScanMs < scanInterval)
2887
+ return null;
2888
+ this.lastReturnedNamePruneScanMs = wallNow;
2889
+ const expired = await this.getExpiredReturnedNames(now);
2890
+ if (expired.length === 0)
2891
+ return null;
2892
+ const batchSize = opts.pruneBatchSize ?? 15;
2893
+ const batch = expired.slice(0, batchSize).map((r) => r.pubkey);
2894
+ const { id } = await this.pruneReturnedNames({
2895
+ maxNames: batch.length,
2896
+ returnedNames: batch,
2897
+ });
2898
+ return {
2899
+ action: 'prune_returned_names',
2900
+ txId: id,
2901
+ progress: { index: batch.length, total: expired.length },
2902
+ };
2903
+ }
2904
+ /**
2905
+ * The DemandFactor account's stored period + period-zero start (seconds) —
2906
+ * the gate for {@link maybeRollDemandFactorStep}. `null` if the account
2907
+ * doesn't exist (pre-genesis).
2908
+ */
2909
+ async getDemandFactorPeriodState() {
2910
+ const [pda] = await getDemandFactorPDA(this.arnsProgram);
2911
+ const account = await fetchEncodedAccount(this.rpc, pda, {
2912
+ commitment: this.commitment,
2913
+ });
2914
+ if (!account.exists)
2915
+ return null;
2916
+ const df = deserializeDemandFactor(Buffer.from(account.data));
2917
+ return {
2918
+ currentPeriod: df.currentPeriod,
2919
+ periodZeroStartTimestamp: df.periodZeroStartTimestamp,
2920
+ };
2921
+ }
2922
+ /**
2923
+ * Read the raw epoch account data for cranker state inspection.
2924
+ * Returns null if the epoch account doesn't exist yet.
2925
+ */
2926
+ async getEpochRaw(epochIndex) {
2927
+ const [epochPda] = await getEpochPDA(epochIndex, this.garProgram);
2928
+ const account = await fetchEncodedAccount(this.rpc, epochPda, {
2929
+ commitment: this.commitment,
2930
+ });
2931
+ if (!account.exists)
2932
+ return null;
2933
+ try {
2934
+ return this.fetchEpochRawFields(Buffer.from(account.data));
2935
+ }
2936
+ catch {
2937
+ return null;
2938
+ }
2939
+ }
2940
+ /**
2941
+ * Parse raw epoch account data for cranker-relevant fields.
2942
+ * Offsets match the Rust Epoch zero-copy struct (repr(C)).
2943
+ *
2944
+ * Layout after 8-byte discriminator:
2945
+ * [8 epoch_index][8 start_ts][8 end_ts][8 total_eligible][8 per_gw]
2946
+ * [8 per_obs][8 reward_rate][8 weight_lo][8 weight_hi][32 hashchain]
2947
+ * [4 active_gw_count][4 dist_idx][4 tally_idx]
2948
+ * [1 observer_count][1 name_count][1 obs_submitted][1 rewards_dist]
2949
+ * [1 weights_tallied][1 prescriptions_done][1 bump][1 obs_closed]
2950
+ * [6000 failure_counts][1600 prescribed_observers]
2951
+ * [1600 prescribed_observer_gateways][64 prescribed_names]
2952
+ * [7 has_observed][5 _pad2]
2953
+ *
2954
+ * NOTE: byte +123 is `observations_closed` (NOT padding) — `close_epoch`
2955
+ * reverts with EpochObservationsNotClosed until it equals obs_submitted.
2956
+ */
2957
+ fetchEpochRawFields(data) {
2958
+ const base = 8;
2959
+ const endTimestamp = Number(data.readBigInt64LE(base + 16));
2960
+ const activeGatewayCount = data.readUInt32LE(base + 104);
2961
+ const distributionIndex = data.readUInt32LE(base + 108);
2962
+ const tallyIndex = data.readUInt32LE(base + 112);
2963
+ const observationsSubmitted = data.readUInt8(base + 118);
2964
+ const rewardsDistributed = data.readUInt8(base + 119);
2965
+ const weightsTallied = data.readUInt8(base + 120);
2966
+ const prescriptionsDone = data.readUInt8(base + 121);
2967
+ const observationsClosed = data.readUInt8(base + 123);
2968
+ return {
2969
+ tallyIndex,
2970
+ distributionIndex,
2971
+ weightsTallied,
2972
+ prescriptionsDone,
2973
+ rewardsDistributed,
2974
+ observationsSubmitted,
2975
+ observationsClosed,
2976
+ activeGatewayCount,
2977
+ endTimestamp,
2978
+ };
2979
+ }
2980
+ // =========================================
2981
+ // Prune / cleanup (permissionless crank)
2982
+ // =========================================
2983
+ //
2984
+ // These mirror `tick()`-driven lazy pruning in the Lua source — on Solana
2985
+ // each is a discrete instruction someone has to call. All are
2986
+ // permissionless except `releaseVault`, which the on-chain handler still
2987
+ // gates on `owner: Signer` (ADR / vault.rs::ReleaseVault). See
2988
+ // docs/CRANKER_PRUNING_PLAN.md for the full design.
2989
+ /**
2990
+ * Batch-prune expired ArnsRecord PDAs from the NameRegistry. The caller
2991
+ * supplies the eligible records as `arnsRecords` — they're appended as
2992
+ * `remaining_accounts` and the on-chain handler verifies each is past
2993
+ * `end_timestamp + grace_period + return_auction_duration` before closing.
2994
+ * `maxNames` caps the per-tx work (u8). Submit in batches of ~10-15.
2995
+ */
2996
+ async pruneExpiredNames(params, _options) {
2997
+ const ix = await getPruneExpiredNamesInstructionAsync(await this.withArnsDefaults({
2998
+ payer: this.signer,
2999
+ maxNames: params.maxNames,
3000
+ }), { programAddress: this.arnsProgram });
3001
+ const remaining = params.arnsRecords.map((a) => ({
3002
+ address: address(a),
3003
+ role: AccountRole.WRITABLE,
3004
+ }));
3005
+ const sig = await this.sendTransaction([withRemainingAccounts(ix, remaining)], 1_000_000);
3006
+ return { id: sig };
3007
+ }
3008
+ /**
3009
+ * Convert a single expired-but-not-yet-returned lease into a `ReturnedName`
3010
+ * (kicks off the Dutch auction). Permissionless.
3011
+ */
3012
+ async pruneNameToReturned(params, _options) {
3013
+ const migrateIxs = await this._buildMigrateArnsRecordIxIfNeeded(params.name);
3014
+ const [arnsRecord] = await getArnsRecordPDA(params.name, this.arnsProgram);
3015
+ const [returnedName] = await getReturnedNamePDA(params.name, this.arnsProgram);
3016
+ const ix = await getPruneNameToReturnedInstructionAsync(await this.withArnsDefaults({
3017
+ arnsRecord,
3018
+ returnedName,
3019
+ payer: this.signer,
3020
+ }), { programAddress: this.arnsProgram });
3021
+ const sig = await this.sendTransaction([...migrateIxs, ix]);
3022
+ return { id: sig };
3023
+ }
3024
+ /**
3025
+ * Batch-prune expired ReturnedName PDAs (auction window elapsed). Caller
3026
+ * supplies the eligible PDAs as `returnedNames`; they're appended as
3027
+ * `remaining_accounts`. `maxNames` caps per-tx work (u8).
3028
+ */
3029
+ async pruneReturnedNames(params, _options) {
3030
+ const ix = await getPruneReturnedNamesInstructionAsync(await this.withArnsDefaults({
3031
+ payer: this.signer,
3032
+ maxNames: params.maxNames,
3033
+ }), { programAddress: this.arnsProgram });
3034
+ const remaining = params.returnedNames.map((a) => ({
3035
+ address: address(a),
3036
+ role: AccountRole.WRITABLE,
3037
+ }));
3038
+ // Match `pruneExpiredNames` (1M CU) — both dispatch the same batched
3039
+ // shape over `remaining_accounts`, and the default 400K is too tight
3040
+ // when the cranker batches near `maxNames` (≈15) on a busy registry.
3041
+ const sig = await this.sendTransaction([withRemainingAccounts(ix, remaining)], 1_000_000);
3042
+ return { id: sig };
3043
+ }
3044
+ /**
3045
+ * Close a single expired ReservedName PDA. Permissionless after
3046
+ * `expires_at`.
3047
+ */
3048
+ async pruneExpiredReservation(params, _options) {
3049
+ const [reservedName] = await getReservedNamePDA(params.name, this.arnsProgram);
3050
+ const ix = getPruneExpiredReservationInstruction({
3051
+ reservedName,
3052
+ payer: this.signer,
3053
+ }, { programAddress: this.arnsProgram });
3054
+ const sig = await this.sendTransaction([ix]);
3055
+ return { id: sig };
3056
+ }
3057
+ /**
3058
+ * Slash and remove a deficient gateway (`stats.failed_consecutive >=
3059
+ * max_consecutive_failures`). Builds the protected exit vault for the
3060
+ * post-slash min portion plus the optional excess vault for any surplus.
3061
+ * The contract's `excess_withdrawal: Option<UncheckedAccount>` slot is
3062
+ * always passed (PDA derived from `next_id + 1`); the handler consumes
3063
+ * it only when the post-slash stake exceeds `min_operator_stake`.
3064
+ * Permissionless.
3065
+ */
3066
+ async pruneGateway(params, _options) {
3067
+ const gatewayAddr = address(params.gateway);
3068
+ const garConfig = await this.getGarConfig();
3069
+ const [gatewayPda] = await getGatewayPDA(gatewayAddr, this.garProgram);
3070
+ const [withdrawalCounterPda] = await getWithdrawalCounterPDA(gatewayAddr, this.garProgram);
3071
+ const nextId = await this.getNextWithdrawalId(gatewayAddr);
3072
+ const [withdrawalPda] = await getWithdrawalPDA(gatewayAddr, nextId, this.garProgram);
3073
+ const [excessWithdrawalPda] = await getWithdrawalPDA(gatewayAddr, nextId + 1n, this.garProgram);
3074
+ const ix = await getPruneGatewayInstructionAsync(await this.withGarDefaults({
3075
+ gateway: gatewayPda,
3076
+ withdrawalCounter: withdrawalCounterPda,
3077
+ withdrawal: withdrawalPda,
3078
+ excessWithdrawal: excessWithdrawalPda,
3079
+ stakeTokenAccount: garConfig.stakeTokenAccount,
3080
+ protocolTokenAccount: garConfig.protocolTokenAccount,
3081
+ payer: this.signer,
3082
+ }), { programAddress: this.garProgram });
3083
+ const sig = await this.sendTransaction([ix], 1_000_000);
3084
+ return { id: sig };
3085
+ }
3086
+ /**
3087
+ * GC a `Leaving`/`Gone` gateway whose leave window has fully elapsed.
3088
+ * Closes the Gateway PDA and refunds rent to the caller. Permissionless.
3089
+ */
3090
+ async finalizeGone(params, _options) {
3091
+ const gatewayAddr = address(params.gateway);
3092
+ const [gatewayPda] = await getGatewayPDA(gatewayAddr, this.garProgram);
3093
+ // `finalize_gone` reclaims this gateway's compact-registry slot by
3094
+ // swap-removing the LAST active slot into it. When this gateway is not the
3095
+ // last slot, the on-chain handler rewrites the swapped gateway's stored
3096
+ // registry_index and therefore requires that swapped Gateway PDA as
3097
+ // writable remaining_accounts[0]
3098
+ // (programs/ario-gar/src/instructions/gateway.rs::finalize_gone). Read the
3099
+ // gateway's slot index + the active registry to decide.
3100
+ const gatewayAccount = await fetchEncodedAccount(this.rpc, gatewayPda, {
3101
+ commitment: this.commitment,
3102
+ });
3103
+ if (!gatewayAccount.exists) {
3104
+ throw new Error(`Gateway not found for operator ${params.gateway}`);
3105
+ }
3106
+ const gateway = getGatewayDecoder().decode(gatewayAccount.data);
3107
+ const registryAddresses = await this.getRegistryGatewayAddresses();
3108
+ const swappedOperator = selectFinalizeGoneSwapOperator(gateway.registryIndex.index, registryAddresses);
3109
+ const ix = await getFinalizeGoneInstructionAsync(await this.withGarDefaults({
3110
+ gateway: gatewayPda,
3111
+ caller: this.signer,
3112
+ }), { programAddress: this.garProgram });
3113
+ let finalIx = ix;
3114
+ if (swappedOperator !== null) {
3115
+ const [swappedGatewayPda] = await getGatewayPDA(address(swappedOperator), this.garProgram);
3116
+ finalIx = withRemainingAccounts(ix, [
3117
+ { address: swappedGatewayPda, role: AccountRole.WRITABLE },
3118
+ ]);
3119
+ }
3120
+ const sig = await this.sendTransaction([finalIx]);
3121
+ return { id: sig };
3122
+ }
3123
+ /**
3124
+ * Reclaim rent from an Observation PDA whose epoch has been distributed.
3125
+ * Permissionless. Pass `epochIndex` and the `observer` address used as
3126
+ * the Observation seed.
3127
+ */
3128
+ async closeObservation(params, _options) {
3129
+ const observerAddr = address(params.observer);
3130
+ const [observationPda] = await getObservationPDA(params.epochIndex, observerAddr, this.garProgram);
3131
+ const ix = await getCloseObservationInstructionAsync({
3132
+ observation: observationPda,
3133
+ payer: this.signer,
3134
+ epochIndex: BigInt(params.epochIndex),
3135
+ }, { programAddress: this.garProgram });
3136
+ const sig = await this.sendTransaction([ix]);
3137
+ return { id: sig };
3138
+ }
3139
+ /**
3140
+ * Close multiple Observation PDAs for one epoch in a single tx (each
3141
+ * `close_observation` increments the parent Epoch's `observations_closed`).
3142
+ * Permissionless; rent is refunded to the payer. Used by the crank to satisfy
3143
+ * `close_epoch`'s `observations_closed == observations_submitted` precondition
3144
+ * before closing a retention-aged epoch. Keep the batch small — each ix carries
3145
+ * the Epoch + Observation + payer + system accounts.
3146
+ */
3147
+ async closeObservations(params, _options) {
3148
+ if (params.observers.length === 0) {
3149
+ throw new Error('closeObservations: observers must be non-empty');
3150
+ }
3151
+ const ixs = await Promise.all(params.observers.map(async (obs) => {
3152
+ const [observationPda] = await getObservationPDA(params.epochIndex, address(obs), this.garProgram);
3153
+ return getCloseObservationInstructionAsync({
3154
+ observation: observationPda,
3155
+ payer: this.signer,
3156
+ epochIndex: BigInt(params.epochIndex),
3157
+ }, { programAddress: this.garProgram });
3158
+ }));
3159
+ const sig = await this.sendTransaction(ixs);
3160
+ return { id: sig };
3161
+ }
3162
+ /**
3163
+ * Close an empty Delegation PDA (`amount == 0`) and refund rent to the
3164
+ * original delegator (NOT the caller — see GAR-016, prevents griefing).
3165
+ * Permissionless.
3166
+ */
3167
+ async closeEmptyDelegation(params, _options) {
3168
+ const gatewayAddr = address(params.gateway);
3169
+ const delegatorAddr = address(params.delegator);
3170
+ const [gatewayPda] = await getGatewayPDA(gatewayAddr, this.garProgram);
3171
+ const [delegationPda] = await getDelegationPDA(gatewayAddr, delegatorAddr, this.garProgram);
3172
+ const ix = getCloseEmptyDelegationInstruction({
3173
+ gateway: gatewayPda,
3174
+ delegation: delegationPda,
3175
+ delegator: delegatorAddr,
3176
+ payer: this.signer,
3177
+ }, { programAddress: this.garProgram });
3178
+ const sig = await this.sendTransaction([ix]);
3179
+ return { id: sig };
3180
+ }
3181
+ /**
3182
+ * Close a drained Withdrawal PDA (`amount == 0`) and refund rent to the
3183
+ * original owner (NOT the caller). Permissionless.
3184
+ */
3185
+ async closeDrainedWithdrawal(params, _options) {
3186
+ const ownerAddr = address(params.owner);
3187
+ const [withdrawalPda] = await getWithdrawalPDA(ownerAddr, BigInt(params.withdrawalId), this.garProgram);
3188
+ const ix = getCloseDrainedWithdrawalInstruction({
3189
+ withdrawal: withdrawalPda,
3190
+ owner: ownerAddr,
3191
+ closer: this.signer,
3192
+ }, { programAddress: this.garProgram });
3193
+ const sig = await this.sendTransaction([ix]);
3194
+ return { id: sig };
3195
+ }
3196
+ }