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

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.
package/README.md CHANGED
@@ -3140,6 +3140,110 @@ In the example above, the query will return ArNS records where:
3140
3140
 
3141
3141
  ## Advanced
3142
3142
 
3143
+ ### RPC Configuration
3144
+
3145
+ The SDK accepts any `@solana/kit` RPC client. For read-only usage, only
3146
+ `rpc` is required. Write operations additionally need `rpcSubscriptions`
3147
+ (WebSocket) for transaction confirmation and a `signer`.
3148
+
3149
+ #### Basic (read-only)
3150
+
3151
+ ```ts
3152
+ import { ARIO } from '@ar.io/sdk';
3153
+ import { createSolanaRpc } from '@solana/kit';
3154
+
3155
+ const rpc = createSolanaRpc('https://api.mainnet-beta.solana.com');
3156
+ const ario = ARIO.init({ rpc });
3157
+ ```
3158
+
3159
+ #### With writes (signer + WebSocket subscriptions)
3160
+
3161
+ ```ts
3162
+ import { ARIO } from '@ar.io/sdk';
3163
+ import {
3164
+ createSolanaRpc,
3165
+ createSolanaRpcSubscriptions,
3166
+ createKeyPairSignerFromBytes,
3167
+ } from '@solana/kit';
3168
+
3169
+ const rpc = createSolanaRpc('https://api.mainnet-beta.solana.com');
3170
+ const rpcSubscriptions = createSolanaRpcSubscriptions(
3171
+ 'wss://api.mainnet-beta.solana.com',
3172
+ );
3173
+ const signer = await createKeyPairSignerFromBytes(/* ... */);
3174
+
3175
+ const ario = ARIO.init({ rpc, rpcSubscriptions, signer });
3176
+ ```
3177
+
3178
+ > **Note:** `rpcSubscriptions` opens a WebSocket connection and is only
3179
+ > needed for writes. If your RPC provider doesn't expose a WebSocket
3180
+ > endpoint, omit it and use the SDK in read-only mode.
3181
+
3182
+ ### Circuit Breaker
3183
+
3184
+ The SDK ships an [opossum]-backed circuit breaker that wraps the RPC
3185
+ transport. When the primary endpoint starts failing (429 rate-limits,
3186
+ 5xx errors, network timeouts) the circuit opens and subsequent calls
3187
+ route transparently to a fallback RPC until the primary recovers.
3188
+
3189
+ ```ts
3190
+ import { ARIO, createCircuitBreakerRpc } from '@ar.io/sdk';
3191
+
3192
+ const rpc = createCircuitBreakerRpc({
3193
+ primaryUrl: 'https://my-premium-rpc.example.com',
3194
+ fallbackUrl: 'https://api.mainnet-beta.solana.com',
3195
+ });
3196
+
3197
+ const ario = ARIO.init({ rpc });
3198
+ ```
3199
+
3200
+ Use `defaultFallbackUrl()` to auto-pick mainnet or devnet based on the
3201
+ primary URL:
3202
+
3203
+ ```ts
3204
+ import {
3205
+ createCircuitBreakerRpc,
3206
+ defaultFallbackUrl,
3207
+ } from '@ar.io/sdk';
3208
+
3209
+ const primaryUrl = 'https://my-premium-rpc.example.com';
3210
+ const rpc = createCircuitBreakerRpc({
3211
+ primaryUrl,
3212
+ fallbackUrl: defaultFallbackUrl(primaryUrl), // → mainnet public RPC
3213
+ });
3214
+ ```
3215
+
3216
+ Tuning knobs (all optional):
3217
+
3218
+ | Option | Default | Description |
3219
+ |---|---|---|
3220
+ | `timeout` | `10000` | ms before a single request is timed out (`false` to disable) |
3221
+ | `errorThresholdPercentage` | `50` | error % at which to open the circuit |
3222
+ | `resetTimeout` | `30000` | ms to wait before probing the primary again (half-open) |
3223
+ | `volumeThreshold` | `5` | minimum requests in the rolling window before the circuit can trip |
3224
+
3225
+ ### Automatic Retries
3226
+
3227
+ All RPC **read** calls (account fetches, `getProgramAccounts`, etc.)
3228
+ automatically retry on transient transport errors with exponential
3229
+ back-off. Writes are **not** retried (to avoid double-sends).
3230
+
3231
+ Retried errors: HTTP 429/5xx, `fetch failed`, `ECONNRESET`,
3232
+ `ETIMEDOUT`, `AbortError` / timeouts. Non-retryable errors (account
3233
+ not found, invalid params, deserialization) throw immediately.
3234
+
3235
+ Defaults: **6 attempts**, 500 ms base delay, 5 s max delay. Override
3236
+ per-call with the exported `withRetry` helper:
3237
+
3238
+ ```ts
3239
+ import { withRetry } from '@ar.io/sdk';
3240
+
3241
+ const result = await withRetry(() => rpc.getAccountInfo(addr).send(), {
3242
+ maxAttempts: 3,
3243
+ baseDelayMs: 1000,
3244
+ });
3245
+ ```
3246
+
3143
3247
  ### Generated instruction builders
3144
3248
 
3145
3249
  For custom transaction building, import Codama-emitted typed clients
@@ -3230,6 +3334,7 @@ For more information on how to contribute, please see [CONTRIBUTING.md].
3230
3334
  [ar-io-node repository]: https://github.com/ar-io/ar-io-node
3231
3335
  [ar.io Gateway Documentation]: https://docs.ar.io/gateways/ar-io-node/overview/
3232
3336
  [ANS-104]: https://github.com/ArweaveTeam/arweave-standards/blob/master/ans/ANS-104.md
3337
+ [opossum]: https://nodeshift.dev/opossum/
3233
3338
 
