From d65aab297c386b12ab9583df2dfb2e421e1d7a83 Mon Sep 17 00:00:00 2001 From: Arminas Attas Date: Fri, 16 Oct 2026 17:29:58 +0000 Subject: [PATCH 1/4] feat(stellar): add cancellation and timeouts to the remaining Horizon helpers findStrictReceivePath(), buildMultisigStealthWithdraw() and submitOfflineStellarTransaction() accept an AbortSignal and the same timeouts as the Horizon and RPC clients. Failures map onto the error taxonomy: the new RPCAbortedError, RPCTimeoutError, RPCRequestError and HorizonResponseError. Refs #235 --- src/chains/stellar/index.ts | 1 + src/chains/stellar/multisig.ts | 55 ++- src/chains/stellar/offline-sign.ts | 52 ++- src/chains/stellar/path-payment.ts | 33 +- src/chains/stellar/request.ts | 94 +++++ src/chains/stellar/timeouts.ts | 26 +- src/errors.ts | 34 ++ src/index.ts | 1 + test/chains/stellar/helper-timeouts.test.ts | 371 ++++++++++++++++++++ test/errors.test.ts | 23 ++ 10 files changed, 652 insertions(+), 38 deletions(-) create mode 100644 src/chains/stellar/request.ts create mode 100644 test/chains/stellar/helper-timeouts.test.ts diff --git a/src/chains/stellar/index.ts b/src/chains/stellar/index.ts index 982a482..83abb13 100644 --- a/src/chains/stellar/index.ts +++ b/src/chains/stellar/index.ts @@ -159,3 +159,4 @@ export type { export { createRpcClient } from './rpc'; export type { RpcClient, RpcClientConfig, RpcEndpoint, RpcRequestOptions } from './rpc'; export type { RequestTimeouts } from './timeouts'; +export type { HorizonCallOptions } from './request'; diff --git a/src/chains/stellar/multisig.ts b/src/chains/stellar/multisig.ts index 11ad407..48571b8 100644 --- a/src/chains/stellar/multisig.ts +++ b/src/chains/stellar/multisig.ts @@ -6,6 +6,8 @@ import { TransactionBuilder, type Horizon, } from '@stellar/stellar-sdk'; +import { HorizonResponseError } from '../../errors'; +import { requestHorizonJson, type HorizonCallOptions } from './request'; interface WeightedSigner { key: string; @@ -21,7 +23,12 @@ interface AccountConfig { signers: WeightedSigner[]; } -export interface BuildMultisigStealthWithdrawOptions { +/** + * Options for {@link buildMultisigStealthWithdraw}. `signal` and `timeouts` apply to the Horizon + * account lookup made when only `horizonUrl` is supplied; `timeouts` defaults to 10 s for the + * response headers and 30 s in total. + */ +export interface BuildMultisigStealthWithdrawOptions extends HorizonCallOptions { /** Stealth source account to close. */ stealthAddress: string; /** Destination that receives the stealth account's remaining native balance. */ @@ -40,7 +47,7 @@ export interface BuildMultisigStealthWithdrawOptions { horizonUrl?: string; /** Base fee in stroops. Defaults to 100. */ fee?: string; - /** Transaction timeout in seconds. Defaults to 180. */ + /** Transaction validity window in seconds. Defaults to 180. Not a network timeout. */ timeout?: number; } @@ -58,6 +65,11 @@ const txState = new WeakMap(); * configured signer weights when `account` or `horizonUrl` is supplied. The * resulting transaction closes the stealth account and sends its native XLM * balance to `destination`. + * + * @throws {RPCAbortedError} If `options.signal` aborts the account lookup. + * @throws {RPCTimeoutError} If Horizon does not answer within `options.timeouts`. + * @throws {RPCRequestError} If the account lookup returns a non-2xx status. + * @throws {HorizonResponseError} If the account record lacks its sequence, thresholds or signers. */ export async function buildMultisigStealthWithdraw( options: BuildMultisigStealthWithdrawOptions, @@ -156,18 +168,39 @@ async function resolveAccountConfig( return null; } - const res = await fetch( - `${options.horizonUrl.replace(/\/$/, '')}/accounts/${options.stealthAddress}`, - ); - if (!res.ok) { - throw new Error(`Horizon account lookup failed: ${res.status} ${res.statusText}`); + const endpoint = options.horizonUrl.replace(/\/$/, ''); + const url = `${endpoint}/accounts/${options.stealthAddress}`; + const record = await requestHorizonJson({ + url, + endpoint, + signal: options.signal, + timeouts: [options.timeouts], + }); + return accountRecordToConfig(parseAccountRecord(record, url)); +} + +type AccountFields = Pick; + +function parseAccountRecord(record: unknown, url: string): AccountFields { + const account = record as Partial | null; + if (!account || typeof account !== 'object' || typeof account.sequence !== 'string') { + throw new HorizonResponseError(url, 'sequence', 'missing or not a string'); + } + if (!account.thresholds || typeof account.thresholds !== 'object') { + throw new HorizonResponseError(url, 'thresholds', 'missing or not an object'); + } + const signersValid = + Array.isArray(account.signers) && + account.signers.every( + (signer) => typeof signer?.key === 'string' && typeof signer.weight === 'number', + ); + if (!signersValid) { + throw new HorizonResponseError(url, 'signers', 'missing or not a list of { key, weight }'); } - return accountRecordToConfig((await res.json()) as Horizon.ServerApi.AccountRecord); + return account as AccountFields; } -function accountRecordToConfig( - account: Pick, -): AccountConfig { +function accountRecordToConfig(account: AccountFields): AccountConfig { return { sequence: account.sequence, thresholds: account.thresholds, diff --git a/src/chains/stellar/offline-sign.ts b/src/chains/stellar/offline-sign.ts index 4b0f0a6..c8443ba 100644 --- a/src/chains/stellar/offline-sign.ts +++ b/src/chains/stellar/offline-sign.ts @@ -1,7 +1,15 @@ import { TransactionBuilder, Account, Keypair, xdr } from '@stellar/stellar-sdk'; import { getDeployment } from './deployments'; import { signWithScalar } from './scalar'; -import { RPCRequestError, InvalidSignatureError } from '../../errors'; +import { requestHorizonJson, type HorizonCallOptions } from './request'; +import type { RequestTimeouts } from './timeouts'; +import { HorizonResponseError, InvalidSignatureError } from '../../errors'; + +/** + * Default timeouts for a submission. Horizon answers only once the transaction is in a ledger or + * its own wait runs out, so the headers can take as long as the whole request. + */ +const SUBMIT_TIMEOUTS: RequestTimeouts = { connectMs: 0, requestMs: 60_000 }; export interface OfflineSignParams { /** Stellar account public key (G...) of the transaction source. */ @@ -169,10 +177,21 @@ export function signOfflineStellarTransaction( * parsed Horizon response, which includes the transaction hash, ledger * sequence, and result envelope on success. * + * Horizon answers only once the transaction is in a ledger or its own wait + * runs out, so by default there is no separate headers timeout and the whole + * request may take 60 s. A submission that times out or is aborted may still + * be applied: look the transaction up by its hash before submitting it again. + * * @param signedXdr - Base64-encoded signed transaction envelope XDR. * @param network - Chain deployment key (default: `"stellar"`). + * @param options - Cancellation signal and timeouts. `timeouts` defaults to + * `{ connectMs: 0, requestMs: 60000 }`. * @returns Parsed Horizon response JSON. - * @throws {RPCRequestError} If the Horizon submission fails. + * @throws {RPCAbortedError} If `options.signal` aborts the submission. + * @throws {RPCTimeoutError} If Horizon does not answer within `options.timeouts`. + * @throws {RPCRequestError} If the Horizon submission fails; the Horizon error + * body, with its result codes, is on `context.responseText`. + * @throws {HorizonResponseError} If Horizon's success response is not a JSON object. * @throws {Error} If `signedXdr` is empty or invalid. * * @example @@ -187,25 +206,30 @@ export function signOfflineStellarTransaction( export async function submitOfflineStellarTransaction( signedXdr: string, network: string = 'stellar', + options: HorizonCallOptions = {}, ): Promise> { if (!signedXdr || typeof signedXdr !== 'string') { throw new Error('signedXdr must be a non-empty base64 string'); } const deployment = getDeployment(network); - const horizonUrl = deployment.horizonUrl; - - const res = await fetch(`${horizonUrl}/transactions`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ tx: signedXdr }), + const endpoint = deployment.horizonUrl.replace(/\/$/, ''); + const url = `${endpoint}/transactions`; + + const data = await requestHorizonJson({ + url, + endpoint, + init: { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ tx: signedXdr }), + }, + signal: options.signal, + timeouts: [SUBMIT_TIMEOUTS, options.timeouts], }); - const data = await res.json(); - - if (!res.ok) { - throw new RPCRequestError(horizonUrl, res.status, JSON.stringify(data)); + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new HorizonResponseError(url, 'body', 'not a JSON object'); } - - return data; + return data as Record; } diff --git a/src/chains/stellar/path-payment.ts b/src/chains/stellar/path-payment.ts index da6c2af..90b24cd 100644 --- a/src/chains/stellar/path-payment.ts +++ b/src/chains/stellar/path-payment.ts @@ -15,7 +15,8 @@ import { decodeStealthMetaAddress } from './meta-address'; import { getDeployment } from './deployments'; import type { GeneratedStealthAddress } from './types'; import { AttemptDeadline, resolveTimeouts } from './timeouts'; -import { HorizonResponseError, RPCRequestError } from '../../errors'; +import type { HorizonCallOptions } from './request'; +import { HorizonResponseError, RPCAbortedError, RPCRequestError } from '../../errors'; /** * Options for {@link buildPathStealthPayment}. @@ -323,7 +324,7 @@ function parseHorizonAsset( /** * Options for {@link findStrictReceivePath}. */ -export interface FindStrictReceivePathOptions { +export interface FindStrictReceivePathOptions extends HorizonCallOptions { /** The asset the sender is spending. */ sendAsset: Asset; /** The asset the stealth address should receive. */ @@ -357,8 +358,13 @@ export interface StrictReceivePathResult { * `sendAsset` to exactly `destAmount` of `receiveAsset`), and the one with the smallest * `source_amount` is returned. * + * Pass `signal` to cancel the lookup and `timeouts` to override the connect and request timeouts, + * which default to those of the SDK's Horizon client. + * * @param options See {@link FindStrictReceivePathOptions}. * @returns The source amount and path for the cheapest swap route. + * @throws {RPCAbortedError} If `options.signal` aborts the lookup. + * @throws {RPCTimeoutError} If Horizon does not answer within `options.timeouts`. * @throws {RPCRequestError} If Horizon answers with a non-2xx status. * @throws {HorizonResponseError} If the response is oversized, is not valid JSON, or a record * fails validation. @@ -400,7 +406,7 @@ export interface StrictReceivePathResult { export async function findStrictReceivePath( options: FindStrictReceivePathOptions, ): Promise { - const { sendAsset, receiveAsset, destAmount, horizonUrl, chain = 'stellar' } = options; + const { sendAsset, receiveAsset, destAmount, horizonUrl, chain = 'stellar', signal } = options; const wantedStroops = toStroops(destAmount); if (wantedStroops === null) { @@ -427,9 +433,18 @@ export async function findStrictReceivePath( const invalid = (field: string, reason: string) => new HorizonResponseError(url, field, reason); - // Same connect / request timeouts as the SDK's Horizon client, so a server that never answers - // or trickles the body cannot hang the call. - const deadline = new AttemptDeadline(resolveTimeouts(), { url, endpoint: baseUrl, attempt: 1 }); + const aborted = () => new RPCAbortedError(url, { cause: signal?.reason }); + if (signal?.aborted) throw aborted(); + + // Same connect / request timeouts as the SDK's Horizon client unless the caller overrides them, + // so a server that never answers or trickles the body cannot hang the call. + const deadline = new AttemptDeadline(resolveTimeouts(options.timeouts), { + url, + endpoint: baseUrl, + attempt: 1, + }); + const onAbort = () => deadline.abort(aborted()); + signal?.addEventListener('abort', onAbort); let body: string; try { const response = await deadline.send(fetch, url, {}); @@ -437,7 +452,10 @@ export async function findStrictReceivePath( // The status is what matters; a body that fails to arrive must not hide it. const { text } = await deadline .read(readCapped(response, MAX_ERROR_BODY_BYTES)) - .catch(() => ({ text: '' })); + .catch((err: unknown) => { + if (err instanceof RPCAbortedError) throw err; + return { text: '' }; + }); throw new RPCRequestError(url, response.status, text.slice(0, 500)); } const declaredLength = Number(response.headers.get('content-length')); @@ -451,6 +469,7 @@ export async function findStrictReceivePath( } body = read.text; } finally { + signal?.removeEventListener('abort', onAbort); deadline.dispose(); } diff --git a/src/chains/stellar/request.ts b/src/chains/stellar/request.ts new file mode 100644 index 0000000..ee3321b --- /dev/null +++ b/src/chains/stellar/request.ts @@ -0,0 +1,94 @@ +import { HorizonResponseError, RPCAbortedError, RPCRequestError } from '../../errors'; +import { AttemptDeadline, resolveTimeouts, type RequestTimeouts } from './timeouts'; + +/** + * Cancellation and timeout options for the one-shot Horizon helpers `findStrictReceivePath()`, + * `buildMultisigStealthWithdraw()` and `submitOfflineStellarTransaction()`. + */ +export interface HorizonCallOptions { + /** + * Cancels the Horizon request. The helper then rejects with `RPCAbortedError`, with the + * signal's `reason` on `cause`. An already aborted signal rejects before anything is sent. + */ + signal?: AbortSignal; + /** + * Connect and request timeouts. A request that runs past either is aborted and the helper + * rejects with `RPCTimeoutError`. Each helper documents its defaults; `0` turns a timeout off. + */ + timeouts?: RequestTimeouts; +} + +/** One request made by {@link requestHorizonJson}. */ +export interface HorizonJsonRequest { + /** Full request URL. */ + url: string; + /** Base URL of the Horizon server, reported on a timeout error. */ + endpoint: string; + /** Fetch options. The signal is set by the helper. */ + init?: RequestInit; + /** Caller's cancellation signal. */ + signal?: AbortSignal; + /** Timeout layers merged over the defaults, later layers winning. */ + timeouts?: Array; +} + +/** Calls the current global `fetch`, so it is looked up per request and called unbound. */ +const globalFetch: typeof fetch = (input, init) => globalThis.fetch(input, init); + +/** + * Sends one Horizon request, without retries, and resolves with the parsed JSON body. + * + * Rejects with `RPCAbortedError` when `signal` aborts, `RPCTimeoutError` when a timeout fires, + * `RPCRequestError` for a non-2xx status (the response body on `responseText`) and + * `HorizonResponseError` when a successful response is not JSON. + */ +export async function requestHorizonJson(request: HorizonJsonRequest): Promise { + const { url, endpoint, init = {}, signal } = request; + const timeouts = resolveTimeouts(...(request.timeouts ?? [])); + const aborted = () => new RPCAbortedError(url, { cause: signal?.reason }); + if (signal?.aborted) throw aborted(); + + const deadline = new AttemptDeadline(timeouts, { url, endpoint, attempt: 1 }); + const onAbort = () => deadline.abort(aborted()); + signal?.addEventListener('abort', onAbort); + try { + const response = await deadline.send(globalFetch, url, init); + if (!response.ok) { + throw new RPCRequestError(url, response.status, await readErrorBody(deadline, response)); + } + if (typeof response.json !== 'function') { + throw new HorizonResponseError(url, 'body', 'missing'); + } + try { + return await deadline.read(response.json()); + } catch (err) { + if (err instanceof SyntaxError) { + throw new HorizonResponseError(url, 'body', 'not valid JSON'); + } + throw err; + } + } finally { + signal?.removeEventListener('abort', onAbort); + deadline.dispose(); + } +} + +/** + * Reads an error response's body for `RPCRequestError.responseText`. JSON is compacted to one + * line. A body that cannot be read leaves the text empty, unless the caller aborted meanwhile. + */ +async function readErrorBody(deadline: AttemptDeadline, response: Response): Promise { + if (typeof response.text !== 'function') return ''; + let text: string; + try { + text = await deadline.read(response.text()); + } catch (err) { + if (err instanceof RPCAbortedError) throw err; + return ''; + } + try { + return JSON.stringify(JSON.parse(text)); + } catch { + return text; + } +} diff --git a/src/chains/stellar/timeouts.ts b/src/chains/stellar/timeouts.ts index 791300d..4ffb090 100644 --- a/src/chains/stellar/timeouts.ts +++ b/src/chains/stellar/timeouts.ts @@ -70,10 +70,10 @@ export class AttemptDeadline { private readonly controller = typeof AbortController === 'function' ? new AbortController() : undefined; private readonly expiry: Promise; - private rejectExpiry!: (error: RPCTimeoutError) => void; + private rejectExpiry!: (error: Error) => void; private connectTimer: ReturnType | undefined; private requestTimer: ReturnType | undefined; - private timeoutError: RPCTimeoutError | undefined; + private failure: Error | undefined; constructor( timeouts: ResolvedTimeouts, @@ -108,6 +108,16 @@ export class AttemptDeadline { return this.race(() => body); } + /** + * Ends the attempt early with `error`, for example when the caller's `AbortSignal` fires. The + * fetch is aborted and a pending {@link AttemptDeadline.send} or {@link AttemptDeadline.read} + * rejects with `error`. Does nothing once the attempt has already failed. + */ + abort(error: Error): void { + if (this.failure) return; + this.fail(error); + } + /** Clears the timers. Safe to call more than once. */ dispose(): void { clearTimeout(this.connectTimer); @@ -121,15 +131,19 @@ export class AttemptDeadline { return await Promise.race([start(), this.expiry]); } catch (err) { // An aborted fetch rejects with whatever its implementation picks, usually a DOMException - // named `AbortError`. Report the timeout that caused the abort instead. - throw this.timeoutError ?? err; + // named `AbortError`. Report the timeout or caller abort that caused it instead. + throw this.failure ?? err; } } private expire(phase: RPCTimeoutPhase, timeoutMs: number): void { + if (this.failure) return; + this.fail(new RPCTimeoutError({ ...this.target, phase, timeoutMs })); + } + + private fail(error: Error): void { this.dispose(); - const error = new RPCTimeoutError({ ...this.target, phase, timeoutMs }); - this.timeoutError = error; + this.failure = error; this.rejectExpiry(error); this.controller?.abort(error); } diff --git a/src/errors.ts b/src/errors.ts index c78640f..b612e75 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -379,6 +379,40 @@ export class RPCTimeoutError extends WraithNetworkError implements RPCTimeoutDet } } +/** + * Thrown when the caller's `AbortSignal` cancels a Horizon request before it completes. The + * signal's `reason` is kept on `cause`, so `error.cause` still tells an explicit + * `controller.abort(reason)` apart from a plain abort. + */ +export class RPCAbortedError extends WraithNetworkError { + readonly code = 'WRAITH/NETWORK/RPC_ABORTED'; + readonly url: string; + + /** + * @param options - `cause` is the abort signal's `reason`. Exposed as `error.cause` and left out + * of `toJSON()`. + */ + constructor(url: string, options?: { cause?: unknown }) { + super(`RPC request to "${url}" was aborted by the caller`, { url }); + this.url = url; + if (options?.cause !== undefined) { + Object.defineProperty(this, 'cause', { + value: options.cause, + writable: true, + configurable: true, + }); + } + } + + describe(): string { + const { url } = this.context ?? {}; + return ( + `The request to "${url}" was cancelled through its AbortSignal before it finished. Try: ` + + `nothing, if the abort was intended; otherwise check what aborted the signal. See ${this.docsLink}.` + ); + } +} + export class RetentionExceededError extends WraithNetworkError { readonly code = 'WRAITH/NETWORK/RETENTION_EXCEEDED'; diff --git a/src/index.ts b/src/index.ts index ab5a440..908bf2d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,6 +66,7 @@ export { HorizonResponseError, RPCRetryExhaustedError, RPCTimeoutError, + RPCAbortedError, RetentionExceededError, NameNotFoundError, NameAlreadyRegisteredError, diff --git a/test/chains/stellar/helper-timeouts.test.ts b/test/chains/stellar/helper-timeouts.test.ts new file mode 100644 index 0000000..b39e69b --- /dev/null +++ b/test/chains/stellar/helper-timeouts.test.ts @@ -0,0 +1,371 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Asset, Keypair, Networks } from '@stellar/stellar-sdk'; +import { findStrictReceivePath } from '../../../src/chains/stellar/path-payment'; +import { buildMultisigStealthWithdraw } from '../../../src/chains/stellar/multisig'; +import { submitOfflineStellarTransaction } from '../../../src/chains/stellar/offline-sign'; +import type { HorizonCallOptions } from '../../../src/chains/stellar/request'; +import { + HorizonResponseError, + RPCAbortedError, + RPCRequestError, + RPCTimeoutError, +} from '../../../src/errors'; + +const HORIZON = 'https://horizon.test'; +const USDC = new Asset('USDC', Keypair.random().publicKey()); +const STEALTH = Keypair.random().publicKey(); +const SIGNER = Keypair.random().publicKey(); + +/** How one scripted fetch call behaves, given the signal the helper passed in. */ +type Behaviour = (signal: AbortSignal | undefined) => Promise; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +const ok = + (body: unknown): Behaviour => + async () => + jsonResponse(body); + +/** Never answers, but rejects when aborted, like a real fetch to a black-holed host. */ +const hang: Behaviour = (signal) => + new Promise((_, reject) => + signal?.addEventListener('abort', () => + reject(signal.reason ?? new DOMException('The operation was aborted.', 'AbortError')), + ), + ); + +/** Never settles and ignores the abort signal, like a broken fetch polyfill. */ +const ignoresAbort: Behaviour = () => new Promise(() => undefined); + +/** Sends 200 headers and part of the body, then stalls until aborted. */ +const stallsMidBody: Behaviour = async (signal) => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"source_amount":')); + signal?.addEventListener('abort', () => controller.error(signal.reason)); + }, + }); + return new Response(stream, { status: 200 }); +}; + +let fetchMock: ReturnType; + +/** Stubs the global fetch with one behaviour per call. */ +function script(...behaviours: Behaviour[]) { + fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + const next = behaviours.shift(); + if (!next) throw new Error('unexpected fetch'); + return next(init?.signal ?? undefined); + }); + vi.stubGlobal('fetch', fetchMock); +} + +function signalOfCall(n = 0): AbortSignal { + return (fetchMock.mock.calls[n][1] as RequestInit).signal as AbortSignal; +} + +/** Records how a promise settled without letting a rejection go unhandled. */ +function track(promise: Promise) { + const state: { settled: boolean; value?: T; error?: unknown } = { settled: false }; + promise.then( + (value) => Object.assign(state, { settled: true, value }), + (error) => Object.assign(state, { settled: true, error }), + ); + return state; +} + +const PATH_PAGE = { + _embedded: { + records: [ + { + source_amount: '50.0000000', + source_asset_type: 'credit_alphanum4', + source_asset_code: 'USDC', + source_asset_issuer: USDC.issuer, + destination_amount: '100.0000000', + destination_asset_type: 'native', + path: [], + }, + ], + }, +}; + +const ACCOUNT = { + sequence: '100', + thresholds: { low_threshold: 1, med_threshold: 2, high_threshold: 2 }, + signers: [ + { key: SIGNER, weight: 2, type: 'ed25519_public_key' }, + { key: STEALTH, weight: 0, type: 'ed25519_public_key' }, + ], +}; + +const SUBMITTED = { hash: 'ab'.repeat(32), ledger: 7, successful: true }; + +interface HelperCase { + name: string; + run: (options: HorizonCallOptions) => Promise; + okBody: unknown; + /** Default headers timeout; 0 means off. */ + connectMs: number; + /** Default whole-request timeout. */ + requestMs: number; +} + +const helpers: HelperCase[] = [ + { + name: 'findStrictReceivePath', + run: (options) => + findStrictReceivePath({ + sendAsset: USDC, + receiveAsset: Asset.native(), + destAmount: '100', + horizonUrl: HORIZON, + ...options, + }), + okBody: PATH_PAGE, + connectMs: 10_000, + requestMs: 30_000, + }, + { + name: 'buildMultisigStealthWithdraw', + run: (options) => + buildMultisigStealthWithdraw({ + stealthAddress: STEALTH, + destination: Keypair.random().publicKey(), + signers: [SIGNER], + networkPassphrase: Networks.TESTNET, + horizonUrl: HORIZON, + ...options, + }), + okBody: ACCOUNT, + connectMs: 10_000, + requestMs: 30_000, + }, + { + name: 'submitOfflineStellarTransaction', + run: (options) => submitOfflineStellarTransaction('AAAAAgAAAAA=', 'stellar', options), + okBody: SUBMITTED, + connectMs: 0, + requestMs: 60_000, + }, +]; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe.each(helpers)('$name', (helper) => { + it('passes an abort signal to fetch and clears its timers once settled', async () => { + script(ok(helper.okBody)); + + await expect(helper.run({})).resolves.toBeDefined(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(signalOfCall()).toBeInstanceOf(AbortSignal); + expect(signalOfCall().aborted).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); + + it('rejects an already aborted signal without sending anything', async () => { + script(); + const controller = new AbortController(); + const reason = new Error('user left the page'); + controller.abort(reason); + + const error = await helper.run({ signal: controller.signal }).catch((err: unknown) => err); + + expect(error).toBeInstanceOf(RPCAbortedError); + expect((error as RPCAbortedError).code).toBe('WRAITH/NETWORK/RPC_ABORTED'); + expect((error as RPCAbortedError).cause).toBe(reason); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('aborts the request in flight when the signal fires', async () => { + script(hang); + const controller = new AbortController(); + const result = track(helper.run({ signal: controller.signal })); + + await vi.advanceTimersByTimeAsync(500); + expect(result.settled).toBe(false); + + controller.abort('cancelled'); + await vi.advanceTimersByTimeAsync(0); + + expect(result.error).toBeInstanceOf(RPCAbortedError); + expect((result.error as RPCAbortedError).cause).toBe('cancelled'); + expect(signalOfCall().aborted).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + + it('times out with the default policy', async () => { + script(hang); + const result = track(helper.run({})); + const firstTimeout = helper.connectMs || helper.requestMs; + + await vi.advanceTimersByTimeAsync(firstTimeout - 1); + expect(result.settled).toBe(false); + expect(signalOfCall().aborted).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + expect(result.error).toBeInstanceOf(RPCTimeoutError); + expect(result.error).toMatchObject({ + attempt: 1, + phase: helper.connectMs ? 'connect' : 'request', + timeoutMs: firstTimeout, + }); + expect(signalOfCall().aborted).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + + it('honours custom timeouts', async () => { + script(hang); + const result = track(helper.run({ timeouts: { connectMs: 250, requestMs: 1_000 } })); + + await vi.advanceTimersByTimeAsync(249); + expect(result.settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + + expect(result.error).toBeInstanceOf(RPCTimeoutError); + expect(result.error).toMatchObject({ phase: 'connect', timeoutMs: 250 }); + }); + + it('times out a fetch that ignores the abort signal', async () => { + script(ignoresAbort); + const result = track(helper.run({ timeouts: { connectMs: 0, requestMs: 500 } })); + + await vi.advanceTimersByTimeAsync(500); + + expect(result.error).toBeInstanceOf(RPCTimeoutError); + expect(result.error).toMatchObject({ phase: 'request', timeoutMs: 500 }); + expect(vi.getTimerCount()).toBe(0); + }); + + it('times out a body that stalls after the headers', async () => { + script(stallsMidBody); + const result = track(helper.run({ timeouts: { connectMs: 0, requestMs: 700 } })); + + await vi.advanceTimersByTimeAsync(699); + expect(result.settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + + expect(result.error).toBeInstanceOf(RPCTimeoutError); + expect(result.error).toMatchObject({ phase: 'request', timeoutMs: 700 }); + }); + + it('maps an HTTP error to RPCRequestError with the response body', async () => { + script(async () => jsonResponse({ title: 'Service Unavailable', status: 503 }, 503)); + + const error = await helper.run({}).catch((err: unknown) => err); + + expect(error).toBeInstanceOf(RPCRequestError); + expect((error as RPCRequestError).statusCode).toBe(503); + expect(JSON.parse((error as RPCRequestError).context?.responseText)).toEqual({ + title: 'Service Unavailable', + status: 503, + }); + expect(vi.getTimerCount()).toBe(0); + }); + + it('rejects a success response that is not JSON', async () => { + script(async () => new Response('gateway', { status: 200 })); + + const error = await helper.run({}).catch((err: unknown) => err); + + expect(error).toBeInstanceOf(HorizonResponseError); + expect((error as HorizonResponseError).code).toBe('WRAITH/NETWORK/HORIZON_RESPONSE'); + expect((error as HorizonResponseError).field).toBe('body'); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe('buildMultisigStealthWithdraw account lookup', () => { + const build = () => + buildMultisigStealthWithdraw({ + stealthAddress: STEALTH, + destination: Keypair.random().publicKey(), + signers: [SIGNER], + networkPassphrase: Networks.TESTNET, + horizonUrl: HORIZON, + }); + + it.each([ + ['no sequence', { ...ACCOUNT, sequence: 100 }, 'sequence'], + ['no thresholds', { ...ACCOUNT, thresholds: undefined }, 'thresholds'], + ['no signers', { ...ACCOUNT, signers: 'none' }, 'signers'], + ['a signer without a weight', { ...ACCOUNT, signers: [{ key: SIGNER }] }, 'signers'], + ])('rejects an account record with %s as malformed', async (_label, body, field) => { + script(ok(body)); + + const error = await build().catch((err: unknown) => err); + + expect(error).toBeInstanceOf(HorizonResponseError); + expect((error as HorizonResponseError).field).toBe(field); + expect((error as HorizonResponseError).context?.url).toBe(`${HORIZON}/accounts/${STEALTH}`); + }); + + it('makes no request when the account record is supplied', async () => { + script(); + + await expect( + buildMultisigStealthWithdraw({ + stealthAddress: STEALTH, + destination: Keypair.random().publicKey(), + signers: [SIGNER], + networkPassphrase: Networks.TESTNET, + account: ACCOUNT, + timeouts: { requestMs: 1 }, + }), + ).resolves.toBeDefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('submitOfflineStellarTransaction responses', () => { + it('keeps the Horizon result codes on the RPCRequestError', async () => { + const problem = { + type: 'https://stellar.org/horizon-errors/transaction_failed', + title: 'Transaction Failed', + status: 400, + extras: { result_codes: { transaction: 'tx_bad_seq' } }, + }; + script(async () => jsonResponse(problem, 400)); + + const error = await submitOfflineStellarTransaction('AAAAAgAAAAA=').catch( + (err: unknown) => err, + ); + + expect(error).toBeInstanceOf(RPCRequestError); + expect((error as RPCRequestError).context?.url).toMatch(/\/transactions$/); + expect(JSON.parse((error as RPCRequestError).context?.responseText)).toEqual(problem); + }); + + it('sends the transaction as a form body', async () => { + script(ok(SUBMITTED)); + + await submitOfflineStellarTransaction('AAAAAgAAAAA='); + + const init = fetchMock.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('POST'); + expect(String(init.body)).toBe('tx=AAAAAgAAAAA%3D'); + }); + + it('rejects a JSON body that is not an object', async () => { + script(ok(['not', 'an', 'object'])); + + const error = await submitOfflineStellarTransaction('AAAAAgAAAAA=').catch( + (err: unknown) => err, + ); + + expect(error).toBeInstanceOf(HorizonResponseError); + expect((error as HorizonResponseError).field).toBe('body'); + }); +}); diff --git a/test/errors.test.ts b/test/errors.test.ts index f4bcf3b..344410b 100644 --- a/test/errors.test.ts +++ b/test/errors.test.ts @@ -17,6 +17,7 @@ import { HorizonResponseError, RPCRetryExhaustedError, RPCTimeoutError, + RPCAbortedError, RetentionExceededError, NameNotFoundError, NameAlreadyRegisteredError, @@ -206,6 +207,27 @@ describe('Wraith Custom Errors Taxonomy', () => { expect(error.toJSON().context).toEqual(error.context); }); + test('RPCAbortedError instanceof checks', () => { + const reason = new Error('component unmounted'); + const error = new RPCAbortedError('https://horizon.stellar.org/transactions', { + cause: reason, + }); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(WraithError); + expect(error).toBeInstanceOf(WraithNetworkError); + expect(error).toBeInstanceOf(RPCAbortedError); + expect(error.code).toBe('WRAITH/NETWORK/RPC_ABORTED'); + expect(error.name).toBe('RPCAbortedError'); + expect(error.url).toBe('https://horizon.stellar.org/transactions'); + expect(error.context).toEqual({ url: 'https://horizon.stellar.org/transactions' }); + expect(error.docsLink).toBe('https://docs.wraith.dev/sdk/errors#rpcaborted'); + expect(error.message).toContain('was aborted by the caller'); + expect(error.cause).toBe(reason); + expect(Object.keys(error)).not.toContain('cause'); + expect(JSON.parse(JSON.stringify(error))).not.toHaveProperty('cause'); + expect('cause' in new RPCAbortedError('https://horizon.stellar.org')).toBe(false); + }); + test('RetentionExceededError instanceof checks', () => { const error = new RetentionExceededError(100, 105); expect(error).toBeInstanceOf(Error); @@ -329,6 +351,7 @@ describe('Wraith Custom Errors Taxonomy', () => { timeoutMs: 10_000, }), ], + ['RPCAbortedError', new RPCAbortedError('https://horizon.stellar.org/transactions')], ['RetentionExceededError', new RetentionExceededError(100, 105)], ['NameNotFoundError', new NameNotFoundError('missing.wraith')], [ From 207d605d833ce44402ef83827aa50a23b005c942 Mon Sep 17 00:00:00 2001 From: Arminas Attas Date: Fri, 16 Oct 2026 17:29:58 +0000 Subject: [PATCH 2/4] docs(stellar): document helper cancellation, timeouts and the abort error Refs #235 --- CHANGELOG.md | 4 +++ docs/chains/stellar-request-timeouts.md | 48 +++++++++++++++++++++++++ docs/errors.md | 2 ++ docs/offline-signing.md | 4 ++- docs/stellar-path-payment.md | 31 ++++++++-------- 5 files changed, 74 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 478b4c0..aefd68f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to the Wraith Protocol SDK will be documented in this file. ### Added +- **Cancellation and Timeouts for the Remaining Stellar Network Helpers** (issue #235): `findStrictReceivePath()`, `buildMultisigStealthWithdraw()` and `submitOfflineStellarTransaction()` accept `signal` and `timeouts`, like the Horizon and RPC clients. + - Defaults: 10 s for the headers and 30 s in total for path finding and the account lookup; no headers timeout and 60 s in total for transaction submission, since Horizon answers a submission only once the transaction is in a ledger. + - New `RPCAbortedError` (`WRAITH/NETWORK/RPC_ABORTED`), exported from the package root, with the signal's `reason` on `cause`. A non-2xx status is an `RPCRequestError`, a timeout an `RPCTimeoutError` and an invalid response a `HorizonResponseError`. See [`docs/chains/stellar-request-timeouts.md`](./docs/chains/stellar-request-timeouts.md). - **Supported Runtime and Peer Dependency Matrix** (issue #209): `compat/matrix.json` is now the single source of truth for the runtimes the SDK supports — Node.js, Bun, evergreen browsers and React Native — and for the dependency ranges it accepts (`@stellar/stellar-sdk`, `@solana/web3.js`, `viem`). - [`COMPAT.md`](./COMPAT.md) is generated from that file and documents the unsupported combinations alongside the exact failure message each one produces. - `pnpm test:compat` validates the matrix against `package.json` and `COMPAT.md`, imports every entry point on the running runtime, repeats that against a simulated React Native global scope, bundles every entry point for `platform: browser`, imports every entry point from an install with the optional peers removed, and asserts the npm tarball contains every file the `exports` map points at. @@ -50,6 +53,7 @@ All notable changes to the Wraith Protocol SDK will be documented in this file. ### Changed +- **Stellar Helper Errors Use the Taxonomy** (issue #235): for a non-2xx Horizon status, `buildMultisigStealthWithdraw()` throws `RPCRequestError` instead of a plain `Error`, and an account record without its sequence, thresholds or signers throws `HorizonResponseError`. `submitOfflineStellarTransaction()` reports the full `/transactions` URL on `RPCRequestError`, throws `HorizonResponseError` for a success body that is not a JSON object, and gives up after 60 s by default; pass `{ timeouts: { requestMs: 0 } }` for the old behaviour. - **Stellar Horizon and RPC Clients Time Out by Default** (issue #202): an attempt now fails after 10 s without response headers or 30 s in total, then retries or fails over, instead of waiting indefinitely. Pass `timeouts: { connectMs: 0, requestMs: 0 }` for the old behaviour, or longer values for slow calls such as Horizon transaction submission. - **Stellar Chain Module Cryptographic Audit Fixes**: Applied all findings from independent cryptographic audit (issue #55). Breaking changes: - `scanAnnouncements()` now skips candidates with zero derived scalars (cryptographically required, probability ~1 in 2^255). diff --git a/docs/chains/stellar-request-timeouts.md b/docs/chains/stellar-request-timeouts.md index 1998c30..09a7977 100644 --- a/docs/chains/stellar-request-timeouts.md +++ b/docs/chains/stellar-request-timeouts.md @@ -74,3 +74,51 @@ try { ``` `cause` is not enumerable and is left out of `toJSON()`, the same as on the wallet errors. + +## One-shot Horizon helpers + +`findStrictReceivePath()`, `buildMultisigStealthWithdraw()` (when it loads the account from +`horizonUrl`) and `submitOfflineStellarTransaction()` each make one Horizon request, with no +retries. They take the same `timeouts` shape plus an `AbortSignal`. + +| Helper | Options | Default `timeouts` | +| :------------------------------------------------------- | :----------------------------------- | :--------------------------------------- | +| `findStrictReceivePath(options)` | `options.signal`, `options.timeouts` | `{ connectMs: 10000, requestMs: 30000 }` | +| `buildMultisigStealthWithdraw(options)` | `options.signal`, `options.timeouts` | `{ connectMs: 10000, requestMs: 30000 }` | +| `submitOfflineStellarTransaction(xdr, network, options)` | `options.signal`, `options.timeouts` | `{ connectMs: 0, requestMs: 60000 }` | + +Horizon answers a submission only once the transaction is in a ledger or its own wait runs out, +so a submission has no separate headers timeout by default. + +```ts +import { + findStrictReceivePath, + submitOfflineStellarTransaction, +} from '@wraith-protocol/sdk/chains/stellar'; + +const controller = new AbortController(); + +const { sourceAmount, path } = await findStrictReceivePath({ + sendAsset, + receiveAsset, + destAmount: '100', + signal: controller.signal, + timeouts: { requestMs: 5_000 }, +}); + +const result = await submitOfflineStellarTransaction(signedXdr, 'stellar', { + signal: controller.signal, +}); +``` + +Every failure maps onto the error taxonomy: + +| Failure | Error | +| :--------------------------------------------------------------- | :------------------------------------------------------------------------------------------- | +| The signal is aborted, before or during the request | `RPCAbortedError` (`WRAITH/NETWORK/RPC_ABORTED`), with the signal's `reason` on `cause` | +| A timeout fires | `RPCTimeoutError` (`WRAITH/NETWORK/RPC_TIMEOUT`), with `attempt: 1` | +| Horizon answers with a non-2xx status | `RPCRequestError` (`WRAITH/NETWORK/RPC_REQUEST`), with the body on `context.responseText` | +| A success response is not JSON, is oversized or fails validation | `HorizonResponseError` (`WRAITH/NETWORK/HORIZON_RESPONSE`), with the failing part on `field` | + +A submission that times out or is aborted may still be applied. Look the transaction up by its +hash before submitting it again. diff --git a/docs/errors.md b/docs/errors.md index 0cfcd7e..61d6ce3 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -26,6 +26,7 @@ WraithError (Abstract Base) │ ├── HorizonResponseError │ ├── RPCRetryExhaustedError │ ├── RPCTimeoutError +│ ├── RPCAbortedError │ └── RetentionExceededError ├── WraithContractError │ ├── NameNotFoundError @@ -85,6 +86,7 @@ Thrown when HTTP queries to Wraith APIs, Horizon/Soroban endpoints, Solana clust | `HorizonResponseError` | `"WRAITH/NETWORK/HORIZON_RESPONSE"` | `url`, `field`, `reason` | Thrown when Horizon answers 2xx but the body is oversized, malformed, or does not match the request. | | `RPCRetryExhaustedError` | `"WRAITH/NETWORK/RPC_RETRY_EXHAUSTED"` | `url`, `attempts`, `lastError` | Thrown when all query retry strategies have timed out or failed. | | `RPCTimeoutError` | `"WRAITH/NETWORK/RPC_TIMEOUT"` | `url`, `endpoint`, `attempt`, `phase`, `timeoutMs` | Thrown when a Horizon/Soroban attempt exceeds its connect or request timeout. | +| `RPCAbortedError` | `"WRAITH/NETWORK/RPC_ABORTED"` | `url` | Thrown when the caller's `AbortSignal` cancels a Horizon request; the signal's reason is on `cause`. | | `RetentionExceededError` | `"WRAITH/NETWORK/RETENTION_EXCEEDED"` | `limit`, `actual` | Thrown when querying historical logs beyond maximum retention boundaries. | When the Horizon or Soroban RPC client gives up, `RPCRetryExhaustedError.cause` holds the last attempt's error, such as an `RPCTimeoutError`. See [Stellar Horizon and RPC request timeouts](./chains/stellar-request-timeouts.md). diff --git a/docs/offline-signing.md b/docs/offline-signing.md index d88b0d3..18b27ad 100644 --- a/docs/offline-signing.md +++ b/docs/offline-signing.md @@ -94,7 +94,9 @@ console.log('Transaction hash:', result.hash); console.log('Ledger:', result.ledger); ``` -The function resolves the Horizon URL from the chain deployment config (`getDeployment('stellar')`) and POSTs the XDR to `/transactions`. On failure it throws an `RPCRequestError` with the Horizon response body. +The function resolves the Horizon URL from the chain deployment config (`getDeployment('stellar')`) and POSTs the XDR to `/transactions`. On failure it throws an `RPCRequestError` with the Horizon response body on `context.responseText`. + +A third argument takes an `AbortSignal` and timeouts: `submitOfflineStellarTransaction(signedXdr, 'stellar', { signal, timeouts })`. By default there is no headers timeout and the request gives up after 60 s, because Horizon answers only once the transaction is in a ledger. An aborted or timed-out submission rejects with `RPCAbortedError` or `RPCTimeoutError` and may still be applied, so look the transaction up by its hash before submitting it again. ## Stealth Address Compatibility diff --git a/docs/stellar-path-payment.md b/docs/stellar-path-payment.md index c4ad44f..a1a4a9e 100644 --- a/docs/stellar-path-payment.md +++ b/docs/stellar-path-payment.md @@ -73,13 +73,15 @@ const { sourceAmount, path } = await findStrictReceivePath(options); #### Parameters -| Field | Type | Required | Description | -| -------------- | -------- | -------- | -------------------------------------------------- | -| `sendAsset` | `Asset` | ✅ | Asset the sender is spending | -| `receiveAsset` | `Asset` | ✅ | Asset the stealth address should receive | -| `destAmount` | `string` | ✅ | Exact amount of `receiveAsset` desired | -| `horizonUrl` | `string` | ❌ | Custom Horizon URL (default: deployment's Horizon) | -| `chain` | `string` | ❌ | Chain deployment key (default: `"stellar"`) | +| Field | Type | Required | Description | +| -------------- | ----------------- | -------- | ------------------------------------------------------------------------------------------------ | +| `sendAsset` | `Asset` | ✅ | Asset the sender is spending | +| `receiveAsset` | `Asset` | ✅ | Asset the stealth address should receive | +| `destAmount` | `string` | ✅ | Exact amount of `receiveAsset` desired | +| `horizonUrl` | `string` | ❌ | Custom Horizon URL (default: deployment's Horizon) | +| `chain` | `string` | ❌ | Chain deployment key (default: `"stellar"`) | +| `signal` | `AbortSignal` | ❌ | Cancels the Horizon request; the call then rejects with `RPCAbortedError` | +| `timeouts` | `RequestTimeouts` | ❌ | Connect and request timeouts (default: the Horizon client's 10 s for the headers, 30 s in total) | #### Return value @@ -103,7 +105,7 @@ Horizon returns every path in one `_embedded.records` page; this endpoint is not The record with the smallest `source_amount` is returned. If any record fails a check, the whole response is rejected with `HorizonResponseError`. -`destAmount` must itself be a positive amount with at most 7 decimals; anything else throws before Horizon is called. The request uses the same timeouts as the SDK's Horizon client (10 s for the response headers, 30 s in total) and throws `RPCTimeoutError` when one fires. +`destAmount` must itself be a positive amount with at most 7 decimals; anything else throws before Horizon is called. By default the request uses the same timeouts as the SDK's Horizon client (10 s for the response headers, 30 s in total); `timeouts` overrides either one, and `RPCTimeoutError` is thrown when one fires. Aborting `signal`, before or during the request, rejects with `RPCAbortedError`, with the signal's `reason` on `cause`. --- @@ -284,12 +286,13 @@ The recipient can claim the balance once they detect the announcement and derive `findStrictReceivePath` throws: -| Error | When | -| ---------------------- | -------------------------------------------------------------------------------------------------------------- | -| `RPCRequestError` | Horizon answers with a non-2xx status (`statusCode` and the start of the body are on the error). | -| `HorizonResponseError` | The response is too large, is not JSON, or a record fails validation. `error.field` names the rejected value. | -| `RPCTimeoutError` | Horizon sends no headers within 10 s, or the whole request takes longer than 30 s. | -| `Error` | Horizon returns no path for the asset pair and amount, or `destAmount` is invalid (thrown before the request). | +| Error | When | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `RPCRequestError` | Horizon answers with a non-2xx status (`statusCode` and the start of the body are on the error). | +| `HorizonResponseError` | The response is too large, is not JSON, or a record fails validation. `error.field` names the rejected value. | +| `RPCTimeoutError` | Horizon sends no headers within 10 s, or the whole request takes longer than 30 s (or the limits in `timeouts`). | +| `RPCAbortedError` | `signal` aborts before or during the request. The signal's `reason` is on `cause`. | +| `Error` | Horizon returns no path for the asset pair and amount, or `destAmount` is invalid (thrown before the request). | ### No payment path found From a1ad21ce89df924a17127225c3110ea3db9ac0c8 Mon Sep 17 00:00:00 2001 From: Arminas Attas Date: Fri, 16 Oct 2026 17:29:58 +0000 Subject: [PATCH 3/4] chore(api): update API reports for helper cancellation options --- etc/sdk-stellar.api.md | 12 +++++++++--- etc/sdk.api.md | 13 +++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/etc/sdk-stellar.api.md b/etc/sdk-stellar.api.md index 1ffe406..dcf672a 100644 --- a/etc/sdk-stellar.api.md +++ b/etc/sdk-stellar.api.md @@ -167,8 +167,8 @@ export interface BuildBatchSendTxResult { // @public export function buildMultisigStealthWithdraw(options: BuildMultisigStealthWithdrawOptions): Promise; -// @public (undocumented) -export interface BuildMultisigStealthWithdrawOptions { +// @public +export interface BuildMultisigStealthWithdrawOptions extends HorizonCallOptions { account?: Pick; destination: string; fee?: string; @@ -382,7 +382,7 @@ export function fetchAnnouncementsStream(chain?: string, sorobanUrlOrOpts?: stri export function findStrictReceivePath(options: FindStrictReceivePathOptions): Promise; // @public -export interface FindStrictReceivePathOptions { +export interface FindStrictReceivePathOptions extends HorizonCallOptions { chain?: string; destAmount: string; horizonUrl?: string; @@ -450,6 +450,12 @@ export type HexString = `0x${string}`; // @public export function hexToBytes(hex: string): Uint8Array; +// @public +export interface HorizonCallOptions { + signal?: AbortSignal; + timeouts?: RequestTimeouts; +} + // @public (undocumented) export interface HorizonClient { get(path: string, overrides?: HorizonRequestOptions): Promise; diff --git a/etc/sdk.api.md b/etc/sdk.api.md index 7bcabef..a6259d0 100644 --- a/etc/sdk.api.md +++ b/etc/sdk.api.md @@ -316,6 +316,19 @@ export class RetentionExceededError extends WraithNetworkError { describe(): string; } +// @public +export class RPCAbortedError extends WraithNetworkError { + constructor(url: string, options?: { + cause?: unknown; + }); + // (undocumented) + readonly code = "WRAITH/NETWORK/RPC_ABORTED"; + // (undocumented) + describe(): string; + // (undocumented) + readonly url: string; +} + // @public (undocumented) export class RPCRequestError extends WraithNetworkError { constructor(url: string, statusCode: number, responseText?: string); From 3b83f7ef3a491d5937caa9ba2a7a0efd8a111e20 Mon Sep 17 00:00:00 2001 From: Arminas Attas Date: Fri, 16 Oct 2026 17:29:58 +0000 Subject: [PATCH 4/4] chore(size): raise the Stellar ESM bundle budget to 31 KB The cancellation and timeout support in the one-shot Horizon helpers brings the Stellar ESM entry to 30.68 KB; develop measured 30.22 KB of the 30.5 KB budget. Refs #235 --- BUNDLE_SIZE.md | 6 +++++- package.json | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/BUNDLE_SIZE.md b/BUNDLE_SIZE.md index cd7b5c3..4e1feb8 100644 --- a/BUNDLE_SIZE.md +++ b/BUNDLE_SIZE.md @@ -21,7 +21,7 @@ Every budget is the measured baseline **+ 15% headroom**, rounded up. | `./chains/ckb` | CJS (`require`) | 128.76 KB | 148.1 KB | | `./vault` | ESM (`import *`) | 1.93 KB | 2.3 KB | | `./vault` | CJS (`require`) | 2.07 KB | 2.4 KB | -| `./chains/stellar` | ESM (`import *`) | 26.48 KB | 30.5 KB | +| `./chains/stellar` | ESM (`import *`) | 26.48 KB | 31 KB | | `./chains/stellar` | CJS (`require`) | 34.29 KB | 39.5 KB | > Stellar note: the original 20 KB-per-format budget predated any actual @@ -29,6 +29,10 @@ Every budget is the measured baseline **+ 15% headroom**, rounded up. > bundle exceeds 20 KB, so its budget was re-baselined on 2026-08-29 using > the same baseline + 15% rule as the other entries. Entry paths and options > are unchanged. +> +> On 2026-10-16 the Stellar ESM budget was raised from 30.5 KB to 31 KB for the +> cancellation and timeout support in the one-shot Horizon helpers (issue #235), +> which brought that entry to 30.68 KB. ## Checking Sizes (CI gate) diff --git a/package.json b/package.json index 51b9a50..86308ea 100644 --- a/package.json +++ b/package.json @@ -130,7 +130,7 @@ "name": "Stellar ESM (import *)", "path": "dist/chains/stellar/index.js", "import": "*", - "limit": "30.5 KB" + "limit": "31 KB" }, { "name": "Stellar CJS (require)",