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

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.
@@ -108,6 +108,12 @@ class BorshReader {
108
108
  return undefined;
109
109
  return this.readU32();
110
110
  }
111
+ readOptionU16() {
112
+ const tag = this.readU8();
113
+ if (tag === 0)
114
+ return undefined;
115
+ return this.readU16();
116
+ }
111
117
  skip(bytes) {
112
118
  this.offset += bytes;
113
119
  }
@@ -332,6 +338,15 @@ export function deserializeGatewayWithAccumulator(data) {
332
338
  const delegateRewardShareRatio = r.readU16() / 100;
333
339
  const minDelegatedStake = r.readU64AsNumber();
334
340
  const allowlistEnabled = r.readBool();
341
+ // GATEWAY_VERSION 1.1.0 added two fields to GatewaySettings2 — MUST read them
342
+ // here to keep the byte stream aligned for every field after `settings`.
343
+ // - pending_delegate_reward_share_ratio: Option<u16> (Fix #7) — basis points
344
+ // of a deferred reward-share change applied at the next epoch's tally.
345
+ // - delegation_disabled_at: Option<i64> (Fix #6) — unix seconds the operator
346
+ // disabled delegation; starts the re-enable cooldown.
347
+ const pendingRatioRaw = r.readOptionU16();
348
+ const pendingDelegateRewardShareRatio = pendingRatioRaw === undefined ? undefined : pendingRatioRaw / 100;
349
+ const delegationDisabledAt = r.readOptionI64();
335
350
  // RegistryIndex (index: u32, _reserved: u8 — was is_registered:bool)
336
351
  r.readU32(); // registryIndex
337
352
  r.readU8(); // _reserved (layout-preserving placeholder for the legacy is_registered byte)
@@ -378,6 +393,8 @@ export function deserializeGatewayWithAccumulator(data) {
378
393
  fqdn,
379
394
  port,
380
395
  protocol: 'https', // protocolIdx: 0=Http, 1=Https — only HTTPS in practice
396
+ pendingDelegateRewardShareRatio, // Fix #7: undefined when no change is queued
397
+ delegationDisabledAt, // Fix #6: undefined when delegation is enabled
381
398
  };
382
399
  return {
383
400
  operator: operator,
@@ -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,
@@ -1743,6 +1743,39 @@ export class SolanaARIOReadable {
1743
1743
  }
1744
1744
  return out;
1745
1745
  }
1746
+ /**
1747
+ * Enumerate Joined Gateway PDAs whose delegation has been DISABLED
1748
+ * (`allow_delegated_staking == false`) yet still hold delegated stake
1749
+ * (`total_delegated_stake > 0`) — i.e. delegates that an operator's disable
1750
+ * left stranded (WP §6.3 / Fix #6). Each such gateway's delegates must be
1751
+ * cranked out via
1752
+ * {@link SolanaARIOWriteable.claimDelegateFromDisabledGateway} (enumerate
1753
+ * them with {@link getGatewayDelegates}) before the operator can re-enable
1754
+ * delegation. This is the discovery primitive a cranker uses to sweep them.
1755
+ */
1756
+ async getDisabledGatewaysWithDelegatedStake() {
1757
+ const accounts = await this.getAccountsByDiscriminator(this.garProgram, GATEWAY_DISCRIMINATOR);
1758
+ const decoder = getGatewayDecoder();
1759
+ const out = [];
1760
+ for (const { pubkey, data } of accounts) {
1761
+ try {
1762
+ const g = decoder.decode(data);
1763
+ if (g.status !== GatewayStatus.Joined)
1764
+ continue;
1765
+ if (!g.settings.allowDelegatedStaking && g.totalDelegatedStake > 0n) {
1766
+ out.push({
1767
+ pubkey,
1768
+ operator: g.operator,
1769
+ totalDelegatedStake: g.totalDelegatedStake,
1770
+ });
1771
+ }
1772
+ }
1773
+ catch {
1774
+ // skip malformed
1775
+ }
1776
+ }
1777
+ return out;
1778
+ }
1746
1779
  /**
1747
1780
  * Enumerate Delegation PDAs with `amount == 0`. Eligible for
1748
1781
  * `closeEmptyDelegation` (rent refund to the original delegator).
@@ -38,12 +38,13 @@ function toGeneratedFundingSourceSpec(s) {
38
38
  import { getSyncAttributesInstruction } from '@ar.io/solana-contracts/ant';
39
39
  import { getApprovePrimaryNameInstructionAsync, getCloseExpiredRequestInstruction, getCreateVaultInstructionAsync, getExtendVaultInstructionAsync, getIncreaseVaultInstructionAsync, getReleaseVaultInstructionAsync, getRemovePrimaryNameInstructionAsync, getRequestAndSetPrimaryNameFromFundingPlanInstructionAsync, getRequestAndSetPrimaryNameInstructionAsync, getRequestPrimaryNameFromFundingPlanInstructionAsync, getRequestPrimaryNameInstructionAsync, getRevokeVaultInstructionAsync, getVaultedTransferInstructionAsync, } from '@ar.io/solana-contracts/core';
40
40
  import { getDelegationDecoder, getGatewayDecoder, } from '@ar.io/solana-contracts/gar';
41
- import { Protocol, getAllowDelegateInstructionAsync, getCancelWithdrawalInstruction, getClaimDelegateFromLeavingGatewayInstructionAsync, getClaimWithdrawalInstructionAsync, getCloseDrainedWithdrawalInstruction, getCloseEmptyDelegationInstruction, getCloseEpochInstructionAsync, getCloseObservationInstructionAsync, getCreateEpochInstructionAsync, getDecreaseDelegateStakeInstructionAsync, getDecreaseOperatorStakeInstructionAsync, getDelegateStakeInstructionAsync, getDisallowDelegateInstructionAsync, getDistributeEpochInstructionAsync, getFinalizeGoneInstructionAsync, getIncreaseOperatorStakeInstructionAsync, getInstantWithdrawalInstructionAsync, getJoinNetworkInstructionAsync, getLeaveNetworkInstructionAsync, getPrescribeEpochInstructionAsync, getPruneGatewayInstructionAsync, getRedelegateStakeInstructionAsync, getSaveObservationsInstructionAsync, getSetAllowlistEnabledInstructionAsync, getTallyWeightsInstructionAsync, getUpdateGatewaySettingsInstructionAsync, } from '@ar.io/solana-contracts/gar';
41
+ import { Protocol, getAllowDelegateInstructionAsync, getCancelWithdrawalInstruction, getClaimDelegateFromDisabledGatewayInstructionAsync, getClaimDelegateFromLeavingGatewayInstructionAsync, getClaimWithdrawalInstructionAsync, getCloseDrainedWithdrawalInstruction, getCloseEmptyDelegationInstruction, getCloseEpochInstructionAsync, getCloseObservationInstructionAsync, getCreateEpochInstructionAsync, getDecreaseDelegateStakeInstructionAsync, getDecreaseOperatorStakeInstructionAsync, getDelegateStakeInstructionAsync, getDisallowDelegateInstructionAsync, getDistributeEpochInstructionAsync, getFinalizeGoneInstructionAsync, getIncreaseOperatorStakeInstructionAsync, getInstantWithdrawalInstructionAsync, getJoinNetworkInstructionAsync, getLeaveNetworkInstructionAsync, getPrescribeEpochInstructionAsync, getPruneGatewayInstructionAsync, getRedelegateStakeInstructionAsync, getSaveObservationsInstructionAsync, getSetAllowlistEnabledInstructionAsync, getTallyWeightsInstructionAsync, getUpdateGatewaySettingsInstructionAsync, } from '@ar.io/solana-contracts/gar';
42
42
  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;
@@ -1747,6 +1771,47 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
1747
1771
  return { id: sig };
1748
1772
  }
1749
1773
  // =========================================
1774
+ // Claim delegation from gateway with delegation DISABLED (ario-gar, Fix #6)
1775
+ // =========================================
1776
+ /**
1777
+ * Claim a delegate's stake out of a gateway that has DISABLED delegation
1778
+ * (`allow_delegated_staking == false`), moving it into the delegate's own
1779
+ * withdrawal vault (WP §6.3 / Fix #6). This is the disabled-gateway analog of
1780
+ * {@link claimDelegateFromLeavingGateway}: the on-chain instruction is
1781
+ * permissionless, so a cranker can sweep delegates out (the operator cannot
1782
+ * re-enable delegation until `total_delegated_stake == 0` and the cooldown
1783
+ * elapses). The withdrawal-counter and withdrawal PDAs are seeded by the
1784
+ * DELEGATOR, so a cranker must pass that delegate's `delegatorAddress`.
1785
+ *
1786
+ * @param params.gatewayAddress The gateway whose delegation was disabled.
1787
+ * @param params.delegatorAddress The delegate to claim for. Defaults to the
1788
+ * signer (self-claim). Pass another address to crank on a delegate's behalf;
1789
+ * the signer covers rent (`payer`) but stake still routes to the delegate's
1790
+ * own vault (the delegator key is bound by the delegation PDA seeds).
1791
+ */
1792
+ async claimDelegateFromDisabledGateway(params, _options) {
1793
+ const gateway = address(params.gatewayAddress);
1794
+ const delegator = params.delegatorAddress
1795
+ ? address(params.delegatorAddress)
1796
+ : this.signer.address;
1797
+ const [gatewayPda] = await getGatewayPDA(gateway, this.garProgram);
1798
+ const [delegationPda] = await getDelegationPDA(gateway, delegator, this.garProgram);
1799
+ // Withdrawal counter + vault are PDA-seeded by the delegator, not the payer.
1800
+ const nextId = await this.getNextWithdrawalId(delegator);
1801
+ const [withdrawalPda] = await getWithdrawalPDA(delegator, nextId, this.garProgram);
1802
+ const ix = await getClaimDelegateFromDisabledGatewayInstructionAsync({
1803
+ gateway: gatewayPda,
1804
+ delegation: delegationPda,
1805
+ withdrawal: withdrawalPda,
1806
+ // `delegator` is an unsigned seeds-derivation key; `payer` (the signer)
1807
+ // covers rent on the init_if_needed counter + the new withdrawal.
1808
+ delegator,
1809
+ payer: this.signer,
1810
+ }, { programAddress: this.garProgram });
1811
+ const sig = await this.sendTransaction([ix], 1_000_000);
1812
+ return { id: sig };
1813
+ }
1814
+ // =========================================
1750
1815
  // Delegation allowlist (ario-gar)
1751
1816
  // =========================================
1752
1817
  /** Add an address to the gateway's delegation allowlist. */
@@ -1995,9 +2060,22 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
1995
2060
  }
