@ar.io/sdk 4.0.0-solana.33 → 4.0.0-solana.34
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/lib/esm/solana/io-writeable.js +132 -19
- package/lib/esm/solana/rpc-circuit-breaker.js +169 -1
- package/lib/esm/version.js +1 -1
- package/lib/types/solana/io-writeable.d.ts +13 -0
- package/lib/types/solana/rpc-circuit-breaker.d.ts +29 -0
- package/lib/types/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -881,7 +881,16 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
881
881
|
params: buyNameParams,
|
|
882
882
|
}, { programAddress: this.arnsProgram });
|
|
883
883
|
}
|
|
884
|
-
else if (params.fundFrom === 'plan' ||
|
|
884
|
+
else if (params.fundFrom === 'plan' ||
|
|
885
|
+
params.fundFrom === 'any' ||
|
|
886
|
+
params.fundFrom === 'stakes' ||
|
|
887
|
+
params.fundFrom === 'withdrawal') {
|
|
888
|
+
// 'stakes'/'withdrawal' WITHOUT an explicit gatewayAddress/withdrawalId
|
|
889
|
+
// land here (the single-source branches above handle the explicit case).
|
|
890
|
+
// Route through the funding planner, which constrains sources to the
|
|
891
|
+
// chosen mode — it never silently spends liquid balance and auto-splits
|
|
892
|
+
// across the caller's delegations/vaults. (Previously these fell through
|
|
893
|
+
// to the balance path below, silently draining liquid ARIO.)
|
|
885
894
|
ix = await this._buildBuyNameFromFundingPlanIx({
|
|
886
895
|
params,
|
|
887
896
|
antPubkey,
|
|
@@ -892,8 +901,10 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
892
901
|
arnsConfig,
|
|
893
902
|
});
|
|
894
903
|
}
|
|
895
|
-
else
|
|
896
|
-
|
|
904
|
+
else if (!params.fundFrom ||
|
|
905
|
+
params.fundFrom === 'balance' ||
|
|
906
|
+
params.fundFrom === 'turbo') {
|
|
907
|
+
// Direct balance-funded buy.
|
|
897
908
|
ix = await getBuyNameInstructionAsync(await this.withArnsDefaults({
|
|
898
909
|
arnsRecord,
|
|
899
910
|
buyerTokenAccount: buyerATA,
|
|
@@ -904,6 +915,9 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
904
915
|
params: buyNameParams,
|
|
905
916
|
}), { programAddress: this.arnsProgram });
|
|
906
917
|
}
|
|
918
|
+
else {
|
|
919
|
+
throw new Error(`unsupported fundFrom mode '${params.fundFrom}' for buyRecord`);
|
|
920
|
+
}
|
|
907
921
|
// Sprint 4 / ADR-016: bundle `ant.sync_attributes` IFF the buyer
|
|
908
922
|
// owns the ANT (preserves BD-096 — non-holder buys defer the trait
|
|
909
923
|
// sync to a later `syncAttributes()` call by the actual owner).
|
|
@@ -1428,7 +1442,14 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
1428
1442
|
return getExtendLeaseFromWithdrawalInstructionAsync({ ...wBase, years: args.years }, { programAddress: this.arnsProgram });
|
|
1429
1443
|
return getIncreaseUndernameLimitFromWithdrawalInstructionAsync({ ...wBase, quantity: args.quantity }, { programAddress: this.arnsProgram });
|
|
1430
1444
|
}
|
|
1431
|
-
if (args.params.fundFrom === 'plan' ||
|
|
1445
|
+
if (args.params.fundFrom === 'plan' ||
|
|
1446
|
+
args.params.fundFrom === 'any' ||
|
|
1447
|
+
args.params.fundFrom === 'stakes' ||
|
|
1448
|
+
args.params.fundFrom === 'withdrawal') {
|
|
1449
|
+
// 'stakes'/'withdrawal' without an explicit gatewayAddress/withdrawalId
|
|
1450
|
+
// route here (the single-source branches above handle the explicit
|
|
1451
|
+
// case). The planner constrains sources to the chosen mode and never
|
|
1452
|
+
// spends liquid balance.
|
|
1432
1453
|
// Cost estimation for manage variants: each operation has its own
|
|
1433
1454
|
// pricing path. Keep it pragmatic — let the planner build the plan
|
|
1434
1455
|
// around the user's desired total (caller can pass explicit sources
|
|
@@ -1987,10 +2008,54 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
1987
2008
|
years: params.years ?? 1,
|
|
1988
2009
|
ant: antPubkey,
|
|
1989
2010
|
};
|
|
2011
|
+
// Returned-name price is a per-slot-decaying Dutch auction, so the
|
|
2012
|
+
// multi-source funding plan (which pre-commits exact source amounts) can't
|
|
2013
|
+
// match the execution-time cost → FundingPlanAmountMismatch (#6066). Prefer
|
|
2014
|
+
// a single-source stake path: it carries no amount, so the program computes
|
|
2015
|
+
// and draws the live cost itself. When the caller asked to fund from
|
|
2016
|
+
// stakes/withdrawal/any without naming a specific gateway/vault,
|
|
2017
|
+
// auto-resolve a single source with enough stake to cover the
|
|
2018
|
+
// (premium-inclusive) cost.
|
|
2019
|
+
let resolvedGateway = params.gatewayAddress;
|
|
2020
|
+
let resolvedFundAsOperator = params.fundAsOperator ?? false;
|
|
2021
|
+
let resolvedWithdrawalId = params.withdrawalId;
|
|
2022
|
+
const wantsStake = params.fundFrom === 'stakes' ||
|
|
2023
|
+
params.fundFrom === 'withdrawal' ||
|
|
2024
|
+
params.fundFrom === 'any';
|
|
2025
|
+
if (wantsStake &&
|
|
2026
|
+
resolvedGateway === undefined &&
|
|
2027
|
+
resolvedWithdrawalId === undefined &&
|
|
2028
|
+
!params.sources?.length) {
|
|
2029
|
+
const picked = await this._autoPickReturnedNameStakeSource(params);
|
|
2030
|
+
if (picked?.kind === 'delegation') {
|
|
2031
|
+
resolvedGateway = picked.gateway;
|
|
2032
|
+
resolvedFundAsOperator = false;
|
|
2033
|
+
}
|
|
2034
|
+
else if (picked?.kind === 'operatorStake') {
|
|
2035
|
+
resolvedGateway = picked.gateway;
|
|
2036
|
+
resolvedFundAsOperator = true;
|
|
2037
|
+
}
|
|
2038
|
+
else if (picked?.kind === 'withdrawal') {
|
|
2039
|
+
resolvedWithdrawalId = picked.withdrawalId;
|
|
2040
|
+
}
|
|
2041
|
+
else if (params.fundFrom !== 'any') {
|
|
2042
|
+
// 'stakes'/'withdrawal' explicitly requested but nothing covers it.
|
|
2043
|
+
throw new Error(`buyReturnedName: no ${params.fundFrom === 'withdrawal'
|
|
2044
|
+
? 'matured withdrawal vault'
|
|
2045
|
+
: 'delegation/operator stake'} large enough to fund '${params.name}' was found for ` +
|
|
2046
|
+
`${this.signer.address}. Fund from balance, or add stake first.`);
|
|
2047
|
+
}
|
|
2048
|
+
// 'any' with nothing found → falls through to the balance path.
|
|
2049
|
+
}
|
|
1990
2050
|
let ix;
|
|
1991
|
-
|
|
2051
|
+
const useBalance = !params.fundFrom ||
|
|
1992
2052
|
params.fundFrom === 'balance' ||
|
|
1993
|
-
params.fundFrom === 'turbo'
|
|
2053
|
+
params.fundFrom === 'turbo' ||
|
|
2054
|
+
(params.fundFrom === 'any' &&
|
|
2055
|
+
resolvedGateway === undefined &&
|
|
2056
|
+
resolvedWithdrawalId === undefined &&
|
|
2057
|
+
!params.sources?.length);
|
|
2058
|
+
if (useBalance) {
|
|
1994
2059
|
ix = await getBuyReturnedNameInstructionAsync(await this.withArnsDefaults({
|
|
1995
2060
|
arnsRecord,
|
|
1996
2061
|
returnedName: returnedNamePda,
|
|
@@ -2019,10 +2084,10 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
2019
2084
|
garProgram: this.garProgram,
|
|
2020
2085
|
params: buyParams,
|
|
2021
2086
|
};
|
|
2022
|
-
if (
|
|
2023
|
-
const gatewayAddr = address(
|
|
2087
|
+
if (resolvedGateway !== undefined) {
|
|
2088
|
+
const gatewayAddr = address(resolvedGateway);
|
|
2024
2089
|
const [gatewayPda] = await getGatewayPDA(gatewayAddr, this.garProgram);
|
|
2025
|
-
if (
|
|
2090
|
+
if (resolvedFundAsOperator) {
|
|
2026
2091
|
ix = await getBuyReturnedNameFromOperatorStakeInstructionAsync({ ...sharedReturnedBase, gateway: gatewayPda }, { programAddress: this.arnsProgram });
|
|
2027
2092
|
}
|
|
2028
2093
|
else {
|
|
@@ -2034,17 +2099,16 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
2034
2099
|
}, { programAddress: this.arnsProgram });
|
|
2035
2100
|
}
|
|
2036
2101
|
}
|
|
2037
|
-
else if (
|
|
2038
|
-
|
|
2039
|
-
const [withdrawalPda] = await getWithdrawalPDA(this.signer.address, params.withdrawalId, this.garProgram);
|
|
2102
|
+
else if (resolvedWithdrawalId !== undefined) {
|
|
2103
|
+
const [withdrawalPda] = await getWithdrawalPDA(this.signer.address, resolvedWithdrawalId, this.garProgram);
|
|
2040
2104
|
ix = await getBuyReturnedNameFromWithdrawalInstructionAsync({ ...sharedReturnedBase, withdrawal: withdrawalPda }, { programAddress: this.arnsProgram });
|
|
2041
2105
|
}
|
|
2042
|
-
else if (params.fundFrom === 'plan'
|
|
2043
|
-
//
|
|
2044
|
-
//
|
|
2045
|
-
//
|
|
2046
|
-
//
|
|
2047
|
-
//
|
|
2106
|
+
else if (params.fundFrom === 'plan' && params.sources?.length) {
|
|
2107
|
+
// Explicit caller-supplied plan only: the caller owns the source
|
|
2108
|
+
// amounts and accepts the decay risk (the price moves per slot, so a
|
|
2109
|
+
// stale plan trips FundingPlanAmountMismatch). We do NOT auto-discover
|
|
2110
|
+
// a multi-source plan for returned names — see the single-source note
|
|
2111
|
+
// above.
|
|
2048
2112
|
const cost = await this._simulateTokenCost({
|
|
2049
2113
|
intent: CostIntent.BuyName,
|
|
2050
2114
|
name: params.name,
|
|
@@ -2068,14 +2132,63 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
2068
2132
|
throw new Error(`unsupported fundFrom mode '${params.fundFrom}' for buyReturnedName`);
|
|
2069
2133
|
}
|
|
2070
2134
|
}
|
|
2135
|
+
// The on-chain `buy_returned_name*` handlers take `initiator_token_account`
|
|
2136
|
+
// and `buyer_token_account` as `Account<TokenAccount>` (NOT `init`), so
|
|
2137
|
+
// Anchor requires both ATAs to already exist or fails with
|
|
2138
|
+
// AccountNotInitialized (#3012). The original initiator may never have held
|
|
2139
|
+
// ARIO, and the premium always settles from the buyer's liquid ATA — bundle
|
|
2140
|
+
// idempotent ATA creates so the buy succeeds without a separate setup step
|
|
2141
|
+
// (mirrors the vault-ATA handling above). Idempotent: ~1500 CU each, no-op
|
|
2142
|
+
// when the account already exists.
|
|
2143
|
+
const createInitiatorAtaIx = buildCreateAtaIdempotentIx(this.signer.address, initiatorATA, initiator, arnsConfig.mint);
|
|
2144
|
+
const createBuyerAtaIx = buildCreateAtaIdempotentIx(this.signer.address, buyerATA, this.signer.address, arnsConfig.mint);
|
|
2071
2145
|
// Sprint 4 / ADR-016: bundle ant.sync_attributes after the buy so the
|
|
2072
2146
|
// Attributes plugin reflects the new record holder. assetOverride =
|
|
2073
2147
|
// antPubkey because the ArnsRecord PDA is created by buy_returned_name
|
|
2074
2148
|
// and doesn't exist on-chain at SDK build time.
|
|
2075
2149
|
const syncIx = await this._buildSyncAttributesIxIfOwner(params.name, antPubkey);
|
|
2076
|
-
const sig = await this.sendTransaction(
|
|
2150
|
+
const sig = await this.sendTransaction([
|
|
2151
|
+
createBuyerAtaIx,
|
|
2152
|
+
createInitiatorAtaIx,
|
|
2153
|
+
ix,
|
|
2154
|
+
...(syncIx ? [syncIx] : []),
|
|
2155
|
+
]);
|
|
2077
2156
|
return { id: sig };
|
|
2078
2157
|
}
|
|
2158
|
+
/**
|
|
2159
|
+
* Pick a single stake-derived funding source that can cover a returned-name
|
|
2160
|
+
* purchase, for the single-source `buy_returned_name_from_*` paths.
|
|
2161
|
+
*
|
|
2162
|
+
* Returned-name prices decay per slot, so the multi-source funding plan
|
|
2163
|
+
* (which pre-commits exact amounts) can't match the execution-time cost. The
|
|
2164
|
+
* single-source paths carry no amount — the program draws the live cost — so
|
|
2165
|
+
* we only need to pick ONE source with enough stake. We size the pick against
|
|
2166
|
+
* the premium-inclusive estimate (an upper bound, since the price only falls
|
|
2167
|
+
* from now) and choose the largest matching source. Returns `null` when no
|
|
2168
|
+
* single source covers the estimate.
|
|
2169
|
+
*/
|
|
2170
|
+
async _autoPickReturnedNameStakeSource(params) {
|
|
2171
|
+
const estimate = BigInt(Math.ceil(await this.getTokenCost({
|
|
2172
|
+
intent: 'Buy-Name',
|
|
2173
|
+
name: params.name,
|
|
2174
|
+
type: params.type,
|
|
2175
|
+
years: params.years ?? 1,
|
|
2176
|
+
})));
|
|
2177
|
+
const arnsConfig = await this.getArnsConfig();
|
|
2178
|
+
const { discoverFundingSources } = await import('./funding-plan.js');
|
|
2179
|
+
const sources = await discoverFundingSources(this.rpc, this.signer.address, { arioMint: arnsConfig.mint, garProgram: this.garProgram });
|
|
2180
|
+
// 'withdrawal' mode → matured withdrawal vaults only; otherwise prefer
|
|
2181
|
+
// operator stake when the caller asked, else a delegation.
|
|
2182
|
+
const wantKind = params.fundFrom === 'withdrawal'
|
|
2183
|
+
? 'withdrawal'
|
|
2184
|
+
: params.fundAsOperator === true
|
|
2185
|
+
? 'operatorStake'
|
|
2186
|
+
: 'delegation';
|
|
2187
|
+
const candidates = sources
|
|
2188
|
+
.filter((s) => s.kind === wantKind && s.available >= estimate)
|
|
2189
|
+
.sort((a, b) => b.available > a.available ? 1 : b.available < a.available ? -1 : 0);
|
|
2190
|
+
return candidates[0] ?? null;
|
|
2191
|
+
}
|
|
2079
2192
|
// =========================================
|
|
2080
2193
|
// Name management (ario-arns)
|
|
2081
2194
|
// =========================================
|
|
@@ -47,6 +47,121 @@ const logger = new Logger({ level: 'error' });
|
|
|
47
47
|
const DEFAULT_MAINNET_RPC = 'https://api.mainnet-beta.solana.com';
|
|
48
48
|
const DEFAULT_DEVNET_RPC = 'https://api.devnet.solana.com';
|
|
49
49
|
// ---------------------------------------------------------------------------
|
|
50
|
+
// Adaptive rate gate (token bucket + cooldown)
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
/** Default ceiling when `maxRequestsPerSecond` is not provided. */
|
|
53
|
+
const DEFAULT_MAX_RPS = 10;
|
|
54
|
+
/** Multiply the current rate by this on a 429 with no usable header. */
|
|
55
|
+
const AIMD_DECREASE = 0.5;
|
|
56
|
+
/** Never throttle below this many requests/second. */
|
|
57
|
+
const MIN_RATE = 1;
|
|
58
|
+
/** Consecutive successes before nudging the rate up by 1 (additive recovery). */
|
|
59
|
+
const RECOVERY_SUCCESSES = 20;
|
|
60
|
+
/** Fraction of a provider-advertised limit to actually use (safety margin). */
|
|
61
|
+
const RATE_SAFETY_FACTOR = 0.9;
|
|
62
|
+
/** Cooldown applied on a 429 that carries no `Retry-After`. */
|
|
63
|
+
const DEFAULT_COOLDOWN_MS = 1_000;
|
|
64
|
+
/**
|
|
65
|
+
* Token-bucket throttle whose rate can be retuned at runtime and which can be
|
|
66
|
+
* paused on demand. Tokens refill continuously at the current rate, capped at
|
|
67
|
+
* one second's worth (the burst allowance); waiters are released FIFO.
|
|
68
|
+
*/
|
|
69
|
+
function createRateGate(initialRate) {
|
|
70
|
+
let rate = Math.max(MIN_RATE, initialRate);
|
|
71
|
+
let capacity = Math.max(1, rate);
|
|
72
|
+
let tokens = capacity;
|
|
73
|
+
let lastRefill = Date.now();
|
|
74
|
+
let pausedUntil = 0;
|
|
75
|
+
const queue = [];
|
|
76
|
+
let timer = null;
|
|
77
|
+
const schedule = (ms) => {
|
|
78
|
+
if (timer !== null)
|
|
79
|
+
return;
|
|
80
|
+
timer = setTimeout(() => {
|
|
81
|
+
timer = null;
|
|
82
|
+
pump();
|
|
83
|
+
}, Math.max(ms, 1));
|
|
84
|
+
};
|
|
85
|
+
const refill = () => {
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const elapsed = (now - lastRefill) / 1000;
|
|
88
|
+
if (elapsed > 0) {
|
|
89
|
+
tokens = Math.min(capacity, tokens + elapsed * rate);
|
|
90
|
+
lastRefill = now;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const pump = () => {
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
if (pausedUntil > now) {
|
|
96
|
+
schedule(pausedUntil - now);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
refill();
|
|
100
|
+
while (tokens >= 1) {
|
|
101
|
+
const release = queue.shift();
|
|
102
|
+
if (!release)
|
|
103
|
+
break;
|
|
104
|
+
tokens -= 1;
|
|
105
|
+
release();
|
|
106
|
+
}
|
|
107
|
+
if (queue.length > 0) {
|
|
108
|
+
// Wake when the next whole token will have accrued.
|
|
109
|
+
schedule(Math.ceil(((1 - tokens) / rate) * 1000));
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
acquire: () => new Promise((resolve) => {
|
|
114
|
+
queue.push(resolve);
|
|
115
|
+
pump();
|
|
116
|
+
}),
|
|
117
|
+
setRate: (ratePerSecond) => {
|
|
118
|
+
rate = Math.max(MIN_RATE, ratePerSecond);
|
|
119
|
+
capacity = Math.max(1, rate);
|
|
120
|
+
tokens = Math.min(tokens, capacity);
|
|
121
|
+
lastRefill = Date.now();
|
|
122
|
+
pump();
|
|
123
|
+
},
|
|
124
|
+
pauseFor: (ms) => {
|
|
125
|
+
const until = Date.now() + Math.max(0, ms);
|
|
126
|
+
if (until > pausedUntil)
|
|
127
|
+
pausedUntil = until;
|
|
128
|
+
schedule(ms);
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// 429 / rate-limit header parsing
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
/**
|
|
136
|
+
* If `err` is a transport HTTP 429, return its response `Headers`; else null.
|
|
137
|
+
* Duck-typed against the `@solana/errors` HTTP-error context
|
|
138
|
+
* (`{ statusCode, headers }`) so we avoid a hard dependency on the error code.
|
|
139
|
+
*/
|
|
140
|
+
function http429Headers(err) {
|
|
141
|
+
const ctx = err
|
|
142
|
+
?.context;
|
|
143
|
+
if (ctx?.statusCode === 429 && ctx.headers instanceof Headers) {
|
|
144
|
+
return ctx.headers;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
/** Parse `Retry-After` (delta-seconds or HTTP-date) into ms, or null. */
|
|
149
|
+
function parseRetryAfterMs(headers) {
|
|
150
|
+
const v = headers.get('retry-after');
|
|
151
|
+
if (v === null || v === '')
|
|
152
|
+
return null;
|
|
153
|
+
const secs = Number(v);
|
|
154
|
+
if (Number.isFinite(secs))
|
|
155
|
+
return Math.max(0, secs * 1000);
|
|
156
|
+
const when = Date.parse(v);
|
|
157
|
+
return Number.isNaN(when) ? null : Math.max(0, when - Date.now());
|
|
158
|
+
}
|
|
159
|
+
/** Provider-advertised requests/second limit (`x-ratelimit-rps-limit`), or null. */
|
|
160
|
+
function parseRpsLimit(headers) {
|
|
161
|
+
const v = Number(headers.get('x-ratelimit-rps-limit'));
|
|
162
|
+
return Number.isFinite(v) && v > 0 ? v : null;
|
|
163
|
+
}
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
50
165
|
// Implementation
|
|
51
166
|
// ---------------------------------------------------------------------------
|
|
52
167
|
/**
|
|
@@ -58,11 +173,51 @@ const DEFAULT_DEVNET_RPC = 'https://api.devnet.solana.com';
|
|
|
58
173
|
export function createCircuitBreakerRpc({ primaryUrl, fallbackUrl, circuitBreakerOptions: opts = {}, }) {
|
|
59
174
|
const primaryTransport = createDefaultRpcTransport({ url: primaryUrl });
|
|
60
175
|
const fallbackTransport = createDefaultRpcTransport({ url: fallbackUrl });
|
|
176
|
+
// Throttling is always on. `maxRequestsPerSecond` is the *ceiling*: every
|
|
177
|
+
// request flows through an adaptive token bucket that backs off on HTTP 429
|
|
178
|
+
// (honoring `Retry-After` and `x-ratelimit-rps-limit` when present, AIMD
|
|
179
|
+
// otherwise) and recovers back toward the ceiling on sustained success.
|
|
180
|
+
const ceilingRate = opts.maxRequestsPerSecond !== undefined && opts.maxRequestsPerSecond > 0
|
|
181
|
+
? opts.maxRequestsPerSecond
|
|
182
|
+
: DEFAULT_MAX_RPS;
|
|
183
|
+
const gate = createRateGate(ceilingRate);
|
|
184
|
+
let currentRate = ceilingRate;
|
|
185
|
+
let successStreak = 0;
|
|
186
|
+
const onError = (err) => {
|
|
187
|
+
const headers = http429Headers(err);
|
|
188
|
+
if (!headers)
|
|
189
|
+
return; // only adapt to rate-limit (429) failures
|
|
190
|
+
successStreak = 0;
|
|
191
|
+
const advertised = parseRpsLimit(headers);
|
|
192
|
+
const next = advertised !== null
|
|
193
|
+
? Math.min(ceilingRate, Math.max(MIN_RATE, advertised * RATE_SAFETY_FACTOR))
|
|
194
|
+
: Math.max(MIN_RATE, currentRate * AIMD_DECREASE);
|
|
195
|
+
if (next !== currentRate) {
|
|
196
|
+
currentRate = next;
|
|
197
|
+
gate.setRate(currentRate);
|
|
198
|
+
}
|
|
199
|
+
const retryAfter = parseRetryAfterMs(headers);
|
|
200
|
+
gate.pauseFor(retryAfter ?? DEFAULT_COOLDOWN_MS);
|
|
201
|
+
logger.warn(`[rpc-circuit-breaker] 429 — throttling to ${currentRate.toFixed(1)} req/s` +
|
|
202
|
+
`, cooling down ${retryAfter ?? DEFAULT_COOLDOWN_MS}ms`);
|
|
203
|
+
};
|
|
204
|
+
const onSuccess = () => {
|
|
205
|
+
if (currentRate >= ceilingRate)
|
|
206
|
+
return;
|
|
207
|
+
if (++successStreak >= RECOVERY_SUCCESSES) {
|
|
208
|
+
successStreak = 0;
|
|
209
|
+
currentRate = Math.min(ceilingRate, currentRate + 1);
|
|
210
|
+
gate.setRate(currentRate);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
61
213
|
const breaker = new CircuitBreaker((request) => primaryTransport(request), {
|
|
62
214
|
timeout: opts.timeout ?? 10_000,
|
|
63
215
|
errorThresholdPercentage: opts.errorThresholdPercentage ?? 25,
|
|
64
216
|
resetTimeout: opts.resetTimeout ?? 60_000,
|
|
65
217
|
volumeThreshold: opts.volumeThreshold ?? 3,
|
|
218
|
+
...(opts.maxConcurrent !== undefined && opts.maxConcurrent > 0
|
|
219
|
+
? { capacity: opts.maxConcurrent }
|
|
220
|
+
: {}),
|
|
66
221
|
});
|
|
67
222
|
breaker.fallback((request) => fallbackTransport(request));
|
|
68
223
|
breaker.on('open', () => {
|
|
@@ -74,7 +229,20 @@ export function createCircuitBreakerRpc({ primaryUrl, fallbackUrl, circuitBreake
|
|
|
74
229
|
breaker.on('close', () => {
|
|
75
230
|
logger.info('[rpc-circuit-breaker] circuit CLOSED — primary RPC recovered');
|
|
76
231
|
});
|
|
77
|
-
|
|
232
|
+
// Adapt the rate to the *primary's* health via opossum's events: `failure`
|
|
233
|
+
// fires whenever the primary call rejects (a 429 included) even when the
|
|
234
|
+
// fallback then masks it by resolving `fire()`, and `success` fires on a
|
|
235
|
+
// healthy primary call. A plain try/catch around `fire()` would miss the
|
|
236
|
+
// fallback-masked 429s entirely.
|
|
237
|
+
breaker.on('failure', (err) => onError(err));
|
|
238
|
+
breaker.on('success', () => onSuccess());
|
|
239
|
+
const transport = (async (request) => {
|
|
240
|
+
// Throttle entry to the breaker so we stay under the provider's rate
|
|
241
|
+
// limit; the queue wait sits outside `fire`, so opossum's per-request
|
|
242
|
+
// timeout only measures the actual transport call.
|
|
243
|
+
await gate.acquire();
|
|
244
|
+
return breaker.fire(request);
|
|
245
|
+
});
|
|
78
246
|
return createSolanaRpcFromTransport(transport);
|
|
79
247
|
}
|
|
80
248
|
/**
|
package/lib/esm/version.js
CHANGED
|
@@ -522,6 +522,19 @@ export declare class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
522
522
|
years?: number;
|
|
523
523
|
processId: string;
|
|
524
524
|
} & Partial<ArNSPurchaseParams>, _options?: WriteOptions): Promise<MessageResult>;
|
|
525
|
+
/**
|
|
526
|
+
* Pick a single stake-derived funding source that can cover a returned-name
|
|
527
|
+
* purchase, for the single-source `buy_returned_name_from_*` paths.
|
|
528
|
+
*
|
|
529
|
+
* Returned-name prices decay per slot, so the multi-source funding plan
|
|
530
|
+
* (which pre-commits exact amounts) can't match the execution-time cost. The
|
|
531
|
+
* single-source paths carry no amount — the program draws the live cost — so
|
|
532
|
+
* we only need to pick ONE source with enough stake. We size the pick against
|
|
533
|
+
* the premium-inclusive estimate (an upper bound, since the price only falls
|
|
534
|
+
* from now) and choose the largest matching source. Returns `null` when no
|
|
535
|
+
* single source covers the estimate.
|
|
536
|
+
*/
|
|
537
|
+
private _autoPickReturnedNameStakeSource;
|
|
525
538
|
/** Reassign an ArNS name to a different ANT. */
|
|
526
539
|
reassignName(params: {
|
|
527
540
|
name: string;
|
|
@@ -24,6 +24,35 @@ export interface CircuitBreakerRpcOptions {
|
|
|
24
24
|
* @default 3
|
|
25
25
|
*/
|
|
26
26
|
volumeThreshold?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Ceiling for requests allowed through per second. Implemented as an
|
|
29
|
+
* adaptive token bucket *in front of* the breaker: excess requests are
|
|
30
|
+
* queued (FIFO) until a token frees, smoothing bursts so you stay under a
|
|
31
|
+
* provider's rate limit (avoids HTTP 429 / Solana error #8100002).
|
|
32
|
+
*
|
|
33
|
+
* The bucket auto-tunes on 429s: it honors `Retry-After`, drops to
|
|
34
|
+
* `x-ratelimit-rps-limit` when the provider advertises one (public Solana
|
|
35
|
+
* RPC does; QuickNode generally does not), otherwise halves the rate
|
|
36
|
+
* (AIMD). It recovers back up toward this ceiling on sustained success.
|
|
37
|
+
*
|
|
38
|
+
* The queue wait happens *before* `breaker.fire`, so it does NOT count
|
|
39
|
+
* against {@link CircuitBreakerRpcOptions.timeout}.
|
|
40
|
+
*
|
|
41
|
+
* Throttling is always on; omitting this (or passing `<= 0`) uses the
|
|
42
|
+
* {@link DEFAULT_MAX_RPS} default. To effectively remove the limit, pass a
|
|
43
|
+
* very large number.
|
|
44
|
+
* @default 10
|
|
45
|
+
*/
|
|
46
|
+
maxRequestsPerSecond?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Maximum number of concurrent in-flight requests (opossum `capacity`
|
|
49
|
+
* semaphore). Unlike {@link CircuitBreakerRpcOptions.maxRequestsPerSecond},
|
|
50
|
+
* excess requests are **rejected immediately** rather than queued — this is
|
|
51
|
+
* concurrency control, not a rate limit. For avoiding 429s you usually want
|
|
52
|
+
* `maxRequestsPerSecond` instead.
|
|
53
|
+
* @default undefined (unlimited)
|
|
54
|
+
*/
|
|
55
|
+
maxConcurrent?: number;
|
|
27
56
|
}
|
|
28
57
|
export interface CircuitBreakerRpcConfig {
|
|
29
58
|
/** URL for the primary (preferred) RPC endpoint. */
|
package/lib/types/version.d.ts
CHANGED