3234
3339
  ```
3235
3340
 
@@ -21,10 +21,11 @@
21
21
  * - Ethereum: `--recipient-ethereum 0x...` parses the 20-byte hex address.
22
22
  */
23
23
  import { readFileSync } from 'node:fs';
24
- import { address, createKeyPairSignerFromBytes, createSolanaRpc, createSolanaRpcSubscriptions, } from '@solana/kit';
24
+ import { address, createKeyPairSignerFromBytes, createSolanaRpcSubscriptions, } from '@solana/kit';
25
25
  import bs58 from 'bs58';
26
26
  import { ARIO_ANT_ESCROW_PROGRAM_ID } from '../../solana/constants.js';
27
27
  import { ANTEscrow } from '../../solana/escrow.js';
28
+ import { createCircuitBreakerRpc, defaultFallbackUrl, } from '../../solana/rpc-circuit-breaker.js';
28
29
  // =========================================
29
30
  // Wiring helpers
30
31
  // =========================================
@@ -39,7 +40,10 @@ function escrowProgramIdFrom(options) {
39
40
  async function readEscrowReader(options) {
40
41
  const rpcUrl = options.rpcUrl ?? 'https://api.mainnet-beta.solana.com';
41
42
  return ANTEscrow.init({
42
- rpc: createSolanaRpc(rpcUrl),
43
+ rpc: createCircuitBreakerRpc({
44
+ primaryUrl: rpcUrl,
45
+ fallbackUrl: defaultFallbackUrl(rpcUrl),
46
+ }),
43
47
  programId: escrowProgramIdFrom(options),
44
48
  });
45
49
  }
@@ -58,7 +62,10 @@ async function writeEscrowFromOptions(options) {
58
62
  const signer = await createKeyPairSignerFromBytes(secretKey);
59
63
  const rpcUrl = options.rpcUrl ?? 'https://api.mainnet-beta.solana.com';
60
64
  return ANTEscrow.init({
61
- rpc: createSolanaRpc(rpcUrl),
65
+ rpc: createCircuitBreakerRpc({
66
+ primaryUrl: rpcUrl,
67
+ fallbackUrl: defaultFallbackUrl(rpcUrl),
68
+ }),
62
69
  rpcSubscriptions: createSolanaRpcSubscriptions(wsUrlFromRpcUrl(rpcUrl)),
63
70
  signer,
64
71
  programId: escrowProgramIdFrom(options),
@@ -14,7 +14,7 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import { readFileSync } from 'fs';
17
- import { address, createKeyPairSignerFromBytes, createSolanaRpc, createSolanaRpcSubscriptions, } from '@solana/kit';
17
+ import { address, createKeyPairSignerFromBytes, createSolanaRpcSubscriptions, } from '@solana/kit';
18
18
  import bs58 from 'bs58';
19
19
  import { program } from 'commander';
20
20
  import prompts from 'prompts';
@@ -22,6 +22,7 @@ import { ANTRegistry } from '../common/ant-registry.js';
22
22
  import { ANT } from '../common/ant.js';
23
23
  import { ARIO } from '../common/io.js';
24
24
  import { Logger } from '../common/logger.js';
25
+ import { createCircuitBreakerRpc, defaultFallbackUrl, } from '../solana/rpc-circuit-breaker.js';
25
26
  import { fundFromOptions, isValidFundFrom, isValidIntent, validIntents, } from '../types/io.js';
26
27
  import { ARIOToken, mARIOToken } from '../types/token.js';
27
28
  import { globalOptions } from './options.js';
@@ -84,7 +85,7 @@ export function readARIOFromOptions(options) {
84
85
  setLoggerIfDebug(options);
85
86
  const rpcUrl = options.rpcUrl ?? 'https://api.mainnet-beta.solana.com';
86
87
  return ARIO.init({
87
- rpc: createSolanaRpc(rpcUrl),
88
+ rpc: createCliRpc(rpcUrl),
88
89
  ...(options.coreProgramId
89
90
  ? { coreProgramId: address(options.coreProgramId) }
90
91
  : {}),
@@ -100,7 +101,7 @@ export async function readANTRegistryFromOptions(options) {
100
101
  setLoggerIfDebug(options);
101
102
  const rpcUrl = options.rpcUrl ?? 'https://api.mainnet-beta.solana.com';
102
103
  return ANTRegistry.init({
103
- rpc: createSolanaRpc(rpcUrl),
104
+ rpc: createCliRpc(rpcUrl),
104
105
  ...(options.antProgramId
105
106
  ? { antProgramId: address(options.antProgramId) }
106
107
  : {}),
@@ -129,6 +130,16 @@ function wsUrlFromRpcUrl(rpcUrl) {
129
130
  url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
130
131
  return url.toString().replace(/\/$/, '');
131
132
  }
133
+ /**
134
+ * Create a {@link SolanaRpc} wrapped with a circuit-breaker that falls back to
135
+ * the cluster's public RPC when the primary endpoint becomes unhealthy.
136
+ */
137
+ function createCliRpc(rpcUrl) {
138
+ return createCircuitBreakerRpc({
139
+ primaryUrl: rpcUrl,
140
+ fallbackUrl: defaultFallbackUrl(rpcUrl),
141
+ });
142
+ }
132
143
  /**
133
144
  * Load a Solana KeyPairSigner from --private-key (base58) or --wallet-file
134
145
  * (JSON array of bytes). Throws with a helpful message if neither is set.
@@ -155,7 +166,7 @@ export async function writeARIOFromOptions(options) {
155
166
  const signer = await loadSolanaSignerFromOptions(options);
156
167
  return {
157
168
  ario: ARIO.init({
158
- rpc: createSolanaRpc(rpcUrl),
169
+ rpc: createCliRpc(rpcUrl),
159
170
  rpcSubscriptions: createSolanaRpcSubscriptions(wsUrlFromRpcUrl(rpcUrl)),
160
171
  signer,
161
172
  // Forward program-id overrides so localnet / devnet writes target the
@@ -427,7 +438,7 @@ export async function readANTFromOptions(options) {
427
438
  const rpcUrl = options.rpcUrl ?? 'https://api.mainnet-beta.solana.com';
428
439
  return ANT.init({
429
440
  processId: requiredProcessIdFromOptions(options),
430
- rpc: createSolanaRpc(rpcUrl),
441
+ rpc: createCliRpc(rpcUrl),
431
442
  ...(options.antProgramId
432
443
  ? { antProgramId: address(options.antProgramId) }
433
444
  : {}),
@@ -438,7 +449,7 @@ export async function writeANTFromOptions(options) {
438
449
  const kitSigner = await loadSolanaSignerFromOptions(options);
439
450
  return ANT.init({
440
451
  processId: requiredProcessIdFromOptions(options),
441
- rpc: createSolanaRpc(rpcUrl),
452
+ rpc: createCliRpc(rpcUrl),
442
453
  rpcSubscriptions: createSolanaRpcSubscriptions(wsUrlFromRpcUrl(rpcUrl)),
443
454
  signer: kitSigner,
444
455
  ...(options.antProgramId
@@ -524,7 +535,7 @@ export async function spawnSolanaANTFromOptions(options) {
524
535
  'See sdk/scripts/devnet-validation/populate-ant.ts for an end-to-end example.');
525
536
  }
526
537
  return spawnSolanaANT({
527
- rpc: createSolanaRpc(rpcUrl),
538
+ rpc: createCliRpc(rpcUrl),
528
539
  rpcSubscriptions: createSolanaRpcSubscriptions(wsUrlFromRpcUrl(rpcUrl)),
529
540
  signer: kitSigner,
530
541
  state: {
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { address, fetchEncodedAccount, } from '@solana/kit';
11
11
  import bs58 from 'bs58';
12
+ import { withRetry } from './retry.js';
12
13
  // AntRecordMetadata discriminator — regenerate via `yarn codegen` after IDL rebase
13
14
  // to expose ANT_RECORD_METADATA_DISCRIMINATOR. Until then, compute inline.
14
15
  import { createHash as __createHash } from 'crypto';
@@ -102,9 +103,9 @@ export class SolanaANTReadable {
102
103
  });
103
104
  }
104
105
  async getAccount(pda) {
105
- return fetchEncodedAccount(this.rpc, pda, {
106
+ return withRetry(() => fetchEncodedAccount(this.rpc, pda, {
106
107
  commitment: this.commitment,
107
- });
108
+ }));
108
109
  }
109
110
  // =========================================
110
111
  // Config reads
@@ -204,20 +205,20 @@ export class SolanaANTReadable {
204
205
  },
205
206
  ];
206
207
  const [recordAccounts, metaAccounts] = (await Promise.all([
207
- this.rpc
208
+ withRetry(() => this.rpc
208
209
  .getProgramAccounts(this.antProgram, {
209
210
  commitment: this.commitment,
210
211
  encoding: 'base64',
211
212
  filters: gpaFilter(bs58.encode(ANT_RECORD_DISCRIMINATOR)),
212
213
  })
213
- .send(),
214
- this.rpc
214
+ .send()),
215
+ withRetry(() => this.rpc
215
216
  .getProgramAccounts(this.antProgram, {
216
217
  commitment: this.commitment,
217
218
  encoding: 'base64',
218
219
  filters: gpaFilter(bs58.encode(ANT_RECORD_METADATA_DISCRIMINATOR)),
219
220
  })
220
- .send(),
221
+ .send()),
221
222
  ]));
222
223
  // Index metadata by undername hash for O(1) lookup.
223
224
  // AntRecordMetadata has undername_hash at offset 40 (8 disc + 32 mint).
@@ -264,21 +264,37 @@ export class SolanaANTWriteable extends SolanaANTReadable {
264
264
  const newOwnerDest = await this.registry.resolveDestinationAclAccounts({
265
265
  user: newOwner,
266
266
  });
267
- // Resolve the old owner's source ACL accounts. The current owner
268
- // *must* have a live `(asset, Owner)` entry every spawn /
269
- // import path seeds it, so a missing entry indicates state
270
- // corruption. We surface that as `AclEntryNotFound` from the
271
- // contract by passing the derived PDAs as a fallback rather than
272
- // failing client-side here.
267
+ // Resolve the old owner's source ACL accounts. If the entry is
268
+ // missing (e.g. the ANT was acquired via a marketplace transfer or
269
+ // before the ACL system existed), heal it by bootstrapping the
270
+ // config/page and recording the owner entry so the on-chain
271
+ // transfer handler can successfully remove it.
273
272
  const oldOwnerSource = await this.registry.resolveSourceAclAccountsForEntry({
274
273
  user: oldOwner,
275
274
  asset: this.mint,
276
275
  role: 'owner',
277
276
  });
278
- const oldOwnerAclConfigPda = oldOwnerSource?.aclConfigPda ??
279
- (await this.registry.deriveAclConfigPda(oldOwner));
280
- const oldOwnerAclPagePda = oldOwnerSource?.aclPagePda ??
281
- (await this.registry.deriveAclPagePda(oldOwner, 0n));
277
+ let oldOwnerAclConfigPda;
278
+ let oldOwnerAclPagePda;
279
+ const oldOwnerHealIxs = [];
280
+ if (oldOwnerSource) {
281
+ oldOwnerAclConfigPda = oldOwnerSource.aclConfigPda;
282
+ oldOwnerAclPagePda = oldOwnerSource.aclPagePda;
283
+ }
284
+ else {
285
+ const dest = await this.registry.resolveDestinationAclAccounts({
286
+ user: oldOwner,
287
+ });
288
+ oldOwnerAclConfigPda = dest.aclConfigPda;
289
+ oldOwnerAclPagePda = dest.aclPagePda;
290
+ oldOwnerHealIxs.push(...dest.prepIxs);
291
+ oldOwnerHealIxs.push(await this.registry.buildRecordIx({
292
+ user: oldOwner,
293
+ asset: this.mint,
294
+ role: 'owner',
295
+ pageIdx: dest.pageIdx,
296
+ }));
297
+ }
282
298
  const transferIx = await getTransferInstructionAsync({
283
299
  asset: this.mint,
284
300
  caller: this.signer,
@@ -306,6 +322,7 @@ export class SolanaANTWriteable extends SolanaANTReadable {
306
322
  controllers: exControllers,
307
323
  });
308
324
  const sig = await this.sendTransaction([
325
+ ...oldOwnerHealIxs,
309
326
  ...newOwnerDest.prepIxs,
310
327
  transferIx,
311
328
  ...cleanupIxs,
@@ -95,6 +95,10 @@ export * from './constants.js';
95
95
  // `/devnet-config.json` — kept in sync via the drift guard test
96
96
  // `clusters.test.ts`.
97
97
  export * from './clusters.js';
98
+ // RPC circuit breaker (opossum-backed transparent fallback)
99
+ export { createCircuitBreakerRpc, defaultFallbackUrl, } from './rpc-circuit-breaker.js';
100
+ // Retry utility (exponential back-off for transient RPC errors)
101
+ export { withRetry, isRetryableError } from './retry.js';
98
102
  // Event decoders
99
103
  //
100
104
  // `parseTransactionEvents(rpc, signature)` and `parseEventsFromLogs(logs)`
@@ -18,6 +18,7 @@ import { computeLiveDelegationBalance } from './delegation-math.js';
18
18
  import { deserializeAllowlist, deserializeArioConfig, deserializeArnsRecord, deserializeDelegation, deserializeDemandFactor, deserializeEpoch, deserializeEpochSettings, deserializeEpochSettingsFull, deserializeGarSettings, deserializeGarSupplyCounters, deserializeGateway, deserializeGatewayWithAccumulator, deserializeObservation, deserializePrimaryName, deserializePrimaryNameRequest, deserializeRedelegationRecord, deserializeReservedName, deserializeReturnedName, deserializeVault, deserializeWithdrawal, } from './deserialize.js';
19
19
  import { TOKEN_PROGRAM_ADDRESS } from './instruction.js';
20
20
  import { getArioConfigPDA, getArnsRecordPDA, getArnsRecordPDAFromHash, getArnsSettingsPDA, getDemandFactorPDA, getEpochPDA, getEpochSettingsPDA, getGarSettingsPDA, getGatewayPDA, getGatewayRegistryPDA, getObserverLookupPDA, getPrimaryNamePDA, getPrimaryNameRequestPDA, getReservedNamePDA, getReturnedNamePDA, getVaultPDA, } from './pda.js';
21
+ import { withRetry } from './retry.js';
21
22
  const addressDecoder = getAddressDecoder();
22
23
  /** All-zero address — equivalent of web3.js `PublicKey.default`. */
23
24
  const DEFAULT_ADDRESS = address('11111111111111111111111111111111');
@@ -141,9 +142,9 @@ export class SolanaARIOReadable {
141
142
  }
142
143
  /** Helper to fetch an encoded account (kit's replacement for Connection.getAccountInfo). */
143
144
  async getAccount(pda) {
144
- return fetchEncodedAccount(this.rpc, pda, {
145
+ return withRetry(() => fetchEncodedAccount(this.rpc, pda, {
145
146
  commitment: this.commitment,
146
- });
147
+ }));
147
148
  }
148
149
  /**
149
150
  * Helper for `getProgramAccounts` with a discriminator memcmp filter.
@@ -168,7 +169,7 @@ export class SolanaARIOReadable {
168
169
  // when called without `withContext: true`. With `encoding: 'base64'`, each
169
170
  // account's `data` is a `[base64, 'base64']` tuple. We bypass kit's strict
170
171
  // generic overload typing here with a cast — the runtime shape is stable.
171
- const result = (await this.rpc
172
+ const result = await withRetry(() => this.rpc
172
173
  .getProgramAccounts(programId, {
173
174
  commitment: this.commitment,
174
175
  encoding: 'base64',
@@ -195,9 +196,9 @@ export class SolanaARIOReadable {
195
196
  if (unique.length === 0)
196
197
  return new Map();
197
198
  const pdas = await Promise.all(unique.map(async (op) => (await getGatewayPDA(address(op), this.garProgram))[0]));
198
- const accounts = await fetchEncodedAccounts(this.rpc, pdas, {
199
+ const accounts = await withRetry(() => fetchEncodedAccounts(this.rpc, pdas, {
199
200
  commitment: this.commitment,
200
- });
201
+ }));
201
202
  const out = new Map();
202
203
  for (let i = 0; i < accounts.length; i++) {
203
204
  const acct = accounts[i];
@@ -387,7 +388,7 @@ export class SolanaARIOReadable {
387
388
  },
388
389
  },
389
390
  ];
390
- const result = (await this.rpc
391
+ const result = await withRetry(() => this.rpc
391
392
  .getProgramAccounts(TOKEN_PROGRAM_ADDRESS, {
392
393
  commitment: this.commitment,
393
394
  encoding: 'base64',
@@ -19,10 +19,10 @@
19
19
  * surface for them.
20
20
  */
21
21
  import { AccountRole, address, fetchEncodedAccount, getAddressDecoder, } from '@solana/kit';
22
- import { PurchaseType, getBuyNameFromDelegationInstructionAsync, getBuyNameFromFundingPlanInstructionAsync, getBuyNameFromOperatorStakeInstructionAsync, getBuyNameFromWithdrawalInstructionAsync, getBuyNameInstructionAsync, getBuyReturnedNameFromDelegationInstructionAsync, getBuyReturnedNameFromFundingPlanInstructionAsync, getBuyReturnedNameFromOperatorStakeInstructionAsync, getBuyReturnedNameFromWithdrawalInstructionAsync, getBuyReturnedNameInstructionAsync, getExtendLeaseFromDelegationInstructionAsync, getExtendLeaseFromFundingPlanInstructionAsync, getExtendLeaseFromOperatorStakeInstructionAsync, getExtendLeaseFromWithdrawalInstructionAsync, getExtendLeaseInstructionAsync, getIncreaseUndernameLimitFromDelegationInstructionAsync, getIncreaseUndernameLimitFromFundingPlanInstructionAsync, getIncreaseUndernameLimitFromOperatorStakeInstructionAsync, getIncreaseUndernameLimitFromWithdrawalInstructionAsync, getIncreaseUndernameLimitInstructionAsync, getPruneExpiredNamesInstructionAsync, getPruneExpiredReservationInstruction, getPruneNameToReturnedInstructionAsync, getPruneReturnedNamesInstructionAsync, getReassignNameInstructionAsync, getReleaseNameInstructionAsync, getUpgradeNameFromDelegationInstructionAsync, getUpgradeNameFromFundingPlanInstructionAsync, getUpgradeNameFromOperatorStakeInstructionAsync, getUpgradeNameFromWithdrawalInstructionAsync, getUpgradeNameInstructionAsync, } from '@ar.io/solana-contracts/arns';
22
+ import { PurchaseType, getBuyNameFromDelegationInstructionAsync, getBuyNameFromFundingPlanInstructionAsync, getBuyNameFromOperatorStakeInstructionAsync, getBuyNameFromWithdrawalInstructionAsync, getBuyNameInstructionAsync, getBuyReturnedNameFromDelegationInstructionAsync, getBuyReturnedNameFromFundingPlanInstructionAsync, getBuyReturnedNameFromOperatorStakeInstructionAsync, getBuyReturnedNameFromWithdrawalInstructionAsync, getBuyReturnedNameInstructionAsync, getExtendLeaseFromDelegationInstructionAsync, getExtendLeaseFromFundingPlanInstructionAsync, getExtendLeaseFromOperatorStakeInstructionAsync, getExtendLeaseFromWithdrawalInstructionAsync, getExtendLeaseInstructionAsync, getIncreaseUndernameLimitFromDelegationInstructionAsync, getIncreaseUndernameLimitFromFundingPlanInstructionAsync, getIncreaseUndernameLimitFromOperatorStakeInstructionAsync, getIncreaseUndernameLimitFromWithdrawalInstructionAsync, getIncreaseUndernameLimitInstructionAsync, getMigrateArnsRecordInstruction, getPruneExpiredNamesInstructionAsync, getPruneExpiredReservationInstruction, getPruneNameToReturnedInstructionAsync, getPruneReturnedNamesInstructionAsync, getReassignNameInstructionAsync, getReleaseNameInstructionAsync, getUpgradeNameFromDelegationInstructionAsync, getUpgradeNameFromFundingPlanInstructionAsync, getUpgradeNameFromOperatorStakeInstructionAsync, getUpgradeNameFromWithdrawalInstructionAsync, getUpgradeNameInstructionAsync, } from '@ar.io/solana-contracts/arns';
23
23
  import { FundingSourceKind as GeneratedFundingSourceKindEnum } from '@ar.io/solana-contracts/gar';
24
24
  import { buildCreateAtaIdempotentIx, getAssociatedTokenAddressKit, } from './ata.js';
25
- import { deserializeArnsRecord, deserializeEpochSettingsFull, } from './deserialize.js';
25
+ import { deserializeArnsRecord, deserializeEpochSettingsFull, deserializePrimaryName, } from './deserialize.js';
26
26
  import { buildFundingPlan as buildFundingPlanCore, buildFundingPlanRemainingAccounts, computeResidueIndexes, predictResidueVaults, } from './funding-plan.js';
27
27
  /** Maps the SDK's user-facing FundingSourceKind string union to the
28
28
  * Codama-generated enum used by the on-chain ix payload. */
@@ -36,7 +36,7 @@ function toGeneratedFundingSourceSpec(s) {
36
36
  return { kind: kindMap[s.kind], amount: s.amount };
37
37
  }
38
38
  import { getSyncAttributesInstruction } from '@ar.io/solana-contracts/ant';
39
- import { getApprovePrimaryNameInstructionAsync, getCloseExpiredRequestInstruction, getCreateVaultInstructionAsync, getExtendVaultInstructionAsync, getIncreaseVaultInstructionAsync, getReleaseVaultInstructionAsync, getRequestAndSetPrimaryNameFromFundingPlanInstructionAsync, getRequestAndSetPrimaryNameInstructionAsync, getRequestPrimaryNameFromFundingPlanInstructionAsync, getRequestPrimaryNameInstructionAsync, getRevokeVaultInstructionAsync, getVaultedTransferInstructionAsync, } from '@ar.io/solana-contracts/core';
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
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';
42
42
  import { getTransferCheckedInstruction } from '@solana-program/token';
@@ -280,6 +280,35 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
280
280
  const [nameRegistry] = await getArnsRegistryPDA(this.arnsProgram);
281
281
  return { config, demandFactor, nameRegistry, ...input };
282
282
  }
283
+ /**
284
+ * If the on-chain ArnsRecord for `name` hasn't been migrated to the
285
+ * current schema (name_hash at offset 8 doesn't match the expected
286
+ * hash), return a `migrate_arns_record` instruction that must be
287
+ * prepended to any operation referencing the record with PDA seed
288
+ * verification.
289
+ *
290
+ * Returns an empty array when the record is already up-to-date or
291
+ * doesn't exist.
292
+ */
293
+ async _buildMigrateArnsRecordIxIfNeeded(name) {
294
+ const [arnsRecordPda] = await getArnsRecordPDA(name, this.arnsProgram);
295
+ const account = await fetchEncodedAccount(this.rpc, arnsRecordPda, {
296
+ commitment: this.commitment,
297
+ });
298
+ if (!account.exists)
299
+ return [];
300
+ const data = Buffer.from(account.data);
301
+ const expectedHash = hashName(name);
302
+ const storedHash = data.subarray(8, 40);
303
+ if (storedHash.equals(expectedHash))
304
+ return [];
305
+ return [
306
+ getMigrateArnsRecordInstruction({
307
+ record: arnsRecordPda,
308
+ payer: this.signer,
309
+ }, { programAddress: this.arnsProgram }),
310
+ ];
311
+ }
283
312
  /** Inject ARIO core default PDAs (config). */
284
313
  async withCoreDefaults(input) {
285
314
  const [config] = await getArioConfigPDA(this.coreProgram);
@@ -1046,12 +1075,13 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
1046
1075
  return pda;
1047
1076
  }
1048
1077
  async upgradeRecord(params, _options) {
1078
+ const migrateIxs = await this._buildMigrateArnsRecordIxIfNeeded(params.name);
1049
1079
  const ix = await this._buildManageStakeIx({
1050
1080
  params,
1051
1081
  operation: 'upgrade',
1052
1082
  });
1053
1083
  const syncIx = await this._buildSyncAttributesIxIfOwner(params.name);
1054
- const sig = await this.sendTransaction(syncIx ? [ix, syncIx] : [ix]);
1084
+ const sig = await this.sendTransaction(syncIx ? [...migrateIxs, ix, syncIx] : [...migrateIxs, ix]);
1055
1085
  return { id: sig };
1056
1086
  }
1057
1087
  async syncAttributes(params, _options) {
@@ -1061,8 +1091,9 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
1061
1091
  // `_buildSyncAttributesIxIfOwner`, which skips when not the owner so
1062
1092
  // the wrapping arns ix can still succeed for non-holder management
1063
1093
  // — see BD-095.)
1094
+ const migrateIxs = await this._buildMigrateArnsRecordIxIfNeeded(params.name);
1064
1095
  const ix = await this._buildSyncAttributesIxUnconditional(params.name);
1065
- const sig = await this.sendTransaction([ix]);
1096
+ const sig = await this.sendTransaction([...migrateIxs, ix]);
1066
1097
  return { id: sig };
1067
1098
  }
1068
1099
  /**
@@ -1130,6 +1161,7 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
1130
1161
  }, { programAddress: this.antProgram });
1131
1162
  }
1132
1163
  async extendLease(params, _options) {
1164
+ const migrateIxs = await this._buildMigrateArnsRecordIxIfNeeded(params.name);
1133
1165
  const ix = await this._buildManageStakeIx({
1134
1166
  params,
1135
1167
  operation: 'extend',
@@ -1137,17 +1169,18 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
1137
1169
  });
1138
1170
  // BD-095: extend_lease changes only `end_timestamp`, which isn't
1139
1171
  // mirrored in any Metaplex Attributes plugin trait. No bundle.
1140
- const sig = await this.sendTransaction([ix]);
1172
+ const sig = await this.sendTransaction([...migrateIxs, ix]);
1141
1173
  return { id: sig };
1142
1174
  }
1143
1175
  async increaseUndernameLimit(params, _options) {
1176
+ const migrateIxs = await this._buildMigrateArnsRecordIxIfNeeded(params.name);
1144
1177
  const ix = await this._buildManageStakeIx({
1145
1178
  params,
1146
1179
  operation: 'increaseUndername',
1147
1180
  quantity: params.increaseCount,
1148
1181
  });
1149
1182
  const syncIx = await this._buildSyncAttributesIxIfOwner(params.name);
1150
- const sig = await this.sendTransaction(syncIx ? [ix, syncIx] : [ix]);
1183
+ const sig = await this.sendTransaction(syncIx ? [...migrateIxs, ix, syncIx] : [...migrateIxs, ix]);
1151
1184
  return { id: sig };
1152
1185
  }
1153
1186
  /**
@@ -1312,6 +1345,60 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
1312
1345
  // =========================================
1313
1346
  // Primary name operations (ario-core)
1314
1347
  // =========================================
1348
+ /**
1349
+ * If the signer already has a primary name set, build the instruction(s)
1350
+ * needed to remove it so they can be prepended to a request/set tx —
1351
+ * enabling single-tx "change primary name" flows.
1352
+ *
1353
+ * Returns an empty array when the signer has no existing primary name.
1354
+ *
1355
+ * Throws when the signer has a legacy primary-name state (forward
1356
+ * `PrimaryName` PDA exists but its paired `PrimaryNameReverse` PDA does
1357
+ * NOT). Both `remove_primary_name` AND `request_and_set_primary_name`
1358
+ * require the reverse PDA on-chain — the latter rejects with
1359
+ * `MustRemoveExistingPrimaryName` (0x1786, code 6022) any time a
1360
+ * forward record already exists for the signer, regardless of reverse
1361
+ * state. Silently skipping the remove would queue a tx guaranteed to
1362
+ * fail with that opaque error. Surfacing it at the client with a clear
1363
+ * remediation pointer is the only safe behavior.
1364
+ *
1365
+ * The legacy state should not exist on any cluster post-snapshot/import
1366
+ * PR #159 (which emits PrimaryNameReverse in lockstep with PrimaryName)
1367
+ * — it's a relic of pre-#159 imports. Operators on affected clusters
1368
+ * must run `yarn workspace @ar-io/migration-import backfill:primary-name-reverse`
1369
+ * (in the solana-ar-io repo) before this method can succeed.
1370
+ */
1371
+ async _buildRemoveExistingPrimaryNameIxs() {
1372
+ const [primaryNamePda] = await getPrimaryNamePDA(this.signer.address, this.coreProgram);
1373
+ const account = await fetchEncodedAccount(this.rpc, primaryNamePda, {
1374
+ commitment: this.commitment,
1375
+ });
1376
+ if (!account.exists)
1377
+ return [];
1378
+ const { name: oldName } = deserializePrimaryName(Buffer.from(account.data));
1379
+ const [primaryNameReversePda] = await getPrimaryNameReversePDA(oldName, this.coreProgram);
1380
+ const reverseAccount = await fetchEncodedAccount(this.rpc, primaryNameReversePda, { commitment: this.commitment });
1381
+ if (!reverseAccount.exists) {
1382
+ // Fail fast with an actionable message. See method docstring for
1383
+ // why request_and_set would reject this regardless.
1384
+ throw new Error(`Cannot change primary name: signer "${this.signer.address}" has a ` +
1385
+ `legacy PrimaryName ("${oldName}") with no paired PrimaryNameReverse PDA ` +
1386
+ `(${primaryNameReversePda}). The on-chain remove_primary_name and ` +
1387
+ `request_and_set_primary_name ixs both require the reverse PDA — ` +
1388
+ `request_and_set will reject with MustRemoveExistingPrimaryName (code 6022). ` +
1389
+ `Run \`yarn workspace @ar-io/migration-import backfill:primary-name-reverse\` ` +
1390
+ `against this cluster's ario-core program to materialize the missing reverse ` +
1391
+ `PDA, then retry.`);
1392
+ }
1393
+ return [
1394
+ await getRemovePrimaryNameInstructionAsync({
1395
+ primaryName: primaryNamePda,
1396
+ primaryNameReverse: primaryNameReversePda,
1397
+ owner: this.signer,
1398
+ reverseLookupHash: hashName(oldName),
1399
+ }, { programAddress: this.coreProgram }),
1400
+ ];
1401
+ }
1315
1402
  /**
1316
1403
  * Build the `remaining_accounts` slice + the `antProgramId` arg the
1317
1404
  * four ario-core primary-name instructions consume. Sprint 2/5
@@ -1399,6 +1486,11 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
1399
1486
  return { remaining, antProgram };
1400
1487
  }
1401
1488
  async requestPrimaryName(params, _options) {
1489
+ // If the caller already has a primary name, prepend remove ixs so
1490
+ // the on-chain handler doesn't reject with MustRemoveExistingPrimaryName.
1491
+ const removeIxs = await this._buildRemoveExistingPrimaryNameIxs();
1492
+ const { baseName } = splitPrimaryName(params.name);
1493
+ const migrateIxs = await this._buildMigrateArnsRecordIxIfNeeded(baseName);
1402
1494
  const coreConfig = await this.getCoreConfig();
1403
1495
  const signerATA = await getAssociatedTokenAddressKit(coreConfig.mint, this.signer.address);
1404
1496
  const { remaining } = await this._buildPrimaryNameValidationAccounts(params.name, 'request');
@@ -1424,13 +1516,18 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
1424
1516
  operation: 'request',
1425
1517
  });
1426
1518
  }
1427
- const sig = await this.sendTransaction([ix]);
1519
+ const sig = await this.sendTransaction([...removeIxs, ...migrateIxs, ix]);
1428
1520
  return { id: sig };
1429
1521
  }
1430
1522
  async setPrimaryName(params, _options) {
1431
1523
  // setPrimaryName routes to the on-chain `request_and_set_primary_name`
1432
1524
  // path — the auto-approve flow when the caller owns the AntRecord
1433
1525
  // for the matching name (undername part, or "@" for base names).
1526
+ // If the caller already has a primary name, prepend remove ixs so
1527
+ // the "change" is atomic in a single transaction.
1528
+ const removeIxs = await this._buildRemoveExistingPrimaryNameIxs();
1529
+ const { baseName } = splitPrimaryName(params.name);
1530
+ const migrateIxs = await this._buildMigrateArnsRecordIxIfNeeded(baseName);
1434
1531
  const coreConfig = await this.getCoreConfig();
1435
1532
  const signerATA = await getAssociatedTokenAddressKit(coreConfig.mint, this.signer.address);
1436
1533
  const { remaining, antProgram } = await this._buildPrimaryNameValidationAccounts(params.name, 'requestAndSet');
@@ -1456,7 +1553,7 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
1456
1553
  antProgramId: antProgram,
1457
1554
  });
1458
1555
  }
1459
- const sig = await this.sendTransaction([ix]);
1556
+ const sig = await this.sendTransaction([...removeIxs, ...migrateIxs, ix]);
1460
1557
  return { id: sig };
1461
1558
  }
1462
1559
  /**
@@ -1544,6 +1641,8 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
1544
1641
  * remaining_accounts: [arns_record(base), ant_record(undername | @)].
1545
1642
  */