1996
2061
  /**
1997
2062
  * 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
2063
+ * weights are tallied.
2064
+ *
2065
+ * `gatewayAccounts` MUST be the Gateway PDAs of the SELECTED observers only
2066
+ * — at most `epoch_settings.prescribed_observer_count` (≤50), NOT the whole
2067
+ * registry. The selection is computed on-chain; mirror it off-chain with
2068
+ * {@link predictPrescribedObservers} / {@link getPredictedObserverPDAs} to
2069
+ * learn the set. Passing every registry gateway (e.g. via
2070
+ * {@link getAllRegistryGatewayPDAs}) hits Solana's `MAX_TX_ACCOUNT_LOCKS = 64`
2071
+ * on large registries and the tx fails at pre-flight.
2072
+ *
2073
+ * The selected PDAs are appended as `remaining_accounts`, followed by the
2074
+ * optional `nameRegistryAccount` (must be LAST) which enables the name
2000
2075
  * prescription leg.
2076
+ *
2077
+ * If a selected gateway leaves between prediction and tx landing, the tx
2078
+ * fails with `InvalidGatewayAccount` — retry once with a fresh prediction.
2001
2079
  */
2002
2080
  async prescribeEpoch(params, _options) {
2003
2081
  const ix = await getPrescribeEpochInstructionAsync(await this.withGarDefaults({
@@ -2014,7 +2092,28 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
2014
2092
  role: AccountRole.READONLY,
2015
2093
  });
2016
2094
  }
2017
- const sig = await this.sendTransaction([withRemainingAccounts(ix, remaining)], 1_000_000);
2095
+ const fullIx = withRemainingAccounts(ix, remaining);
2096
+ // A prescribe tx with the selected observer set (~50 PDAs) exceeds Solana's
2097
+ // 1232-byte limit once there are more than ~24 remaining accounts, so route
2098
+ // those through an ephemeral Address Lookup Table (create → extend →
2099
+ // compressed v0 tx). Small sets (sparse testnets) take the cheaper inline
2100
+ // path. `prescribe_epoch` searches `remaining_accounts` by PDA, so serving
2101
+ // them via the ALT (which preserves instruction account order) is
2102
+ // transparent — incl. NameRegistry staying last. Validated on staging
2103
+ // (667 gateways, 50 observers): 428k CU, name prescription intact.
2104
+ if (remaining.length > 24) {
2105
+ const id = await sendWithEphemeralLookupTable({
2106
+ rpc: this.rpc,
2107
+ rpcSubscriptions: this.rpcSubscriptions,
2108
+ signer: this.signer,
2109
+ instruction: fullIx,
2110
+ lookupAddresses: remaining.map((a) => a.address),
2111
+ commitment: this.commitment,
2112
+ computeUnitLimit: 1_000_000,
2113
+ });
2114
+ return { id };
2115
+ }
2116
+ const sig = await this.sendTransaction([fullIx], 1_000_000);
2018
2117
  return { id: sig };
2019
2118
  }
