@augustdigital/sdk 8.10.0 → 8.12.0

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.
Files changed (42) hide show
  1. package/lib/adapters/evm/index.d.ts +17 -1
  2. package/lib/adapters/evm/index.js +20 -0
  3. package/lib/adapters/stellar/actions.js +4 -4
  4. package/lib/adapters/stellar/getters.d.ts +9 -3
  5. package/lib/adapters/stellar/getters.js +18 -6
  6. package/lib/adapters/stellar/index.d.ts +17 -2
  7. package/lib/adapters/stellar/index.js +24 -5
  8. package/lib/adapters/stellar/soroban.d.ts +89 -2
  9. package/lib/adapters/stellar/soroban.js +487 -64
  10. package/lib/adapters/stellar/submit.d.ts +4 -1
  11. package/lib/adapters/stellar/submit.js +7 -3
  12. package/lib/adapters/stellar/types.d.ts +12 -0
  13. package/lib/core/analytics/method-taxonomy.js +1 -0
  14. package/lib/core/analytics/sanitize.js +19 -3
  15. package/lib/core/analytics/version.d.ts +1 -1
  16. package/lib/core/analytics/version.js +1 -1
  17. package/lib/core/base.class.d.ts +2 -1
  18. package/lib/core/constants/swap-router.d.ts +2 -0
  19. package/lib/core/constants/swap-router.js +1 -0
  20. package/lib/core/constants/web3.d.ts +19 -0
  21. package/lib/core/constants/web3.js +37 -1
  22. package/lib/core/helpers/multicall.d.ts +68 -0
  23. package/lib/core/helpers/multicall.js +103 -0
  24. package/lib/core/helpers/swap-router.d.ts +36 -1
  25. package/lib/core/helpers/swap-router.js +80 -0
  26. package/lib/core/helpers/vault-version.d.ts +4 -1
  27. package/lib/core/helpers/vaults.d.ts +1 -1
  28. package/lib/core/index.d.ts +1 -0
  29. package/lib/core/index.js +1 -0
  30. package/lib/main.js +6 -1
  31. package/lib/modules/vaults/getters.d.ts +25 -2
  32. package/lib/modules/vaults/getters.js +46 -12
  33. package/lib/modules/vaults/main.d.ts +28 -0
  34. package/lib/modules/vaults/main.js +89 -0
  35. package/lib/modules/vaults/prefetch.d.ts +65 -0
  36. package/lib/modules/vaults/prefetch.js +120 -0
  37. package/lib/modules/vaults/types.d.ts +11 -1
  38. package/lib/sdk.d.ts +6725 -6507
  39. package/lib/types/subgraph.d.ts +9 -0
  40. package/lib/types/vaults.d.ts +44 -0
  41. package/lib/types/web3.d.ts +15 -0
  42. package/package.json +1 -1
@@ -8,6 +8,12 @@
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.createServer = createServer;
10
10
  exports.resolveNetworkConfig = resolveNetworkConfig;
11
+ exports.resetHealthyServerCache = resetHealthyServerCache;
12
+ exports.redactRpcUrl = redactRpcUrl;
13
+ exports.getHealthyServer = getHealthyServer;
14
+ exports.isRetryableRpcError = isRetryableRpcError;
15
+ exports.isRetryableSimulationError = isRetryableSimulationError;
16
+ exports.withEndpointFailover = withEndpointFailover;
11
17
  exports.toBigIntAmount = toBigIntAmount;
12
18
  exports.queryContract = queryContract;
13
19
  exports.buildSorobanTx = buildSorobanTx;
@@ -20,14 +26,410 @@ function createServer(rpcUrl) {
20
26
  });
21
27
  }
22
28
  /**
23
- * Resolve RPC URL and network passphrase for a Stellar network.
29
+ * Ordered public Soroban RPC endpoints per network, for health-gated failover.
30
+ *
31
+ * The first entry is the default primary ({@link SOROBAN_RPC_URLS}); the rest
32
+ * are keyless public fallbacks. A provider node can stall — most visibly around
33
+ * a protocol upgrade, when it freezes behind the network head and every
34
+ * simulation fails with a snapshot error — so a single endpoint means one bad
35
+ * node takes down all reads, deposits, and submits. Module-private: an internal
36
+ * resilience detail, not part of the adapter's public surface.
37
+ */
38
+ const SOROBAN_FALLBACK_RPC_URLS = {
39
+ mainnet: [constants_1.SOROBAN_RPC_URLS.mainnet, 'https://mainnet.sorobanrpc.com'],
40
+ testnet: [constants_1.SOROBAN_RPC_URLS.testnet],
41
+ };
42
+ /**
43
+ * Resolve the ordered RPC endpoints and network passphrase for a Stellar network.
44
+ *
45
+ * When `overrideRpcUrl` is supplied (e.g. a consumer's keyed Alchemy endpoint),
46
+ * it becomes the primary with the built-in public endpoints kept behind it as
47
+ * failover — so opting into a premium provider still degrades to the public
48
+ * network rather than becoming a new single point of failure. The endpoint is
49
+ * injected rather than hardcoded, so the adapter depends on configuration, not a
50
+ * constant.
24
51
  */