1546
1643
  async approvePrimaryName(params, _options) {
1644
+ const { baseName } = splitPrimaryName(params.name);
1645
+ const migrateIxs = await this._buildMigrateArnsRecordIxIfNeeded(baseName);
1547
1646
  const [requestPda] = await getPrimaryNameRequestPDA(params.initiator, this.coreProgram);
1548
1647
  const [primaryNamePda] = await getPrimaryNamePDA(params.initiator, this.coreProgram);
1549
1648
  const [primaryNameReversePda] = await getPrimaryNameReversePDA(params.name, this.coreProgram);
@@ -1565,7 +1664,7 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
1565
1664
  reverseLookupHash: hashName(params.name),
1566
1665
  antProgramId: antProgram,
1567
1666
  }), { programAddress: this.coreProgram }), remaining);
1568
- const sig = await this.sendTransaction([ix]);
1667
+ const sig = await this.sendTransaction([...migrateIxs, ix]);
1569
1668
  return { id: sig };
1570
1669
  }
1571
1670
  // =========================================
@@ -1803,18 +1902,14 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
1803
1902
  // =========================================
1804
1903
  /** Reassign an ArNS name to a different ANT. */
1805
1904
  async reassignName(params, _options) {
1905
+ const migrateIxs = await this._buildMigrateArnsRecordIxIfNeeded(params.name);
1806
1906
  const newAnt = address(params.processId);
1807
1907
  const [arnsRecord] = await getArnsRecordPDA(params.name, this.arnsProgram);
1808
- // The on-chain `reassign_name` (PR #73 / BD-106 / BD-095) now authorizes
1809
- // against the CURRENT Metaplex Core holder of `record.ant` via a named
1810
- // `ant_asset` account constrained to `arns_record.ant`. We must read the
1811
- // current record to know which asset to pass — that's the OLD ant (the
1812
- // one we're reassigning AWAY FROM), not `newAnt`.
1813
- const currentRecord = await this.getArNSRecord({ name: params.name });
1814
- const currentAnt = address(currentRecord.processId);
1908
+ const record = await this.getArNSRecord({ name: params.name });
1909
+ const antAsset = address(record.processId);
1815
1910
  const ix = await getReassignNameInstructionAsync(await this.withArnsDefaults({
1816
1911
  arnsRecord,
1817
- antAsset: currentAnt,
1912
+ antAsset,
1818
1913
  caller: this.signer,
1819
1914
  newAnt,
1820
1915
  }), { programAddress: this.arnsProgram });
@@ -1828,21 +1923,25 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
1828
1923
  // the new ANT's holder; otherwise the ix is sent alone and the new
1829
1924
  // owner runs `syncAttributes()` later (BD-095/096).
1830
1925
  const syncIx = await this._buildSyncAttributesIxIfOwner(params.name, newAnt);
1831
- const sig = await this.sendTransaction(syncIx ? [ix, syncIx] : [ix]);
1926
+ const reassignWithMetas = withRemainingAccounts(ix, [
1927
+ { address: newAnt, role: AccountRole.READONLY },
1928
+ ]);
1929
+ const sig = await this.sendTransaction(syncIx
1930
+ ? [...migrateIxs, reassignWithMetas, syncIx]
1931
+ : [...migrateIxs, reassignWithMetas]);
1832
1932
  return { id: sig };
1833
1933
  }
