@ar.io/sdk 4.0.0-solana.22 → 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 +105 -0
- package/lib/esm/cli/commands/escrowCommands.js +10 -3
- package/lib/esm/cli/utils.js +18 -7
- package/lib/esm/solana/ant-readable.js +7 -6
- package/lib/esm/solana/index.js +4 -0
- package/lib/esm/solana/io-readable.js +7 -6
- package/lib/esm/solana/json-rpc.js +5 -4
- package/lib/esm/solana/retry.js +102 -0
- package/lib/esm/solana/rpc-circuit-breaker.js +75 -0
- package/lib/esm/version.js +1 -1
- package/lib/types/solana/index.d.ts +4 -0
- package/lib/types/solana/retry.d.ts +47 -0
- package/lib/types/solana/rpc-circuit-breaker.d.ts +49 -0
- package/lib/types/version.d.ts +1 -1
- package/package.json +4 -2
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,
|
|
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:
|
|
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:
|
|
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),
|
package/lib/esm/cli/utils.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
import { readFileSync } from 'fs';
|
|
17
|
-
import { address, createKeyPairSignerFromBytes,
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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).
|
package/lib/esm/solana/index.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
391
|
+
const result = await withRetry(() => this.rpc
|
|
391
392
|
.getProgramAccounts(TOKEN_PROGRAM_ADDRESS, {
|
|
392
393
|
commitment: this.commitment,
|
|
393
394
|
encoding: 'base64',
|
|
@@ -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
|
+
}
|
package/lib/esm/version.js
CHANGED
|
@@ -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';
|
|
@@ -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;
|
package/lib/types/version.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ar.io/sdk",
|
|
3
|
-
"version": "4.0.0-solana.
|
|
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",
|
|
@@ -128,11 +129,12 @@
|
|
|
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,
|
|
137
|
+
"**/*.{ts,js,mjs,cjs,json}": ["biome format --write"],
|
|
136
138
|
"**/README.md": ["markdown-toc-gen insert --max-depth 2"]
|
|
137
139
|
}
|
|
138
140
|
}
|