@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.
@@ -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' || params.fundFrom === 'any') {
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
- // 'balance' or undefined falls through to the original direct-buy path.
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' || args.params.fundFrom === 'any') {
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
- if (!params.fundFrom ||
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 (params.fundFrom === 'stakes' && params.gatewayAddress) {
2023
- const gatewayAddr = address(params.gatewayAddress);
2087
+ if (resolvedGateway !== undefined) {
2088
+ const gatewayAddr = address(resolvedGateway);
2024
2089
  const [gatewayPda] = await getGatewayPDA(gatewayAddr, this.garProgram);
2025
- if (params.fundAsOperator) {
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 (params.fundFrom === 'withdrawal' &&
2038
- params.withdrawalId !== undefined) {
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' || params.fundFrom === 'any') {
2043
- // Returned-name pricing is dynamic (Dutch auction premium); we trust
2044
- // explicit caller-supplied sources here and skip auto-discovery if
2045
- // sources is provided. For 'any' without sources, we fall back to a
2046
- // best-effort estimate using the plain registration fee caller can
2047
- // always retry with explicit sources on FundingPlanAmountMismatch.
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(syncIx ? [ix, syncIx] : [ix]);
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
- const transport = ((request) => breaker.fire(request));
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
  /**
@@ -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.33';
17
+ export const version = '4.0.0-solana.34';
@@ -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. */
@@ -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.32";
16
+ export declare const version = "4.0.0-solana.33";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ar.io/sdk",
3
- "version": "4.0.0-solana.33",
3
+ "version": "4.0.0-solana.34",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/ar-io/ar-io-sdk.git"