@ar.io/sdk 4.0.0-solana.23 → 4.0.0-solana.24

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.
@@ -54,7 +54,7 @@ export { ARWEAVE_TX_REGEX, AR_IO_PROTOCOL, arweaveUri, FQDN_REGEX, MARIO_PER_ARI
54
54
  // Solana implementation classes (still exported for advanced/direct usage —
55
55
  // the `ARIO` / `ANT` factories above wrap these).
56
56
  export { SolanaARIOReadable } from './io-readable.js';
57
- export { SolanaARIOWriteable } from './io-writeable.js';
57
+ export { isInvalidGatewayAccountError, SolanaARIOWriteable, } from './io-writeable.js';
58
58
  // ANT classes
59
59
  export { SolanaANTReadable } from './ant-readable.js';
60
60
  export { SolanaANTWriteable } from './ant-writeable.js';
@@ -88,6 +88,8 @@ export { hashName, getArioConfigPDA, getBalancePDA, getVaultPDA, getVaultCounter
88
88
  // `ANT_RECORD_DISCRIMINATOR`). Pull them from there instead of asking
89
89
  // this module — single source of truth, derived from the IDL.
90
90
  export { BorshReader, BorshWriter, deserializeGateway, deserializeArnsRecord, deserializeVault, deserializeDelegation, deserializeBalance, deserializeEpochSettings, deserializeArioConfig, deserializeDemandFactor, deserializeReservedName, deserializeReturnedName, deserializeWithdrawal, deserializeRedelegationRecord, deserializePrimaryNameRequest, deserializePrimaryName, deserializeAllowlist, deserializeGarSettings, deserializeEpochSettingsFull, deserializeEpoch, deserializeObservation, deserializeAntConfig, deserializeAntControllers, deserializeAntRecord, deserializeAclConfig, deserializeAclPage, } from './deserialize.js';
91
+ // Off-chain prediction of prescribe_epoch's observer selection (cranker helper)
92
+ export { predictPrescribedObservers, } from './predict-prescribed-observers.js';
91
93
  // Constants
92
94
  export * from './constants.js';
93
95
  // Cluster-specific deployment constants (devnet program IDs, RPC URL,
@@ -43,7 +43,8 @@ import { getTransferCheckedInstruction } from '@solana-program/token';
43
43
  import { ARIO_ANT_PROGRAM_ID, TOKEN_DECIMALS } from './constants.js';
44
44
  import { SolanaARIOReadable } from './io-readable.js';
45
45
  import { getAntRecordPDA, getArioConfigPDA, getArnsRecordPDA, getArnsRegistryPDA, getArnsSettingsPDA, getDelegationPDA, getDemandFactorPDA, getEpochPDA, getEpochSettingsPDA, getGarSettingsPDA, getGatewayPDA, getGatewayRegistryPDA, getObservationPDA, getObserverLookupPDA, getPrimaryNamePDA, getPrimaryNameRequestPDA, getPrimaryNameReversePDA, getReservedNamePDA, getReturnedNamePDA, getVaultPDA, getWithdrawalCounterPDA, getWithdrawalPDA, hashName, } from './pda.js';
46
- import { sendAndConfirm } from './send.js';
46
+ import { predictPrescribedObservers, } from './predict-prescribed-observers.js';
47
+ import { reclaimLookupTablesForSigner, sendAndConfirm, sendWithEphemeralLookupTable, } from './send.js';
47
48
  const addressDecoder = getAddressDecoder();
48
49
  /** Resolve mARIOToken | number to a plain number */
49
50
  function toAmount(qty) {
@@ -181,6 +182,29 @@ export function encodeReportTxId(reportTxId) {
181
182
  decoded.copy(out);
182
183
  return out;
183
184
  }
185
+ /**
186
+ * Detect the GAR `InvalidGatewayAccount` error by Anchor error name/message
187
+ * (walking the cause chain + `context.logs`), NOT by numeric code — codes are
188
+ * `6000 + enum-index` and shift across program versions, but the name and
189
+ * message are stable. `prescribe_epoch` raises this when a supplied observer
190
+ * Gateway PDA is missing/spoofed (e.g. a predicted observer left the registry
191
+ * between prediction and tx landing).
192
+ */
193
+ export function isInvalidGatewayAccountError(error) {
194
+ const parts = [];
195
+ let cur = error;
196
+ for (let i = 0; cur != null && i < 8; i++) {
197
+ const e = cur;
198
+ if (e.message)
199
+ parts.push(e.message);
200
+ if (Array.isArray(e.context?.logs))
201
+ parts.push(e.context.logs.join('\n'));
202
+ cur = e.cause;
203
+ }
204
+ const text = parts.join('\n');
205
+ return (text.includes('InvalidGatewayAccount') ||
206
+ text.includes('Invalid gateway account'));
207
+ }
184
208
  export class SolanaARIOWriteable extends SolanaARIOReadable {
185
209
  signer;
186
210
  rpcSubscriptions;
@@ -1995,9 +2019,22 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
1995
2019
  }
1996
2020
  /**
1997
2021
  * Prescribe observers and names for an epoch. Permissionless — call after
1998
- * weights are tallied. Gateway PDAs are appended as `remaining_accounts`,
1999
- * and the optional `nameRegistryAccount` (must be last) enables the name
2022
+ * weights are tallied.
2023
+ *
2024
+ * `gatewayAccounts` MUST be the Gateway PDAs of the SELECTED observers only
2025
+ * — at most `epoch_settings.prescribed_observer_count` (≤50), NOT the whole
2026
+ * registry. The selection is computed on-chain; mirror it off-chain with
2027
+ * {@link predictPrescribedObservers} / {@link getPredictedObserverPDAs} to
2028
+ * learn the set. Passing every registry gateway (e.g. via
2029
+ * {@link getAllRegistryGatewayPDAs}) hits Solana's `MAX_TX_ACCOUNT_LOCKS = 64`
2030
+ * on large registries and the tx fails at pre-flight.
2031
+ *
2032
+ * The selected PDAs are appended as `remaining_accounts`, followed by the
2033
+ * optional `nameRegistryAccount` (must be LAST) which enables the name
2000
2034
  * prescription leg.
2035
+ *
2036
+ * If a selected gateway leaves between prediction and tx landing, the tx
2037
+ * fails with `InvalidGatewayAccount` — retry once with a fresh prediction.
2001
2038
  */
2002
2039
  async prescribeEpoch(params, _options) {
2003
2040
  const ix = await getPrescribeEpochInstructionAsync(await this.withGarDefaults({
@@ -2014,7 +2051,28 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
2014
2051
  role: AccountRole.READONLY,
2015
2052
  });
2016
2053
  }
2017
- const sig = await this.sendTransaction([withRemainingAccounts(ix, remaining)], 1_000_000);
2054
+ const fullIx = withRemainingAccounts(ix, remaining);
2055
+ // A prescribe tx with the selected observer set (~50 PDAs) exceeds Solana's
2056
+ // 1232-byte limit once there are more than ~24 remaining accounts, so route
2057
+ // those through an ephemeral Address Lookup Table (create → extend →
2058
+ // compressed v0 tx). Small sets (sparse testnets) take the cheaper inline
2059
+ // path. `prescribe_epoch` searches `remaining_accounts` by PDA, so serving
2060
+ // them via the ALT (which preserves instruction account order) is
2061
+ // transparent — incl. NameRegistry staying last. Validated on staging
2062
+ // (667 gateways, 50 observers): 428k CU, name prescription intact.
2063
+ if (remaining.length > 24) {
2064
+ const id = await sendWithEphemeralLookupTable({
2065
+ rpc: this.rpc,
2066
+ rpcSubscriptions: this.rpcSubscriptions,
2067
+ signer: this.signer,
2068
+ instruction: fullIx,
2069
+ lookupAddresses: remaining.map((a) => a.address),
2070
+ commitment: this.commitment,
2071
+ computeUnitLimit: 1_000_000,
2072
+ });
2073
+ return { id };
2074
+ }
2075
+ const sig = await this.sendTransaction([fullIx], 1_000_000);
2018
2076
  return { id: sig };
2019
2077
  }
2020
2078
  /**
@@ -2115,6 +2173,244 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
2115
2173
  }
2116
2174
  return pdas;
2117
2175
  }
2176
+ /**
2177
+ * Predict the Gateway PDAs that `prescribe_epoch` will select as observers
2178
+ * for `epochIndex`, mirroring the on-chain weighted-roulette selection.
2179
+ *
2180
+ * Returns at most `epoch_settings.prescribed_observer_count` (≤50) PDAs
2181
+ * regardless of registry size — the set to pass as `gatewayAccounts` to
2182
+ * {@link prescribeEpoch}. This is the size-safe replacement for
2183
+ * {@link getAllRegistryGatewayPDAs} on the prescribe path (which oversupplies
2184
+ * and trips `MAX_TX_ACCOUNT_LOCKS = 64` on large registries).
2185
+ *
2186
+ * Reads three accounts (epoch, registry, epoch settings) at the configured
2187
+ * commitment so the prediction reflects live registry weights. If a selected
2188
+ * gateway races out before the tx lands, `prescribeEpoch` throws
2189
+ * `InvalidGatewayAccount` — re-call this and retry once.
2190
+ */
2191
+ async getPredictedObserverPDAs(epochIndex) {
2192
+ // --- Epoch: hashchain (frozen entropy) + active_gateway_count (walk bound) ---
2193
+ const [epochPda] = await getEpochPDA(epochIndex, this.garProgram);
2194
+ const epochAccount = await fetchEncodedAccount(this.rpc, epochPda, {
2195
+ commitment: this.commitment,
2196
+ });
2197
+ if (!epochAccount.exists)
2198
+ throw new Error(`Epoch ${epochIndex} not found`);
2199
+ const epochData = Buffer.from(epochAccount.data);
2200
+ // After the 8-byte discriminator (see fetchEpochRawFields): 9×u64 = 72
2201
+ // bytes, then hashchain[32], then active_gateway_count(u32).
2202
+ const EPOCH_BASE = 8;
2203
+ const hashchain = epochData.subarray(EPOCH_BASE + 72, EPOCH_BASE + 72 + 32);
2204
+ const activeGatewayCount = epochData.readUInt32LE(EPOCH_BASE + 104);
2205
+ // --- Registry: slots[0..activeGatewayCount] (address + composite_weight) ---
2206
+ const [registryPda] = await getGatewayRegistryPDA(this.garProgram);
2207
+ const registryAccount = await fetchEncodedAccount(this.rpc, registryPda, {
2208
+ commitment: this.commitment,
2209
+ });
2210
+ if (!registryAccount.exists)
2211
+ throw new Error('GatewayRegistry not found');
2212
+ const registryData = Buffer.from(registryAccount.data);
2213
+ const registryCount = registryData.readUInt32LE(40); // 8 disc + 32 authority
2214
+ const SLOTS_OFFSET = 48; // 8 + 32 + 4 count + 4 pad
2215
+ const SLOT_STRIDE = 56; // address(32)+weight(8)+start_ts(8)+status(1)+pad(7)
2216
+ // Walk exactly the on-chain prefix. The roulette uses
2217
+ // registry.gateways[0..epoch.active_gateway_count]; include zero-weight
2218
+ // slots so the cumulative walk and weight sum match byte-for-byte.
2219
+ const walkCount = Math.min(activeGatewayCount, registryCount, 3000);
2220
+ const slots = [];
2221
+ for (let i = 0; i < walkCount; i++) {
2222
+ const slotOffset = SLOTS_OFFSET + i * SLOT_STRIDE;
2223
+ slots.push({
2224
+ address: addressDecoder.decode(registryData.subarray(slotOffset, slotOffset + 32)),
2225
+ compositeWeight: registryData.readBigUInt64LE(slotOffset + 32),
2226
+ });
2227
+ }
2228
+ // --- Epoch settings: prescribed_observer_count ---
2229
+ const [epochSettingsPda] = await getEpochSettingsPDA(this.garProgram);
2230
+ const settingsAccount = await fetchEncodedAccount(this.rpc, epochSettingsPda, {
2231
+ commitment: this.commitment,
2232
+ });
2233
+ if (!settingsAccount.exists)
2234
+ throw new Error('EpochSettings not found');
2235
+ const settings = deserializeEpochSettingsFull(Buffer.from(settingsAccount.data));
2236
+ // --- Predict selected operators, then derive their Gateway PDAs ---
2237
+ const operators = predictPrescribedObservers(hashchain, slots, settings.prescribedObserverCount);
2238
+ const pdas = [];
2239
+ for (const operator of operators) {
2240
+ const [gatewayPda] = await getGatewayPDA(operator, this.garProgram);
2241
+ pdas.push(gatewayPda);
2242
+ }
2243
+ return pdas;
2244
+ }
2245
+ /**
2246
+ * Reclaim rent from the ephemeral Address Lookup Tables this signer created
2247
+ * for `prescribe_epoch` (see {@link sendWithEphemeralLookupTable}). Each
2248
+ * prescribe leaves a single-use table allocated (~0.0126 SOL); reclaiming
2249
+ * needs a deactivate → ~513-slot cooldown → close sequence, so it can't run
2250
+ * inline. Call this from a throttled/permissionless cleanup pass (cranker /
2251
+ * observer) to deactivate active tables and close cooled-down ones, refunding
2252
+ * the rent to the signer.
2253
+ *
2254
+ * Discovery reads the signer's transaction history (RPC-portable; the ALT
2255
+ * program can't be enumerated via `getProgramAccounts`). The GAR + ArNS
2256
+ * program IDs are passed as the entry-ownership fingerprint so only genuine
2257
+ * prescribe tables are touched. Best-effort: at most `maxTables` submissions
2258
+ * per call, scanning at most `scanLimit` recent signatures.
2259
+ */
2260
+ async reclaimLookupTableRent(opts) {
2261
+ return reclaimLookupTablesForSigner({
2262
+ rpc: this.rpc,
2263
+ rpcSubscriptions: this.rpcSubscriptions,
2264
+ signer: this.signer,
2265
+ allowedEntryOwners: [this.garProgram, this.arnsProgram],
2266
+ commitment: this.commitment,
2267
+ maxTables: opts?.maxTables,
2268
+ scanLimit: opts?.scanLimit,
2269
+ });
2270
+ }
2271
+ /** Read and deserialize the full EpochSettings account. */
2272
+ async getEpochSettingsFull() {
2273
+ const [esPda] = await getEpochSettingsPDA(this.garProgram);
2274
+ const account = await fetchEncodedAccount(this.rpc, esPda, {
2275
+ commitment: this.commitment,
2276
+ });
2277
+ if (!account.exists)
2278
+ throw new Error('EpochSettings not found');
2279
+ return deserializeEpochSettingsFull(Buffer.from(account.data));
2280
+ }
2281
+ /**
2282
+ * Submit `prescribe_epoch` using the off-chain-predicted observer set, with a
2283
+ * single re-predict-and-retry on `InvalidGatewayAccount` (covers a gateway
2284
+ * leaving the registry between the prediction read and the tx landing).
2285
+ */
2286
+ async prescribeWithPrediction(epochIndex, nameRegistryAccount) {
2287
+ const submit = async () => this.prescribeEpoch({
2288
+ epochIndex,
2289
+ gatewayAccounts: await this.getPredictedObserverPDAs(epochIndex),
2290
+ nameRegistryAccount,
2291
+ });
2292
+ try {
2293
+ return await submit();
2294
+ }
2295
+ catch (err) {
2296
+ if (!isInvalidGatewayAccountError(err))
2297
+ throw err;
2298
+ return submit();
2299
+ }
2300
+ }
2301
+ /**
2302
+ * Advance the epoch lifecycle by ONE on-chain action and return what it did.
2303
+ *
2304
+ * Stateless and idempotent: it reads `EpochSettings` + the current `Epoch`,
2305
+ * determines the single next required step
2306
+ * (`create` → `tally` → `prescribe` → `distribute` → `close`), submits it,
2307
+ * and returns a {@link CrankEpochStepResult}. Call it repeatedly on your own
2308
+ * schedule — it owns *which* on-chain action is correct and *which accounts*
2309
+ * it needs; you own scheduling, logging, error classification, and any
2310
+ * permissionless cleanup.
2311
+ *
2312
+ * Crucially, the `prescribe` leg uses {@link getPredictedObserverPDAs} (only
2313
+ * the ~`prescribed_observer_count` selected Gateway PDAs), so it never trips
2314
+ * `MAX_TX_ACCOUNT_LOCKS = 64` on large registries — and it re-predicts and
2315
+ * retries once on `InvalidGatewayAccount`.
2316
+ *
2317
+ * Errors propagate to the caller (classify/retry as you see fit); the only
2318
+ * internally-handled error is the prescribe `InvalidGatewayAccount` retry.
2319
+ */
2320
+ async crankEpochStep(opts = {}) {
2321
+ // tally_weights / distribute_epoch append the batch's Gateway PDAs as
2322
+ // remaining_accounts. distribute also CPIs into ario-core (treasury
2323
+ // release) so it carries 10 named accounts; with ~18+ gateway PDAs on top
2324
+ // the tx exceeds Solana's 1232-byte limit. Cap the lifecycle batch at 18 so
2325
+ // an oversized caller `batchSize` can't produce an unsendable tx (verified:
2326
+ // 30 gateways → 1527B; 18 → ~1050B). prescribe is the exception — it needs
2327
+ // ALL selected observers in one tx, so it uses an ALT instead (see
2328
+ // prescribeEpoch).
2329
+ const MAX_LIFECYCLE_BATCH = 18;
2330
+ const batchSize = Math.min(opts.batchSize ?? MAX_LIFECYCLE_BATCH, MAX_LIFECYCLE_BATCH);
2331
+ const enableClose = opts.enableClose ?? true;
2332
+ const retention = opts.epochRetention ?? 7;
2333
+ const now = opts.now ?? Math.floor(Date.now() / 1000);
2334
+ const settings = await this.getEpochSettingsFull();
2335
+ if (!settings.enabled)
2336
+ return { action: 'idle', reason: 'epochs_disabled' };
2337
+ const currentIndex = settings.currentEpochIndex;
2338
+ // currentIndex is the NEXT epoch to create; the live one is currentIndex-1.
2339
+ const targetEpochIndex = currentIndex > 0 ? currentIndex - 1 : 0;
2340
+ const nextEpochStart = settings.genesisTimestamp + currentIndex * settings.epochDuration;
2341
+ // Bootstrap: no epochs yet.
2342
+ if (currentIndex === 0) {
2343
+ if (now < nextEpochStart)
2344
+ return { action: 'idle', reason: 'waiting_for_genesis' };
2345
+ const { id } = await this.createEpoch();
2346
+ return { action: 'create', epochIndex: 0, txId: id };
2347
+ }
2348
+ const epoch = await this.getEpochRaw(targetEpochIndex);
2349
+ if (!epoch)
2350
+ return { action: 'idle', reason: 'waiting_for_epoch' };
2351
+ // Tally (batched). activeGatewayCount===0 still needs one tx to flip the flag.
2352
+ if (epoch.weightsTallied === 0) {
2353
+ const gatewayAccounts = epoch.activeGatewayCount > 0
2354
+ ? await this.getRegistryGatewayPDAs(epoch.tallyIndex, batchSize)
2355
+ : [];
2356
+ const { id } = await this.tallyWeights({
2357
+ epochIndex: targetEpochIndex,
2358
+ gatewayAccounts,
2359
+ });
2360
+ return {
2361
+ action: 'tally',
2362
+ epochIndex: targetEpochIndex,
2363
+ txId: id,
2364
+ progress: { index: epoch.tallyIndex, total: epoch.activeGatewayCount },
2365
+ };
2366
+ }
2367
+ // Prescribe (predicted observers only — the size-safe path).
2368
+ if (epoch.prescriptionsDone === 0) {
2369
+ const nameRegistryAccount = opts.nameRegistryAccount === null
2370
+ ? undefined
2371
+ : (opts.nameRegistryAccount ??
2372
+ (await getArnsRegistryPDA(this.arnsProgram))[0]);
2373
+ const { id } = await this.prescribeWithPrediction(targetEpochIndex, nameRegistryAccount);
2374
+ return { action: 'prescribe', epochIndex: targetEpochIndex, txId: id };
2375
+ }
2376
+ // Observations happen while the epoch is live.
2377
+ if (now < epoch.endTimestamp)
2378
+ return { action: 'idle', reason: 'waiting_for_observations' };
2379
+ // Distribute (batched).
2380
+ if (epoch.rewardsDistributed === 0) {
2381
+ const gatewayAccounts = epoch.activeGatewayCount > 0
2382
+ ? await this.getRegistryGatewayPDAs(epoch.distributionIndex, batchSize)
2383
+ : [];
2384
+ const { id } = await this.distributeEpoch({
2385
+ epochIndex: targetEpochIndex,
2386
+ gatewayAccounts,
2387
+ });
2388
+ return {
2389
+ action: 'distribute',
2390
+ epochIndex: targetEpochIndex,
2391
+ txId: id,
2392
+ progress: {
2393
+ index: epoch.distributionIndex,
2394
+ total: epoch.activeGatewayCount,
2395
+ },
2396
+ };
2397
+ }
2398
+ // Close a fully-distributed epoch past retention (GAR-006).
2399
+ if (enableClose && targetEpochIndex >= retention) {
2400
+ const closeTarget = targetEpochIndex - retention;
2401
+ const old = await this.getEpochRaw(closeTarget);
2402
+ if (old && old.rewardsDistributed === 1) {
2403
+ const { id } = await this.closeEpoch({ epochIndex: closeTarget });
2404
+ return { action: 'close', epochIndex: closeTarget, txId: id };
2405
+ }
2406
+ }
2407
+ // Current epoch fully processed — create the next once its start arrives.
2408
+ if (now >= nextEpochStart) {
2409
+ const { id } = await this.createEpoch();
2410
+ return { action: 'create', epochIndex: currentIndex, txId: id };
2411
+ }
2412
+ return { action: 'idle', reason: 'epoch_complete' };
2413
+ }
2118
2414
  /**
2119
2415
  * Read the raw epoch account data for cranker state inspection.
2120
2416
  * Returns null if the epoch account doesn't exist yet.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Off-chain prediction of `prescribe_epoch`'s observer selection.
3
+ *
4
+ * `ario_gar::prescribe_epoch` selects observers INTERNALLY via a weighted
5
+ * roulette over the `GatewayRegistry`, then uses `remaining_accounts` only as a
6
+ * lookup table for the ~50 selected Gateway PDAs (+ NameRegistry). A cranker
7
+ * that instead supplies every registry gateway hits Solana's
8
+ * `MAX_TX_ACCOUNT_LOCKS = 64` on large registries and the tx is rejected at
9
+ * pre-flight. This helper mirrors the on-chain selection so the caller can
10
+ * supply exactly the selected set.
11
+ *
12
+ * The selection is deterministic from on-chain state that is stable once
13
+ * `epoch.weights_tallied == 1`: the frozen `epoch.hashchain` entropy beacon and
14
+ * the live `registry.gateways[*].composite_weight`.
15
+ *
16
+ * Cross-language parity with the Rust handler
17
+ * (`ar-io-solana-contracts/programs/ario-gar/src/instructions/epoch.rs`, the
18
+ * `prescribe_epoch` observer-selection block) is asserted in
19
+ * `predict-prescribed-observers.test.ts` against vectors generated by the Rust
20
+ * reference example `programs/ario-gar/examples/predict_prescribed_observers.rs`.
21
+ * If you change this algorithm, update that example and the test together.
22
+ */
23
+ import { createHash } from 'crypto';
24
+ function sha256(data) {
25
+ return createHash('sha256').update(data).digest();
26
+ }
27
+ /** `u128::from_le_bytes(bytes[0..16])` — little-endian, first 16 bytes only. */
28
+ function leU128(bytes) {
29
+ let v = 0n;
30
+ for (let i = 15; i >= 0; i--) {
31
+ v = (v << 8n) | BigInt(bytes[i]);
32
+ }
33
+ return v;
34
+ }
35
+ /**
36
+ * Predict the operator pubkeys `prescribe_epoch` will select as observers.
37
+ *
38
+ * Returns the selected `GatewaySlot.address` values (operator pubkeys) in
39
+ * selection order, at most `maxObservers`. These correspond 1:1 to the on-chain
40
+ * `epoch.prescribed_observer_gateways` array, and are what Gateway PDAs are
41
+ * derived from (`[GATEWAY_SEED, operator]`). NOTE this is NOT
42
+ * `epoch.prescribed_observers`, which `prescribe_epoch` later overwrites with
43
+ * each gateway's resolved `observer_address`.
44
+ *
45
+ * @param epochHashchain `epoch.hashchain` — exactly 32 bytes, frozen at
46
+ * `create_epoch`.
47
+ * @param slots `registry.gateways[0 .. epoch.active_gateway_count]` in registry
48
+ * (slot-index) order. Pass the whole prefix including any zero-weight slots —
49
+ * order and the live weight sum must match the on-chain walk exactly. Empty /
50
+ * zero-weight slots contribute nothing and can never be selected.
51
+ * @param maxObservers `epoch_settings.prescribed_observer_count`. Clamped to
52
+ * `slots.length` (the on-chain `min(prescribed_observer_count, active_count)`).
53
+ */
54
+ export function predictPrescribedObservers(epochHashchain, slots, maxObservers) {
55
+ if (epochHashchain.length !== 32) {
56
+ throw new Error(`epochHashchain must be 32 bytes, got ${epochHashchain.length}`);
57
+ }
58
+ const activeCount = slots.length;
59
+ const cap = Math.min(maxObservers, activeCount);
60
+ // Live total weight (epoch.rs: `for i in 0..active_count { total += w }`).
61
+ // u128 sum of u64 weights over <=3000 slots cannot overflow, so the Rust
62
+ // `saturating_add` never actually saturates; plain BigInt addition matches.
63
+ let totalWeight = 0n;
64
+ for (const slot of slots) {
65
+ totalWeight += slot.compositeWeight;
66
+ }
67
+ const selected = [];
68
+ if (totalWeight === 0n || activeCount === 0 || cap === 0) {
69
+ return selected;
70
+ }
71
+ // Initial entropy: sha256(hashchain).
72
+ let hashBytes = sha256(epochHashchain);
73
+ // GAR-019: bounded retries, up to cap * 10 rounds.
74
+ const maxRounds = cap * 10;
75
+ for (let round = 0; round < maxRounds; round++) {
76
+ if (selected.length >= cap)
77
+ break;
78
+ const randomValue = leU128(hashBytes.subarray(0, 16)) % totalWeight;
79
+ let cumulative = 0n;
80
+ for (const slot of slots) {
81
+ cumulative += slot.compositeWeight;
82
+ if (cumulative > randomValue && slot.compositeWeight > 0n) {
83
+ // Anti-duplicate: skip if already chosen, but the round still consumes
84
+ // the roulette hit — break the walk either way (epoch.rs).
85
+ if (!selected.includes(slot.address)) {
86
+ selected.push(slot.address);
87
+ }
88
+ break;
89
+ }
90
+ }
91
+ // Re-hash for the next round: sha256(hash_bytes).
92
+ hashBytes = sha256(hashBytes);
93
+ }
94
+ return selected;
95
+ }
@@ -8,17 +8,18 @@
8
8
  * favor of the official package. See `sendAndConfirm` below for why we always
9
9
  * pin BOTH instructions (even with a 0 priority fee).
10
10
  */
11
+ import { ADDRESS_LOOKUP_TABLE_PROGRAM_ADDRESS, getCloseLookupTableInstruction, getCreateLookupTableInstructionAsync, getDeactivateLookupTableInstruction, getExtendLookupTableInstruction, } from '@solana-program/address-lookup-table';
11
12
  import { getSetComputeUnitLimitInstruction, getSetComputeUnitPriceInstruction, } from '@solana-program/compute-budget';
12
- import { appendTransactionMessageInstructions, compileTransaction, createTransactionMessage, getBase64EncodedWireTransaction, getSignatureFromTransaction, pipe, sendAndConfirmTransactionFactory, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, signTransactionMessageWithSigners, } from '@solana/kit';
13
+ import { appendTransactionMessageInstructions, compileTransaction, compressTransactionMessageUsingAddressLookupTables, createTransactionMessage, getAddressDecoder, getBase64EncodedWireTransaction, getSignatureFromTransaction, pipe, sendAndConfirmTransactionFactory, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, signTransactionMessageWithSigners, } from '@solana/kit';
13
14
  /**
14
15
  * Build, sign, send, and confirm a transaction in one call.
15
16
  *
16
17
  * The caller supplies the core instructions; a compute-unit-limit instruction
17
18
  * is prepended automatically.
18
19
  */
19
- export async function sendAndConfirm({ rpc, rpcSubscriptions, signer, instructions, commitment = 'confirmed', computeUnitLimit = 400_000, }) {
20
+ export async function sendAndConfirm({ rpc, rpcSubscriptions, signer, instructions, commitment = 'confirmed', computeUnitLimit = 400_000, addressLookupTables, }) {
20
21
  const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();
21
- const message = pipe(createTransactionMessage({ version: 0 }), (tx) => setTransactionMessageFeePayerSigner(signer, tx), (tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx), (tx) => appendTransactionMessageInstructions([
22
+ const baseMessage = pipe(createTransactionMessage({ version: 0 }), (tx) => setTransactionMessageFeePayerSigner(signer, tx), (tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx), (tx) => appendTransactionMessageInstructions([
22
23
  getSetComputeUnitLimitInstruction({ units: computeUnitLimit }),
23
24
  // Always pin the priority fee (even at 0) so wallets like Phantom
24
25
  // don't silently *append* their own compute-budget instructions
@@ -32,6 +33,10 @@ export async function sendAndConfirm({ rpc, rpcSubscriptions, signer, instructio
32
33
  getSetComputeUnitPriceInstruction({ microLamports: 0n }),
33
34
  ...instructions,
34
35
  ], tx));
36
+ // Compress against any supplied lookup tables (v0). No-op when none given.
37
+ const message = addressLookupTables
38
+ ? compressTransactionMessageUsingAddressLookupTables(baseMessage, addressLookupTables)
39
+ : baseMessage;
35
40
  const signedTx = await signTransactionMessageWithSigners(message);
36
41
  const sendAndConfirmFactory = sendAndConfirmTransactionFactory({
37
42
  rpc,
@@ -115,3 +120,238 @@ async function logSimulationDiagnostics(rpc, message, originalError) {
115
120
  console.warn('[solana-send] failed to collect diagnostics', diagErr);
116
121
  }
117
122
  }
123
+ /**
124
+ * Submit `instruction` in a v0 transaction whose `lookupAddresses` (read-only
125
+ * accounts) are served from a freshly-created, ephemeral Address Lookup Table,
126
+ * so an instruction touching far more accounts than fit inline (e.g.
127
+ * `prescribe_epoch` with ≤50 observer PDAs + NameRegistry, ~2 KB of keys) still
128
+ * fits Solana's 1232-byte transaction-size limit.
129
+ *
130
+ * Three confirmed steps: create the table, extend it with the addresses (in
131
+ * ≤20-address batches to stay within the extend tx size), then send
132
+ * `instruction` compressed against the table. The sequential confirmations
133
+ * satisfy the rule that appended addresses are only usable the slot AFTER they
134
+ * are added. `signer` is the table's authority + payer; the table's (tiny) rent
135
+ * is left allocated — a future cleanup pass can deactivate + close it.
136
+ */
137
+ export async function sendWithEphemeralLookupTable({ rpc, rpcSubscriptions, signer, instruction, lookupAddresses, commitment = 'confirmed', computeUnitLimit = 1_000_000, }) {
138
+ const recentSlot = await rpc.getSlot({ commitment: 'finalized' }).send();
139
+ const createIx = await getCreateLookupTableInstructionAsync({
140
+ authority: signer.address,
141
+ payer: signer,
142
+ recentSlot,
143
+ });
144
+ const tableAddress = createIx.accounts[0].address;
145
+ // Create the (empty) table.
146
+ await sendAndConfirm({
147
+ rpc,
148
+ rpcSubscriptions,
149
+ signer,
150
+ instructions: [createIx],
151
+ commitment,
152
+ computeUnitLimit: 60_000,
153
+ });
154
+ // Fill it, ≤20 addresses per extend tx.
155
+ const BATCH = 20;
156
+ for (let i = 0; i < lookupAddresses.length; i += BATCH) {
157
+ const extendIx = getExtendLookupTableInstruction({
158
+ address: tableAddress,
159
+ authority: signer,
160
+ payer: signer,
161
+ addresses: lookupAddresses.slice(i, i + BATCH),
162
+ });
163
+ await sendAndConfirm({
164
+ rpc,
165
+ rpcSubscriptions,
166
+ signer,
167
+ instructions: [extendIx],
168
+ commitment,
169
+ computeUnitLimit: 60_000,
170
+ });
171
+ }
172
+ // Wait until the table holds every address AND one slot has elapsed since —
173
+ // addresses appended to a lookup table are only usable the slot AFTER they're
174
+ // added, and the validator that processes the prescribe must already see
175
+ // them. Skipping this yields "address table lookup uses an invalid index".
176
+ await waitForLookupTableActive(rpc, tableAddress, lookupAddresses.length);
177
+ // Send the real instruction, compressed against the now-active table.
178
+ return sendAndConfirm({
179
+ rpc,
180
+ rpcSubscriptions,
181
+ signer,
182
+ instructions: [instruction],
183
+ commitment,
184
+ computeUnitLimit,
185
+ addressLookupTables: { [tableAddress]: lookupAddresses },
186
+ });
187
+ }
188
+ /**
189
+ * Poll until an Address Lookup Table holds at least `expectedCount` addresses
190
+ * AND at least one slot has elapsed since they all landed. Lookup-table entries
191
+ * are only usable the slot AFTER they are appended, and the leader processing
192
+ * the consuming tx must already see them — otherwise the runtime rejects the tx
193
+ * with "address table lookup uses an invalid index". ALT account layout is a
194
+ * 56-byte metadata header followed by 32-byte addresses.
195
+ */
196
+ async function waitForLookupTableActive(rpc, table, expectedCount, maxWaitMs = 30_000) {
197
+ const META = 56;
198
+ const start = Date.now();
199
+ let slotAllPresent = null;
200
+ while (Date.now() - start < maxWaitMs) {
201
+ const acc = await rpc.getAccountInfo(table, { encoding: 'base64' }).send();
202
+ const slot = acc.context.slot;
203
+ if (acc.value) {
204
+ const len = Buffer.from(acc.value.data[0], 'base64').length;
205
+ const count = len >= META ? Math.floor((len - META) / 32) : 0;
206
+ if (count >= expectedCount) {
207
+ if (slotAllPresent === null) {
208
+ slotAllPresent = slot;
209
+ }
210
+ else if (slot > slotAllPresent) {
211
+ return; // all addresses present + a slot has elapsed → warm
212
+ }
213
+ }
214
+ }
215
+ await new Promise((r) => setTimeout(r, 800));
216
+ }
217
+ throw new Error(`lookup table ${table} not active (≥${expectedCount} addresses + 1 slot) within ${maxWaitMs}ms`);
218
+ }
219
+ /**
220
+ * Reclaim rent from the ephemeral Address Lookup Tables `signer` created for
221
+ * prescribe (see {@link sendWithEphemeralLookupTable}). Each prescribe leaves a
222
+ * single-use table allocated (~0.0126 SOL of rent); reclaiming needs a
223
+ * deactivate → ~513-slot cooldown → close sequence, so it can't run inline — a
224
+ * throttled permissionless cleanup pass (cranker / observer) calls this.
225
+ *
226
+ * Discovery is RPC-portable. `getProgramAccounts` on the Address Lookup Table
227
+ * program is rejected by Agave RPCs (`Invalid param: WrongSize`, on public
228
+ * devnet/mainnet-beta and dedicated providers alike — the ALT program can't be
229
+ * enumerated), so instead we read the signer's own transaction history
230
+ * (`getSignaturesForAddress` + `getTransaction`) and collect the tables it
231
+ * referenced via `message.addressTableLookups` — a prescribe ALT is used in
232
+ * exactly one transaction.
233
+ *
234
+ * Safety fingerprint: a candidate is only touched when EVERY one of its entries
235
+ * is owned by a program in `allowedEntryOwners` (the GAR + ArNS programs — i.e.
236
+ * observer Gateway PDAs + the ArNS NameRegistry). That composition uniquely
237
+ * identifies a prescribe ephemeral, so the pass never deactivates/closes an
238
+ * unrelated table even if `signer` is also used to author Address Lookup Tables
239
+ * for other purposes.
240
+ *
241
+ * DEACTIVATES still-active matches (starts the cooldown) and CLOSES deactivated
242
+ * matches past the cooldown (refunding rent to `signer`). At most `maxTables`
243
+ * submissions per call; scans at most `scanLimit` recent signatures. Best-effort:
244
+ * per-table failures are skipped and retried on the next pass.
245
+ */
246
+ export async function reclaimLookupTablesForSigner({ rpc, rpcSubscriptions, signer, allowedEntryOwners, commitment = 'confirmed', maxTables = 10, scanLimit = 500, }) {
247
+ const ALT_META = 56; // metadata header before the 32-byte address array
248
+ const ACTIVE = 0xffffffffffffffffn; // u64::MAX = not yet deactivated
249
+ const COOLDOWN_SLOTS = 513n; // deactivation_slot must age out of SlotHashes
250
+ const allowed = new Set(allowedEntryOwners);
251
+ const addressDecoder = getAddressDecoder();
252
+ // getTransaction only honours 'confirmed' | 'finalized'.
253
+ const historyCommitment = commitment === 'finalized' ? 'finalized' : 'confirmed';
254
+ // --- Discover candidate tables from the signer's transaction history -------
255
+ const sigs = await rpc
256
+ .getSignaturesForAddress(signer.address, { limit: scanLimit })
257
+ .send();
258
+ const candidates = new Set();
259
+ for (const { signature } of sigs) {
260
+ // A little headroom over maxTables so already-closed candidates don't
261
+ // starve the budget; the rest get picked up next pass.
262
+ if (candidates.size >= maxTables * 3)
263
+ break;
264
+ const tx = await rpc
265
+ .getTransaction(signature, {
266
+ encoding: 'json',
267
+ maxSupportedTransactionVersion: 0,
268
+ commitment: historyCommitment,
269
+ })
270
+ .send();
271
+ const lookups = tx?.transaction?.message?.addressTableLookups ?? [];
272
+ for (const l of lookups)
273
+ candidates.add(l.accountKey);
274
+ }
275
+ // --- Reclaim ----------------------------------------------------------------
276
+ const currentSlot = await rpc.getSlot().send();
277
+ let deactivated = 0;
278
+ let closed = 0;
279
+ for (const table of candidates) {
280
+ if (deactivated + closed >= maxTables)
281
+ break;
282
+ const address = table;
283
+ try {
284
+ const info = await rpc
285
+ .getAccountInfo(address, { encoding: 'base64' })
286
+ .send();
287
+ const value = info.value;
288
+ if (!value)
289
+ continue; // already closed
290
+ if (value.owner !==
291
+ ADDRESS_LOOKUP_TABLE_PROGRAM_ADDRESS) {
292
+ continue;
293
+ }
294
+ const data = Buffer.from(value.data[0], 'base64');
295
+ if (data.length < ALT_META)
296
+ continue;
297
+ const deactivationSlot = data.readBigUInt64LE(4);
298
+ // Fingerprint: every entry must be owned by an allowed program. A prescribe
299
+ // ALT is exclusively observer Gateway PDAs (GAR) + the NameRegistry (ArNS).
300
+ const entries = [];
301
+ for (let off = ALT_META; off + 32 <= data.length; off += 32) {
302
+ entries.push(addressDecoder.decode(data.subarray(off, off + 32)));
303
+ }
304
+ if (entries.length === 0)
305
+ continue;
306
+ const owners = await rpc
307
+ .getMultipleAccounts(entries, {
308
+ encoding: 'base64',
309
+ dataSlice: { offset: 0, length: 0 },
310
+ })
311
+ .send();
312
+ const allOwned = owners.value.every((a) => a != null && allowed.has(a.owner));
313
+ if (!allOwned)
314
+ continue; // not a prescribe ephemeral — leave it alone
315
+ if (deactivationSlot === ACTIVE) {
316
+ await sendAndConfirm({
317
+ rpc,
318
+ rpcSubscriptions,
319
+ signer,
320
+ commitment,
321
+ computeUnitLimit: 30_000,
322
+ instructions: [
323
+ getDeactivateLookupTableInstruction({ address, authority: signer }),
324
+ ],
325
+ });
326
+ deactivated += 1;
327
+ }
328
+ else if (currentSlot > deactivationSlot + COOLDOWN_SLOTS) {
329
+ await sendAndConfirm({
330
+ rpc,
331
+ rpcSubscriptions,
332
+ signer,
333
+ commitment,
334
+ computeUnitLimit: 30_000,
335
+ instructions: [
336
+ getCloseLookupTableInstruction({
337
+ address,
338
+ authority: signer,
339
+ recipient: signer.address,
340
+ }),
341
+ ],
342
+ });
343
+ closed += 1;
344
+ }
345
+ }
346
+ catch {
347
+ // best-effort: a racing close / not-yet-cooled table just gets retried
348
+ // on the next cleanup pass.
349
+ }
350
+ }
351
+ return {
352
+ deactivated,
353
+ closed,
354
+ candidates: candidates.size,
355
+ scannedSignatures: sigs.length,
356
+ };
357
+ }
@@ -14,4 +14,4 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  // AUTOMATICALLY GENERATED FILE - DO NOT TOUCH
17
- export const version = '4.0.0-solana.23';
17
+ export const version = '4.0.0-solana.24';
@@ -43,7 +43,7 @@ export * from '../types/index.js';
43
43
  export * from '../utils/index.js';
44
44
  export { ARWEAVE_TX_REGEX, AR_IO_PROTOCOL, arweaveUri, FQDN_REGEX, MARIO_PER_ARIO, } from '../constants.js';
45
45
  export { SolanaARIOReadable } from './io-readable.js';
46
- export { SolanaARIOWriteable } from './io-writeable.js';
46
+ export { type CrankAction, type CrankEpochStepOptions, type CrankEpochStepResult, isInvalidGatewayAccountError, SolanaARIOWriteable, } from './io-writeable.js';
47
47
  export { SolanaANTReadable } from './ant-readable.js';
48
48
  export { SolanaANTWriteable } from './ant-writeable.js';
49
49
  export { SolanaANTRegistryReadable } from './ant-registry-readable.js';
@@ -60,6 +60,7 @@ export type { SpawnSolanaANTParams, SpawnSolanaANTResult, SpawnSolanaANTState, }
60
60
  export { hashName, getArioConfigPDA, getBalancePDA, getVaultPDA, getVaultCounterPDA, getPrimaryNamePDA, getPrimaryNameRequestPDA, getGatewayRegistryPDA, getGarSettingsPDA, getGatewayPDA, getDelegationPDA, getWithdrawalPDA, getWithdrawalCounterPDA, getAllowlistPDA, getEpochPDA, getEpochSettingsPDA, getObservationPDA, getArnsRegistryPDA, getArnsSettingsPDA, getArnsRecordPDA, getArnsRecordPDAFromHash, getReservedNamePDA, getReturnedNamePDA, getDemandFactorPDA, getPrimaryNameReversePDA, getRedelegationRecordPDA, getAntConfigPDA, getAntControllersPDA, getAntRecordPDA, getAclConfigPDA, getAclPagePDA, getEscrowAntPDA, getEscrowTokenPDA, getEscrowVaultPDA, } from './pda.js';
61
61
  export { BorshReader, BorshWriter, deserializeGateway, deserializeArnsRecord, deserializeVault, deserializeDelegation, deserializeBalance, deserializeEpochSettings, deserializeArioConfig, deserializeDemandFactor, deserializeReservedName, deserializeReturnedName, deserializeWithdrawal, deserializeRedelegationRecord, deserializePrimaryNameRequest, deserializePrimaryName, deserializeAllowlist, deserializeGarSettings, deserializeEpochSettingsFull, deserializeEpoch, deserializeObservation, deserializeAntConfig, deserializeAntControllers, deserializeAntRecord, deserializeAclConfig, deserializeAclPage, } from './deserialize.js';
62
62
  export type { DeserializedAclEntry } from './deserialize.js';
63
+ export { predictPrescribedObservers, type RegistrySlotWeight, } from './predict-prescribed-observers.js';
63
64
  export * from './constants.js';
64
65
  export * from './clusters.js';
65
66
  export { createCircuitBreakerRpc, defaultFallbackUrl, } from './rpc-circuit-breaker.js';
@@ -23,6 +23,7 @@ import type { ILogger } from '../common/logger.js';
23
23
  import type { MessageResult, WriteOptions } from '../types/common.js';
24
24
  import type { ArNSPurchaseParams, BuyRecordParams, CreateVaultParams, DelegateStakeParams, ExtendLeaseParams, ExtendVaultParams, IncreaseUndernameLimitParams, IncreaseVaultParams, JoinNetworkParams, RedelegateStakeParams, RevokeVaultParams, UpdateGatewaySettingsParams, VaultedTransferParams } from '../types/io.js';
25
25
  import type { mARIOToken } from '../types/token.js';
26
+ import { deserializeEpochSettingsFull } from './deserialize.js';
26
27
  import { SolanaARIOReadable } from './io-readable.js';
27
28
  import type { SolanaRpcSubscriptions, SolanaSigner, SolanaWriteConfig } from './types.js';
28
29
  /**
@@ -82,6 +83,50 @@ export declare function buildObservationBitmap(registryAddresses: string[], fail
82
83
  * auditability that the field exists for.
83
84
  */
84
85
  export declare function encodeReportTxId(reportTxId: string | undefined): Buffer;
86
+ /** The single on-chain action a {@link SolanaARIOWriteable.crankEpochStep} call performed. */
87
+ export type CrankAction = 'create' | 'tally' | 'prescribe' | 'distribute' | 'close' | 'idle';
88
+ /** Options for {@link SolanaARIOWriteable.crankEpochStep}. */
89
+ export interface CrankEpochStepOptions {
90
+ /** Gateways per tally/distribute batch. Default 30. */
91
+ batchSize?: number;
92
+ /**
93
+ * NameRegistry account for the name-prescription leg. Defaults to the
94
+ * registry derived from the configured ArNS program. Pass `null` to disable
95
+ * name prescription entirely.
96
+ */
97
+ nameRegistryAccount?: Address | null;
98
+ /** Close fully-distributed epochs older than `epochRetention`. Default true. */
99
+ enableClose?: boolean;
100
+ /** Epochs of retention before an epoch may be closed (GAR-006). Default 7. */
101
+ epochRetention?: number;
102
+ /** Unix seconds; defaults to the wall clock. Injectable for testing. */
103
+ now?: number;
104
+ }
105
+ /** Result of a single {@link SolanaARIOWriteable.crankEpochStep} call. */
106
+ export interface CrankEpochStepResult {
107
+ /** The action performed (or `'idle'` when nothing was due). */
108
+ action: CrankAction;
109
+ /** The epoch the action targeted (absent for `'idle'`). */
110
+ epochIndex?: number;
111
+ /** Confirmed transaction signature, when an action was submitted. */
112
+ txId?: string;
113
+ /** Batch progress for `'tally'` / `'distribute'`. */
114
+ progress?: {
115
+ index: number;
116
+ total: number;
117
+ };
118
+ /** For `action: 'idle'`, why nothing was done. */
119
+ reason?: 'epochs_disabled' | 'waiting_for_genesis' | 'waiting_for_epoch' | 'waiting_for_observations' | 'epoch_complete';
120
+ }
121
+ /**
122
+ * Detect the GAR `InvalidGatewayAccount` error by Anchor error name/message
123
+ * (walking the cause chain + `context.logs`), NOT by numeric code — codes are
124
+ * `6000 + enum-index` and shift across program versions, but the name and
125
+ * message are stable. `prescribe_epoch` raises this when a supplied observer
126
+ * Gateway PDA is missing/spoofed (e.g. a predicted observer left the registry
127
+ * between prediction and tx landing).
128
+ */
129
+ export declare function isInvalidGatewayAccountError(error: unknown): boolean;
85
130
  export declare class SolanaARIOWriteable extends SolanaARIOReadable {
86
131
  protected readonly signer: SolanaSigner;
87
132
  protected readonly rpcSubscriptions: SolanaRpcSubscriptions;
@@ -428,9 +473,22 @@ export declare class SolanaARIOWriteable extends SolanaARIOReadable {
428
473
  }, _options?: WriteOptions): Promise<MessageResult>;
429
474
  /**
430
475
  * Prescribe observers and names for an epoch. Permissionless — call after
431
- * weights are tallied. Gateway PDAs are appended as `remaining_accounts`,
432
- * and the optional `nameRegistryAccount` (must be last) enables the name
476
+ * weights are tallied.
477
+ *
478
+ * `gatewayAccounts` MUST be the Gateway PDAs of the SELECTED observers only
479
+ * — at most `epoch_settings.prescribed_observer_count` (≤50), NOT the whole
480
+ * registry. The selection is computed on-chain; mirror it off-chain with
481
+ * {@link predictPrescribedObservers} / {@link getPredictedObserverPDAs} to
482
+ * learn the set. Passing every registry gateway (e.g. via
483
+ * {@link getAllRegistryGatewayPDAs}) hits Solana's `MAX_TX_ACCOUNT_LOCKS = 64`
484
+ * on large registries and the tx fails at pre-flight.
485
+ *
486
+ * The selected PDAs are appended as `remaining_accounts`, followed by the
487
+ * optional `nameRegistryAccount` (must be LAST) which enables the name
433
488
  * prescription leg.
489
+ *
490
+ * If a selected gateway leaves between prediction and tx landing, the tx
491
+ * fails with `InvalidGatewayAccount` — retry once with a fresh prediction.
434
492
  */
435
493
  prescribeEpoch(params: {
436
494
  epochIndex: number;
@@ -461,6 +519,74 @@ export declare class SolanaARIOWriteable extends SolanaARIOReadable {
461
519
  getRegistryGatewayPDAs(startIndex: number, batchSize: number): Promise<Address[]>;
462
520
  /** Get ALL active gateway PDAs from the registry. */
463
521
  getAllRegistryGatewayPDAs(): Promise<Address[]>;
522
+ /**
523
+ * Predict the Gateway PDAs that `prescribe_epoch` will select as observers
524
+ * for `epochIndex`, mirroring the on-chain weighted-roulette selection.
525
+ *
526
+ * Returns at most `epoch_settings.prescribed_observer_count` (≤50) PDAs
527
+ * regardless of registry size — the set to pass as `gatewayAccounts` to
528
+ * {@link prescribeEpoch}. This is the size-safe replacement for
529
+ * {@link getAllRegistryGatewayPDAs} on the prescribe path (which oversupplies
530
+ * and trips `MAX_TX_ACCOUNT_LOCKS = 64` on large registries).
531
+ *
532
+ * Reads three accounts (epoch, registry, epoch settings) at the configured
533
+ * commitment so the prediction reflects live registry weights. If a selected
534
+ * gateway races out before the tx lands, `prescribeEpoch` throws
535
+ * `InvalidGatewayAccount` — re-call this and retry once.
536
+ */
537
+ getPredictedObserverPDAs(epochIndex: number): Promise<Address[]>;
538
+ /**
539
+ * Reclaim rent from the ephemeral Address Lookup Tables this signer created
540
+ * for `prescribe_epoch` (see {@link sendWithEphemeralLookupTable}). Each
541
+ * prescribe leaves a single-use table allocated (~0.0126 SOL); reclaiming
542
+ * needs a deactivate → ~513-slot cooldown → close sequence, so it can't run
543
+ * inline. Call this from a throttled/permissionless cleanup pass (cranker /
544
+ * observer) to deactivate active tables and close cooled-down ones, refunding
545
+ * the rent to the signer.
546
+ *
547
+ * Discovery reads the signer's transaction history (RPC-portable; the ALT
548
+ * program can't be enumerated via `getProgramAccounts`). The GAR + ArNS
549
+ * program IDs are passed as the entry-ownership fingerprint so only genuine
550
+ * prescribe tables are touched. Best-effort: at most `maxTables` submissions
551
+ * per call, scanning at most `scanLimit` recent signatures.
552
+ */
553
+ reclaimLookupTableRent(opts?: {
554
+ maxTables?: number;
555
+ scanLimit?: number;
556
+ }): Promise<{
557
+ deactivated: number;
558
+ closed: number;
559
+ candidates: number;
560
+ scannedSignatures: number;
561
+ }>;
562
+ /** Read and deserialize the full EpochSettings account. */
563
+ getEpochSettingsFull(): Promise<ReturnType<typeof deserializeEpochSettingsFull>>;
564
+ /**
565
+ * Submit `prescribe_epoch` using the off-chain-predicted observer set, with a
566
+ * single re-predict-and-retry on `InvalidGatewayAccount` (covers a gateway
567
+ * leaving the registry between the prediction read and the tx landing).
568
+ */
569
+ protected prescribeWithPrediction(epochIndex: number, nameRegistryAccount?: Address): Promise<MessageResult>;
570
+ /**
571
+ * Advance the epoch lifecycle by ONE on-chain action and return what it did.
572
+ *
573
+ * Stateless and idempotent: it reads `EpochSettings` + the current `Epoch`,
574
+ * determines the single next required step
575
+ * (`create` → `tally` → `prescribe` → `distribute` → `close`), submits it,
576
+ * and returns a {@link CrankEpochStepResult}. Call it repeatedly on your own
577
+ * schedule — it owns *which* on-chain action is correct and *which accounts*
578
+ * it needs; you own scheduling, logging, error classification, and any
579
+ * permissionless cleanup.
580
+ *
581
+ * Crucially, the `prescribe` leg uses {@link getPredictedObserverPDAs} (only
582
+ * the ~`prescribed_observer_count` selected Gateway PDAs), so it never trips
583
+ * `MAX_TX_ACCOUNT_LOCKS = 64` on large registries — and it re-predicts and
584
+ * retries once on `InvalidGatewayAccount`.
585
+ *
586
+ * Errors propagate to the caller (classify/retry as you see fit); the only
587
+ * internally-handled error is the prescribe `InvalidGatewayAccount` retry.
588
+ */
589
+ crankEpochStep(opts?: CrankEpochStepOptions): Promise<CrankEpochStepResult>;
464
590
  /**
465
591
  * Read the raw epoch account data for cranker state inspection.
466
592
  * Returns null if the epoch account doesn't exist yet.
@@ -0,0 +1,28 @@
1
+ import { type Address } from '@solana/kit';
2
+ /** One registry slot's selection-relevant fields (`GatewaySlot`). */
3
+ export interface RegistrySlotWeight {
4
+ /** `GatewaySlot.address` — the operator pubkey. Gateway PDAs derive from this. */
5
+ address: Address;
6
+ /** `GatewaySlot.composite_weight` (u64, mARIO-scaled). */
7
+ compositeWeight: bigint;
8
+ }
9
+ /**
10
+ * Predict the operator pubkeys `prescribe_epoch` will select as observers.
11
+ *
12
+ * Returns the selected `GatewaySlot.address` values (operator pubkeys) in
13
+ * selection order, at most `maxObservers`. These correspond 1:1 to the on-chain
14
+ * `epoch.prescribed_observer_gateways` array, and are what Gateway PDAs are
15
+ * derived from (`[GATEWAY_SEED, operator]`). NOTE this is NOT
16
+ * `epoch.prescribed_observers`, which `prescribe_epoch` later overwrites with
17
+ * each gateway's resolved `observer_address`.
18
+ *
19
+ * @param epochHashchain `epoch.hashchain` — exactly 32 bytes, frozen at
20
+ * `create_epoch`.
21
+ * @param slots `registry.gateways[0 .. epoch.active_gateway_count]` in registry
22
+ * (slot-index) order. Pass the whole prefix including any zero-weight slots —
23
+ * order and the live weight sum must match the on-chain walk exactly. Empty /
24
+ * zero-weight slots contribute nothing and can never be selected.
25
+ * @param maxObservers `epoch_settings.prescribed_observer_count`. Clamped to
26
+ * `slots.length` (the on-chain `min(prescribed_observer_count, active_count)`).
27
+ */
28
+ export declare function predictPrescribedObservers(epochHashchain: Uint8Array, slots: RegistrySlotWeight[], maxObservers: number): Address[];
@@ -1,4 +1,4 @@
1
- import { type Commitment, type Instruction, type TransactionSigner } from '@solana/kit';
1
+ import { type Address, type Commitment, type Instruction, type TransactionSigner } from '@solana/kit';
2
2
  import type { SolanaRpc, SolanaRpcSubscriptions } from './types.js';
3
3
  /**
4
4
  * Build, sign, send, and confirm a transaction in one call.
@@ -6,11 +6,89 @@ import type { SolanaRpc, SolanaRpcSubscriptions } from './types.js';
6
6
  * The caller supplies the core instructions; a compute-unit-limit instruction
7
7
  * is prepended automatically.
8
8
  */
9
- export declare function sendAndConfirm({ rpc, rpcSubscriptions, signer, instructions, commitment, computeUnitLimit, }: {
9
+ export declare function sendAndConfirm({ rpc, rpcSubscriptions, signer, instructions, commitment, computeUnitLimit, addressLookupTables, }: {
10
10
  rpc: SolanaRpc;
11
11
  rpcSubscriptions: SolanaRpcSubscriptions;
12
12
  signer: TransactionSigner;
13
13
  instructions: Instruction[];
14
14
  commitment?: Commitment;
15
15
  computeUnitLimit?: number;
16
+ /**
17
+ * Address Lookup Tables to compress the (v0) message against, as
18
+ * `{ [tableAddress]: addresses }`. Accounts present in a table are referenced
19
+ * by 1-byte index instead of their 32-byte key, shrinking the transaction —
20
+ * required when an instruction touches more accounts than fit inline (e.g.
21
+ * `prescribe_epoch` with ~50 observer PDAs). The tables MUST already be
22
+ * on-chain and active. See {@link sendWithEphemeralLookupTable}.
23
+ */
24
+ addressLookupTables?: Record<string, Address[]>;
16
25
  }): Promise<string>;
26
+ /**
27
+ * Submit `instruction` in a v0 transaction whose `lookupAddresses` (read-only
28
+ * accounts) are served from a freshly-created, ephemeral Address Lookup Table,
29
+ * so an instruction touching far more accounts than fit inline (e.g.
30
+ * `prescribe_epoch` with ≤50 observer PDAs + NameRegistry, ~2 KB of keys) still
31
+ * fits Solana's 1232-byte transaction-size limit.
32
+ *
33
+ * Three confirmed steps: create the table, extend it with the addresses (in
34
+ * ≤20-address batches to stay within the extend tx size), then send
35
+ * `instruction` compressed against the table. The sequential confirmations
36
+ * satisfy the rule that appended addresses are only usable the slot AFTER they
37
+ * are added. `signer` is the table's authority + payer; the table's (tiny) rent
38
+ * is left allocated — a future cleanup pass can deactivate + close it.
39
+ */
40
+ export declare function sendWithEphemeralLookupTable({ rpc, rpcSubscriptions, signer, instruction, lookupAddresses, commitment, computeUnitLimit, }: {
41
+ rpc: SolanaRpc;
42
+ rpcSubscriptions: SolanaRpcSubscriptions;
43
+ signer: TransactionSigner;
44
+ instruction: Instruction;
45
+ lookupAddresses: Address[];
46
+ commitment?: Commitment;
47
+ computeUnitLimit?: number;
48
+ }): Promise<string>;
49
+ /**
50
+ * Reclaim rent from the ephemeral Address Lookup Tables `signer` created for
51
+ * prescribe (see {@link sendWithEphemeralLookupTable}). Each prescribe leaves a
52
+ * single-use table allocated (~0.0126 SOL of rent); reclaiming needs a
53
+ * deactivate → ~513-slot cooldown → close sequence, so it can't run inline — a
54
+ * throttled permissionless cleanup pass (cranker / observer) calls this.
55
+ *
56
+ * Discovery is RPC-portable. `getProgramAccounts` on the Address Lookup Table
57
+ * program is rejected by Agave RPCs (`Invalid param: WrongSize`, on public
58
+ * devnet/mainnet-beta and dedicated providers alike — the ALT program can't be
59
+ * enumerated), so instead we read the signer's own transaction history
60
+ * (`getSignaturesForAddress` + `getTransaction`) and collect the tables it
61
+ * referenced via `message.addressTableLookups` — a prescribe ALT is used in
62
+ * exactly one transaction.
63
+ *
64
+ * Safety fingerprint: a candidate is only touched when EVERY one of its entries
65
+ * is owned by a program in `allowedEntryOwners` (the GAR + ArNS programs — i.e.
66
+ * observer Gateway PDAs + the ArNS NameRegistry). That composition uniquely
67
+ * identifies a prescribe ephemeral, so the pass never deactivates/closes an
68
+ * unrelated table even if `signer` is also used to author Address Lookup Tables
69
+ * for other purposes.
70
+ *
71
+ * DEACTIVATES still-active matches (starts the cooldown) and CLOSES deactivated
72
+ * matches past the cooldown (refunding rent to `signer`). At most `maxTables`
73
+ * submissions per call; scans at most `scanLimit` recent signatures. Best-effort:
74
+ * per-table failures are skipped and retried on the next pass.
75
+ */
76
+ export declare function reclaimLookupTablesForSigner({ rpc, rpcSubscriptions, signer, allowedEntryOwners, commitment, maxTables, scanLimit, }: {
77
+ rpc: SolanaRpc;
78
+ rpcSubscriptions: SolanaRpcSubscriptions;
79
+ signer: TransactionSigner;
80
+ /**
81
+ * Program IDs that EVERY entry of a reclaimable prescribe ALT must be owned by
82
+ * — pass the GAR + ArNS program IDs. The fingerprint that keeps reclamation
83
+ * from touching unrelated lookup tables.
84
+ */
85
+ allowedEntryOwners: Address[];
86
+ commitment?: Commitment;
87
+ maxTables?: number;
88
+ scanLimit?: number;
89
+ }): Promise<{
90
+ deactivated: number;
91
+ closed: number;
92
+ candidates: number;
93
+ scannedSignatures: number;
94
+ }>;
@@ -13,4 +13,4 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
- export declare const version = "4.0.0-solana.22";
16
+ export declare const version = "4.0.0-solana.23";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ar.io/sdk",
3
- "version": "4.0.0-solana.23",
3
+ "version": "4.0.0-solana.24",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/ar-io/ar-io-sdk.git"
@@ -124,6 +124,7 @@
124
124
  },
125
125
  "dependencies": {
126
126
  "@ar.io/solana-contracts": "0.4.0",
127
+ "@solana-program/address-lookup-table": "^0.11.0",
127
128
  "@solana-program/compute-budget": "^0.15.0",
128
129
  "@solana-program/token": "^0.13.0",
129
130
  "@solana/kit": "^6.8.0",