2020
2119
  /**
@@ -2115,6 +2214,244 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
2115
2214
  }
2116
2215
  return pdas;
2117
2216
  }
2217
+ /**
2218
+ * Predict the Gateway PDAs that `prescribe_epoch` will select as observers
2219
+ * for `epochIndex`, mirroring the on-chain weighted-roulette selection.
2220
+ *
2221
+ * Returns at most `epoch_settings.prescribed_observer_count` (≤50) PDAs
2222
+ * regardless of registry size — the set to pass as `gatewayAccounts` to
2223
+ * {@link prescribeEpoch}. This is the size-safe replacement for
2224
+ * {@link getAllRegistryGatewayPDAs} on the prescribe path (which oversupplies
2225
+ * and trips `MAX_TX_ACCOUNT_LOCKS = 64` on large registries).
2226
+ *
2227
+ * Reads three accounts (epoch, registry, epoch settings) at the configured
2228
+ * commitment so the prediction reflects live registry weights. If a selected
2229
+ * gateway races out before the tx lands, `prescribeEpoch` throws
2230
+ * `InvalidGatewayAccount` — re-call this and retry once.
2231
+ */
2232
+ async getPredictedObserverPDAs(epochIndex) {
2233
+ // --- Epoch: hashchain (frozen entropy) + active_gateway_count (walk bound) ---
2234
+ const [epochPda] = await getEpochPDA(epochIndex, this.garProgram);
2235
+ const epochAccount = await fetchEncodedAccount(this.rpc, epochPda, {
2236
+ commitment: this.commitment,
2237
+ });
2238
+ if (!epochAccount.exists)
2239
+ throw new Error(`Epoch ${epochIndex} not found`);
2240
+ const epochData = Buffer.from(epochAccount.data);
2241
+ // After the 8-byte discriminator (see fetchEpochRawFields): 9×u64 = 72
2242
+ // bytes, then hashchain[32], then active_gateway_count(u32).
2243
+ const EPOCH_BASE = 8;
2244
+ const hashchain = epochData.subarray(EPOCH_BASE + 72, EPOCH_BASE + 72 + 32);
2245
+ const activeGatewayCount = epochData.readUInt32LE(EPOCH_BASE + 104);
2246
+ // --- Registry: slots[0..activeGatewayCount] (address + composite_weight) ---
2247
+ const [registryPda] = await getGatewayRegistryPDA(this.garProgram);
2248
+ const registryAccount = await fetchEncodedAccount(this.rpc, registryPda, {
2249
+ commitment: this.commitment,
2250
+ });
2251
+ if (!registryAccount.exists)
2252
+ throw new Error('GatewayRegistry not found');
2253
+ const registryData = Buffer.from(registryAccount.data);
2254
+ const registryCount = registryData.readUInt32LE(40); // 8 disc + 32 authority
2255
+ const SLOTS_OFFSET = 48; // 8 + 32 + 4 count + 4 pad
2256
+ const SLOT_STRIDE = 56; // address(32)+weight(8)+start_ts(8)+status(1)+pad(7)
2257
+ // Walk exactly the on-chain prefix. The roulette uses
2258
+ // registry.gateways[0..epoch.active_gateway_count]; include zero-weight
2259
+ // slots so the cumulative walk and weight sum match byte-for-byte.
2260
+ const walkCount = Math.min(activeGatewayCount, registryCount, 3000);
2261
+ const slots = [];
2262
+ for (let i = 0; i < walkCount; i++) {
2263
+ const slotOffset = SLOTS_OFFSET + i * SLOT_STRIDE;
2264
+ slots.push({
2265
+ address: addressDecoder.decode(registryData.subarray(slotOffset, slotOffset + 32)),
2266
+ compositeWeight: registryData.readBigUInt64LE(slotOffset + 32),
2267
+ });
2268
+ }
2269
+ // --- Epoch settings: prescribed_observer_count ---
2270
+ const [epochSettingsPda] = await getEpochSettingsPDA(this.garProgram);
2271
+ const settingsAccount = await fetchEncodedAccount(this.rpc, epochSettingsPda, {
2272
+ commitment: this.commitment,
2273
+ });
2274
+ if (!settingsAccount.exists)
2275
+ throw new Error('EpochSettings not found');
2276
+ const settings = deserializeEpochSettingsFull(Buffer.from(settingsAccount.data));
2277
+ // --- Predict selected operators, then derive their Gateway PDAs ---
2278
+ const operators = predictPrescribedObservers(hashchain, slots, settings.prescribedObserverCount);
2279
+ const pdas = [];
2280
+ for (const operator of operators) {
2281
+ const [gatewayPda] = await getGatewayPDA(operator, this.garProgram);
2282
+ pdas.push(gatewayPda);
2283
+ }
2284
+ return pdas;
2285
+ }
2286
+ /**
2287
+ * Reclaim rent from the ephemeral Address Lookup Tables this signer created
2288
+ * for `prescribe_epoch` (see {@link sendWithEphemeralLookupTable}). Each
2289
+ * prescribe leaves a single-use table allocated (~0.0126 SOL); reclaiming
2290
+ * needs a deactivate → ~513-slot cooldown → close sequence, so it can't run
2291
+ * inline. Call this from a throttled/permissionless cleanup pass (cranker /
2292
+ * observer) to deactivate active tables and close cooled-down ones, refunding
2293
+ * the rent to the signer.
2294
+ *
2295
+ * Discovery reads the signer's transaction history (RPC-portable; the ALT
2296
+ * program can't be enumerated via `getProgramAccounts`). The GAR + ArNS
2297
+ * program IDs are passed as the entry-ownership fingerprint so only genuine
2298
+ * prescribe tables are touched. Best-effort: at most `maxTables` submissions
2299
+ * per call, scanning at most `scanLimit` recent signatures.
2300
+ */
2301
+ async reclaimLookupTableRent(opts) {
2302
+ return reclaimLookupTablesForSigner({
2303
+ rpc: this.rpc,
2304
+ rpcSubscriptions: this.rpcSubscriptions,
2305
+ signer: this.signer,
2306
+ allowedEntryOwners: [this.garProgram, this.arnsProgram],
2307
+ commitment: this.commitment,
2308
+ maxTables: opts?.maxTables,
2309
+ scanLimit: opts?.scanLimit,
2310
+ });
2311
+ }
2312
+ /** Read and deserialize the full EpochSettings account. */
2313
+ async getEpochSettingsFull() {
2314
+ const [esPda] = await getEpochSettingsPDA(this.garProgram);
2315
+ const account = await fetchEncodedAccount(this.rpc, esPda, {
2316
+ commitment: this.commitment,
2317
+ });
2318
+ if (!account.exists)
2319
+ throw new Error('EpochSettings not found');
2320
+ return deserializeEpochSettingsFull(Buffer.from(account.data));
2321
+ }
2322
+ /**
2323
+ * Submit `prescribe_epoch` using the off-chain-predicted observer set, with a
2324
+ * single re-predict-and-retry on `InvalidGatewayAccount` (covers a gateway
2325
+ * leaving the registry between the prediction read and the tx landing).
2326
+ */
2327
+ async prescribeWithPrediction(epochIndex, nameRegistryAccount) {
2328
+ const submit = async () => this.prescribeEpoch({
2329
+ epochIndex,
2330
+ gatewayAccounts: await this.getPredictedObserverPDAs(epochIndex),
2331
+ nameRegistryAccount,
2332
+ });
2333
+ try {
2334
+ return await submit();
2335
+ }
2336
+ catch (err) {
2337
+ if (!isInvalidGatewayAccountError(err))
2338
+ throw err;
2339
+ return submit();
2340
+ }
2341
+ }
2342
+ /**
2343
+ * Advance the epoch lifecycle by ONE on-chain action and return what it did.
2344
+ *
2345
+ * Stateless and idempotent: it reads `EpochSettings` + the current `Epoch`,
2346
+ * determines the single next required step
2347
+ * (`create` → `tally` → `prescribe` → `distribute` → `close`), submits it,
2348
+ * and returns a {@link CrankEpochStepResult}. Call it repeatedly on your own
2349
+ * schedule — it owns *which* on-chain action is correct and *which accounts*
2350
+ * it needs; you own scheduling, logging, error classification, and any
2351
+ * permissionless cleanup.
2352
+ *
2353
+ * Crucially, the `prescribe` leg uses {@link getPredictedObserverPDAs} (only
2354
+ * the ~`prescribed_observer_count` selected Gateway PDAs), so it never trips
2355
+ * `MAX_TX_ACCOUNT_LOCKS = 64` on large registries — and it re-predicts and
2356
+ * retries once on `InvalidGatewayAccount`.
2357
+ *
2358
+ * Errors propagate to the caller (classify/retry as you see fit); the only
2359
+ * internally-handled error is the prescribe `InvalidGatewayAccount` retry.
2360
+ */
2361
+ async crankEpochStep(opts = {}) {
2362
+ // tally_weights / distribute_epoch append the batch's Gateway PDAs as
2363
+ // remaining_accounts. distribute also CPIs into ario-core (treasury
2364
+ // release) so it carries 10 named accounts; with ~18+ gateway PDAs on top
2365
+ // the tx exceeds Solana's 1232-byte limit. Cap the lifecycle batch at 18 so
2366
+ // an oversized caller `batchSize` can't produce an unsendable tx (verified:
2367
+ // 30 gateways → 1527B; 18 → ~1050B). prescribe is the exception — it needs
2368
+ // ALL selected observers in one tx, so it uses an ALT instead (see
2369
+ // prescribeEpoch).
2370
+ const MAX_LIFECYCLE_BATCH = 18;
2371
+ const batchSize = Math.min(opts.batchSize ?? MAX_LIFECYCLE_BATCH, MAX_LIFECYCLE_BATCH);
2372
+ const enableClose = opts.enableClose ?? true;
2373
+ const retention = opts.epochRetention ?? 7;
2374
+ const now = opts.now ?? Math.floor(Date.now() / 1000);
2375
+ const settings = await this.getEpochSettingsFull();
2376
+ if (!settings.enabled)
2377
+ return { action: 'idle', reason: 'epochs_disabled' };
2378
+ const currentIndex = settings.currentEpochIndex;
2379
+ // currentIndex is the NEXT epoch to create; the live one is currentIndex-1.
2380
+ const targetEpochIndex = currentIndex > 0 ? currentIndex - 1 : 0;
2381
+ const nextEpochStart = settings.genesisTimestamp + currentIndex * settings.epochDuration;
2382
+ // Bootstrap: no epochs yet.
2383
+ if (currentIndex === 0) {
2384
+ if (now < nextEpochStart)
2385
+ return { action: 'idle', reason: 'waiting_for_genesis' };
2386
+ const { id } = await this.createEpoch();
2387
+ return { action: 'create', epochIndex: 0, txId: id };
2388
+ }
2389
+ const epoch = await this.getEpochRaw(targetEpochIndex);
2390
+ if (!epoch)
2391
+ return { action: 'idle', reason: 'waiting_for_epoch' };
2392
+ // Tally (batched). activeGatewayCount===0 still needs one tx to flip the flag.
2393
+ if (epoch.weightsTallied === 0) {
2394
+ const gatewayAccounts = epoch.activeGatewayCount > 0
2395
+ ? await this.getRegistryGatewayPDAs(epoch.tallyIndex, batchSize)
2396
+ : [];
2397
+ const { id } = await this.tallyWeights({
2398
+ epochIndex: targetEpochIndex,
2399
+ gatewayAccounts,
2400
+ });
2401
+ return {
2402
+ action: 'tally',
2403
+ epochIndex: targetEpochIndex,
2404
+ txId: id,
2405
+ progress: { index: epoch.tallyIndex, total: epoch.activeGatewayCount },
2406
+ };
2407
+ }
2408
+ // Prescribe (predicted observers only — the size-safe path).
2409
+ if (epoch.prescriptionsDone === 0) {
2410
+ const nameRegistryAccount = opts.nameRegistryAccount === null
2411
+ ? undefined
2412
+ : (opts.nameRegistryAccount ??
2413
+ (await getArnsRegistryPDA(this.arnsProgram))[0]);
2414
+ const { id } = await this.prescribeWithPrediction(targetEpochIndex, nameRegistryAccount);
2415
+ return { action: 'prescribe', epochIndex: targetEpochIndex, txId: id };
2416
+ }
2417
+ // Observations happen while the epoch is live.
2418
+ if (now < epoch.endTimestamp)
2419
+ return { action: 'idle', reason: 'waiting_for_observations' };
2420
+ // Distribute (batched).
2421
+ if (epoch.rewardsDistributed === 0) {
2422
+ const gatewayAccounts = epoch.activeGatewayCount > 0
2423
+ ? await this.getRegistryGatewayPDAs(epoch.distributionIndex, batchSize)
2424
+ : [];
2425
+ const { id } = await this.distributeEpoch({
2426
+ epochIndex: targetEpochIndex,
2427
+ gatewayAccounts,
2428
+ });
2429
+ return {
2430
+ action: 'distribute',
2431
+ epochIndex: targetEpochIndex,
2432
+ txId: id,
2433
+ progress: {
2434
+ index: epoch.distributionIndex,
2435
+ total: epoch.activeGatewayCount,
2436
+ },
2437
+ };
2438
+ }
2439
+ // Close a fully-distributed epoch past retention (GAR-006).
2440
+ if (enableClose && targetEpochIndex >= retention) {
2441
+ const closeTarget = targetEpochIndex - retention;
2442
+ const old = await this.getEpochRaw(closeTarget);
2443
+ if (old && old.rewardsDistributed === 1) {
2444
+ const { id } = await this.closeEpoch({ epochIndex: closeTarget });
2445
+ return { action: 'close', epochIndex: closeTarget, txId: id };
2446
+ }
2447
+ }
2448
+ // Current epoch fully processed — create the next once its start arrives.
2449
+ if (now >= nextEpochStart) {
2450
+ const { id } = await this.createEpoch();
2451
+ return { action: 'create', epochIndex: currentIndex, txId: id };
2452
+ }
2453
+ return { action: 'idle', reason: 'epoch_complete' };
2454
+ }
2118
2455
  /**
2119
2456
  * Read the raw epoch account data for cranker state inspection.
2120
2457
  * 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.25';
@@ -46,6 +46,7 @@ declare class BorshReader {
46
46
  readString(): string;
47
47
  readOptionI64(): number | undefined;
48
48
  readOptionU32(): number | undefined;
49
+ readOptionU16(): number | undefined;
49
50
  skip(bytes: number): void;
50
51
  getOffset(): number;
51
52
  remaining(): number;
@@ -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';
@@ -309,6 +309,21 @@ export declare class SolanaARIOReadable {
309
309
  pubkey: Address;
310
310
  operator: Address;
311
311
  }>>;
312
+ /**
313
+ * Enumerate Joined Gateway PDAs whose delegation has been DISABLED
314
+ * (`allow_delegated_staking == false`) yet still hold delegated stake
315
+ * (`total_delegated_stake > 0`) — i.e. delegates that an operator's disable
316
+ * left stranded (WP §6.3 / Fix #6). Each such gateway's delegates must be
317
+ * cranked out via
318
+ * {@link SolanaARIOWriteable.claimDelegateFromDisabledGateway} (enumerate
319
+ * them with {@link getGatewayDelegates}) before the operator can re-enable
320
+ * delegation. This is the discovery primitive a cranker uses to sweep them.
321
+ */
322
+ getDisabledGatewaysWithDelegatedStake(): Promise<Array<{
323
+ pubkey: Address;
324
+ operator: Address;
325
+ totalDelegatedStake: bigint;
326
+ }>>;
312
327
  /**
313
328
  * Enumerate Delegation PDAs with `amount == 0`. Eligible for
314
329
  * `closeEmptyDelegation` (rent refund to the original delegator).
@@ -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;
@@ -377,6 +422,26 @@ export declare class SolanaARIOWriteable extends SolanaARIOReadable {
377
422
  claimDelegateFromLeavingGateway(params: {
378
423
  gatewayAddress: string;
379
424
  }, _options?: WriteOptions): Promise<MessageResult>;
425
+ /**
426
+ * Claim a delegate's stake out of a gateway that has DISABLED delegation
427
+ * (`allow_delegated_staking == false`), moving it into the delegate's own
428
+ * withdrawal vault (WP §6.3 / Fix #6). This is the disabled-gateway analog of
429
+ * {@link claimDelegateFromLeavingGateway}: the on-chain instruction is
430
+ * permissionless, so a cranker can sweep delegates out (the operator cannot
431
+ * re-enable delegation until `total_delegated_stake == 0` and the cooldown
432
+ * elapses). The withdrawal-counter and withdrawal PDAs are seeded by the
433
+ * DELEGATOR, so a cranker must pass that delegate's `delegatorAddress`.
434
+ *
435
+ * @param params.gatewayAddress The gateway whose delegation was disabled.
436
+ * @param params.delegatorAddress The delegate to claim for. Defaults to the
437
+ * signer (self-claim). Pass another address to crank on a delegate's behalf;
438
+ * the signer covers rent (`payer`) but stake still routes to the delegate's
439
+ * own vault (the delegator key is bound by the delegation PDA seeds).
440
+ */
441
+ claimDelegateFromDisabledGateway(params: {
442
+ gatewayAddress: string;
443
+ delegatorAddress?: string;
444
+ }, _options?: WriteOptions): Promise<MessageResult>;
380
445
  /** Add an address to the gateway's delegation allowlist. */