1834
1934
  /** Release a permabuy name back to the registry (creates a returned name auction). */
1835
1935
  async releaseName(params, _options) {
1936
+ const migrateIxs = await this._buildMigrateArnsRecordIxIfNeeded(params.name);
1836
1937
  const [returnedNamePda] = await getReturnedNamePDA(params.name, this.arnsProgram);
1837
1938
  const [arnsRecord] = await getArnsRecordPDA(params.name, this.arnsProgram);
1838
- // PR #73 / BD-106: `release_name` now authorizes against the current
1839
- // Metaplex Core holder of `record.ant` via a named `ant_asset` account
1840
- // constrained to `arns_record.ant`. Fetch the record to know which.
1841
- const currentRecord = await this.getArNSRecord({ name: params.name });
1939
+ const record = await this.getArNSRecord({ name: params.name });
1940
+ const antAsset = address(record.processId);
1842
1941
  const ix = await getReleaseNameInstructionAsync(await this.withArnsDefaults({
1843
1942
  arnsRecord,
1844
1943
  returnedName: returnedNamePda,
1845
- antAsset: address(currentRecord.processId),
1944
+ antAsset,
1846
1945
  caller: this.signer,
1847
1946
  }), { programAddress: this.arnsProgram });
1848
1947
  // Note: no sync_attributes bundle here — release_name closes the
@@ -1850,7 +1949,7 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
1850
1949
  // asset's stale traits remain pointing at the released name; off-chain
1851
1950
  // resolvers should treat ArnsRecord as the source of truth and ignore
1852
1951
  // a "ArNS Name" trait that no longer resolves.
1853
- const sig = await this.sendTransaction([ix]);
1952
+ const sig = await this.sendTransaction([...migrateIxs, ix]);
1854
1953
  return { id: sig };
1855
1954
  }
