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/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 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); 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)", 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')], [