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

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 +682 -600
  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 +221 -0
  6. package/lib/esm/cli/commands/gatewayWriteCommands.js +142 -23
  7. package/lib/esm/cli/commands/pruneCommands.js +150 -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 +280 -174
  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 +11 -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 +51 -263
  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,2182 @@
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 the ARIORead interface.
18
+ *
19
+ * Reads AR.IO protocol state directly from Solana PDAs using RPC,
20
+ * returning the same types that the AO implementation returns.
21
+ * This allows consumers to switch backends transparently.
22
+ */
23
+ import { address, fetchEncodedAccount, fetchEncodedAccounts, getAddressDecoder, } from '@solana/kit';
24
+ import bs58 from 'bs58';
25
+ import { ARNS_RECORD_DISCRIMINATOR, RESERVED_NAME_DISCRIMINATOR, RETURNED_NAME_DISCRIMINATOR, getArnsConfigDecoder, getArnsRecordDecoder, getReservedNameDecoder, getReturnedNameDecoder, } from '@ar.io/solana-contracts/arns';
26
+ import { PRIMARY_NAME_DISCRIMINATOR, PRIMARY_NAME_REQUEST_DISCRIMINATOR, VAULT_DISCRIMINATOR, getPrimaryNameRequestDecoder, getVaultDecoder, } from '@ar.io/solana-contracts/core';
27
+ import { ALLOWLIST_ENTRY_DISCRIMINATOR, DELEGATION_DISCRIMINATOR, GATEWAY_DISCRIMINATOR, GatewayStatus, OBSERVATION_DISCRIMINATOR, WITHDRAWAL_DISCRIMINATOR, getDelegationDecoder, getGatewayDecoder, getWithdrawalDecoder, } from '@ar.io/solana-contracts/gar';
28
+ import { Logger } from '../common/logger.js';
29
+ import { SolanaANTRegistryReadable } from './ant-registry-readable.js';
30
+ import { getAssociatedTokenAddressKit } from './ata.js';
31
+ import { ARIO_ANT_PROGRAM_ID, ARIO_ARNS_PROGRAM_ID, ARIO_CORE_PROGRAM_ID, ARIO_GAR_PROGRAM_ID, ARNS_RECORD_ANT_OFFSET, RATE_SCALE, } from './constants.js';
32
+ import { computeLiveDelegationBalance, selectCompoundableDelegations, } from './delegation-math.js';
33
+ import { deserializeAllowlist, deserializeArioConfig, deserializeArnsRecord, deserializeDelegation, deserializeDemandFactor, deserializeEpoch, deserializeEpochSettings, deserializeEpochSettingsFull, deserializeGarSettings, deserializeGarSupplyCounters, deserializeGateway, deserializeGatewayWithAccumulator, deserializeObservation, deserializePrimaryName, deserializePrimaryNameRequest, deserializeRedelegationRecord, deserializeReservedName, deserializeReturnedName, deserializeVault, deserializeWithdrawal, } from './deserialize.js';
34
+ import { TOKEN_PROGRAM_ADDRESS } from './instruction.js';
35
+ import { getArioConfigPDA, getArnsRecordPDA, getArnsRecordPDAFromHash, getArnsSettingsPDA, getDemandFactorPDA, getEpochPDA, getEpochSettingsPDA, getGarSettingsPDA, getGatewayPDA, getGatewayRegistryPDA, getObserverLookupPDA, getPrimaryNamePDA, getPrimaryNameRequestPDA, getReservedNamePDA, getReturnedNamePDA, getVaultPDA, } from './pda.js';
36
+ import { withRetry } from './retry.js';
37
+ const addressDecoder = getAddressDecoder();
38
+ /** All-zero address — equivalent of web3.js `PublicKey.default`. */
39
+ const DEFAULT_ADDRESS = address('11111111111111111111111111111111');
40
+ // =========================================
41
+ // Pagination helper
42
+ // =========================================
43
+ /**
44
+ * Normalize whatever `params.filters?.processId` shape came in (undefined,
45
+ * single string, or array — `PaginationParams` widens it past what ArNS
46
+ * actually supports) into a flat `string[]` ANT-mint list.
47
+ */
48
+ function normalizeProcessIdFilter(raw) {
49
+ if (raw == null)
50
+ return [];
51
+ if (typeof raw === 'string')
52
+ return [raw];
53
+ if (Array.isArray(raw))
54
+ return raw.filter((v) => typeof v === 'string');
55
+ return [];
56
+ }
57
+ /**
58
+ * On-chain timestamps in the Solana programs are stored in **seconds**
59
+ * (matching the Lua-source convention as ported), but the public SDK
60
+ * surface — shared with the AO backend — exposes them in **milliseconds**
61
+ * because every JS consumer (UI, cranker, migration tools) feeds them into
62
+ * `new Date()`/`Date.now()` arithmetic. This boundary helper converts at
63
+ * the read-path projection layer so internal arithmetic in this file can
64
+ * keep working in seconds against the deserializer output, but everything
65
+ * we hand back to a caller is in JS-millisecond units.
66
+ *
67
+ * Use `toMsTimestamps(obj)` for projection-layer return values, and
68
+ * `secToMs(n)` for one-off scalars.
69
+ */
70
+ const TIMESTAMP_FIELDS_MS = [
71
+ 'startTimestamp',
72
+ 'endTimestamp',
73
+ 'distributionTimestamp',
74
+ ];
75
+ function secToMs(n) {
76
+ return n * 1000;
77
+ }
78
+ function toMsTimestamps(obj) {
79
+ const out = { ...obj };
80
+ for (const f of TIMESTAMP_FIELDS_MS) {
81
+ const v = out[f];
82
+ if (typeof v === 'number')
83
+ out[f] = v * 1000;
84
+ }
85
+ return out;
86
+ }
87
+ /**
88
+ * Drop the SDK-internal extras (`name`, `owner`) and the `processId` re-key
89
+ * that `deserializeArnsRecord` adds, projecting back to the cross-backend
90
+ * `ArNSNameDataWithName` shape consumers expect.
91
+ *
92
+ * Timestamps are converted from on-chain seconds to JS milliseconds here
93
+ * (see `toMsTimestamps` above for rationale).
94
+ */
95
+ function arnsRecordToWithName(record) {
96
+ return {
97
+ name: record.name,
98
+ processId: record.processId,
99
+ startTimestamp: secToMs(record.startTimestamp),
100
+ undernameLimit: record.undernameLimit,
101
+ purchasePrice: record.purchasePrice,
102
+ type: record.type,
103
+ ...('endTimestamp' in record
104
+ ? { endTimestamp: secToMs(record.endTimestamp) }
105
+ : {}),
106
+ };
107
+ }
108
+ /**
109
+ * TTL for {@link SolanaARIOReadable.getCachedAccount}. DemandFactor / config
110
+ * accounts change on the order of epochs, so a few seconds of staleness is a
111
+ * safe trade for collapsing burst reads (e.g. per-row cost lookups).
112
+ */
113
+ const CONFIG_CACHE_TTL_MS = 30_000;
114
+ function paginate(items, params) {
115
+ const limit = params?.limit ?? 100;
116
+ const startIdx = params?.cursor ? parseInt(params.cursor, 10) : 0;
117
+ const page = items.slice(startIdx, startIdx + limit);
118
+ const hasMore = startIdx + limit < items.length;
119
+ return {
120
+ items: page,
121
+ limit,
122
+ totalItems: items.length,
123
+ sortOrder: params?.sortOrder ?? 'asc',
124
+ hasMore,
125
+ nextCursor: hasMore ? String(startIdx + limit) : undefined,
126
+ };
127
+ }
128
+ /**
129
+ * Solana-backed read-only client for the AR.IO protocol.
130
+ *
131
+ * Usage:
132
+ * ```ts
133
+ * import { createSolanaRpc } from '@solana/kit';
134
+ * import { SolanaARIOReadable } from '@ar.io/sdk/solana';
135
+ *
136
+ * const rpc = createSolanaRpc('https://api.mainnet-beta.solana.com');
137
+ * const ario = new SolanaARIOReadable({ rpc });
138
+ *
139
+ * const gateway = await ario.getGateway({ address: 'GatewayOperatorPubkey...' });
140
+ * const record = await ario.getArNSRecord({ name: 'ardrive' });
141
+ * ```
142
+ */
143
+ export class SolanaARIOReadable {
144
+ rpc;
145
+ commitment;
146
+ logger;
147
+ // Allow overriding program IDs (e.g., for devnet/localnet)
148
+ coreProgram;
149
+ garProgram;
150
+ arnsProgram;
151
+ antProgram;
152
+ // Memoized ARIO mint address (read once from ArioConfig.mint and reused
153
+ // for every SPL-ATA derivation in getBalance/getBalances).
154
+ _arioMint;
155
+ // Short-TTL cache for slow-changing config-ish accounts (DemandFactor,
156
+ // ArnsConfig, etc.). Collapses the many identical reads a UI fires in a
157
+ // burst — e.g. the returned-names page running `getCostDetails` per row,
158
+ // each re-reading the same DemandFactor PDA — into a single network call.
159
+ _accountCache = new Map();
160
+ constructor(config) {
161
+ this.rpc = config.rpc;
162
+ this.commitment = config.commitment ?? 'confirmed';
163
+ this.logger = config.logger ?? Logger.default;
164
+ this.coreProgram = config.coreProgramId ?? ARIO_CORE_PROGRAM_ID;
165
+ this.garProgram = config.garProgramId ?? ARIO_GAR_PROGRAM_ID;
166
+ this.arnsProgram = config.arnsProgramId ?? ARIO_ARNS_PROGRAM_ID;
167
+ this.antProgram = config.antProgramId ?? ARIO_ANT_PROGRAM_ID;
168
+ }
169
+ /** Helper to fetch an encoded account (kit's replacement for Connection.getAccountInfo). */
170
+ async getAccount(pda) {
171
+ return withRetry(() => fetchEncodedAccount(this.rpc, pda, {
172
+ commitment: this.commitment,
173
+ }));
174
+ }
175
+ /**
176
+ * Like {@link getAccount} but caches the result per-PDA for `ttlMs`. Use only
177
+ * for accounts that change slowly (DemandFactor, ArnsConfig) where a few
178
+ * seconds of staleness is acceptable in exchange for collapsing repeated
179
+ * reads. A successful fetch is cached; misses (`exists: false`) are not.
180
+ */
181
+ async getCachedAccount(pda, ttlMs = CONFIG_CACHE_TTL_MS) {
182
+ const key = String(pda);
183
+ const now = Date.now();
184
+ const hit = this._accountCache.get(key);
185
+ if (hit && hit.expiresAt > now)
186
+ return hit.account;
187
+ const account = await this.getAccount(pda);
188
+ if (account.exists) {
189
+ this._accountCache.set(key, { account, expiresAt: now + ttlMs });
190
+ }
191
+ return account;
192
+ }
193
+ /**
194
+ * Helper for `getProgramAccounts` with a discriminator memcmp filter.
195
+ *
196
+ * Pass the Codama-generated `<NAME>_DISCRIMINATOR: Uint8Array` constant
197
+ * directly — kit's RPC requires a base58 string for `memcmp.bytes`, so
198
+ * we bs58-encode here to keep call sites from doing it inline (and to
199
+ * keep the IDL-derived bytes as the single source of truth).
200
+ */
201
+ async getAccountsByDiscriminator(programId, discriminator, extraFilters = []) {
202
+ const filters = [
203
+ {
204
+ memcmp: {
205
+ offset: 0n,
206
+ bytes: bs58.encode(discriminator),
207
+ encoding: 'base58',
208
+ },
209
+ },
210
+ ...extraFilters,
211
+ ];
212
+ // Note: kit's getProgramAccounts returns a plain array (no context wrapper)
213
+ // when called without `withContext: true`. With `encoding: 'base64'`, each
214
+ // account's `data` is a `[base64, 'base64']` tuple. We bypass kit's strict
215
+ // generic overload typing here with a cast — the runtime shape is stable.
216
+ const result = await withRetry(() => this.rpc
217
+ .getProgramAccounts(programId, {
218
+ commitment: this.commitment,
219
+ encoding: 'base64',
220
+ filters,
221
+ })
222
+ .send());
223
+ return result.map((entry) => ({
224
+ pubkey: entry.pubkey,
225
+ data: Buffer.from(entry.account.data[0], 'base64'),
226
+ }));
227
+ }
228
+ /**
229
+ * Batch-fetch the `cumulative_reward_per_token` accumulator for every gateway
230
+ * in `operatorAddresses`. Returns a Map keyed by base58 operator address.
231
+ * Used by the delegate readers below to compute the live delegation balance
232
+ * without an on-chain settlement call (see {@link computeLiveDelegationBalance}
233
+ * and `INVARIANTS.md` in the contracts repo). Missing gateways are silently
234
+ * skipped — callers fall back to the stale `Delegation.amount` for those
235
+ * (the accumulator delta is 0 and live == stored anyway when the gateway
236
+ * has no rewards to distribute).
237
+ */
238
+ async getGatewayAccumulators(operatorAddresses) {
239
+ const unique = Array.from(new Set(operatorAddresses));
240
+ if (unique.length === 0)
241
+ return new Map();
242
+ const pdas = await Promise.all(unique.map(async (op) => (await getGatewayPDA(address(op), this.garProgram))[0]));
243
+ const accounts = await withRetry(() => fetchEncodedAccounts(this.rpc, pdas, {
244
+ commitment: this.commitment,
245
+ }));
246
+ const out = new Map();
247
+ for (let i = 0; i < accounts.length; i++) {
248
+ const acct = accounts[i];
249
+ if (!acct.exists)
250
+ continue;
251
+ try {
252
+ // Internal variant: surfaces the u128 accumulator that the public
253
+ // `deserializeGateway` deliberately drops (BigInt is not
254
+ // JSON-serializable and would leak through getGateway).
255
+ const gw = deserializeGatewayWithAccumulator(Buffer.from(acct.data));
256
+ out.set(unique[i], gw.cumulativeRewardPerToken);
257
+ }
258
+ catch {
259
+ // Skip malformed; the caller will fall back to the raw delegation amount.
260
+ }
261
+ }
262
+ return out;
263
+ }
264
+ /** Read the gateway registry and return addresses in registry index order */
265
+ async getRegistryGatewayAddresses() {
266
+ const [registryPda] = await getGatewayRegistryPDA(this.garProgram);
267
+ const registryAccount = await this.getAccount(registryPda);
268
+ if (!registryAccount.exists)
269
+ return [];
270
+ const registryData = Buffer.from(registryAccount.data);
271
+ const count = registryData.readUInt32LE(40); // 8 disc + 32 authority
272
+ const slotsOffset = 48; // 8 + 32 + 4 + 4
273
+ // GatewaySlot: address(32) + composite_weight(8) + start_timestamp(8)
274
+ // + status(1) + _padding(7) = 56 bytes (see ario-gar
275
+ // state/mod.rs::GatewaySlot).
276
+ const SLOT_STRIDE = 56;
277
+ const addresses = [];
278
+ for (let i = 0; i < count && i < 3000; i++) {
279
+ const slotOffset = slotsOffset + i * SLOT_STRIDE;
280
+ const addr = addressDecoder.decode(registryData.subarray(slotOffset, slotOffset + 32));
281
+ addresses.push(addr);
282
+ }
283
+ return addresses;
284
+ }
285
+ // =========================================
286
+ // Protocol info
287
+ // =========================================
288
+ async getInfo() {
289
+ const [[configPda], [epochSettingsPda]] = await Promise.all([
290
+ getArioConfigPDA(this.coreProgram),
291
+ getEpochSettingsPDA(this.garProgram),
292
+ ]);
293
+ const [configAccount, epochAccount] = await Promise.all([
294
+ this.getAccount(configPda),
295
+ this.getAccount(epochSettingsPda),
296
+ ]);
297
+ const config = configAccount.exists
298
+ ? deserializeArioConfig(Buffer.from(configAccount.data))
299
+ : null;
300
+ const epoch = epochAccount.exists
301
+ ? deserializeEpochSettings(Buffer.from(epochAccount.data))
302
+ : null;
303
+ return {
304
+ Ticker: 'ARIO',
305
+ Name: 'AR.IO',
306
+ Logo: '',
307
+ Denomination: 6,
308
+ Handlers: [],
309
+ LastCreatedEpochIndex: 0,
310
+ LastDistributedEpochIndex: 0,
311
+ ...(config
312
+ ? {
313
+ totalSupply: config.totalSupply,
314
+ protocolBalance: config.protocolBalance,
315
+ }
316
+ : {}),
317
+ ...(epoch ? { epochSettings: epoch } : {}),
318
+ };
319
+ }
320
+ async getTokenSupply() {
321
+ const [configPda] = await getArioConfigPDA(this.coreProgram);
322
+ const [garSettingsPda] = await getGarSettingsPDA(this.garProgram);
323
+ const [configAccount, garSettingsAccount] = await Promise.all([
324
+ this.getAccount(configPda),
325
+ this.getAccount(garSettingsPda),
326
+ ]);
327
+ if (!configAccount.exists) {
328
+ throw new Error('ArioConfig account not found');
329
+ }
330
+ const config = deserializeArioConfig(Buffer.from(configAccount.data));
331
+ // Supply counters from GatewaySettings (staked/delegated/withdrawn).
332
+ // Falls back to 0 if GatewaySettings doesn't exist yet or is at the
333
+ // old size (pre-supply-counters layout).
334
+ let staked = 0;
335
+ let delegated = 0;
336
+ let withdrawn = 0;
337
+ // `protocolBalance` is the REWARD RESERVE: the live balance of the protocol
338
+ // token account the epoch cranker emits from (ario-gar `distribute_epoch`
339
+ // reads `protocol_token_account.amount`). This matches AO's `protocolBalance`
340
+ // semantics (the qNvAoz0 reserve) and makes the six buckets sum to `total`:
341
+ // circulating + locked + staked + delegated + withdrawn + protocolBalance == total
342
+ //
343
+ // It is deliberately NOT `ArioConfig.protocol_balance`, which is a *folded*
344
+ // accounting field (= reserve + staked + delegated + withdrawn) and so
345
+ // double-counts the staking buckets — surfacing it as "protocol balance"
346
+ // over-reports the reward reserve by tens of millions of ARIO. We fall back
347
+ // to the folded value only when GatewaySettings or the token account can't
348
+ // be read (e.g. pre-init).
349
+ let protocolBalance = config.protocolBalance;
350
+ if (garSettingsAccount.exists) {
351
+ const garData = Buffer.from(garSettingsAccount.data);
352
+ try {
353
+ const counters = deserializeGarSupplyCounters(garData);
354
+ staked = counters.totalStaked;
355
+ delegated = counters.totalDelegated;
356
+ withdrawn = counters.totalWithdrawn;
357
+ }
358
+ catch {
359
+ // Old-layout account without supply counters — fall back to 0
360
+ }
361
+ // The protocol token account is pinned in GatewaySettings at offset 189
362
+ // (see ario-gar state/mod.rs::GatewaySettings: 8 disc + 32 authority +
363
+ // 32 mint + 6×8 economic params + 4 max_delegates + 1 migration_active +
364
+ // 32 migration_authority + 32 stake_token_account = 189; the pubkey runs
365
+ // [189, 221)). Read its live SPL balance as the reward reserve.
366
+ if (garData.length >= 221) {
367
+ try {
368
+ const protocolTokenAccount = addressDecoder.decode(garData.subarray(189, 221));
369
+ const reserve = await this.getTokenAccountAmount(protocolTokenAccount);
370
+ if (reserve !== null)
371
+ protocolBalance = reserve;
372
+ }
373
+ catch {
374
+ // Couldn't read the reserve — keep the folded fallback.
375
+ }
376
+ }
377
+ }
378
+ return {
379
+ total: config.totalSupply,
380
+ circulating: config.circulatingSupply,
381
+ locked: config.lockedSupply,
382
+ staked,
383
+ delegated,
384
+ withdrawn,
385
+ protocolBalance,
386
+ };
387
+ }
388
+ /**
389
+ * Read the `amount` (in mARIO) of an SPL token account directly by address.
390
+ * Returns `null` if the account doesn't exist or is too small to be a token
391
+ * account, so callers can distinguish "absent" from a real zero balance.
392
+ *
393
+ * Uses a portable little-endian u64 decode — some browser bundlers strip the
394
+ * BigInt readers from the `buffer` shim's prototype (see `getBalance`).
395
+ */
396
+ async getTokenAccountAmount(tokenAccount) {
397
+ const account = await this.getAccount(tokenAccount);
398
+ if (!account.exists)
399
+ return null;
400
+ const data = account.data;
401
+ if (data.length < 72)
402
+ return null;
403
+ let amount = 0n;
404
+ for (let i = 7; i >= 0; i--) {
405
+ amount = (amount << 8n) | BigInt(data[64 + i]);
406
+ }
407
+ // ARIO supply caps at 1B * 1e6 mARIO ≈ 2^50, well under MAX_SAFE_INTEGER.
408
+ return Number(amount);
409
+ }
410
+ // =========================================
411
+ // Balance read methods
412
+ // =========================================
413
+ /**
414
+ * Resolve the ARIO SPL mint address from the on-chain `ArioConfig`.
415
+ *
416
+ * `ArioConfig` layout: [8 disc][32 authority][32 mint][...]. We decode
417
+ * the mint at offset 40 and cache it for the lifetime of this instance —
418
+ * the mint never changes once the protocol is deployed.
419
+ */
420
+ async getArioMint() {
421
+ if (this._arioMint)
422
+ return this._arioMint;
423
+ const [configPda] = await getArioConfigPDA(this.coreProgram);
424
+ const account = await this.getAccount(configPda);
425
+ if (!account.exists) {
426
+ throw new Error(`ArioConfig not found at ${configPda} on coreProgram ${this.coreProgram} — is the program deployed and initialized?`);
427
+ }
428
+ const data = Buffer.from(account.data);
429
+ const mint = addressDecoder.decode(data.subarray(40, 72));
430
+ this._arioMint = mint;
431
+ return mint;
432
+ }
433
+ /**
434
+ * Liquid ARIO balance for an address.
435
+ *
436
+ * On Solana the ARIO token is a real SPL mint, so the canonical liquid
437
+ * balance lives on the user's Associated Token Account — *not* the
438
+ * `ario-core::Balance` PDA. The Balance PDA is only populated by the
439
+ * one-shot AO-to-Solana migration importer for legacy snapshot accounts;
440
+ * spending it requires a separate claim flow that mints/transfers SPL
441
+ * tokens to the user's ATA. Steady-state instructions like `buy_name`,
442
+ * gateway/delegate stake, and ARIO transfers all move SPL tokens, so the
443
+ * ATA is what every UI and on-chain caller cares about.
444
+ *
445
+ * Returns 0 if the user has no ATA initialized yet.
446
+ */
447
+ async getBalance({ address: owner, }) {
448
+ const mint = await this.getArioMint();
449
+ const ata = await getAssociatedTokenAddressKit(mint, address(owner));
450
+ const account = await this.getAccount(ata);
451
+ if (!account.exists)
452
+ return 0;
453
+ // SPL Token Account layout: [0..32]=mint, [32..64]=owner, [64..72]=amount(u64 LE), …
454
+ const data = account.data;
455
+ if (data.length < 72)
456
+ return 0;
457
+ // NOTE: avoid `Buffer.readBigUInt64LE` — some browser bundlers (notably
458
+ // arns-react's Vite output) strip the BigInt readers from the
459
+ // `buffer@6.0.3` shim's prototype. Manual little-endian u64 decode is
460
+ // portable across every JS runtime.
461
+ let amount = 0n;
462
+ for (let i = 7; i >= 0; i--) {
463
+ amount = (amount << 8n) | BigInt(data[64 + i]);
464
+ }
465
+ // ARIO supply caps at 1B * 1e6 mARIO ≈ 2^50, well under Number.MAX_SAFE_INTEGER.
466
+ return Number(amount);
467
+ }
468
+ /**
469
+ * Enumerate liquid ARIO balances by querying the SPL Token program for
470
+ * every initialized token account on the ARIO mint.
471
+ *
472
+ * Filters: token-account size = 165, mint at offset 0. We then decode
473
+ * `owner` (offset 32) and `amount` (offset 64) from each.
474
+ */
475
+ async getBalances(params) {
476
+ const mint = await this.getArioMint();
477
+ const filters = [
478
+ { dataSize: 165n },
479
+ {
480
+ memcmp: {
481
+ offset: 0n,
482
+ bytes: mint,
483
+ encoding: 'base58',
484
+ },
485
+ },
486
+ ];
487
+ const result = await withRetry(() => this.rpc
488
+ .getProgramAccounts(TOKEN_PROGRAM_ADDRESS, {
489
+ commitment: this.commitment,
490
+ encoding: 'base64',
491
+ filters,
492
+ })
493
+ .send());
494
+ const items = [];
495
+ for (const entry of result) {
496
+ try {
497
+ const data = Buffer.from(entry.account.data[0], 'base64');
498
+ if (data.length < 72)
499
+ continue;
500
+ const ownerAddress = addressDecoder.decode(data.subarray(32, 64));
501
+ const amount = Number(data.readBigUInt64LE(64));
502
+ if (amount > 0) {
503
+ items.push({ address: ownerAddress, balance: amount });
504
+ }
505
+ }
506
+ catch {
507
+ // Skip malformed accounts
508
+ }
509
+ }
510
+ return paginate(items, params);
511
+ }
512
+ // =========================================
513
+ // Vault read methods
514
+ // =========================================
515
+ async getVault({ address: owner, vaultId, }) {
516
+ const [pda] = await getVaultPDA(address(owner), BigInt(vaultId), this.coreProgram);
517
+ const account = await this.getAccount(pda);
518
+ if (!account.exists) {
519
+ throw new Error(`Vault not found for ${owner}:${vaultId}`);
520
+ }
521
+ const vault = deserializeVault(Buffer.from(account.data));
522
+ return {
523
+ balance: vault.balance,
524
+ startTimestamp: secToMs(vault.startTimestamp),
525
+ endTimestamp: secToMs(vault.endTimestamp),
526
+ controller: vault.controller,
527
+ };
528
+ }
529
+ async getVaults(params) {
530
+ const accounts = await this.getAccountsByDiscriminator(this.coreProgram, VAULT_DISCRIMINATOR);
531
+ const items = [];
532
+ for (const { pubkey, data } of accounts) {
533
+ try {
534
+ const vault = deserializeVault(data);
535
+ items.push({
536
+ address: vault.owner,
537
+ vaultId: pubkey,
538
+ balance: vault.balance,
539
+ startTimestamp: secToMs(vault.startTimestamp),
540
+ endTimestamp: secToMs(vault.endTimestamp),
541
+ controller: vault.controller,
542
+ });
543
+ }
544
+ catch {
545
+ // Skip malformed accounts
546
+ }
547
+ }
548
+ return paginate(items, params);
549
+ }
550
+ // =========================================
551
+ // Gateway read methods
552
+ // =========================================
553
+ async getGateway({ address: addr }) {
554
+ const [pda] = await getGatewayPDA(address(addr), this.garProgram);
555
+ const account = await this.getAccount(pda);
556
+ if (!account.exists) {
557
+ throw new Error(`Gateway not found for operator ${addr}`);
558
+ }
559
+ const gw = deserializeGateway(Buffer.from(account.data));
560
+ const { operator: _, ...gateway } = gw;
561
+ return toMsTimestamps(gateway);
562
+ }
563
+ async getGateways(params) {
564
+ const [registryPda] = await getGatewayRegistryPDA(this.garProgram);
565
+ const registryAccount = await this.getAccount(registryPda);
566
+ if (!registryAccount.exists) {
567
+ return paginate([], params);
568
+ }
569
+ const registryData = Buffer.from(registryAccount.data);
570
+ const count = registryData.readUInt32LE(40);
571
+ const slotsOffset = 48;
572
+ // GatewaySlot = address(32) + composite_weight(8) + start_timestamp(8)
573
+ // + status(1) + _padding(7) = 56 bytes (see ario-gar
574
+ // state/mod.rs::GatewaySlot). A previous off-by-16-bytes-per-slot
575
+ // stride silently read garbage for slots 1+, returning at most
576
+ // one gateway no matter how many had joined.
577
+ const SLOT_STRIDE = 56;
578
+ const gatewayAddresses = [];
579
+ for (let i = 0; i < count && i < 3000; i++) {
580
+ const slotOffset = slotsOffset + i * SLOT_STRIDE;
581
+ const addr = addressDecoder.decode(registryData.subarray(slotOffset, slotOffset + 32));
582
+ if (addr !== DEFAULT_ADDRESS) {
583
+ gatewayAddresses.push(addr);
584
+ }
585
+ }
586
+ // Batch fetch gateway PDAs (kit has no hard limit but keep 100-at-a-time
587
+ // for sensible RPC request sizes).
588
+ const allItems = [];
589
+ for (let i = 0; i < gatewayAddresses.length; i += 100) {
590
+ const batch = gatewayAddresses.slice(i, i + 100);
591
+ const pdas = await Promise.all(batch.map(async (addr) => (await getGatewayPDA(addr, this.garProgram))[0]));
592
+ const accounts = await fetchEncodedAccounts(this.rpc, pdas, {
593
+ commitment: this.commitment,
594
+ });
595
+ for (const acct of accounts) {
596
+ if (!acct.exists)
597
+ continue;
598
+ try {
599
+ const gw = deserializeGateway(Buffer.from(acct.data));
600
+ allItems.push(toMsTimestamps({ ...gw, gatewayAddress: gw.operator }));
601
+ }
602
+ catch {
603
+ // Skip malformed
604
+ }
605
+ }
606
+ }
607
+ return paginate(allItems, params);
608
+ }
609
+ async getGatewayDelegates(params) {
610
+ const gateway = address(params.address);
611
+ // Filter delegations by gateway pubkey at offset 8 (after discriminator)
612
+ const accounts = await this.getAccountsByDiscriminator(this.garProgram, DELEGATION_DISCRIMINATOR, [
613
+ {
614
+ memcmp: { offset: 8n, bytes: gateway, encoding: 'base58' },
615
+ },
616
+ ]);
617
+ // Fetch this gateway's current reward accumulator so we can return live
618
+ // balances (raw `Delegation.amount` is stale between settlements — see
619
+ // INVARIANTS.md and `computeLiveDelegationBalance`).
620
+ const accumulators = await this.getGatewayAccumulators([gateway]);
621
+ const cumulative = accumulators.get(gateway) ?? 0n;
622
+ const items = [];
623
+ for (const { data } of accounts) {
624
+ try {
625
+ const del = deserializeDelegation(data);
626
+ items.push({
627
+ address: del.delegator,
628
+ delegatedStake: computeLiveDelegationBalance({
629
+ delegatedStake: del.delegatedStake,
630
+ rewardDebt: del.rewardDebt,
631
+ cumulativeRewardPerToken: cumulative,
632
+ }),
633
+ startTimestamp: secToMs(del.startTimestamp),
634
+ });
635
+ }
636
+ catch {
637
+ // Skip malformed
638
+ }
639
+ }
640
+ return paginate(items, params);
641
+ }
642
+ async getGatewayDelegateAllowList(params) {
643
+ const gateway = address(params.address);
644
+ // Filter allowlist entries by gateway pubkey at offset 8
645
+ const accounts = await this.getAccountsByDiscriminator(this.garProgram, ALLOWLIST_ENTRY_DISCRIMINATOR, [
646
+ {
647
+ memcmp: { offset: 8n, bytes: gateway, encoding: 'base58' },
648
+ },
649
+ ]);
650
+ const items = [];
651
+ for (const { data } of accounts) {
652
+ try {
653
+ const entry = deserializeAllowlist(data);
654
+ items.push(entry.delegate);
655
+ }
656
+ catch {
657
+ // Skip malformed
658
+ }
659
+ }
660
+ return paginate(items, params);
661
+ }
662
+ /**
663
+ * Returns every delegation a wallet currently has, covering both halves
664
+ * of the `Delegation` union:
665
+ *
666
+ * - `type: 'stake'` — active `Delegation` PDAs (filtered by delegator at
667
+ * memcmp offset 40 = 8 disc + 32 gateway).
668
+ * - `type: 'vault'` — pending delegate-stake withdrawals: `Withdrawal`
669
+ * PDAs filtered by owner at memcmp offset 8 (= 8 disc), then narrowed
670
+ * client-side to `isDelegate: true`. Operator-stake withdrawals are
671
+ * excluded — those are surfaced via `getWithdrawals` /
672
+ * `getGatewayVaults`.
673
+ *
674
+ * Both queries run in parallel; consumers see a single merged result
675
+ * matching the cross-backend interface contract.
676
+ */
677
+ async getDelegations(params) {
678
+ const owner = address(params.address);
679
+ const [delegationAccounts, withdrawalAccounts] = await Promise.all([
680
+ // Active delegations — `Delegation` PDA layout:
681
+ // disc(8) + gateway(32) + delegator(32) + ... — delegator at offset 40.
682
+ this.getAccountsByDiscriminator(this.garProgram, DELEGATION_DISCRIMINATOR, [
683
+ {
684
+ memcmp: { offset: 40n, bytes: owner, encoding: 'base58' },
685
+ },
686
+ ]),
687
+ // Pending vault delegations — `Withdrawal` PDA layout:
688
+ // disc(8) + owner(32) + withdrawal_id(8) + gateway(32) + ... — owner at offset 8.
689
+ this.getAccountsByDiscriminator(this.garProgram, WITHDRAWAL_DISCRIMINATOR, [
690
+ {
691
+ memcmp: { offset: 8n, bytes: owner, encoding: 'base58' },
692
+ },
693
+ ]),
694
+ ]);
695
+ const decodedDelegations = [];
696
+ for (const { pubkey, data } of delegationAccounts) {
697
+ try {
698
+ decodedDelegations.push({
699
+ pubkey: pubkey,
700
+ del: deserializeDelegation(data),
701
+ });
702
+ }
703
+ catch {
704
+ // Skip malformed
705
+ }
706
+ }
707
+ // Batch-fetch each referenced gateway's reward accumulator so we can
708
+ // return live balances. See INVARIANTS.md and `computeLiveDelegationBalance`.
709
+ const accumulators = await this.getGatewayAccumulators(decodedDelegations.map(({ del }) => del.gateway));
710
+ const stakeItems = decodedDelegations.map(({ pubkey, del }) => ({
711
+ type: 'stake',
712
+ gatewayAddress: del.gateway,
713
+ delegationId: pubkey,
714
+ startTimestamp: secToMs(del.startTimestamp),
715
+ balance: computeLiveDelegationBalance({
716
+ delegatedStake: del.delegatedStake,
717
+ rewardDebt: del.rewardDebt,
718
+ cumulativeRewardPerToken: accumulators.get(del.gateway) ?? 0n,
719
+ }),
720
+ }));
721
+ const vaultItems = [];
722
+ for (const { pubkey, data } of withdrawalAccounts) {
723
+ try {
724
+ const w = deserializeWithdrawal(data);
725
+ // Delegate-stake decreases only. Operator-stake withdrawals (the
726
+ // operator's own decreaseOperatorStake calls) belong on
727
+ // `getWithdrawals` / `getGatewayVaults`, not `getDelegations`.
728
+ if (!w.isDelegate)
729
+ continue;
730
+ vaultItems.push({
731
+ type: 'vault',
732
+ gatewayAddress: w.gateway,
733
+ delegationId: pubkey,
734
+ vaultId: w.vaultId,
735
+ balance: w.balance,
736
+ startTimestamp: secToMs(w.startTimestamp),
737
+ endTimestamp: secToMs(w.endTimestamp),
738
+ });
739
+ }
740
+ catch {
741
+ // Skip malformed
742
+ }
743
+ }
744
+ return paginate([...stakeItems, ...vaultItems], params);
745
+ }
746
+ async getAllowedDelegates(params) {
747
+ return this.getGatewayDelegateAllowList(params);
748
+ }
749
+ async getGatewayVaults(params) {
750
+ const gateway = address(params.address);
751
+ // Withdrawal: disc(8) + owner(32) + withdrawal_id(8) + gateway(32)
752
+ const accounts = await this.getAccountsByDiscriminator(this.garProgram, WITHDRAWAL_DISCRIMINATOR, [
753
+ {
754
+ memcmp: { offset: 48n, bytes: gateway, encoding: 'base58' },
755
+ },
756
+ ]);
757
+ const items = [];
758
+ for (const { pubkey, data } of accounts) {
759
+ try {
760
+ const w = deserializeWithdrawal(data);
761
+ if (!w.isDelegate) {
762
+ items.push({
763
+ cursorId: pubkey,
764
+ vaultId: w.vaultId,
765
+ balance: w.balance,
766
+ startTimestamp: secToMs(w.startTimestamp),
767
+ endTimestamp: secToMs(w.endTimestamp),
768
+ });
769
+ }
770
+ }
771
+ catch {
772
+ // Skip malformed
773
+ }
774
+ }
775
+ return paginate(items, params);
776
+ }
777
+ /**
778
+ * Return every pending stake withdrawal owned by `address` — operator-stake
779
+ * decreases (`isDelegate: false`) and delegate-stake decreases
780
+ * (`isDelegate: true`) in one paginated result. A withdrawal is claimable
781
+ * when `Date.now() >= endTimestamp`; release the funds via
782
+ * `claimWithdrawal({ withdrawalId: item.vaultId })`.
783
+ *
784
+ * Solana-only: AO releases withdrawals automatically at maturity and has no
785
+ * equivalent per-owner read; the AO backend throws.
786
+ */
787
+ async getWithdrawals(params) {
788
+ const owner = address(params.address);
789
+ // Withdrawal layout: disc(8) + owner(32) + withdrawal_id(8) + gateway(32).
790
+ // Filter by owner at offset 8 — returns both operator-stake (isDelegate=false)
791
+ // and delegate-stake (isDelegate=true) withdrawals for this wallet.
792
+ const accounts = await this.getAccountsByDiscriminator(this.garProgram, WITHDRAWAL_DISCRIMINATOR, [
793
+ {
794
+ memcmp: { offset: 8n, bytes: owner, encoding: 'base58' },
795
+ },
796
+ ]);
797
+ const items = [];
798
+ for (const { pubkey, data } of accounts) {
799
+ try {
800
+ const w = deserializeWithdrawal(data);
801
+ items.push({
802
+ cursorId: pubkey,
803
+ vaultId: w.vaultId,
804
+ balance: w.balance,
805
+ startTimestamp: secToMs(w.startTimestamp),
806
+ endTimestamp: secToMs(w.endTimestamp),
807
+ gatewayAddress: w.gateway,
808
+ isDelegate: w.isDelegate,
809
+ });
810
+ }
811
+ catch {
812
+ // Skip malformed
813
+ }
814
+ }
815
+ return paginate(items, params);
816
+ }
817
+ // =========================================
818
+ // ArNS read methods
819
+ // =========================================
820
+ async getArNSRecord({ name }) {
821
+ const [pda] = await getArnsRecordPDA(name, this.arnsProgram);
822
+ const account = await this.getAccount(pda);
823
+ if (!account.exists) {
824
+ throw new Error(`ArNS record not found: ${name}`);
825
+ }
826
+ const record = deserializeArnsRecord(Buffer.from(account.data));
827
+ const { name: _, owner: __, ...nameData } = record;
828
+ return nameData;
829
+ }
830
+ async getArNSRecords(params) {
831
+ // `processId` is the only filter the AO backend supports today and the
832
+ // only one that maps to a fixed-offset memcmp on `ArnsRecord` (the
833
+ // `ant` field, see `ARNS_RECORD_ANT_OFFSET`). When supplied we
834
+ // dispatch through the bulk-by-mint path so the RPC does the
835
+ // filtering instead of streaming every record back to the client.
836
+ const filterMints = normalizeProcessIdFilter(params?.filters?.processId);
837
+ if (filterMints.length > 0) {
838
+ const items = await this.fetchArnsRecordsByAntMints(filterMints);
839
+ return paginate(items, params);
840
+ }
841
+ const accounts = await this.getAccountsByDiscriminator(this.arnsProgram, ARNS_RECORD_DISCRIMINATOR);
842
+ const items = [];
843
+ for (const { data } of accounts) {
844
+ try {
845
+ items.push(arnsRecordToWithName(deserializeArnsRecord(data)));
846
+ }
847
+ catch {
848
+ // Skip accounts that don't match
849
+ }
850
+ }
851
+ return paginate(items, params);
852
+ }
853
+ /**
854
+ * Fetch every `ArnsRecord` whose `ant` field equals one of `mints`.
855
+ *
856
+ * Issues one `getProgramAccounts` per mint with a memcmp filter at
857
+ * `ARNS_RECORD_ANT_OFFSET`, in parallel. Cheaper than scanning the
858
+ * whole registry as soon as the caller has fewer mints than the
859
+ * registry has records (today the break-even is ≈ a few hundred
860
+ * mints against ≈ 4k records, and rises as the registry grows).
861
+ *
862
+ * The shape mirrors `getArNSRecord` / `getArNSRecords` — same
863
+ * `ArNSNameDataWithName` items, no pagination wrapper. Callers
864
+ * that want pagination should drive it via `getArNSRecords({
865
+ * filters: { processId: mints } })` instead.
866
+ */
867
+ async getArNSRecordsByAntMints({ mints, }) {
868
+ return this.fetchArnsRecordsByAntMints(mints);
869
+ }
870
+ async fetchArnsRecordsByAntMints(mints) {
871
+ const unique = Array.from(new Set(mints));
872
+ if (unique.length === 0)
873
+ return [];
874
+ // Parallel fan-out: one filtered gPA per mint. Each request is
875
+ // selective (matches at most one record on a healthy registry),
876
+ // so the marginal cost is mostly the round trip; major RPCs index
877
+ // memcmp filters at stable offsets, keeping this O(N) in network
878
+ // round trips rather than O(N) in registry size.
879
+ const perMint = await Promise.all(unique.map((mint) => this.getAccountsByDiscriminator(this.arnsProgram, ARNS_RECORD_DISCRIMINATOR, [
880
+ {
881
+ memcmp: {
882
+ offset: BigInt(ARNS_RECORD_ANT_OFFSET),
883
+ bytes: mint,
884
+ encoding: 'base58',
885
+ },
886
+ },
887
+ ])));
888
+ const items = [];
889
+ const seen = new Set();
890
+ for (const accounts of perMint) {
891
+ for (const { pubkey, data } of accounts) {
892
+ const key = String(pubkey);
893
+ if (seen.has(key))
894
+ continue;
895
+ seen.add(key);
896
+ try {
897
+ items.push(arnsRecordToWithName(deserializeArnsRecord(data)));
898
+ }
899
+ catch {
900
+ // Skip malformed
901
+ }
902
+ }
903
+ }
904
+ return items;
905
+ }
906
+ /**
907
+ * Resolve every ArNS record currently controlled by `address`.
908
+ *
909
+ * Mirrors the AO backend: walk the on-chain ANT ACL for the wallet
910
+ * (`Owned ∪ Controlled`), then issue point-queries against the
911
+ * ArNS registry for those mints. This is semantically *not* a query
912
+ * over `ArnsRecord.owner` — that field is a write-once "purchase
913
+ * receipt" and never reflects current control on Solana (see
914
+ * ISSUES.md). Authoritative control flows through the ANT NFT
915
+ * owner / `AntControllers`, which is exactly what the ACL
916
+ * indexes.
917
+ */
918
+ async getArNSRecordsForAddress(params) {
919
+ const registry = new SolanaANTRegistryReadable({
920
+ rpc: this.rpc,
921
+ commitment: this.commitment,
922
+ logger: this.logger,
923
+ antProgramId: this.antProgram,
924
+ });
925
+ const { Owned = [], Controlled = [] } = await registry.accessControlList({
926
+ address: params.address,
927
+ });
928
+ const mints = Array.from(new Set([...Owned, ...Controlled]));
929
+ if (mints.length === 0) {
930
+ return {
931
+ items: [],
932
+ hasMore: false,
933
+ nextCursor: undefined,
934
+ limit: params.limit ?? 100,
935
+ totalItems: 0,
936
+ sortOrder: params.sortOrder ?? 'asc',
937
+ };
938
+ }
939
+ const items = await this.fetchArnsRecordsByAntMints(mints);
940
+ return paginate(items, params);
941
+ }
942
+ async getArNSReservedNames(params) {
943
+ const accounts = await this.getAccountsByDiscriminator(this.arnsProgram, RESERVED_NAME_DISCRIMINATOR);
944
+ const items = [];
945
+ for (const { data } of accounts) {
946
+ try {
947
+ const reserved = deserializeReservedName(data);
948
+ items.push({
949
+ name: reserved.name,
950
+ target: reserved.target,
951
+ endTimestamp: typeof reserved.endTimestamp === 'number'
952
+ ? secToMs(reserved.endTimestamp)
953
+ : reserved.endTimestamp,
954
+ });
955
+ }
956
+ catch {
957
+ // Skip malformed
958
+ }
959
+ }
960
+ return paginate(items, params);
961
+ }
962
+ async getArNSReservedName({ name, }) {
963
+ const [pda] = await getReservedNamePDA(name, this.arnsProgram);
964
+ const account = await this.getAccount(pda);
965
+ if (!account.exists) {
966
+ throw new Error(`Reserved name not found: ${name}`);
967
+ }
968
+ const reserved = deserializeReservedName(Buffer.from(account.data));
969
+ return {
970
+ target: reserved.target,
971
+ endTimestamp: typeof reserved.endTimestamp === 'number'
972
+ ? secToMs(reserved.endTimestamp)
973
+ : reserved.endTimestamp,
974
+ };
975
+ }
976
+ async getArNSReturnedNames(params) {
977
+ const accounts = await this.getAccountsByDiscriminator(this.arnsProgram, RETURNED_NAME_DISCRIMINATOR);
978
+ const items = [];
979
+ for (const { data } of accounts) {
980
+ try {
981
+ items.push(toMsTimestamps(deserializeReturnedName(data)));
982
+ }
983
+ catch {
984
+ // Skip malformed
985
+ }
986
+ }
987
+ return paginate(items, params);
988
+ }
989
+ async getArNSReturnedName({ name, }) {
990
+ const [pda] = await getReturnedNamePDA(name, this.arnsProgram);
991
+ // A ReturnedName account is immutable once created (its premium is derived
992
+ // client-side from the static start/end timestamps), so cache the read.
993
+ // The returned-names price table reads each name's PDA twice (lease +
994
+ // permabuy) and `getTokenCost` reads it again — all share one fetch.
995
+ const account = await this.getCachedAccount(pda);
996
+ if (!account.exists) {
997
+ throw new Error(`Returned name not found: ${name}`);
998
+ }
999
+ return toMsTimestamps(deserializeReturnedName(Buffer.from(account.data)));
1000
+ }
1001
+ // =========================================
1002
+ // Epoch read methods
1003
+ // =========================================
1004
+ async getEpochSettings() {
1005
+ const [pda] = await getEpochSettingsPDA(this.garProgram);
1006
+ const account = await this.getAccount(pda);
1007
+ if (!account.exists) {
1008
+ throw new Error('Epoch settings account not found');
1009
+ }
1010
+ return deserializeEpochSettings(Buffer.from(account.data));
1011
+ }
1012
+ /**
1013
+ * Resolve an EpochInput to an epoch index number.
1014
+ * - undefined: returns current epoch index from EpochSettings
1015
+ * - { epochIndex }: returns directly
1016
+ * - { timestamp }: computes from genesis timestamp and epoch duration
1017
+ */
1018
+ async resolveEpochIndex(epoch) {
1019
+ if (epoch && 'epochIndex' in epoch) {
1020
+ return epoch.epochIndex;
1021
+ }
1022
+ const [pda] = await getEpochSettingsPDA(this.garProgram);
1023
+ const account = await this.getAccount(pda);
1024
+ if (!account.exists)
1025
+ throw new Error('EpochSettings account not found');
1026
+ const settings = deserializeEpochSettingsFull(Buffer.from(account.data));
1027
+ if (!epoch) {
1028
+ // On-chain `current_epoch_index` is "NEXT epoch to be created"
1029
+ // (incremented inside `create_epoch` AFTER the PDA is initialized
1030
+ // — see programs/ario-gar/src/instructions/epoch.rs:161). The
1031
+ // currently-active epoch is therefore one back. Floor at 0 for
1032
+ // the pre-bootstrap edge case where no epochs have been created
1033
+ // yet. Without this adjustment, every call to getEpoch(undefined)
1034
+ // sits in the cranker's close_epoch ↔ create_epoch gap and throws
1035
+ // "Epoch N not found" — which broke ContractEpochSource on a
1036
+ // live cluster (May 2026 devnet).
1037
+ return Math.max(0, settings.currentEpochIndex - 1);
1038
+ }
1039
+ // { timestamp } — compute epoch index. The public API takes `timestamp`
1040
+ // in JS milliseconds (matching the AO contract convention), but
1041
+ // genesisTimestamp/epochDuration come straight off chain in seconds, so
1042
+ // normalize to seconds before doing the division.
1043
+ const tsSeconds = Math.floor(epoch.timestamp / 1000);
1044
+ const elapsed = tsSeconds - settings.genesisTimestamp;
1045
+ return Math.floor(elapsed / settings.epochDuration);
1046
+ }
1047
+ /** Fetch and deserialize an Epoch account by index */
1048
+ async fetchEpoch(epochIndex) {
1049
+ const [pda] = await getEpochPDA(epochIndex, this.garProgram);
1050
+ const account = await this.getAccount(pda);
1051
+ if (!account.exists) {
1052
+ throw new Error(`Epoch ${epochIndex} not found`);
1053
+ }
1054
+ return deserializeEpoch(Buffer.from(account.data));
1055
+ }
1056
+ async getEpoch(epoch) {
1057
+ const epochIndex = await this.resolveEpochIndex(epoch);
1058
+ const epochData = await this.fetchEpoch(epochIndex);
1059
+ // Build prescribed observers list (only up to observerCount)
1060
+ const prescribedObservers = [];
1061
+ for (let i = 0; i < epochData.observerCount; i++) {
1062
+ const observerAddress = epochData.prescribedObservers[i];
1063
+ const gatewayAddress = epochData.prescribedObserverGateways[i];
1064
+ if (observerAddress === DEFAULT_ADDRESS)
1065
+ continue;
1066
+ // Try to fetch gateway data for weights
1067
+ let weights = {
1068
+ stakeWeight: 0,
1069
+ tenureWeight: 0,
1070
+ gatewayRewardRatioWeight: 0,
1071
+ observerRewardRatioWeight: 0,
1072
+ gatewayPerformanceRatio: 0,
1073
+ observerPerformanceRatio: 0,
1074
+ compositeWeight: 0,
1075
+ normalizedCompositeWeight: 0,
1076
+ };
1077
+ let stake = 0;
1078
+ let startTimestamp = 0;
1079
+ try {
1080
+ const gw = await this.getGateway({ address: gatewayAddress });
1081
+ weights = gw.weights;
1082
+ stake = gw.operatorStake;
1083
+ // gw.startTimestamp is already converted to ms by getGateway.
1084
+ startTimestamp = gw.startTimestamp;
1085
+ }
1086
+ catch {
1087
+ // Gateway may no longer exist
1088
+ }
1089
+ prescribedObservers.push({
1090
+ gatewayAddress: gatewayAddress,
1091
+ observerAddress: observerAddress,
1092
+ stake,
1093
+ startTimestamp,
1094
+ ...weights,
1095
+ });
1096
+ }
1097
+ // Build prescribed names list by resolving hashes → ArnsRecord PDAs
1098
+ const prescribedNames = [];
1099
+ const zeroHash = Buffer.alloc(32);
1100
+ for (let i = 0; i < epochData.nameCount; i++) {
1101
+ const nameHash = epochData.prescribedNameHashes[i];
1102
+ if (!nameHash || nameHash.equals(zeroHash))
1103
+ continue;
1104
+ try {
1105
+ const [recordPda] = await getArnsRecordPDAFromHash(nameHash, this.arnsProgram);
1106
+ const recordAccount = await this.getAccount(recordPda);
1107
+ if (recordAccount.exists) {
1108
+ const record = deserializeArnsRecord(Buffer.from(recordAccount.data));
1109
+ prescribedNames.push(record.name);
1110
+ }
1111
+ }
1112
+ catch {
1113
+ // Record may have been removed
1114
+ }
1115
+ }
1116
+ // Build observations from Observation PDAs
1117
+ const observations = await this.getObservations({ epochIndex });
1118
+ // Build distribution totals
1119
+ const distributions = {
1120
+ totalEligibleGateways: epochData.activeGatewayCount,
1121
+ totalEligibleRewards: epochData.totalEligibleRewards,
1122
+ totalEligibleObserverReward: epochData.perObserverReward * epochData.observerCount,
1123
+ totalEligibleGatewayReward: epochData.perGatewayReward * epochData.activeGatewayCount,
1124
+ };
1125
+ return {
1126
+ epochIndex,
1127
+ startHeight: 0, // Solana doesn't use AO block heights
1128
+ startTimestamp: secToMs(epochData.startTimestamp),
1129
+ endTimestamp: secToMs(epochData.endTimestamp),
1130
+ distributionTimestamp: secToMs(epochData.endTimestamp),
1131
+ observations,
1132
+ prescribedObservers,
1133
+ prescribedNames,
1134
+ distributions,
1135
+ arnsStats: {
1136
+ totalReturnedNames: 0,
1137
+ totalActiveNames: 0,
1138
+ totalGracePeriodNames: 0,
1139
+ totalReservedNames: 0,
1140
+ },
1141
+ };
1142
+ }
1143
+ async getCurrentEpoch() {
1144
+ return this.getEpoch(undefined);
1145
+ }
1146
+ async getPrescribedObservers(epoch) {
1147
+ const epochData = await this.getEpoch(epoch);
1148
+ return epochData.prescribedObservers;
1149
+ }
1150
+ async getPrescribedNames(epoch) {
1151
+ const epochIndex = await this.resolveEpochIndex(epoch);
1152
+ const epochData = await this.fetchEpoch(epochIndex);
1153
+ const names = [];
1154
+ const zeroHash = Buffer.alloc(32);
1155
+ for (let i = 0; i < epochData.nameCount; i++) {
1156
+ const nameHash = epochData.prescribedNameHashes[i];
1157
+ if (!nameHash || nameHash.equals(zeroHash))
1158
+ continue;
1159
+ try {
1160
+ const [recordPda] = await getArnsRecordPDAFromHash(nameHash, this.arnsProgram);
1161
+ const recordAccount = await this.getAccount(recordPda);
1162
+ if (recordAccount.exists) {
1163
+ const record = deserializeArnsRecord(Buffer.from(recordAccount.data));
1164
+ names.push(record.name);
1165
+ }
1166
+ }
1167
+ catch {
1168
+ // Record may have been removed
1169
+ }
1170
+ }
1171
+ return names;
1172
+ }
1173
+ async getObservations(epoch) {
1174
+ const epochIndex = await this.resolveEpochIndex(epoch);
1175
+ // Fetch all Observation accounts for this epoch
1176
+ const epochIndexBuf = Buffer.alloc(8);
1177
+ epochIndexBuf.writeBigUInt64LE(BigInt(epochIndex));
1178
+ const accounts = await this.getAccountsByDiscriminator(this.garProgram, OBSERVATION_DISCRIMINATOR, [
1179
+ {
1180
+ memcmp: {
1181
+ offset: 8n,
1182
+ bytes: bs58.encode(epochIndexBuf),
1183
+ encoding: 'base58',
1184
+ },
1185
+ },
1186
+ ]);
1187
+ const failureSummaries = {};
1188
+ const reports = {};
1189
+ // Read gateway registry to get index-to-address mapping (matches bitfield order)
1190
+ const gatewayAddresses = await this.getRegistryGatewayAddresses();
1191
+ for (const { data } of accounts) {
1192
+ try {
1193
+ const obs = deserializeObservation(data);
1194
+ reports[obs.observer] = obs.reportTxId;
1195
+ // Parse gateway_results bitmap — 1 = passed, 0 = failed (on-chain convention)
1196
+ for (let i = 0; i < obs.gatewayCount && i < gatewayAddresses.length; i++) {
1197
+ const byteIdx = Math.floor(i / 8);
1198
+ const bitIdx = i % 8;
1199
+ const passed = (obs.gatewayResults[byteIdx] >> bitIdx) & 1;
1200
+ if (!passed) {
1201
+ const gwAddr = gatewayAddresses[i];
1202
+ if (!failureSummaries[gwAddr]) {
1203
+ failureSummaries[gwAddr] = [];
1204
+ }
1205
+ failureSummaries[gwAddr].push(obs.observer);
1206
+ }
1207
+ }
1208
+ }
1209
+ catch {
1210
+ // Skip malformed
1211
+ }
1212
+ }
1213
+ return { failureSummaries, reports };
1214
+ }
1215
+ /**
1216
+ * Observer pubkeys that have an OPEN Observation PDA for `epochIndex`. Lean —
1217
+ * reads only the Observation accounts (no gateway-registry decode like
1218
+ * {@link getObservations}). Used by the crank to close observations before
1219
+ * `close_epoch`, whose `observations_closed == observations_submitted`
1220
+ * precondition would otherwise wedge epoch progression.
1221
+ */
1222
+ async getEpochObservers(epochIndex) {
1223
+ const epochIndexBuf = Buffer.alloc(8);
1224
+ epochIndexBuf.writeBigUInt64LE(BigInt(epochIndex));
1225
+ const accounts = await this.getAccountsByDiscriminator(this.garProgram, OBSERVATION_DISCRIMINATOR, [
1226
+ {
1227
+ memcmp: {
1228
+ offset: 8n,
1229
+ bytes: bs58.encode(epochIndexBuf),
1230
+ encoding: 'base58',
1231
+ },
1232
+ },
1233
+ ]);
1234
+ const observers = [];
1235
+ for (const { data } of accounts) {
1236
+ try {
1237
+ observers.push(address(deserializeObservation(data).observer));
1238
+ }
1239
+ catch {
1240
+ // skip malformed
1241
+ }
1242
+ }
1243
+ return observers;
1244
+ }
1245
+ async getDistributions(epoch) {
1246
+ const epochIndex = await this.resolveEpochIndex(epoch);
1247
+ const epochData = await this.fetchEpoch(epochIndex);
1248
+ return {
1249
+ totalEligibleGateways: epochData.activeGatewayCount,
1250
+ totalEligibleRewards: epochData.totalEligibleRewards,
1251
+ totalEligibleObserverReward: epochData.perObserverReward * epochData.observerCount,
1252
+ totalEligibleGatewayReward: epochData.perGatewayReward * epochData.activeGatewayCount,
1253
+ };
1254
+ }
1255
+ async getEligibleEpochRewards(epoch, params) {
1256
+ const epochIndex = await this.resolveEpochIndex(epoch);
1257
+ const epochData = await this.fetchEpoch(epochIndex);
1258
+ const items = [];
1259
+ // Each gateway operator gets a gateway reward
1260
+ const gatewayAccounts = await this.getAccountsByDiscriminator(this.garProgram, GATEWAY_DISCRIMINATOR);
1261
+ for (const { data } of gatewayAccounts) {
1262
+ try {
1263
+ const gw = deserializeGateway(data);
1264
+ if (gw.status !== 'joined')
1265
+ continue;
1266
+ items.push({
1267
+ type: 'operatorReward',
1268
+ recipient: gw.operator,
1269
+ eligibleReward: epochData.perGatewayReward,
1270
+ gatewayAddress: gw.operator,
1271
+ cursorId: gw.operator,
1272
+ });
1273
+ }
1274
+ catch {
1275
+ // skip
1276
+ }
1277
+ }
1278
+ // Each prescribed observer gets an observer reward
1279
+ for (let i = 0; i < epochData.observerCount; i++) {
1280
+ const observerAddr = epochData.prescribedObservers[i];
1281
+ const gatewayAddr = epochData.prescribedObserverGateways[i];
1282
+ if (observerAddr === DEFAULT_ADDRESS)
1283
+ continue;
1284
+ items.push({
1285
+ type: 'operatorReward',
1286
+ recipient: gatewayAddr,
1287
+ eligibleReward: epochData.perObserverReward,
1288
+ gatewayAddress: gatewayAddr,
1289
+ cursorId: `${gatewayAddr}-observer`,
1290
+ });
1291
+ }
1292
+ return paginate(items, params);
1293
+ }
1294
+ // =========================================
1295
+ // Pricing / cost read methods
1296
+ // =========================================
1297
+ /**
1298
+ * Compute the token cost for an ArNS operation.
1299
+ *
1300
+ * Mirrors the Rust pricing functions in ario-arns/src/pricing.rs.
1301
+ * Uses BigInt for u128-equivalent overflow-safe arithmetic.
1302
+ */
1303
+ async getTokenCost(params) {
1304
+ const [dfPda] = await getDemandFactorPDA(this.arnsProgram);
1305
+ const dfAccount = await this.getCachedAccount(dfPda);
1306
+ if (!dfAccount.exists)
1307
+ throw new Error('DemandFactor account not found');
1308
+ const df = deserializeDemandFactor(Buffer.from(dfAccount.data));
1309
+ const name = params.name.toLowerCase();
1310
+ const nameLen = Math.min(Math.max(name.length, 1), 51);
1311
+ const baseFee = df.fees[nameLen - 1] ?? df.fees[df.fees.length - 1];
1312
+ // currentDemandFactor is already divided by RATE_SCALE in deserializer,
1313
+ // but we need the raw scaled value for integer math
1314
+ const demandFactorRaw = BigInt(Math.round(df.currentDemandFactor * RATE_SCALE));
1315
+ const scale = BigInt(RATE_SCALE);
1316
+ const bf = BigInt(baseFee);
1317
+ let cost;
1318
+ switch (params.intent) {
1319
+ case 'Buy-Name':
1320
+ case 'Buy-Record': {
1321
+ const purchaseType = params.type ?? 'lease';
1322
+ if (purchaseType === 'permabuy') {
1323
+ cost = (bf * demandFactorRaw * 5n) / scale;
1324
+ }
1325
+ else {
1326
+ const years = BigInt(params.years ?? 1);
1327
+ const annualPct = 200000n;
1328
+ const yearFactor = scale + annualPct * years;
1329
+ cost = (bf * demandFactorRaw * yearFactor) / scale / scale;
1330
+ }
1331
+ try {
1332
+ const returned = await this.getArNSReturnedName({ name });
1333
+ if (returned) {
1334
+ // returned.startTimestamp is in ms (public API convention),
1335
+ // so the rest of this comparison is in ms too.
1336
+ const now = Date.now();
1337
+ const elapsed = now - returned.startTimestamp;
1338
+ const duration = 14 * 86_400_000;
1339
+ if (elapsed < duration) {
1340
+ const remaining = BigInt(duration - elapsed);
1341
+ const dur = BigInt(duration);
1342
+ const pctRemaining = (remaining * scale) / dur;
1343
+ const multiplier = 50n * pctRemaining;
1344
+ cost = (cost * multiplier) / scale;
1345
+ }
1346
+ }
1347
+ }
1348
+ catch {
1349
+ // Not a returned name — no premium
1350
+ }
1351
+ break;
1352
+ }
1353
+ case 'Extend-Lease': {
1354
+ const years = BigInt(params.years ?? 1);
1355
+ const annualPct = 200000n;
1356
+ cost = (bf * demandFactorRaw * annualPct * years) / scale / scale;
1357
+ break;
1358
+ }
1359
+ // A Primary-Name-Request is priced identically to a single
1360
+ // Increase-Undername-Limit operation against the same name: it uses the
1361
+ // name-length-indexed base fee (`bf`) and a quantity of 1. Fall through
1362
+ // to share that logic rather than duplicating it.
1363
+ case 'Primary-Name-Request':
1364
+ case 'Increase-Undername-Limit': {
1365
+ const qty = BigInt(params.quantity ?? 1);
1366
+ let isPermabuy = false;
1367
+ try {
1368
+ const record = await this.getArNSRecord({ name });
1369
+ isPermabuy = record.type === 'permabuy';
1370
+ }
1371
+ catch {
1372
+ // default to lease pricing
1373
+ }
1374
+ const pct = isPermabuy ? 5000n : 1000n;
1375
+ cost = (bf * demandFactorRaw * pct * qty) / scale / scale;
1376
+ break;
1377
+ }
1378
+ case 'Upgrade-Name': {
1379
+ const permabuyCost = (bf * demandFactorRaw * 5n) / scale;
1380
+ cost = permabuyCost;
1381
+ break;
1382
+ }
1383
+ default:
1384
+ throw new Error(`Unknown intent: ${params.intent}`);
1385
+ }
1386
+ return Number(cost);
1387
+ }
1388
+ async getCostDetails(params) {
1389
+ const tokenCost = await this.getTokenCost(params);
1390
+ const discounts = [];
1391
+ if (params.fromAddress) {
1392
+ try {
1393
+ // Operator-discount check. Read the gateway PDA through the short-TTL
1394
+ // cache (NOT public `getGateway`, which stays fresh for gateway pages):
1395
+ // a price table calls `getCostDetails` many times for the SAME
1396
+ // `fromAddress`, so this collapses N redundant gateway reads to one.
1397
+ const [gwPda] = await getGatewayPDA(address(params.fromAddress), this.garProgram);
1398
+ const gwAccount = await this.getCachedAccount(gwPda);
1399
+ if (gwAccount.exists) {
1400
+ const gw = deserializeGateway(Buffer.from(gwAccount.data));
1401
+ if (gw.status === 'joined') {
1402
+ // Match on-chain eligibility from ario-arns pricing.rs
1403
+ // `try_apply_gateway_discount`:
1404
+ // 1. Tenure: gateway running >= 180 days (15_552_000 seconds)
1405
+ const GATEWAY_DISCOUNT_MIN_TENURE_S = 15_552_000;
1406
+ const nowSeconds = Math.floor(Date.now() / 1000);
1407
+ const timeRunning = nowSeconds - gw.startTimestamp;
1408
+ // 2. Performance: >= 90% epoch pass rate
1409
+ const passRate = ((1 + gw.stats.passedEpochCount) /
1410
+ (1 + gw.stats.totalEpochCount)) *
1411
+ 1_000_000;
1412
+ if (timeRunning >= GATEWAY_DISCOUNT_MIN_TENURE_S &&
1413
+ passRate >= 900_000) {
1414
+ const discountAmount = Math.floor((tokenCost * 200_000) / RATE_SCALE);
1415
+ discounts.push({
1416
+ name: 'Gateway Operator',
1417
+ discountTotal: discountAmount,
1418
+ multiplier: 0.8,
1419
+ });
1420
+ }
1421
+ }
1422
+ }
1423
+ }
1424
+ catch {
1425
+ // Not a gateway operator — no discount
1426
+ }
1427
+ }
1428
+ const totalDiscount = discounts.reduce((sum, d) => sum + d.discountTotal, 0);
1429
+ const finalCost = tokenCost - totalDiscount;
1430
+ // Project Solana state into the public-facing `FundingPlan` shape when
1431
+ // the caller asks about a specific funding source for a specific wallet.
1432
+ // (`fromAddress` is required — we can't enumerate funding sources for
1433
+ // an unknown wallet; without `fundFrom` we don't know what to budget
1434
+ // against.) The internal `funding-plan.ts` `FundingPlan` is a
1435
+ // separate, instruction-building plan — keep them distinct.
1436
+ let fundingPlan;
1437
+ if (params.fromAddress && params.fundFrom !== undefined) {
1438
+ fundingPlan = await this.buildPublicFundingPlan({
1439
+ fromAddress: params.fromAddress,
1440
+ fundFrom: params.fundFrom,
1441
+ cost: finalCost,
1442
+ });
1443
+ }
1444
+ return {
1445
+ tokenCost: finalCost,
1446
+ discounts,
1447
+ ...(fundingPlan ? { fundingPlan } : {}),
1448
+ };
1449
+ }
1450
+ /**
1451
+ * Project Solana on-chain state into the cross-backend `FundingPlan`
1452
+ * shape consumed by UI flows like "how short are you on this purchase,
1453
+ * and from which sources?". Always returns the wallet's `balance` and
1454
+ * full per-gateway `stakes` breakdown (active delegations + pending
1455
+ * delegate-stake withdrawals); `shortfall` is computed against the
1456
+ * specific `fundFrom` semantics.
1457
+ *
1458
+ * Note: the internal `src/solana/funding-plan.ts` `FundingPlan` is a
1459
+ * different type — that's the multi-source instruction-building plan
1460
+ * used by `buyRecord({ fundFrom: 'any' })`. The two share a concept but
1461
+ * not a shape; the public type here is what consumer UIs see.
1462
+ */
1463
+ async buildPublicFundingPlan({ fromAddress, fundFrom, cost, }) {
1464
+ // Pull balance + full delegation list (stake + vault) in parallel.
1465
+ // Limit is intentionally large — the public FundingPlan reports the
1466
+ // *entire* per-gateway breakdown, not a pagination window.
1467
+ const [balance, delegations] = await Promise.all([
1468
+ this.getBalance({ address: fromAddress }),
1469
+ this.getDelegations({ address: fromAddress, limit: 10_000 }),
1470
+ ]);
1471
+ const stakes = {};
1472
+ for (const d of delegations.items) {
1473
+ const gateway = d.gatewayAddress;
1474
+ if (!stakes[gateway]) {
1475
+ stakes[gateway] = { vaults: [], delegatedStake: 0 };
1476
+ }
1477
+ if (d.type === 'stake') {
1478
+ stakes[gateway].delegatedStake = d.balance;
1479
+ }
1480
+ else {
1481
+ // `Record<string, number>[]` per-vault entries — AO-era shape we
1482
+ // keep for cross-backend compatibility.
1483
+ stakes[gateway].vaults.push({ [d.vaultId]: d.balance });
1484
+ }
1485
+ }
1486
+ const sumDelegated = Object.values(stakes).reduce((sum, g) => sum + g.delegatedStake, 0);
1487
+ const sumVaulted = Object.values(stakes).reduce((sum, g) => sum +
1488
+ g.vaults.reduce((vsum, v) => vsum + Object.values(v).reduce((a, b) => a + b, 0), 0), 0);
1489
+ // Compute shortfall against the *eligible* pool for the chosen
1490
+ // `fundFrom`. `turbo` and `plan` are special: turbo is paid in
1491
+ // off-chain credits (no mARIO shortfall meaningful here), and plan
1492
+ // is caller-supplied (caller did their own arithmetic).
1493
+ let eligible;
1494
+ switch (fundFrom) {
1495
+ case 'balance':
1496
+ eligible = balance;
1497
+ break;
1498
+ case 'stakes':
1499
+ eligible = sumDelegated;
1500
+ break;
1501
+ case 'withdrawal':
1502
+ eligible = sumVaulted;
1503
+ break;
1504
+ case 'any':
1505
+ eligible = balance + sumDelegated + sumVaulted;
1506
+ break;
1507
+ case 'turbo':
1508
+ case 'plan':
1509
+ eligible = Number.MAX_SAFE_INTEGER;
1510
+ break;
1511
+ default: {
1512
+ // Exhaustiveness check — surface a missed FundFrom variant at
1513
+ // type-check time, not at runtime.
1514
+ const _exhaustive = fundFrom;
1515
+ eligible = 0;
1516
+ void _exhaustive;
1517
+ }
1518
+ }
1519
+ return {
1520
+ address: fromAddress,
1521
+ balance,
1522
+ stakes,
1523
+ shortfall: Math.max(0, cost - eligible),
1524
+ };
1525
+ }
1526
+ async getRegistrationFees() {
1527
+ const [dfPda] = await getDemandFactorPDA(this.arnsProgram);
1528
+ const account = await this.getCachedAccount(dfPda);
1529
+ if (!account.exists) {
1530
+ throw new Error('DemandFactor account not found');
1531
+ }
1532
+ const df = deserializeDemandFactor(Buffer.from(account.data));
1533
+ const result = {};
1534
+ for (let len = 1; len <= 51; len++) {
1535
+ const baseFee = df.fees[len - 1] ?? 0;
1536
+ result[len] = {
1537
+ lease: {
1538
+ 1: Math.floor(baseFee * (1 + 0.2 * 1) * df.currentDemandFactor),
1539
+ 2: Math.floor(baseFee * (1 + 0.2 * 2) * df.currentDemandFactor),
1540
+ 3: Math.floor(baseFee * (1 + 0.2 * 3) * df.currentDemandFactor),
1541
+ 4: Math.floor(baseFee * (1 + 0.2 * 4) * df.currentDemandFactor),
1542
+ 5: Math.floor(baseFee * (1 + 0.2 * 5) * df.currentDemandFactor),
1543
+ },
1544
+ permabuy: Math.floor(baseFee * 5 * df.currentDemandFactor),
1545
+ };
1546
+ }
1547
+ return result;
1548
+ }
1549
+ async getDemandFactor() {
1550
+ const [pda] = await getDemandFactorPDA(this.arnsProgram);
1551
+ const account = await this.getCachedAccount(pda);
1552
+ if (!account.exists) {
1553
+ throw new Error('DemandFactor account not found');
1554
+ }
1555
+ return deserializeDemandFactor(Buffer.from(account.data))
1556
+ .currentDemandFactor;
1557
+ }
1558
+ async getDemandFactorSettings() {
1559
+ const [pda] = await getDemandFactorPDA(this.arnsProgram);
1560
+ const account = await this.getCachedAccount(pda);
1561
+ if (!account.exists) {
1562
+ throw new Error('DemandFactor account not found');
1563
+ }
1564
+ const df = deserializeDemandFactor(Buffer.from(account.data));
1565
+ return {
1566
+ periodZeroStartTimestamp: df.periodZeroStartTimestamp,
1567
+ movingAvgPeriodCount: 7,
1568
+ periodLengthMs: 86_400 * 1000,
1569
+ demandFactorBaseValue: 1,
1570
+ demandFactorMin: 0.5,
1571
+ demandFactorUpAdjustmentRate: 50,
1572
+ demandFactorDownAdjustmentRate: 25,
1573
+ maxPeriodsAtMinDemandFactor: df.consecutivePeriodsWithMinDemandFactor,
1574
+ criteria: 'revenue',
1575
+ };
1576
+ }
1577
+ // =========================================
1578
+ // Primary name read methods
1579
+ // =========================================
1580
+ async getPrimaryName(params) {
1581
+ // On-chain `PrimaryName` stores only {owner, name, set_at}. The ANT mint
1582
+ // that PrimaryName.processId expects lives on the matching ArnsRecord
1583
+ // (looked up by the base name). Both lookup paths below deserialize the
1584
+ // on-chain account and then enrich with the ArnsRecord lookup.
1585
+ const baseNameOf = (n) => {
1586
+ const parts = n.toLowerCase().split('_');
1587
+ return parts.length === 2 ? parts[1] : parts[0];
1588
+ };
1589
+ const enrich = async (pn) => {
1590
+ const rec = await this.getArNSRecord({ name: baseNameOf(pn.name) });
1591
+ return { ...pn, processId: rec.processId };
1592
+ };
1593
+ if ('address' in params) {
1594
+ const [pda] = await getPrimaryNamePDA(address(params.address), this.coreProgram);
1595
+ const account = await this.getAccount(pda);
1596
+ if (!account.exists) {
1597
+ throw new Error(`Primary name not found for address ${params.address}`);
1598
+ }
1599
+ return enrich(deserializePrimaryName(Buffer.from(account.data)));
1600
+ }
1601
+ // Lookup by name — scan all primary name accounts
1602
+ const accounts = await this.getAccountsByDiscriminator(this.coreProgram, PRIMARY_NAME_DISCRIMINATOR);
1603
+ for (const { data } of accounts) {
1604
+ try {
1605
+ const pn = deserializePrimaryName(data);
1606
+ if (pn.name === params.name) {
1607
+ return enrich(pn);
1608
+ }
1609
+ }
1610
+ catch {
1611
+ // Skip malformed
1612
+ }
1613
+ }
1614
+ throw new Error(`Primary name not found: ${params.name}`);
1615
+ }
1616
+ async getPrimaryNameRequest(params) {
1617
+ const [pda] = await getPrimaryNameRequestPDA(address(params.initiator), this.coreProgram);
1618
+ const account = await this.getAccount(pda);
1619
+ if (!account.exists) {
1620
+ throw new Error(`Primary name request not found for ${params.initiator}`);
1621
+ }
1622
+ return deserializePrimaryNameRequest(Buffer.from(account.data));
1623
+ }
1624
+ async getPrimaryNameRequests(params) {
1625
+ const accounts = await this.getAccountsByDiscriminator(this.coreProgram, PRIMARY_NAME_REQUEST_DISCRIMINATOR);
1626
+ const items = [];
1627
+ for (const { data } of accounts) {
1628
+ try {
1629
+ items.push(deserializePrimaryNameRequest(data));
1630
+ }
1631
+ catch {
1632
+ // Skip malformed
1633
+ }
1634
+ }
1635
+ return paginate(items, params);
1636
+ }
1637
+ async getPrimaryNames(params) {
1638
+ const accounts = await this.getAccountsByDiscriminator(this.coreProgram, PRIMARY_NAME_DISCRIMINATOR);
1639
+ // Enrich each on-chain PrimaryName with its ArnsRecord.processId (the
1640
+ // on-chain account doesn't store it; see deserializePrimaryName).
1641
+ // Records that no longer have a matching ArnsRecord are silently
1642
+ // skipped — same forgiveness the per-name lookup already applies.
1643
+ const baseNameOf = (n) => {
1644
+ const parts = n.toLowerCase().split('_');
1645
+ return parts.length === 2 ? parts[1] : parts[0];
1646
+ };
1647
+ const items = [];
1648
+ for (const { data } of accounts) {
1649
+ try {
1650
+ const pn = deserializePrimaryName(data);
1651
+ const rec = await this.getArNSRecord({ name: baseNameOf(pn.name) });
1652
+ items.push({ ...pn, processId: rec.processId });
1653
+ }
1654
+ catch {
1655
+ // Skip malformed or orphaned (ArnsRecord missing).
1656
+ }
1657
+ }
1658
+ return paginate(items, params);
1659
+ }
1660
+ // =========================================
1661
+ // Redelegation fee
1662
+ // =========================================
1663
+ async getRedelegationFee(params) {
1664
+ const { getRedelegationRecordPDA } = await import('./pda.js');
1665
+ const [pda] = await getRedelegationRecordPDA(address(params.address), this.garProgram);
1666
+ const account = await this.getAccount(pda);
1667
+ if (!account.exists) {
1668
+ return { redelegationFeeRate: 0, feeResetTimestamp: 0 };
1669
+ }
1670
+ const record = deserializeRedelegationRecord(Buffer.from(account.data));
1671
+ const now = Math.floor(Date.now() / 1000);
1672
+ if (now >= record.feeResetAt) {
1673
+ return { redelegationFeeRate: 0, feeResetTimestamp: record.feeResetAt };
1674
+ }
1675
+ const feeRate = Math.min(record.redelegationCount * 10, 60);
1676
+ return {
1677
+ redelegationFeeRate: feeRate,
1678
+ feeResetTimestamp: record.feeResetAt,
1679
+ };
1680
+ }
1681
+ // =========================================
1682
+ // Gateway registry settings
1683
+ // =========================================
1684
+ async getGatewayRegistrySettings() {
1685
+ const [pda] = await getGarSettingsPDA(this.garProgram);
1686
+ const account = await this.getAccount(pda);
1687
+ if (!account.exists) {
1688
+ throw new Error('GarSettings account not found');
1689
+ }
1690
+ return deserializeGarSettings(Buffer.from(account.data));
1691
+ }
1692
+ // =========================================
1693
+ // Aggregate queries
1694
+ // =========================================
1695
+ async getAllDelegates(params) {
1696
+ const accounts = await this.getAccountsByDiscriminator(this.garProgram, DELEGATION_DISCRIMINATOR);
1697
+ const decoded = [];
1698
+ for (const { pubkey, data } of accounts) {
1699
+ try {
1700
+ decoded.push({
1701
+ pubkey: pubkey,
1702
+ del: deserializeDelegation(data),
1703
+ });
1704
+ }
1705
+ catch {
1706
+ // Skip malformed
1707
+ }
1708
+ }
1709
+ // Batch-fetch each referenced gateway's reward accumulator so we can
1710
+ // return live balances. See INVARIANTS.md and `computeLiveDelegationBalance`.
1711
+ const accumulators = await this.getGatewayAccumulators(decoded.map(({ del }) => del.gateway));
1712
+ const items = decoded.map(({ pubkey, del }) => ({
1713
+ address: del.delegator,
1714
+ gatewayAddress: del.gateway,
1715
+ delegatedStake: computeLiveDelegationBalance({
1716
+ delegatedStake: del.delegatedStake,
1717
+ rewardDebt: del.rewardDebt,
1718
+ cumulativeRewardPerToken: accumulators.get(del.gateway) ?? 0n,
1719
+ }),
1720
+ startTimestamp: secToMs(del.startTimestamp),
1721
+ vaultedStake: 0,
1722
+ cursorId: pubkey,
1723
+ }));
1724
+ return paginate(items, params);
1725
+ }
1726
+ /**
1727
+ * Enumerate every delegation that has pending (unsettled) rewards — the work
1728
+ * list for the permissionless `compound_delegation_rewards` crank. Pending is
1729
+ * computed from the gateway's reward-per-share accumulator (mirrors
1730
+ * {@link computeLiveDelegationBalance}); the crank only changes balances, so
1731
+ * rewards already accrue correctly without it.
1732
+ *
1733
+ * Skips delegations whose gateway is `Leaving` (those settle through
1734
+ * `claim_delegate_from_leaving_gateway`, not compounding) and any below
1735
+ * `minPendingRewards` — compounding sub-threshold dust just advances
1736
+ * `reward_debt` for no balance gain. Feed the result, chunked, to
1737
+ * `SolanaARIOWriteable.compoundDelegationRewardsBatch`.
1738
+ */
1739
+ async getDelegationsToCompound(params) {
1740
+ const minPending = params?.minPendingRewards ?? 0;
1741
+ // One scan for gateways → accumulator + status.
1742
+ const gatewayAccounts = await this.getAccountsByDiscriminator(this.garProgram, GATEWAY_DISCRIMINATOR);
1743
+ const gateways = new Map();
1744
+ for (const { data } of gatewayAccounts) {
1745
+ try {
1746
+ const gw = deserializeGatewayWithAccumulator(data);
1747
+ gateways.set(gw.operator, {
1748
+ cumulativeRewardPerToken: gw.cumulativeRewardPerToken,
1749
+ status: gw.status,
1750
+ });
1751
+ }
1752
+ catch {
1753
+ // Skip malformed.
1754
+ }
1755
+ }
1756
+ // One scan for delegations; decode then delegate the selection logic to the
1757
+ // pure `selectCompoundableDelegations` (unit-tested in delegation-math).
1758
+ const delegationAccounts = await this.getAccountsByDiscriminator(this.garProgram, DELEGATION_DISCRIMINATOR);
1759
+ const delegations = [];
1760
+ for (const { data } of delegationAccounts) {
1761
+ try {
1762
+ const del = deserializeDelegation(data);
1763
+ delegations.push({
1764
+ gateway: del.gateway,
1765
+ delegator: del.delegator,
1766
+ delegatedStake: del.delegatedStake,
1767
+ rewardDebt: del.rewardDebt,
1768
+ });
1769
+ }
1770
+ catch {
1771
+ // Skip malformed.
1772
+ }
1773
+ }
1774
+ return selectCompoundableDelegations(delegations, gateways, minPending);
1775
+ }
1776
+ async getAllGatewayVaults(params) {
1777
+ const accounts = await this.getAccountsByDiscriminator(this.garProgram, WITHDRAWAL_DISCRIMINATOR);
1778
+ const items = [];
1779
+ for (const { pubkey, data } of accounts) {
1780
+ try {
1781
+ const w = deserializeWithdrawal(data);
1782
+ items.push({
1783
+ cursorId: pubkey,
1784
+ vaultId: w.vaultId,
1785
+ balance: w.balance,
1786
+ startTimestamp: secToMs(w.startTimestamp),
1787
+ endTimestamp: secToMs(w.endTimestamp),
1788
+ gatewayAddress: w.gateway,
1789
+ });
1790
+ }
1791
+ catch {
1792
+ // Skip malformed
1793
+ }
1794
+ }
1795
+ return paginate(items, params);
1796
+ }
1797
+ // =========================================
1798
+ // Prune / cleanup discovery (Solana-only)
1799
+ // =========================================
1800
+ //
1801
+ // These helpers enumerate accounts eligible for the permissionless prune
1802
+ // ix surface (see SolanaARIOWriteable). All read on-chain via
1803
+ // `getProgramAccounts` + the Codama decoders, then post-filter
1804
+ // client-side because most eligibility predicates can't be expressed as
1805
+ // memcmp filters (variable-length names shift offsets; Option<i64>
1806
+ // adds a tag byte). Volume is bounded — the cranker is expected to
1807
+ // call these once per epoch cycle, not per-tx.
1808
+ //
1809
+ // See `docs/CRANKER_PRUNING_PLAN.md` for the design.
1810
+ /**
1811
+ * Enumerate ArnsRecord PDAs whose lease has fully expired
1812
+ * (`end_timestamp + grace_period + return_auction_duration <= now`).
1813
+ * Permabuys (no `end_timestamp`) are excluded. Pass a unix-seconds `now`.
1814
+ */
1815
+ async getExpiredArnsRecords(now) {
1816
+ const [arnsConfigPda] = await getArnsSettingsPDA(this.arnsProgram);
1817
+ const cfgAccount = await this.getCachedAccount(arnsConfigPda);
1818
+ if (!cfgAccount.exists)
1819
+ return [];
1820
+ const cfg = getArnsConfigDecoder().decode(cfgAccount.data);
1821
+ const grace = Number(cfg.gracePeriodSeconds);
1822
+ const auction = Number(cfg.returnAuctionDurationSeconds);
1823
+ const accounts = await this.getAccountsByDiscriminator(this.arnsProgram, ARNS_RECORD_DISCRIMINATOR);
1824
+ const decoder = getArnsRecordDecoder();
1825
+ const out = [];
1826
+ for (const { pubkey, data } of accounts) {
1827
+ try {
1828
+ const r = decoder.decode(data);
1829
+ if (r.endTimestamp.__option !== 'Some')
1830
+ continue;
1831
+ const end = Number(r.endTimestamp.value);
1832
+ if (end + grace + auction <= now) {
1833
+ out.push({
1834
+ pubkey,
1835
+ name: r.name,
1836
+ endTimestamp: r.endTimestamp.value,
1837
+ });
1838
+ }
1839
+ }
1840
+ catch {
1841
+ // skip malformed
1842
+ }
1843
+ }
1844
+ return out;
1845
+ }
1846
+ /**
1847
+ * Enumerate ReturnedName PDAs whose Dutch auction window has fully
1848
+ * elapsed (`returned_at + return_auction_duration <= now`).
1849
+ */
1850
+ async getExpiredReturnedNames(now) {
1851
+ const [arnsConfigPda] = await getArnsSettingsPDA(this.arnsProgram);
1852
+ const cfgAccount = await this.getCachedAccount(arnsConfigPda);
1853
+ if (!cfgAccount.exists)
1854
+ return [];
1855
+ const cfg = getArnsConfigDecoder().decode(cfgAccount.data);
1856
+ const auction = Number(cfg.returnAuctionDurationSeconds);
1857
+ const accounts = await this.getAccountsByDiscriminator(this.arnsProgram, RETURNED_NAME_DISCRIMINATOR);
1858
+ const decoder = getReturnedNameDecoder();
1859
+ const out = [];
1860
+ for (const { pubkey, data } of accounts) {
1861
+ try {
1862
+ const r = decoder.decode(data);
1863
+ if (Number(r.returnedAt) + auction <= now) {
1864
+ out.push({ pubkey, name: r.name, returnedAt: r.returnedAt });
1865
+ }
1866
+ }
1867
+ catch {
1868
+ // skip malformed
1869
+ }
1870
+ }
1871
+ return out;
1872
+ }
1873
+ /**
1874
+ * Enumerate ReservedName PDAs whose `expires_at` has passed.
1875
+ * Permanent reservations (`expires_at: None`) are excluded.
1876
+ */
1877
+ async getExpiredReservations(now) {
1878
+ const accounts = await this.getAccountsByDiscriminator(this.arnsProgram, RESERVED_NAME_DISCRIMINATOR);
1879
+ const decoder = getReservedNameDecoder();
1880
+ const out = [];
1881
+ for (const { pubkey, data } of accounts) {
1882
+ try {
1883
+ const r = decoder.decode(data);
1884
+ if (r.expiresAt.__option !== 'Some')
1885
+ continue;
1886
+ if (Number(r.expiresAt.value) <= now) {
1887
+ out.push({ pubkey, name: r.name });
1888
+ }
1889
+ }
1890
+ catch {
1891
+ // skip malformed
1892
+ }
1893
+ }
1894
+ return out;
1895
+ }
1896
+ /**
1897
+ * Enumerate Gateway PDAs in `Joined` status with
1898
+ * `stats.failed_consecutive >= maxFailures`. These are eligible for
1899
+ * `pruneGateway` (slash + remove from registry).
1900
+ */
1901
+ async getDeficientGateways(maxFailures) {
1902
+ const accounts = await this.getAccountsByDiscriminator(this.garProgram, GATEWAY_DISCRIMINATOR);
1903
+ const decoder = getGatewayDecoder();
1904
+ const out = [];
1905
+ for (const { pubkey, data } of accounts) {
1906
+ try {
1907
+ const g = decoder.decode(data);
1908
+ if (g.status !== GatewayStatus.Joined)
1909
+ continue;
1910
+ if (g.stats.failedConsecutive >= maxFailures) {
1911
+ out.push({
1912
+ pubkey,
1913
+ operator: g.operator,
1914
+ failedConsecutive: g.stats.failedConsecutive,
1915
+ });
1916
+ }
1917
+ }
1918
+ catch {
1919
+ // skip malformed
1920
+ }
1921
+ }
1922
+ return out;
1923
+ }
1924
+ /**
1925
+ * Enumerate Gateway PDAs that are candidates for `finalizeGone` GC — i.e.
1926
+ * those whose `status == Leaving`.
1927
+ *
1928
+ * NOTE: despite the historical name, this returns `Leaving` (not `Gone`)
1929
+ * gateways. `Gone` is NOT a persistent discovery state: the on-chain
1930
+ * `finalize_gone` instruction accepts a `Leaving` gateway, flips it to
1931
+ * `Gone`, and closes the Gateway PDA in the *same* instruction
1932
+ * (programs/ario-gar/src/instructions/gateway.rs::finalize_gone), so a
1933
+ * gateway is never observably parked at `Gone`. Filtering on `Gone` matched
1934
+ * nothing and left Leaving gateways un-GC'd. Time/delegation eligibility is
1935
+ * still enforced on-chain (`finalize_gone` reverts early if the leave window
1936
+ * hasn't elapsed or delegations remain), so over-returning not-yet-eligible
1937
+ * Leaving gateways is safe — the tx just no-ops/reverts.
1938
+ *
1939
+ * @deprecated Prefer {@link getLeavingGateways} — same result, accurate name.
1940
+ */
1941
+ async getGoneGateways() {
1942
+ const accounts = await this.getAccountsByDiscriminator(this.garProgram, GATEWAY_DISCRIMINATOR);
1943
+ const decoder = getGatewayDecoder();
1944
+ const out = [];
1945
+ for (const { pubkey, data } of accounts) {
1946
+ try {
1947
+ const g = decoder.decode(data);
1948
+ if (g.status === GatewayStatus.Leaving) {
1949
+ out.push({ pubkey, operator: g.operator });
1950
+ }
1951
+ }
1952
+ catch {
1953
+ // skip malformed
1954
+ }
1955
+ }
1956
+ return out;
1957
+ }
1958
+ /**
1959
+ * Enumerate Gateway PDAs whose `status == Leaving` — the persistent
1960
+ * pre-finalization state that `finalizeGone` GC's. Alias for
1961
+ * {@link getGoneGateways} with a name that matches the on-chain state.
1962
+ */
1963
+ async getLeavingGateways() {
1964
+ return this.getGoneGateways();
1965
+ }
1966
+ /**
1967
+ * Enumerate Joined Gateway PDAs whose delegation has been DISABLED
1968
+ * (`allow_delegated_staking == false`) yet still hold delegated stake
1969
+ * (`total_delegated_stake > 0`) — i.e. delegates that an operator's disable
1970
+ * left stranded (WP §6.3 / Fix #6). Each such gateway's delegates must be
1971
+ * cranked out via
1972
+ * {@link SolanaARIOWriteable.claimDelegateFromDisabledGateway} (enumerate
1973
+ * them with {@link getGatewayDelegates}) before the operator can re-enable
1974
+ * delegation. This is the discovery primitive a cranker uses to sweep them.
1975
+ */
1976
+ async getDisabledGatewaysWithDelegatedStake() {
1977
+ const accounts = await this.getAccountsByDiscriminator(this.garProgram, GATEWAY_DISCRIMINATOR);
1978
+ const decoder = getGatewayDecoder();
1979
+ const out = [];
1980
+ for (const { pubkey, data } of accounts) {
1981
+ try {
1982
+ const g = decoder.decode(data);
1983
+ if (g.status !== GatewayStatus.Joined)
1984
+ continue;
1985
+ if (!g.settings.allowDelegatedStaking && g.totalDelegatedStake > 0n) {
1986
+ out.push({
1987
+ pubkey,
1988
+ operator: g.operator,
1989
+ totalDelegatedStake: g.totalDelegatedStake,
1990
+ });
1991
+ }
1992
+ }
1993
+ catch {
1994
+ // skip malformed
1995
+ }
1996
+ }
1997
+ return out;
1998
+ }
1999
+ /**
2000
+ * Enumerate Delegation PDAs with `amount == 0`. Eligible for
2001
+ * `closeEmptyDelegation` (rent refund to the original delegator).
2002
+ */
2003
+ async getEmptyDelegations() {
2004
+ const accounts = await this.getAccountsByDiscriminator(this.garProgram, DELEGATION_DISCRIMINATOR);
2005
+ const decoder = getDelegationDecoder();
2006
+ const out = [];
2007
+ for (const { pubkey, data } of accounts) {
2008
+ try {
2009
+ const d = decoder.decode(data);
2010
+ if (d.amount === 0n) {
2011
+ out.push({ pubkey, gateway: d.gateway, delegator: d.delegator });
2012
+ }
2013
+ }
2014
+ catch {
2015
+ // skip malformed
2016
+ }
2017
+ }
2018
+ return out;
2019
+ }
2020
+ /**
2021
+ * Enumerate Withdrawal PDAs with `amount == 0` (drained via
2022
+ * fund-from-withdrawal payments). Eligible for `closeDrainedWithdrawal`
2023
+ * (rent refund to owner).
2024
+ */
2025
+ async getDrainedWithdrawals() {
2026
+ const accounts = await this.getAccountsByDiscriminator(this.garProgram, WITHDRAWAL_DISCRIMINATOR);
2027
+ const decoder = getWithdrawalDecoder();
2028
+ const out = [];
2029
+ for (const { pubkey, data } of accounts) {
2030
+ try {
2031
+ const w = decoder.decode(data);
2032
+ if (w.amount === 0n) {
2033
+ out.push({ pubkey, owner: w.owner, withdrawalId: w.withdrawalId });
2034
+ }
2035
+ }
2036
+ catch {
2037
+ // skip malformed
2038
+ }
2039
+ }
2040
+ return out;
2041
+ }
2042
+ /**
2043
+ * Enumerate Vault PDAs whose `end_timestamp` has passed (eligible for
2044
+ * `releaseVault`). Note: `releaseVault` is owner-signed, so the cranker
2045
+ * can only release its own vaults — the helper still surfaces every
2046
+ * expired vault so other consumers (UIs, indexers) can use it too.
2047
+ */
2048
+ async getExpiredVaults(now) {
2049
+ const accounts = await this.getAccountsByDiscriminator(this.coreProgram, VAULT_DISCRIMINATOR);
2050
+ const decoder = getVaultDecoder();
2051
+ const out = [];
2052
+ for (const { pubkey, data } of accounts) {
2053
+ try {
2054
+ const v = decoder.decode(data);
2055
+ if (Number(v.endTimestamp) <= now) {
2056
+ out.push({
2057
+ pubkey,
2058
+ owner: v.owner,
2059
+ vaultId: v.vaultId,
2060
+ endTimestamp: v.endTimestamp,
2061
+ });
2062
+ }
2063
+ }
2064
+ catch {
2065
+ // skip malformed
2066
+ }
2067
+ }
2068
+ return out;
2069
+ }
2070
+ /**
2071
+ * Enumerate PrimaryNameRequest PDAs whose `expires_at` has passed.
2072
+ * Eligible for `closeExpiredRequest` (rent refund to original initiator).
2073
+ */
2074
+ async getExpiredPrimaryNameRequests(now) {
2075
+ const accounts = await this.getAccountsByDiscriminator(this.coreProgram, PRIMARY_NAME_REQUEST_DISCRIMINATOR);
2076
+ const decoder = getPrimaryNameRequestDecoder();
2077
+ const out = [];
2078
+ for (const { pubkey, data } of accounts) {
2079
+ try {
2080
+ const r = decoder.decode(data);
2081
+ if (Number(r.expiresAt) <= now) {
2082
+ out.push({ pubkey, initiator: r.initiator });
2083
+ }
2084
+ }
2085
+ catch {
2086
+ // skip malformed
2087
+ }
2088
+ }
2089
+ return out;
2090
+ }
2091
+ /**
2092
+ * Read the live `ArnsConfig` (used by the cranker to gate
2093
+ * `pruneExpiredNames` / `pruneReturnedNames` on the
2094
+ * `next_*_prune_timestamp` hints).
2095
+ */
2096
+ async getArnsConfigRaw() {
2097
+ const [pda] = await getArnsSettingsPDA(this.arnsProgram);
2098
+ const account = await this.getCachedAccount(pda);
2099
+ if (!account.exists)
2100
+ return null;
2101
+ const cfg = getArnsConfigDecoder().decode(account.data);
2102
+ return {
2103
+ nextRecordsPruneTimestamp: cfg.nextRecordsPruneTimestamp,
2104
+ nextReturnedNamesPruneTimestamp: cfg.nextReturnedNamesPruneTimestamp,
2105
+ gracePeriodSeconds: cfg.gracePeriodSeconds,
2106
+ returnAuctionDurationSeconds: cfg.returnAuctionDurationSeconds,
2107
+ };
2108
+ }
2109
+ // =========================================
2110
+ // Name resolution (ArNSNameResolver)
2111
+ // =========================================
2112
+ async resolveArNSName({ name }) {
2113
+ const parts = name.split('_');
2114
+ const baseName = parts.length > 1 ? parts[parts.length - 1] : parts[0];
2115
+ const record = await this.getArNSRecord({ name: baseName });
2116
+ // TODO: resolve undername via ANT program when undername !== '@'
2117
+ return {
2118
+ name: baseName,
2119
+ txId: '',
2120
+ type: record.type,
2121
+ processId: record.processId,
2122
+ ttlSeconds: 3600,
2123
+ undernameLimit: record.undernameLimit,
2124
+ };
2125
+ }
2126
+ // =========================================================================
2127
+ // Observer helpers (Solana-only; used by gateway-side report submission)
2128
+ // =========================================================================
2129
+ /**
2130
+ * Resolve the gateway operator pubkey backing a given observer pubkey.
2131
+ * The `ObserverLookup` PDA is written at `join_network` (and rotated by
2132
+ * `update_observer_address`); when present its `gateway` field is the
2133
+ * operator pubkey. Returns `undefined` when the observer isn't
2134
+ * registered on any gateway.
2135
+ */
2136
+ async getObserverLookup(observer) {
2137
+ const [pda] = await getObserverLookupPDA(observer, this.garProgram);
2138
+ const account = await this.getAccount(pda);
2139
+ if (!account.exists)
2140
+ return undefined;
2141
+ const data = Buffer.from(account.data);
2142
+ // Layout: 8 disc + 32 gateway + 1 bump.
2143
+ const gateway = addressDecoder.decode(data.subarray(8, 40));
2144
+ const bump = data.readUInt8(40);
2145
+ return { gateway, bump };
2146
+ }
2147
+ /**
2148
+ * Pre-flight gate for `save_observations` submission. Reads the Epoch
2149
+ * account once and reports whether the given observer pubkey is:
2150
+ * - `prescribed`: in `epoch.prescribed_observers[..observer_count]`
2151
+ * - `observerIdx`: position in the array (matches the `has_observed`
2152
+ * bit index when prescribed)
2153
+ * - `alreadyObserved`: whether the bit at `observerIdx` is set
2154
+ * - `windowOpen`: whether `now < epoch.end_timestamp`
2155
+ *
2156
+ * Use this from a sink/wrapper to skip cheap-to-skip cases before
2157
+ * paying for a transaction simulation that would just bounce.
2158
+ */
2159
+ async getEpochObservationStatus(epochIndex, observer) {
2160
+ const epoch = await this.fetchEpoch(epochIndex);
2161
+ let observerIdx = -1;
2162
+ for (let i = 0; i < epoch.observerCount; i++) {
2163
+ if (epoch.prescribedObservers[i] === observer) {
2164
+ observerIdx = i;
2165
+ break;
2166
+ }
2167
+ }
2168
+ const prescribed = observerIdx !== -1;
2169
+ const alreadyObserved = prescribed &&
2170
+ ((epoch.hasObserved[Math.floor(observerIdx / 8)] >> (observerIdx % 8)) &
2171
+ 1) ===
2172
+ 1;
2173
+ const nowSec = Math.floor(Date.now() / 1000);
2174
+ return {
2175
+ prescribed,
2176
+ observerIdx,
2177
+ alreadyObserved,
2178
+ windowOpen: nowSec < epoch.endTimestamp,
2179
+ endTimestampSec: epoch.endTimestamp,
2180
+ };
2181
+ }
2182
+ }