1856
1955
  // =========================================
@@ -2100,6 +2199,7 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
2100
2199
  * (kicks off the Dutch auction). Permissionless.
2101
2200
  */
2102
2201
  async pruneNameToReturned(params, _options) {
2202
+ const migrateIxs = await this._buildMigrateArnsRecordIxIfNeeded(params.name);
2103
2203
  const [arnsRecord] = await getArnsRecordPDA(params.name, this.arnsProgram);
2104
2204
  const [returnedName] = await getReturnedNamePDA(params.name, this.arnsProgram);
2105
2205
  const ix = await getPruneNameToReturnedInstructionAsync(await this.withArnsDefaults({
@@ -2107,7 +2207,7 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
2107
2207
  returnedName,
2108
2208
  payer: this.signer,
2109
2209
  }), { programAddress: this.arnsProgram });
2110
- const sig = await this.sendTransaction([ix]);
2210
+ const sig = await this.sendTransaction([...migrateIxs, ix]);
2111
2211
  return { id: sig };
2112
2212
  }
2113
2213
  /**
@@ -5,6 +5,7 @@
5
5
  * the RPC client's response shape.
6
6
  */
7
7
  import { address } from '@solana/kit';
8
+ import { withRetry } from './retry.js';
8
9
  /**
9
10
  * Map an arbitrary commitment string to one of kit's three supported tiers.
10
11
  * Anything we don't recognise (or `undefined`) falls back to `confirmed`,
@@ -24,12 +25,12 @@ function toKitCommitment(commitment) {
24
25
  * doesn't exist (so callers can handle "missing PDA" without try/catch).
25
26
  */