381
446
  allowDelegate(params: {
382
447
  delegate: string;
@@ -428,9 +493,22 @@ export declare class SolanaARIOWriteable extends SolanaARIOReadable {
428
493
  }, _options?: WriteOptions): Promise<MessageResult>;
429
494
  /**
430
495
  * 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
496
+ * weights are tallied.
497
+ *
498
+ * `gatewayAccounts` MUST be the Gateway PDAs of the SELECTED observers only
499
+ * — at most `epoch_settings.prescribed_observer_count` (≤50), NOT the whole
500
+ * registry. The selection is computed on-chain; mirror it off-chain with
501
+ * {@link predictPrescribedObservers} / {@link getPredictedObserverPDAs} to
502
+ * learn the set. Passing every registry gateway (e.g. via
503
+ * {@link getAllRegistryGatewayPDAs}) hits Solana's `MAX_TX_ACCOUNT_LOCKS = 64`
504
+ * on large registries and the tx fails at pre-flight.
505
+ *
506
+ * The selected PDAs are appended as `remaining_accounts`, followed by the
507
+ * optional `nameRegistryAccount` (must be LAST) which enables the name
433
508
  * prescription leg.
509
+ *
510
+ * If a selected gateway leaves between prediction and tx landing, the tx
511
+ * fails with `InvalidGatewayAccount` — retry once with a fresh prediction.
434
512
  */
435
513
  prescribeEpoch(params: {
436
514
  epochIndex: number;
@@ -461,6 +539,74 @@ export declare class SolanaARIOWriteable extends SolanaARIOReadable {
461
539
  getRegistryGatewayPDAs(startIndex: number, batchSize: number): Promise<Address[]>;
462
540
  /** Get ALL active gateway PDAs from the registry. */
463
541
  getAllRegistryGatewayPDAs(): Promise<Address[]>;
542
+ /**
543
+ * Predict the Gateway PDAs that `prescribe_epoch` will select as observers
544
+ * for `epochIndex`, mirroring the on-chain weighted-roulette selection.
545
+ *
546
+ * Returns at most `epoch_settings.prescribed_observer_count` (≤50) PDAs
547
+ * regardless of registry size — the set to pass as `gatewayAccounts` to
548
+ * {@link prescribeEpoch}. This is the size-safe replacement for
549
+ * {@link getAllRegistryGatewayPDAs} on the prescribe path (which oversupplies
550
+ * and trips `MAX_TX_ACCOUNT_LOCKS = 64` on large registries).
551
+ *
552
+ * Reads three accounts (epoch, registry, epoch settings) at the configured
553
+ * commitment so the prediction reflects live registry weights. If a selected
554
+ * gateway races out before the tx lands, `prescribeEpoch` throws
555
+ * `InvalidGatewayAccount` — re-call this and retry once.
556
+ */
557
+ getPredictedObserverPDAs(epochIndex: number): Promise<Address[]>;
558
+ /**
559
+ * Reclaim rent from the ephemeral Address Lookup Tables this signer created
560
+ * for `prescribe_epoch` (see {@link sendWithEphemeralLookupTable}). Each
561
+ * prescribe leaves a single-use table allocated (~0.0126 SOL); reclaiming
562
+ * needs a deactivate → ~513-slot cooldown → close sequence, so it can't run
563
+ * inline. Call this from a throttled/permissionless cleanup pass (cranker /
564
+ * observer) to deactivate active tables and close cooled-down ones, refunding
565
+ * the rent to the signer.
566
+ *
567
+ * Discovery reads the signer's transaction history (RPC-portable; the ALT
568
+ * program can't be enumerated via `getProgramAccounts`). The GAR + ArNS
569
+ * program IDs are passed as the entry-ownership fingerprint so only genuine
570
+ * prescribe tables are touched. Best-effort: at most `maxTables` submissions
571
+ * per call, scanning at most `scanLimit` recent signatures.
572
+ */
573
+ reclaimLookupTableRent(opts?: {
574
+ maxTables?: number;
575
+ scanLimit?: number;
576
+ }): Promise<{
577
+ deactivated: number;
578
+ closed: number;
579
+ candidates: number;
580
+ scannedSignatures: number;
581
+ }>;
582
+ /** Read and deserialize the full EpochSettings account. */
583
+ getEpochSettingsFull(): Promise<ReturnType<typeof deserializeEpochSettingsFull>>;
584
+ /**
585
+ * Submit `prescribe_epoch` using the off-chain-predicted observer set, with a
586
+ * single re-predict-and-retry on `InvalidGatewayAccount` (covers a gateway
587
+ * leaving the registry between the prediction read and the tx landing).
588
+ */
589
+ protected prescribeWithPrediction(epochIndex: number, nameRegistryAccount?: Address): Promise<MessageResult>;
590
+ /**
591
+ * Advance the epoch lifecycle by ONE on-chain action and return what it did.
592
+ *
593
+ * Stateless and idempotent: it reads `EpochSettings` + the current `Epoch`,
594
+ * determines the single next required step
595
+ * (`create` → `tally` → `prescribe` → `distribute` → `close`), submits it,
596
+ * and returns a {@link CrankEpochStepResult}. Call it repeatedly on your own
597
+ * schedule — it owns *which* on-chain action is correct and *which accounts*
598
+ * it needs; you own scheduling, logging, error classification, and any
599
+ * permissionless cleanup.
600
+ *
601
+ * Crucially, the `prescribe` leg uses {@link getPredictedObserverPDAs} (only
602
+ * the ~`prescribed_observer_count` selected Gateway PDAs), so it never trips
603
+ * `MAX_TX_ACCOUNT_LOCKS = 64` on large registries — and it re-predicts and
604
+ * retries once on `InvalidGatewayAccount`.
605
+ *
606
+ * Errors propagate to the caller (classify/retry as you see fit); the only
607
+ * internally-handled error is the prescribe `InvalidGatewayAccount` retry.
608
+ */
609
+ crankEpochStep(opts?: CrankEpochStepOptions): Promise<CrankEpochStepResult>;
464
610
  /**
465
611
  * Read the raw epoch account data for cranker state inspection.
466
612
  * 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
+ }>;
@@ -232,6 +232,22 @@ export type GatewaySettings = {
232
232
  fqdn: string;
233
233
  port: number;
234
234
  protocol: 'https';
235
+ /**
236
+ * Solana only (GATEWAY_VERSION 1.1.0+). A `delegateRewardShareRatio` change
237
+ * requested mid-epoch is staged here and applied at the next epoch's
238
+ * `tally_weights` (WP §6.3 / Fix #7), so the active value stays epoch-stable.
239
+ * When set, render the active `delegateRewardShareRatio` as the current rate
240
+ * and this as "pending until next epoch". Percent (0-95), same scale as
241
+ * `delegateRewardShareRatio`. Undefined when no change is queued.
242
+ */
243
+ pendingDelegateRewardShareRatio?: number;
244
+ /**
245
+ * Solana only (GATEWAY_VERSION 1.1.0+). Unix seconds when the operator
246
+ * disabled delegation (WP §6.3 / Fix #6). Re-enabling is blocked until every
247
+ * delegate has been withdrawn AND the withdrawal-period cooldown has elapsed
248
+ * since this time. Undefined when delegation is enabled.
249
+ */
250
+ delegationDisabledAt?: number;
235
251
  };
236
252
  export type BalanceWithAddress = {
237
253
  address: WalletAddress;
@@ -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.24";
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.25",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/ar-io/ar-io-sdk.git"
@@ -123,7 +123,8 @@
123
123
  "typescript": "^5.1.6"
124
124
  },
125
125
  "dependencies": {
126
- "@ar.io/solana-contracts": "0.4.0",
126
+ "@ar.io/solana-contracts": "0.5.0-staging.15",
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",