25
- function resolveNetworkConfig(network) {
52
+ function resolveNetworkConfig(network, overrideRpcUrl) {
53
+ const defaults = SOROBAN_FALLBACK_RPC_URLS[network];
54
+ const rpcUrls = overrideRpcUrl
55
+ ? [overrideRpcUrl, ...defaults]
56
+ : [...defaults];
26
57
  return {
27
- rpcUrl: constants_1.SOROBAN_RPC_URLS[network],
58
+ rpcUrl: rpcUrls[0],
59
+ rpcUrls,
28
60
  passphrase: constants_1.NETWORK_PASSPHRASES[network],
29
61
  };
30
62
  }
63
+ /** How long a health-checked endpoint choice is reused before re-probing. */
64
+ const HEALTHY_SERVER_TTL_MS = 30_000;
65
+ /**
66
+ * Shorter reuse window after a fully-failed probe (no endpoint healthy). Caps
67
+ * probe traffic during an outage — failing fast on the primary beats re-probing
68
+ * every endpoint on every call — while keeping recovery within a few seconds.
69
+ */
70
+ const NEGATIVE_CACHE_TTL_MS = 5_000;
71
+ /**
72
+ * Bound on a single `getHealth` probe so a *hung* (not erroring) node can't
73
+ * block the failover loop; a timeout is treated like an unhealthy endpoint.
74
+ */
75
+ const HEALTH_PROBE_TIMEOUT_MS = 3_000;
76
+ /**
77
+ * Bound on a single real RPC operation (simulate / getAccount / build) so an
78
+ * endpoint that passed `getHealth()` but then *hangs* on the actual call fails
79
+ * over instead of blocking indefinitely; a timeout is treated as retryable.
80
+ */
81
+ const RPC_OPERATION_TIMEOUT_MS = 15_000;
82
+ const healthyServerCache = new Map();
83
+ // De-dupes concurrent probes for the same endpoint set (e.g. the parallel reads
84
+ // a vault listing issues) so a cold cache costs one probe, not one per call.
85
+ const inFlightProbes = new Map();
86
+ /**
87
+ * Clear the health-check cache and any in-flight probes. Test-only seam.
88
+ * @internal
89
+ */
90
+ function resetHealthyServerCache() {
91
+ healthyServerCache.clear();
92
+ inFlightProbes.clear();
93
+ }
94
+ /**
95
+ * Host-only view of an RPC URL, safe for logs. Keyed provider endpoints put the
96
+ * API key in the path (e.g. Alchemy `/v2/<key>`), so logging the raw URL would
97
+ * leak the secret into SDK logs / crash reporters. Returns just protocol+host.
98
+ * @internal
99
+ */
100
+ function redactRpcUrl(url) {
101
+ try {
102
+ const { protocol, host } = new URL(url);
103
+ return `${protocol}//${host}`;
104
+ }
105
+ catch {
106
+ return '<invalid-url>';
107
+ }
108
+ }
109
+ /**
110
+ * Return a copy of `value` with every configured RPC URL reduced to protocol+host
111
+ * in any string it carries, so a keyed endpoint (e.g. Alchemy `/v2/<key>`) can't
112
+ * leak its API key through a thrown error's chained `cause`.
113
+ *
114
+ * Handles Errors, plain objects, and arrays — not just Errors: Soroban's
115
+ * JSON-RPC helper throws the raw `{ code, message, data }` object, which
116
+ * `AugustSDKError.toJSON()` serializes wholesale, so an unscrubbed object cause
117
+ * would leak the key. Cloned recursively (depth- and cycle-bounded); the
118
+ * original is never mutated.
119
+ */
120
+ function scrubErrorUrls(value, scrub, depth = 0, seen = new WeakSet()) {
121
+ if (typeof value === 'string')
122
+ return scrub(value);
123
+ if (value === null || typeof value !== 'object')
124
+ return value;
125
+ if (depth >= 4)
126
+ return '[TRUNCATED]';
127
+ if (seen.has(value))
128
+ return '[Circular]';
129
+ seen.add(value);
130
+ if (value instanceof Error) {
131
+ const clone = new Error(scrub(value.message));
132
+ clone.name = value.name;
133
+ if (value.stack)
134
+ clone.stack = scrub(value.stack);
135
+ // Carry over any extra own props (e.g. a JSON-RPC `code` / `data`), scrubbed.
136
+ for (const key of Object.getOwnPropertyNames(value)) {
137
+ if (key === 'message' || key === 'stack' || key === 'name')
138
+ continue;
139
+ clone[key] = scrubErrorUrls(value[key], scrub, depth + 1, seen);
140
+ }
141
+ return clone;
142
+ }
143
+ if (Array.isArray(value)) {
144
+ return value.map((v) => scrubErrorUrls(v, scrub, depth + 1, seen));
145
+ }
146
+ const out = {};
147
+ for (const [key, v] of Object.entries(value)) {
148
+ out[key] = scrubErrorUrls(v, scrub, depth + 1, seen);
149
+ }
150
+ return out;
151
+ }
152
+ /**
153
+ * Race a promise against a timeout so a hung call can't block indefinitely. The
154
+ * timer is always cleared, so a fast-resolving promise leaves nothing pending.
155
+ */
156
+ function withTimeout(promise, ms, label) {
157
+ let timer;
158
+ const timeout = new Promise((_resolve, reject) => {
159
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
160
+ });
161
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
162
+ }
163
+ /**
164
+ * Return a Soroban RPC server backed by a healthy endpoint for `config`. Thin
165
+ * wrapper over {@link resolveHealthyServer} for callers (e.g. submission) that
166
+ * only need the server, not the URL it resolved to.
167
+ *
168
+ * @internal
169
+ */
170
+ function getHealthyServer(config) {
171
+ return resolveHealthyServer(config).then((r) => r.server);
172
+ }
173
+ /**
174
+ * Resolve a healthy endpoint for `config`, returning both the server and the URL
175
+ * it was built from.
176
+ *
177
+ * Probes `config.rpcUrls` in priority order via `getHealth()` and uses the first
178
+ * that reports healthy, so a stalled or lagging provider node is skipped instead
179
+ * of failing every call. The choice is cached for {@link HEALTHY_SERVER_TTL_MS},
180
+ * and concurrent callers on a cold cache share a single in-flight probe — so a
181
+ * burst of parallel reads costs one health probe, not one per call. When no
182
+ * endpoint reports healthy, falls back to the first reachable endpoint so the
183
+ * caller still attempts the request and surfaces the node's real error.
184
+ *
185
+ * The URL is returned (not just the server) so {@link withEndpointFailover} can
186
+ * skip the just-attempted endpoint and promote a working fallback into the cache.
187
+ */
188
+ function resolveHealthyServer(config) {
189
+ // Prefer the ordered failover list; fall back to the single primary for a
190
+ // hand-built config that predates `rpcUrls`.
191
+ const rpcUrls = config.rpcUrls && config.rpcUrls.length > 0
192
+ ? config.rpcUrls
193
+ : [config.rpcUrl];
194
+ // Key by the full ordered endpoint set, not just the primary: the same
195
+ // primary paired with different fallback lists selects a different server, so
196
+ // keying on `rpcUrls[0]` alone could return one endpoint set's choice for
197
+ // another during the TTL.
198
+ const cacheKey = rpcUrls.join('|');
199
+ const cached = healthyServerCache.get(cacheKey);
200
+ if (cached) {
201
+ // A fully-failed probe is cached only briefly (NEGATIVE_CACHE_TTL_MS) so an
202
+ // outage doesn't re-probe every endpoint on every call, yet recovers fast.
203
+ const ttl = cached.healthy ? HEALTHY_SERVER_TTL_MS : NEGATIVE_CACHE_TTL_MS;
204
+ if (Date.now() - cached.checkedAt < ttl) {
205
+ return Promise.resolve({
206
+ server: cached.server,
207
+ url: cached.url,
208
+ healthy: cached.healthy,
209
+ });
210
+ }
211
+ }
212
+ const existing = inFlightProbes.get(cacheKey);
213
+ if (existing)
214
+ return existing;
215
+ const probe = probeHealthyServer(rpcUrls, cacheKey).finally(() => {
216
+ inFlightProbes.delete(cacheKey);
217
+ });
218
+ inFlightProbes.set(cacheKey, probe);
219
+ return probe;
220
+ }
221
+ async function probeHealthyServer(rpcUrls, cacheKey) {
222
+ // The first endpoint we can even construct — the fallback when none report
223
+ // healthy. Tracked separately from `rpcUrls[0]` so a malformed primary (e.g. a
224
+ // typo'd consumer override) is skipped rather than disabling every call.
225
+ let firstConstructable;
226
+ let firstConstructableUrl;
227
+ for (const url of rpcUrls) {
228
+ let server;
229
+ try {
230
+ // Construct inside the guard: a malformed URL throws from the rpc.Server
231
+ // ctor, and must skip to the next endpoint rather than abort the loop and
232
+ // disable every fallback.
233
+ server = createServer(url);
234
+ }
235
+ catch (err) {
236
+ core_1.Logger.log.warn('getHealthyServer', 'Skipping invalid Soroban RPC endpoint; trying next endpoint', { url: redactRpcUrl(url), error: String(err) });
237
+ continue;
238
+ }
239
+ if (firstConstructable === undefined) {
240
+ firstConstructable = server;
241
+ firstConstructableUrl = url;
242
+ }
243
+ try {
244
+ const health = await withTimeout(server.getHealth(), HEALTH_PROBE_TIMEOUT_MS, 'getHealth');
245
+ if (health.status === 'healthy') {
246
+ healthyServerCache.set(cacheKey, {
247
+ server,
248
+ url,
249
+ checkedAt: Date.now(),
250
+ healthy: true,
251
+ });
252
+ return { server, url, healthy: true };
253
+ }
254
+ core_1.Logger.log.warn('getHealthyServer', `Soroban RPC reported status "${health.status}"; trying next endpoint`, { url: redactRpcUrl(url) });
255
+ }
256
+ catch (err) {
257
+ core_1.Logger.log.warn('getHealthyServer', 'Soroban RPC health check failed; trying next endpoint',
258
+ // Scrub the endpoint from the error message too — SDK errors often
259
+ // embed the request URL (key and all).
260
+ {
261
+ url: redactRpcUrl(url),
262
+ error: String(err).split(url).join(redactRpcUrl(url)),
263
+ });
264
+ }
265
+ }
266
+ if (firstConstructable === undefined) {
267
+ // Every configured URL was malformed — there is nothing to call.
268
+ throw new core_1.AugustSDKError('UNKNOWN', 'No valid Soroban RPC endpoint could be constructed from the configured URLs');
269
+ }
270
+ const fallbackUrl = firstConstructableUrl;
271
+ core_1.Logger.log.warn('getHealthyServer', 'No healthy Soroban RPC endpoint; falling back to first reachable endpoint', { primary: redactRpcUrl(fallbackUrl) });
272
+ // Negative-cache the fallback (short TTL) so a sustained outage fails fast
273
+ // instead of re-probing every endpoint on every call.
274
+ healthyServerCache.set(cacheKey, {
275
+ server: firstConstructable,
276
+ url: fallbackUrl,
277
+ checkedAt: Date.now(),
278
+ healthy: false,
279
+ });
280
+ return { server: firstConstructable, url: fallbackUrl, healthy: false };
281
+ }
282
+ /**
283
+ * A thrown RPC error is retryable on another endpoint UNLESS it is one of our
284
+ * typed domain errors. Those mark a *deterministic* outcome — a contract revert,
285
+ * an unfunded account, archived state needing restore, a bad input, or a local
286
+ * assembly failure — which reproduces identically on every node, so retrying
287
+ * elsewhere only wastes calls. Any other throw (a transport hang/reset, or a
288
+ * method-specific node error from an endpoint that still answered `getHealth()`)
289
+ * is worth the next endpoint.
290
+ *
291
+ * Exported so an operation with different semantics (e.g. an idempotency-aware
292
+ * submission path) can compose or replace this policy via
293
+ * {@link FailoverOptions.isRetryable} — extension without editing the core.
294
+ * @internal
295
+ */
296
+ function isRetryableRpcError(err) {
297
+ return !(err instanceof core_1.AugustValidationError || err instanceof core_1.AugustSDKError);
298
+ }
299
+ /**
300
+ * Signatures of simulation errors that indicate a stalled/lagging *node* rather
301
+ * than a deterministic contract revert. The canonical case is the Protocol 27
302
+ * boundary, where a node frozen behind the network head answered `getHealth()`
303
+ * but reported reads with a "not present in the snapshot" error. Extend this
304
+ * list as new node-side signatures surface.
305
+ */
306
+ const RETRYABLE_SIMULATION_ERROR_SIGNATURES = ['not present in the snapshot'];
307
+ /**
308
+ * Whether a simulation *error* string is a node/infrastructure failure (which
309
+ * clears on another endpoint) rather than a genuine contract revert (which
310
+ * reproduces on every node). `isSimulationError` alone can't tell these apart —
311
+ * both arrive as a populated `error` field — so callers use this to decide
312
+ * whether to fail over or treat the error as terminal.
313
+ * @internal
314
+ */
315
+ function isRetryableSimulationError(errorText) {
316
+ const lower = errorText.toLowerCase();
317
+ return RETRYABLE_SIMULATION_ERROR_SIGNATURES.some((sig) => lower.includes(sig));
318
+ }
319
+ /**
320
+ * Drop a cached endpoint choice so a server that just failed a *real* call is
321
+ * not re-used for the remainder of its {@link HEALTHY_SERVER_TTL_MS} window.
322
+ */
323
+ function dropCachedServer(rpcUrls) {
324
+ healthyServerCache.delete(rpcUrls.join('|'));
325
+ }
326
+ /**
327
+ * Run an idempotent, side-effect-free `operation` (a read or a tx build) against
328
+ * a Soroban endpoint, failing over to the remaining endpoints on a retryable
329
+ * failure.
330
+ *
331
+ * {@link getHealthyServer} only proves an endpoint answered `getHealth()`, and
332
+ * caches that choice for {@link HEALTHY_SERVER_TTL_MS} — but the node can still
333
+ * hang or return a method-specific error on the *real* call. So we run the op on
334
+ * the health-gated server first (the fast, cached path) and, on a retryable
335
+ * failure, drop that cached choice and retry the op against every endpoint in
336
+ * priority order. Deterministic contract/domain errors short-circuit (see
337
+ * {@link isRetryableRpcError}); each attempt is time-boxed so a hung node can't
338
+ * stall the loop.
339
+ *
340
+ * The `operation` is a strategy (dependency-injected), so new read/build calls
341
+ * reuse this failover without changing it; `options` keeps retry policy and
342
+ * timeout open for extension. Submission is deliberately NOT routed through here
343
+ * — re-sending a signed transaction across endpoints risks a double-submit, so
344
+ * it needs sequence/DUPLICATE-based idempotency rather than blind retry.
345
+ * @internal
346
+ */
347
+ async function withEndpointFailover(config, method, operation, options = {}) {
348
+ const isRetryable = options.isRetryable ?? isRetryableRpcError;
349
+ const timeoutMs = options.timeoutMs ?? RPC_OPERATION_TIMEOUT_MS;
350
+ const rpcUrls = config.rpcUrls && config.rpcUrls.length > 0
351
+ ? config.rpcUrls
352
+ : [config.rpcUrl];
353
+ const runOn = (server) => withTimeout(operation(server), timeoutMs, `Soroban ${method}`);
354
+ const cacheKey = rpcUrls.join('|');
355
+ // Reduce every configured endpoint URL to protocol+host in anything surfaced
356
+ // from here (logs, the thrown message, the chained cause). SDK errors embed the
357
+ // request URL — which for a keyed provider carries the API key in its path — so
358
+ // an unscrubbed `String(err)` would leak it. Belt-and-suspenders with the
359
+ // analytics sanitizer, and it also covers the thrown error that escapes to the
360
+ // caller (which our telemetry sanitizer never sees).
361
+ const scrub = (text) => rpcUrls.reduce((acc, u) => acc.split(u).join(redactRpcUrl(u)), text);
362
+ let lastError;
363
+ // A terminal "everything failed" error, scrubbed so a keyed endpoint can't
364
+ // leak through the message or chained cause.
365
+ const failAllEndpoints = () => new core_1.AugustSDKError('UNKNOWN', `Soroban ${method} failed on all endpoints: ${scrub(String(lastError))}`, { cause: scrubErrorUrls(lastError, scrub), context: { method } });
366
+ // Fast path: the health-gated, cross-call-cached endpoint.
367
+ const { server: primaryServer, url: primaryUrl, healthy, } = await resolveHealthyServer(config);
368
+ try {
369
+ return await runOn(primaryServer);
370
+ }
371
+ catch (err) {
372
+ if (!isRetryable(err))
373
+ throw err;
374
+ lastError = err;
375
+ if (!healthy) {
376
+ // The resolved endpoint is a negative-cached fallback: a prior probe found
377
+ // no healthy endpoint, so the whole set is down. Re-looping every endpoint
378
+ // (and re-probing on the next read) would only amplify load during the
379
+ // outage, so fail fast and leave the short-TTL negative entry in place —
380
+ // subsequent reads within NEGATIVE_CACHE_TTL_MS fast-fail on the primary too.
381
+ throw failAllEndpoints();
382
+ }
383
+ // A "healthy" endpoint failed the real call — drop it and fail over.
384
+ dropCachedServer(rpcUrls);
385
+ core_1.Logger.log.warn('withEndpointFailover', `Soroban ${method} failed on the health-gated endpoint; failing over`, { error: scrub(String(err)) });
386
+ }
387
+ // Failover: the remaining endpoints in priority order. Skip the one the fast
388
+ // path already tried (no point paying its timeout twice). On success, promote
389
+ // the working endpoint into the cache so the next call starts there instead of
390
+ // repeating the failed-primary-then-fallback sequence.
391
+ for (const url of rpcUrls) {
392
+ if (url === primaryUrl)
393
+ continue;
394
+ let server;
395
+ try {
396
+ server = createServer(url);
397
+ }
398
+ catch {
399
+ continue; // malformed endpoint URL — skip
400
+ }
401
+ try {
402
+ const result = await runOn(server);
403
+ healthyServerCache.set(cacheKey, {
404
+ server,
405
+ url,
406
+ checkedAt: Date.now(),
407
+ healthy: true,
408
+ });
409
+ return result;
410
+ }
411
+ catch (err) {
412
+ if (!isRetryable(err))
413
+ throw err;
414
+ lastError = err;
415
+ core_1.Logger.log.warn('withEndpointFailover', `Soroban ${method} failed; trying next endpoint`, {
416
+ url: redactRpcUrl(url),
417
+ error: scrub(String(err)),
418
+ });
419
+ }
420
+ }
421
+ // Every endpoint's real op failed even though one health-probed OK. Negative-
422
+ // cache the health-gated server (it constructed fine) so subsequent reads
423
+ // fast-fail within NEGATIVE_CACHE_TTL_MS instead of re-probing + re-looping the
424
+ // whole set during a sustained outage.
425
+ healthyServerCache.set(cacheKey, {
426
+ server: primaryServer,
427
+ url: primaryUrl,
428
+ checkedAt: Date.now(),
429
+ healthy: false,
430
+ });
431
+ throw failAllEndpoints();
432
+ }
31
433
  /**
32
434
  * Validate and convert a string amount to a non-negative BigInt.
33
435
  * Throws a descriptive error if the value is not a valid non-negative integer string.
@@ -60,7 +462,8 @@ function extractSimulationError(simulated) {
60
462
  }
61
463
  async function queryContract(config, contractId, method, args = []) {
62
464
  try {
63
- const server = createServer(config.rpcUrl);
465
+ // Build the simulation tx once — it is endpoint-independent, so only the
466
+ // simulate call is failed over across endpoints (not the tx assembly).
64
467
  const contract = new stellar_sdk_1.Contract(contractId);
65
468
  const sourceKeypair = stellar_sdk_1.Keypair.random();
66
469
  const account = new stellar_sdk_1.Account(sourceKeypair.publicKey(), '0');
@@ -73,20 +476,27 @@ async function queryContract(config, contractId, method, args = []) {
73
476
  .addOperation(contract.call(method, ...args))
74
477
  .setTimeout(constants_1.QUERY_TIMEOUT_SECONDS)
75
478
  .build();
76
- const simulated = await server.simulateTransaction(tx);
77
- if (stellar_sdk_1.rpc.Api.isSimulationError(simulated)) {
78
- core_1.Logger.log.warn('queryContract', `Soroban simulation error for ${method}: ${extractSimulationError(simulated)}`, {
79
- contractId,
80
- });
479
+ return await withEndpointFailover(config, method, async (server) => {
480
+ const simulated = await server.simulateTransaction(tx);
481
+ if (stellar_sdk_1.rpc.Api.isSimulationError(simulated)) {
482
+ const errorText = extractSimulationError(simulated);
483
+ if (isRetryableSimulationError(errorText)) {
484
+ // A stalled/lagging node reports the read as a simulation error too
485
+ // (e.g. a post-upgrade snapshot gap) — not a real revert. Throw so the
486
+ // failover wrapper tries another endpoint instead of returning null.
487
+ throw new Error(`Soroban simulation node error for ${method}: ${errorText}`);
488
+ }
489
+ // A genuine contract revert is deterministic across nodes — surface null
490
+ // (best-effort read) rather than failing over to repeat it.
491
+ core_1.Logger.log.warn('queryContract', `Soroban simulation error for ${method}: ${errorText}`, { contractId });
492
+ return null;
493
+ }
494
+ if (stellar_sdk_1.rpc.Api.isSimulationSuccess(simulated) && simulated.result) {
495
+ return simulated.result.retval;
496
+ }
497
+ core_1.Logger.log.warn('queryContract', `Unexpected simulation state for ${method} — no result returned`, { contractId });
81
498
  return null;
82
- }
83
- if (stellar_sdk_1.rpc.Api.isSimulationSuccess(simulated) && simulated.result) {
84
- return simulated.result.retval;
85
- }
86
- core_1.Logger.log.warn('queryContract', `Unexpected simulation state for ${method} — no result returned`, {
87
- contractId,
88
499
  });
89
- return null;
90
500
  }
91
501
  catch (err) {
92
502
  core_1.Logger.log.warn('queryContract', `Soroban query failed: ${method}`, {
@@ -132,54 +542,67 @@ async function getNetworkCloseTime(server) {
132
542
  * using MAX_FEE_STROOPS as the fee ceiling and incorporating the simulated resource fee.
133
543
  */
134
544
  async function buildSorobanTx(config, sourceAddress, contractId, method, args) {
135
- const server = createServer(config.rpcUrl);
136
- // Read the network clock in parallel with the account fetch — it overlaps the
137
- // account round-trip, so it adds no latency on the happy path.
138
- const networkCloseTimePromise = getNetworkCloseTime(server);
139
- let account;
140
- try {
141
- account = await server.getAccount(sourceAddress);
142
- }
143
- catch (err) {
144
- // Soroban rpc currently throws plain `Error("Account not found: ...")`
145
- // for unfunded accounts; check `NotFoundError` too so this survives an
146
- // SDK refactor to typed errors.
147
- const msg = err instanceof Error ? err.message : String(err);
148
- const isAccountNotFound = err instanceof stellar_sdk_1.NotFoundError || /Account not found/.test(msg);
149
- if (isAccountNotFound) {
150
- throw new core_1.AugustValidationError('ACCOUNT_NOT_FUNDED', `Stellar account ${sourceAddress} is not funded. Send at least 1 XLM to this address to activate it before depositing or redeeming.`, { cause: err, context: { sourceAddress, method, contractId } });
151
- }
152
- throw err;
153
- }
154
545
  const contract = new stellar_sdk_1.Contract(contractId);
155
- const builder = new stellar_sdk_1.TransactionBuilder(account, {
156
- fee: constants_1.MAX_FEE_STROOPS,
157
- networkPassphrase: config.passphrase,
158
- }).addOperation(contract.call(method, ...args));
159
- const networkCloseTime = await networkCloseTimePromise;
160
- if (networkCloseTime !== null) {
161
- // Absolute deadline anchored to the network clock survives signer skew.
162
- builder.setTimebounds(0, networkCloseTime + constants_1.TX_TIMEOUT_SECONDS);
163
- }
164
- else {
165
- // RPC couldn't report network time; fall back to the local-clock window.
166
- builder.setTimeout(constants_1.TX_TIMEOUT_SECONDS);
167
- }
168
- const tx = builder.build();
169
- const simulated = await server.simulateTransaction(tx);
170
- if (stellar_sdk_1.rpc.Api.isSimulationError(simulated)) {
171
- throw new core_1.AugustSDKError('UNKNOWN', `Soroban simulation failed: ${extractSimulationError(simulated)}`, { context: { method, contractId } });
172
- }
173
- if ('restorePreamble' in simulated && simulated.restorePreamble) {
174
- throw new core_1.AugustSDKError('UNKNOWN', `Contract ledger state needs restoration before invoking ${method}. ` +
175
- 'Submit a restore footprint transaction first.', { context: { method, contractId } });
176
- }
177
- try {
178
- const assembled = stellar_sdk_1.rpc.assembleTransaction(tx, simulated).build();
179
- return assembled.toXDR();
180
- }
181
- catch (assemblyErr) {
182
- throw new core_1.AugustSDKError('UNKNOWN', `Failed to assemble Soroban transaction for ${method}: ${String(assemblyErr)}`, { cause: assemblyErr, context: { method, contractId } });
183
- }
546
+ // Build is idempotent (no on-chain side effects), so the whole account-fetch →
547
+ // simulate → assemble sequence is failed over across endpoints. Deterministic
548
+ // outcomes (unfunded account, simulation/restore/assembly errors) throw typed
549
+ // errors and short-circuit the failover; transport errors get the next node.
550
+ return withEndpointFailover(config, method, async (server) => {
551
+ // Read the network clock in parallel with the account fetch — it overlaps
552
+ // the account round-trip, so it adds no latency on the happy path.
553
+ const networkCloseTimePromise = getNetworkCloseTime(server);
554
+ let account;
555
+ try {
556
+ account = await server.getAccount(sourceAddress);
557
+ }
558
+ catch (err) {
559
+ // Soroban rpc currently throws plain `Error("Account not found: ...")`
560
+ // for unfunded accounts; check `NotFoundError` too so this survives an
561
+ // SDK refactor to typed errors. An unfunded account is deterministic
562
+ // across nodes (typed error no failover); any other getAccount error is
563
+ // transport-ish and falls over.
564
+ const msg = err instanceof Error ? err.message : String(err);
565
+ const isAccountNotFound = err instanceof stellar_sdk_1.NotFoundError || /Account not found/.test(msg);
566
+ if (isAccountNotFound) {
567
+ throw new core_1.AugustValidationError('ACCOUNT_NOT_FUNDED', `Stellar account ${sourceAddress} is not funded. Send at least 1 XLM to this address to activate it before depositing or redeeming.`, { cause: err, context: { sourceAddress, method, contractId } });
568
+ }
569
+ throw err;
570
+ }
571
+ const builder = new stellar_sdk_1.TransactionBuilder(account, {
572
+ fee: constants_1.MAX_FEE_STROOPS,
573
+ networkPassphrase: config.passphrase,
574
+ }).addOperation(contract.call(method, ...args));
575
+ const networkCloseTime = await networkCloseTimePromise;
576
+ if (networkCloseTime !== null) {
577
+ // Absolute deadline anchored to the network clock — survives signer skew.
578
+ builder.setTimebounds(0, networkCloseTime + constants_1.TX_TIMEOUT_SECONDS);
579
+ }
580
+ else {
581
+ // RPC couldn't report network time; fall back to the local-clock window.
582
+ builder.setTimeout(constants_1.TX_TIMEOUT_SECONDS);
583
+ }
584
+ const tx = builder.build();
585
+ const simulated = await server.simulateTransaction(tx);
586
+ if (stellar_sdk_1.rpc.Api.isSimulationError(simulated)) {
587
+ const errorText = extractSimulationError(simulated);
588
+ if (isRetryableSimulationError(errorText)) {
589
+ // Node/snapshot error (a stale node), not a contract revert — throw a
590
+ // retryable error so the failover wrapper tries another endpoint.
591
+ throw new Error(`Soroban simulation node error for ${method}: ${errorText}`);
592
+ }
593
+ throw new core_1.AugustSDKError('UNKNOWN', `Soroban simulation failed: ${errorText}`, { context: { method, contractId } });
594
+ }
595
+ if ('restorePreamble' in simulated && simulated.restorePreamble) {
596
+ throw new core_1.AugustSDKError('UNKNOWN', `Contract ledger state needs restoration before invoking ${method}. ` +
597
+ 'Submit a restore footprint transaction first.', { context: { method, contractId } });
598
+ }
599
+ try {
600
+ const assembled = stellar_sdk_1.rpc.assembleTransaction(tx, simulated).build();
601
+ return assembled.toXDR();
602
+ }
603
+ catch (assemblyErr) {
604
+ throw new core_1.AugustSDKError('UNKNOWN', `Failed to assemble Soroban transaction for ${method}: ${String(assemblyErr)}`, { cause: assemblyErr, context: { method, contractId } });
605
+ }
606
+ });
184
607
  }
185
608
  //# sourceMappingURL=soroban.js.map
@@ -9,6 +9,9 @@ import type { IStellarNetwork } from './types';
9
9
  *
10
10
  * @param signedXdr - Base64-encoded XDR of the signed transaction
11
11
  * @param network - Stellar network name
12
+ * @param sorobanRpcUrl - Optional override Soroban RPC endpoint (e.g. a keyed
13
+ * Alchemy URL); becomes the primary with the SDK's public endpoints kept as
14
+ * failover. Omit to use the public endpoints.
12
15
  * @returns The transaction hash once the network confirms it as successful
13
16
  * @throws {@link AugustTimeoutError} when the transaction is not confirmed
14
17
  * within the poll budget (`MAX_POLL_ATTEMPTS`).
@@ -28,4 +31,4 @@ import type { IStellarNetwork } from './types';
28
31
  * }