26
27
  export async function getAccountInfoLegacy(rpc, pda, commitment) {
27
- const res = await rpc
28
+ const res = await withRetry(() => rpc
28
29
  .getAccountInfo(pda, {
29
30
  encoding: 'base64',
30
31
  commitment: toKitCommitment(commitment),
31
32
  })
32
- .send();
33
+ .send());
33
34
  if (!res.value)
34
35
  return null;
35
36
  const [dataB64] = res.value.data;
@@ -53,12 +54,12 @@ export async function getAccountInfoLegacy(rpc, pda, commitment) {
53
54
  export async function getMultipleAccountsInfoLegacy(rpc, pdas, commitment) {
54
55
  if (pdas.length === 0)
55
56
  return [];
56
- const res = await rpc
57
+ const res = await withRetry(() => rpc
57
58
  .getMultipleAccounts(pdas, {
58
59
  encoding: 'base64',
59
60
  commitment: toKitCommitment(commitment),
60
61
  })
61
- .send();
62
+ .send());
62
63
  return res.value.map((acct) => {
63
64
  if (!acct)
64
65
  return null;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Lightweight retry helper with exponential back-off + jitter for Solana RPC
3
+ * read calls.
4
+ *
5
+ * Wraps any async function so transient transport errors (HTTP 429/5xx,
6
+ * network timeouts, etc.) are retried automatically while "normal" failures
7
+ * (account-not-found, deserialization) bubble immediately.
8
+ *
9
+ * Usage:
10
+ * ```ts
11
+ * const account = await withRetry(() => fetchEncodedAccount(rpc, pda));
12
+ * ```
13
+ */
14
+ import { Logger } from '../common/logger.js';
15
+ const logger = new Logger({ level: 'error' });
16
+ const DEFAULT_MAX_ATTEMPTS = 6;
17
+ const DEFAULT_BASE_DELAY_MS = 500;
18
+ const DEFAULT_MAX_DELAY_MS = 5_000;
19
+ /**
20
+ * Default retryable-error heuristic.
21
+ *
22
+ * We retry on:
23
+ * - Network / fetch errors (`TypeError: fetch failed`)
24
+ * - HTTP 429 (rate-limit) and 5xx (server) surfaced by kit's transport as
25
+ * `SolanaError` with "HTTP error (4xx|5xx)" in the message.
26
+ * - Timeout errors from opossum or native `AbortError`.
27
+ *
28
+ * We do NOT retry on:
29
+ * - JSON-RPC application errors (account not found, invalid params, etc.)
30
+ * - Deserialization / decoding errors
31
+ * - Any error without a recognisable transport signature
32
+ */
33
+ export function isRetryableError(error) {
34
+ if (error == null)
35
+ return false;
36
+ const name = error.name ?? '';
37
+ const message = String(error.message ?? '');
38
+ const code = error.context
39
+ ?.__code;
40
+ // SolanaError from kit's transport for HTTP 429 / 5xx
41
+ if (/HTTP error \(4(?:0[89]|[1-9]\d)|HTTP error \(5\d\d\)/.test(message))
42
+ return true;
43
+ // Specific 429 match (rate-limit) — always retry
44
+ if (/HTTP error \(429\)/.test(message))
45
+ return true;
46
+ // Network-level failures: fetch failed, ECONNRESET, ETIMEDOUT, etc.
47
+ if (name === 'TypeError' && /fetch failed/i.test(message))
48
+ return true;
49
+ if (/ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN/i.test(message))
50
+ return true;
51
+ // Abort / timeout
52
+ if (name === 'AbortError' || name === 'TimeoutError')
53
+ return true;
54
+ if (/timed out/i.test(message))
55
+ return true;
56
+ // Opossum circuit-breaker open — the breaker itself will fallback, but if
57
+ // we're layered on top we shouldn't retry (the breaker handles it).
58
+ if (/breaker is open/i.test(message))
59
+ return false;
60
+ // Numeric Solana JSON-RPC codes that indicate transient overload
61
+ if (typeof code === 'number' && (code === -32005 || code === -32016))
62
+ return true;
63
+ return false;
64
+ }
65
+ function sleep(ms) {
66
+ return new Promise((resolve) => setTimeout(resolve, ms));
67
+ }
68
+ function jitteredDelay(base, attempt, cap) {
69
+ const exponential = base * 2 ** attempt;
70
+ const capped = Math.min(exponential, cap);
71
+ return capped * (0.5 + Math.random() * 0.5);
72
+ }
73
+ /**
74
+ * Execute `fn` with automatic retries on transient failures.
75
+ *
76
+ * ```ts
77
+ * const data = await withRetry(() => rpc.getAccountInfo(addr).send());
78
+ * ```
79
+ */
80
+ export async function withRetry(fn, opts) {
81
+ const maxAttempts = opts?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
82
+ const baseDelayMs = opts?.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
83
+ const maxDelayMs = opts?.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
84
+ const retryable = opts?.isRetryable ?? isRetryableError;
85
+ let lastError;
86
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
87
+ try {
88
+ return await fn();
89
+ }
90
+ catch (error) {
91
+ lastError = error;
92
+ const isLast = attempt === maxAttempts - 1;
93
+ if (isLast || !retryable(error)) {
94
+ throw error;
95
+ }
96
+ const delay = jitteredDelay(baseDelayMs, attempt, maxDelayMs);
97
+ logger.debug(`[retry] attempt ${attempt + 1}/${maxAttempts} failed, retrying in ${Math.round(delay)}ms`, { error: String(error) });
98
+ await sleep(delay);
99
+ }
100
+ }
101
+ throw lastError;
102
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Circuit-breaker wrapper for Solana RPC transports using
3
+ * [opossum](https://nodeshift.dev/opossum/).
4
+ *
5
+ * When the primary RPC endpoint starts failing (rate-limits, downtime, etc.)
6
+ * the circuit opens and subsequent calls are routed transparently to a
7
+ * fallback RPC until the primary recovers.
8
+ *
9
+ * Works at the **transport level** — no Proxy magic required. Every RPC
10
+ * method (`getAccountInfo`, `sendTransaction`, etc.) goes through the same
11
+ * transport function, so a single circuit breaker covers them all.
12
+ *
13
+ * Usage:
14
+ * ```ts
15
+ * import { ARIO, createCircuitBreakerRpc } from '@ar.io/sdk';
16
+ *
17
+ * const rpc = createCircuitBreakerRpc({
18
+ * primaryUrl: 'https://my-premium-rpc.example.com',
19
+ * fallbackUrl: 'https://api.mainnet-beta.solana.com',
20
+ * });
21
+ *
22
+ * const ario = ARIO.init({ rpc });
23
+ * ```
24
+ */
25
+ import { createDefaultRpcTransport, createSolanaRpcFromTransport, } from '@solana/kit';
26
+ import CircuitBreaker from 'opossum';
27
+ import { Logger } from '../common/logger.js';
28
+ const logger = new Logger({ level: 'error' });
29
+ // ---------------------------------------------------------------------------
30
+ // Defaults
31
+ // ---------------------------------------------------------------------------
32
+ const DEFAULT_MAINNET_RPC = 'https://api.mainnet-beta.solana.com';
33
+ const DEFAULT_DEVNET_RPC = 'https://api.devnet.solana.com';
34
+ // ---------------------------------------------------------------------------
35
+ // Implementation
36
+ // ---------------------------------------------------------------------------
37
+ /**
38
+ * Create a {@link SolanaRpc} whose transport is backed by an opossum circuit
39
+ * breaker. Reads and writes flow through the primary transport; when it
40
+ * becomes unhealthy the circuit opens and subsequent calls are routed to
41
+ * the fallback transport until the primary recovers.
42
+ */
43
+ export function createCircuitBreakerRpc({ primaryUrl, fallbackUrl, circuitBreakerOptions: opts = {}, }) {
44
+ const primaryTransport = createDefaultRpcTransport({ url: primaryUrl });
45
+ const fallbackTransport = createDefaultRpcTransport({ url: fallbackUrl });
46
+ const breaker = new CircuitBreaker((request) => primaryTransport(request), {
47
+ timeout: opts.timeout ?? 10_000,
48
+ errorThresholdPercentage: opts.errorThresholdPercentage ?? 50,
49
+ resetTimeout: opts.resetTimeout ?? 30_000,
50
+ volumeThreshold: opts.volumeThreshold ?? 5,
51
+ });
52
+ breaker.fallback((request) => fallbackTransport(request));
53
+ breaker.on('open', () => {
54
+ logger.warn('[rpc-circuit-breaker] circuit OPEN — routing to fallback RPC');
55
+ });
56
+ breaker.on('halfOpen', () => {
57
+ logger.info('[rpc-circuit-breaker] circuit HALF-OPEN — probing primary RPC');
58
+ });
59
+ breaker.on('close', () => {
60
+ logger.info('[rpc-circuit-breaker] circuit CLOSED — primary RPC recovered');
61
+ });
62
+ const transport = ((request) => breaker.fire(request));
63
+ return createSolanaRpcFromTransport(transport);
64
+ }
65
+ /**
66
+ * Convenience: pick a sensible public fallback URL based on the primary URL.
67
+ *
68
+ * - Primary contains `devnet` → devnet public RPC
69
+ * - Everything else → mainnet-beta public RPC
70
+ */
71
+ export function defaultFallbackUrl(primaryUrl) {
72
+ if (/devnet/i.test(primaryUrl))
73
+ return DEFAULT_DEVNET_RPC;
74
+ return DEFAULT_MAINNET_RPC;
75
+ }
@@ -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.21';
17
+ export const version = '4.0.0-solana.23';
@@ -62,6 +62,10 @@ export { BorshReader, BorshWriter, deserializeGateway, deserializeArnsRecord, de
62
62
  export type { DeserializedAclEntry } from './deserialize.js';
63
63
  export * from './constants.js';
64
64
  export * from './clusters.js';
65
+ export { createCircuitBreakerRpc, defaultFallbackUrl, } from './rpc-circuit-breaker.js';
66
+ export type { CircuitBreakerRpcConfig, CircuitBreakerRpcOptions, } from './rpc-circuit-breaker.js';
67
+ export { withRetry, isRetryableError } from './retry.js';
68
+ export type { RetryOptions } from './retry.js';
65
69
  export type { SolanaConfig, SolanaReadConfig, SolanaWriteConfig, SolanaRpc, SolanaRpcSubscriptions, SolanaSigner, SolanaTransactionResult, AccountData, } from './types.js';
66
70
  export { parseTransactionEvents, parseEventsFromLogs, isEvent, } from './events.js';
67
71
  export type { AnyEvent, AnyArioCoreEvent, AnyArioGarEvent, AnyArioArnsEvent, AnyArioAntEvent, AnyArioAntEscrowEvent, EventName, } from './events.js';
@@ -109,6 +109,17 @@ export declare class SolanaARIOWriteable extends SolanaARIOReadable {
109
109
  * (codama only reads the named keys from `input`).
110
110
  */
111
111
  private withArnsDefaults;
112
+ /**
113
+ * If the on-chain ArnsRecord for `name` hasn't been migrated to the
114
+ * current schema (name_hash at offset 8 doesn't match the expected
115
+ * hash), return a `migrate_arns_record` instruction that must be
116
+ * prepended to any operation referencing the record with PDA seed
117
+ * verification.
118
+ *
119
+ * Returns an empty array when the record is already up-to-date or
120
+ * doesn't exist.
121
+ */
122
+ private _buildMigrateArnsRecordIxIfNeeded;
112
123
  /** Inject ARIO core default PDAs (config). */
113
124
  private withCoreDefaults;
114
125
  /** Inject GAR default PDAs (settings, epochSettings, registry). */
@@ -266,6 +277,30 @@ export declare class SolanaARIOWriteable extends SolanaARIOReadable {
266
277
  * Reads the live DemandFactor account + applies the on-chain pricing math.
267
278
  */
268
279
  private _estimateManageStakeCost;
280
+ /**
281
+ * If the signer already has a primary name set, build the instruction(s)
282
+ * needed to remove it so they can be prepended to a request/set tx —
283
+ * enabling single-tx "change primary name" flows.
284
+ *
285
+ * Returns an empty array when the signer has no existing primary name.
286
+ *
287
+ * Throws when the signer has a legacy primary-name state (forward
288
+ * `PrimaryName` PDA exists but its paired `PrimaryNameReverse` PDA does
289
+ * NOT). Both `remove_primary_name` AND `request_and_set_primary_name`
290
+ * require the reverse PDA on-chain — the latter rejects with
291
+ * `MustRemoveExistingPrimaryName` (0x1786, code 6022) any time a
292
+ * forward record already exists for the signer, regardless of reverse
293
+ * state. Silently skipping the remove would queue a tx guaranteed to
294
+ * fail with that opaque error. Surfacing it at the client with a clear
295
+ * remediation pointer is the only safe behavior.
296
+ *
297
+ * The legacy state should not exist on any cluster post-snapshot/import
298
+ * PR #159 (which emits PrimaryNameReverse in lockstep with PrimaryName)
299
+ * — it's a relic of pre-#159 imports. Operators on affected clusters
300
+ * must run `yarn workspace @ar-io/migration-import backfill:primary-name-reverse`
301
+ * (in the solana-ar-io repo) before this method can succeed.
302
+ */
303
+ private _buildRemoveExistingPrimaryNameIxs;
269
304
  /**
270
305
  * Build the `remaining_accounts` slice + the `antProgramId` arg the
271
306
  * four ario-core primary-name instructions consume. Sprint 2/5
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Lightweight retry helper with exponential back-off + jitter for Solana RPC
3
+ * read calls.
4
+ *
5
+ * Wraps any async function so transient transport errors (HTTP 429/5xx,
6
+ * network timeouts, etc.) are retried automatically while "normal" failures
7
+ * (account-not-found, deserialization) bubble immediately.
8
+ *
9
+ * Usage:
10
+ * ```ts
11
+ * const account = await withRetry(() => fetchEncodedAccount(rpc, pda));
12
+ * ```
13
+ */
14
+ export interface RetryOptions {
15
+ /** Maximum number of attempts (first call + retries). @default 6 */
16
+ maxAttempts?: number;
17
+ /** Base delay in ms before the first retry. Doubled each attempt. @default 500 */
18
+ baseDelayMs?: number;
19
+ /** Cap on any single delay in ms. @default 5_000 */
20
+ maxDelayMs?: number;
21
+ /** Predicate that decides whether a thrown error is retryable.
22
+ * Defaults to {@link isRetryableError}. */
23
+ isRetryable?: (error: unknown) => boolean;
24
+ }
25
+ /**
26
+ * Default retryable-error heuristic.
27
+ *
28
+ * We retry on:
29
+ * - Network / fetch errors (`TypeError: fetch failed`)
30
+ * - HTTP 429 (rate-limit) and 5xx (server) surfaced by kit's transport as
31
+ * `SolanaError` with "HTTP error (4xx|5xx)" in the message.
32
+ * - Timeout errors from opossum or native `AbortError`.
33
+ *
34
+ * We do NOT retry on:
35
+ * - JSON-RPC application errors (account not found, invalid params, etc.)
36
+ * - Deserialization / decoding errors
37
+ * - Any error without a recognisable transport signature
38
+ */
39
+ export declare function isRetryableError(error: unknown): boolean;
40
+ /**
41
+ * Execute `fn` with automatic retries on transient failures.
42
+ *
43
+ * ```ts
44
+ * const data = await withRetry(() => rpc.getAccountInfo(addr).send());
45
+ * ```
46
+ */
47
+ export declare function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): Promise<T>;
@@ -0,0 +1,49 @@
1
+ import type { SolanaRpc } from './types.js';
2
+ export interface CircuitBreakerRpcOptions {
3
+ /**
4
+ * Time in ms before a single RPC request is considered timed-out.
5
+ * Set to `false` to disable the opossum-level timeout (rely on the
6
+ * underlying transport timeout only).
7
+ * @default 10_000
8
+ */
9
+ timeout?: number | false;
10
+ /**
11
+ * Error percentage (0-100) at which to open the circuit.
12
+ * @default 50
13
+ */
14
+ errorThresholdPercentage?: number;
15
+ /**
16
+ * Time in ms to wait before entering half-open state and retrying
17
+ * the primary.
18
+ * @default 30_000
19
+ */
20
+ resetTimeout?: number;
21
+ /**
22
+ * Minimum number of requests within the rolling window before the
23
+ * circuit can trip. Prevents opening the circuit after a single failure.
24
+ * @default 5
25
+ */
26
+ volumeThreshold?: number;
27
+ }
28
+ export interface CircuitBreakerRpcConfig {
29
+ /** URL for the primary (preferred) RPC endpoint. */
30
+ primaryUrl: string;
31
+ /** URL for the fallback RPC endpoint (used when the circuit opens). */
32
+ fallbackUrl: string;
33
+ /** Opossum circuit-breaker tuning knobs. */
34
+ circuitBreakerOptions?: CircuitBreakerRpcOptions;
35
+ }
36
+ /**
37
+ * Create a {@link SolanaRpc} whose transport is backed by an opossum circuit
38
+ * breaker. Reads and writes flow through the primary transport; when it
39
+ * becomes unhealthy the circuit opens and subsequent calls are routed to
40
+ * the fallback transport until the primary recovers.
41
+ */
42
+ export declare function createCircuitBreakerRpc({ primaryUrl, fallbackUrl, circuitBreakerOptions: opts, }: CircuitBreakerRpcConfig): SolanaRpc;
43
+ /**
44
+ * Convenience: pick a sensible public fallback URL based on the primary URL.
45
+ *
46
+ * - Primary contains `devnet` → devnet public RPC
47
+ * - Everything else → mainnet-beta public RPC
48
+ */
49
+ export declare function defaultFallbackUrl(primaryUrl: string): string;
@@ -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.20";
16
+ export declare const version = "4.0.0-solana.22";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ar.io/sdk",
3
- "version": "4.0.0-solana.21",
3
+ "version": "4.0.0-solana.23",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/ar-io/ar-io-sdk.git"
@@ -87,6 +87,7 @@
87
87
  "@swc/core": "^1.11.22",
88
88
  "@trivago/prettier-plugin-sort-imports": "^4.2.0",
89
89
  "@types/node": "^22.14.1",
90
+ "@types/opossum": "^8.1.9",
90
91
  "@types/prompts": "^2.4.9",
91
92
  "@types/sinon": "^10.0.15",
92
93
  "@typescript-eslint/eslint-plugin": "^5.62.0",
@@ -122,17 +123,18 @@
122
123
  "typescript": "^5.1.6"
123
124
  },
124
125
  "dependencies": {
125
- "@ar.io/solana-contracts": "^0.4.0",
126
+ "@ar.io/solana-contracts": "0.4.0",
126
127
  "@solana-program/compute-budget": "^0.15.0",
127
128
  "@solana-program/token": "^0.13.0",
128
129
  "@solana/kit": "^6.8.0",
129
130
  "bs58": "^6.0.0",
130
131
  "commander": "^12.1.0",
132
+ "opossum": "^9.0.0",
131
133
  "prompts": "^2.4.2",
132
134
  "zod": "^3.25.76"
133
135
  },
134
136
  "lint-staged": {
135
- "**/*.{ts,js,mjs,cjs,md,json}": ["biome format --write"],
137
+ "**/*.{ts,js,mjs,cjs,json}": ["biome format --write"],
136
138
  "**/README.md": ["markdown-toc-gen insert --max-depth 2"]
137
139
  }
138
140
  }