29
32
  * ```
30
33
  */
31
- export declare function submitStellarTransaction(signedXdr: string, network: IStellarNetwork): Promise<string>;
34
+ export declare function submitStellarTransaction(signedXdr: string, network: IStellarNetwork, sorobanRpcUrl?: string): Promise<string>;
@@ -53,6 +53,9 @@ function safeStringify(value) {
53
53
  *
54
54
  * @param signedXdr - Base64-encoded XDR of the signed transaction
55
55
  * @param network - Stellar network name
56
+ * @param sorobanRpcUrl - Optional override Soroban RPC endpoint (e.g. a keyed
57
+ * Alchemy URL); becomes the primary with the SDK's public endpoints kept as
58
+ * failover. Omit to use the public endpoints.
56
59
  * @returns The transaction hash once the network confirms it as successful
57
60
  * @throws {@link AugustTimeoutError} when the transaction is not confirmed
58
61
  * within the poll budget (`MAX_POLL_ATTEMPTS`).
@@ -72,9 +75,10 @@ function safeStringify(value) {
72
75
  * }
73
76
  * ```
74
77
  */
75
- async function submitStellarTransaction(signedXdr, network) {
76
- const { rpcUrl, passphrase } = (0, soroban_1.resolveNetworkConfig)(network);
77
- const server = (0, soroban_1.createServer)(rpcUrl);
78
+ async function submitStellarTransaction(signedXdr, network, sorobanRpcUrl) {
79
+ const config = (0, soroban_1.resolveNetworkConfig)(network, sorobanRpcUrl);
80
+ const { passphrase } = config;
81
+ const server = await (0, soroban_1.getHealthyServer)(config);
78
82
  const tx = stellar_sdk_1.TransactionBuilder.fromXDR(signedXdr, passphrase);
79
83
  const sendResult = await server.sendTransaction(tx);
80
84
  if (sendResult.status === 'ERROR') {
@@ -9,6 +9,12 @@ export interface IStellarDepositParams {
9
9
  amount: string;
10
10
  senderAddress: string;
11
11
  network: IStellarNetwork;
12
+ /**
13
+ * Optional override Soroban RPC endpoint (e.g. a keyed Alchemy URL). When set
14
+ * it is the primary, with the SDK's public endpoints kept behind it as
15
+ * failover.
16
+ */
17
+ sorobanRpcUrl?: string;
12
18
  }
13
19
  /** Parameters for building an unsigned Stellar vault redeem transaction. */
14
20
  export interface IStellarRedeemParams {
@@ -16,6 +22,12 @@ export interface IStellarRedeemParams {
16
22
  shares: string;
17
23
  receiverAddress: string;
18
24
  network: IStellarNetwork;
25
+ /**
26
+ * Optional override Soroban RPC endpoint (e.g. a keyed Alchemy URL). When set
27
+ * it is the primary, with the SDK's public endpoints kept behind it as
28
+ * failover.
29
+ */
30
+ sorobanRpcUrl?: string;
19
31
  }
20
32
  /**
21
33
  * User vault position returned by on-chain balance queries.
@@ -39,6 +39,7 @@ exports.METHOD_CATEGORIES = {
39
39
  getVaultBorrowerHealthFactor: 'read.vault',
40
40
  getYieldLastRealizedOn: 'read.vault',
41
41
  getVaultActivity: 'read.vault',
42
+ getSwapRouterDepositResult: 'read.vault',
42
43
  getVaultPositions: 'read.position',
43
44
  getVaultUserHistory: 'read.position',
44
45
  getUserHistory: 'read.position',