diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 8a74a86..7b2a46d 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -61,6 +61,8 @@ import { } from "./errors.js"; import { normalizeRoundId, normalizeSorobanContractId } from "./ids.js"; import { + assertRpcTimeoutMs, + DEFAULT_RPC_TIMEOUT_MS, validateContractNetwork, validatePasskeySession, type PasskeySessionBinding, @@ -93,6 +95,11 @@ export interface SubRosaClientConfig { publicKey?: string; /** Allow http RPC URLs (e.g. a local quickstart node). Default: false. */ allowHttp?: boolean; + /** + * Per-request timeout (ms) for external RPC calls. + * Defaults to `DEFAULT_RPC_TIMEOUT_MS` (10_000). + */ + rpcTimeoutMs?: number; /** Optional external submitter. Direct Soroban RPC remains the default. */ submitter?: TransactionSubmitter; /** @@ -197,6 +204,7 @@ export class SubRosaClient { readonly contract: RoundContract; readonly contractId: string; readonly networkPassphrase: string; + readonly rpcTimeoutMs: number; readonly #source?: string; readonly #rpcUrl: string; readonly #allowHttp: boolean; @@ -233,6 +241,11 @@ export class SubRosaClient { ); } + this.rpcTimeoutMs = assertRpcTimeoutMs( + config.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS, + "rpcTimeoutMs", + ); + const keypair = config.secretKey ? Keypair.fromSecret(config.secretKey) : undefined; diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index 3b44f34..b7fb374 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -4,6 +4,152 @@ import type { EscrowConservationReport, } from "./conservation.js"; +export type RoundContractErrorCode = + | "NotInitialized" + | "AlreadyInitialized" + | "RoundNotFound" + | "BidNotFound" + | "CommitClosed" + | "CommitNotClosed" + | "CommitDeadlineAfterReveal" + | "RevealNotOpen" + | "RevealAlreadyOpen" + | "RevealWindowClosed" + | "RevealStillOpen" + | "NotCleared" + | "AlreadyCleared" + | "AlreadySettled" + | "RoundVoided" + | "NotVoidable" + | "WrongStatus" + | "InvalidDrandSignature" + | "HashMismatch" + | "AlreadyRevealed" + | "PayloadTooLarge" + | "InvalidAmount" + | "BidExceedsEscrow" + | "DeadlineInPast" + | "NoValidBids" + | "RoundFull" + | "InvalidLimit" + | "EscrowNotConserved"; + +export interface RoundContractErrorSpec { + readonly code: number; + readonly name: RoundContractErrorCode; + readonly retryable: boolean; +} + +export interface ContractErrorEntry { + readonly code: number; + readonly name: string; +} + +/** + * Complete, single-source mapping of Soroban round contract errors to stable SDK codes and retryable flags. + * Derived from contracts/round/src/error_paths.rs, contracts/round/src/types.rs, and ERRORS.md. + */ +export const ROUND_CONTRACT_ERRORS: Readonly> = Object.freeze({ + 1: { code: 1, name: "NotInitialized", retryable: false }, + 2: { code: 2, name: "AlreadyInitialized", retryable: false }, + 3: { code: 3, name: "RoundNotFound", retryable: false }, + 4: { code: 4, name: "BidNotFound", retryable: false }, + 10: { code: 10, name: "CommitClosed", retryable: false }, + 11: { code: 11, name: "CommitNotClosed", retryable: true }, + 12: { code: 12, name: "CommitDeadlineAfterReveal", retryable: false }, + 13: { code: 13, name: "RevealNotOpen", retryable: true }, + 14: { code: 14, name: "RevealAlreadyOpen", retryable: false }, + 15: { code: 15, name: "RevealWindowClosed", retryable: false }, + 16: { code: 16, name: "RevealStillOpen", retryable: true }, + 17: { code: 17, name: "NotCleared", retryable: true }, + 18: { code: 18, name: "AlreadyCleared", retryable: false }, + 19: { code: 19, name: "AlreadySettled", retryable: false }, + 20: { code: 20, name: "RoundVoided", retryable: false }, + 21: { code: 21, name: "NotVoidable", retryable: true }, + 22: { code: 22, name: "WrongStatus", retryable: false }, + 30: { code: 30, name: "InvalidDrandSignature", retryable: false }, + 31: { code: 31, name: "HashMismatch", retryable: false }, + 32: { code: 32, name: "AlreadyRevealed", retryable: false }, + 33: { code: 33, name: "PayloadTooLarge", retryable: false }, + 34: { code: 34, name: "InvalidAmount", retryable: false }, + 35: { code: 35, name: "BidExceedsEscrow", retryable: false }, + 36: { code: 36, name: "DeadlineInPast", retryable: false }, + 37: { code: 37, name: "NoValidBids", retryable: false }, + 38: { code: 38, name: "RoundFull", retryable: false }, + 39: { code: 39, name: "InvalidLimit", retryable: false }, + 40: { code: 40, name: "EscrowNotConserved", retryable: false }, +}); + +export const ROUND_CONTRACT_ERRORS_BY_NAME: Readonly> = Object.freeze( + Object.fromEntries( + Object.values(ROUND_CONTRACT_ERRORS).map((spec) => [spec.name, spec]), + ), +); + +/** + * Look up a contract error specification by numeric code or variant name. + */ +export function getRoundContractError( + codeOrName: number | string | undefined | null, +): RoundContractErrorSpec | undefined { + if (codeOrName === undefined || codeOrName === null) return undefined; + if (typeof codeOrName === "number") { + return ROUND_CONTRACT_ERRORS[codeOrName]; + } + const numeric = Number(codeOrName); + if (!Number.isNaN(numeric) && Object.prototype.hasOwnProperty.call(ROUND_CONTRACT_ERRORS, numeric)) { + return ROUND_CONTRACT_ERRORS[numeric]; + } + return ROUND_CONTRACT_ERRORS_BY_NAME[codeOrName]; +} + +/** + * Determine whether a contract error (by numeric code or variant name) is retryable. + * Unknown / unmapped errors always evaluate to false (non-retryable). + */ +export function isRoundContractErrorRetryable( + codeOrName: number | string | undefined | null, +): boolean { + const spec = getRoundContractError(codeOrName); + return spec !== undefined ? spec.retryable : false; +} + +/** + * Compare an external list of contract error items (e.g. from error_paths.rs, types.rs, or ERRORS.md) + * against the SDK mapping. Returns a list of drift failure messages, or an empty array when in sync. + */ +export function diffContractErrorMapping( + contractErrors: readonly ContractErrorEntry[], +): string[] { + const sdkByCode = new Map(Object.values(ROUND_CONTRACT_ERRORS).map((e) => [e.code, e])); + const inputByCode = new Map(contractErrors.map((e) => [e.code, e])); + + const failures: string[] = []; + + for (const item of contractErrors) { + const mapped = sdkByCode.get(item.code); + if (!mapped) { + failures.push( + `Contract error ${item.name} (#${item.code}) is not mapped in SDK ROUND_CONTRACT_ERRORS`, + ); + } else if (mapped.name !== item.name) { + failures.push( + `Contract error code #${item.code} maps to '${mapped.name}' in SDK but '${item.name}' in contract source`, + ); + } + } + + for (const mapped of Object.values(ROUND_CONTRACT_ERRORS)) { + if (!inputByCode.has(mapped.code)) { + failures.push( + `SDK maps error ${mapped.name} (#${mapped.code}) which is missing from contract errors list`, + ); + } + } + + return failures; +} + export class SubRosaClientConfigError extends Error { readonly name = "SubRosaClientConfigError"; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 0d7d15c..6eadc01 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -71,8 +71,19 @@ export { type ProveEscrowConservationOptions, } from "./conservation.js"; export { + assertRpcTimeoutMs, + createBoundedFetch, + DEFAULT_RPC_TIMEOUT_MS, + describeNetworkPassphraseMismatch, + networkDisplayName, + networkPassphrasesMatch, + SubRosaNetworkPassphraseMismatchError, + SubRosaRpcRequestTimeoutError, validateContractNetwork, validatePasskeySession, + withBoundedTimeout, + type BoundedFetchOptions, + type BoundedTimeoutOptions, type ContractNetworkValidationConfig, type NetworkValidationServer, type PasskeySessionBinding, diff --git a/packages/sdk/src/network.test.ts b/packages/sdk/src/network.test.ts index 1edb817..36fa7b5 100644 --- a/packages/sdk/src/network.test.ts +++ b/packages/sdk/src/network.test.ts @@ -1,13 +1,26 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { StrKey } from "@stellar/stellar-sdk"; +import { createFakeTime } from "@sub-rosa/time"; import { + SubRosaClientConfigError, SubRosaNetworkMismatchError, SubRosaSessionMismatchError, } from "./errors.js"; +import { SubRosaClient } from "./client.js"; +import { KeeperStatusClient } from "./status-client.js"; import { + assertRpcTimeoutMs, + createBoundedFetch, + DEFAULT_RPC_TIMEOUT_MS, + describeNetworkPassphraseMismatch, + networkDisplayName, + networkPassphrasesMatch, + SubRosaNetworkPassphraseMismatchError, + SubRosaRpcRequestTimeoutError, validateContractNetwork, validatePasskeySession, + withBoundedTimeout, type NetworkValidationServer, } from "./network.js"; @@ -245,3 +258,70 @@ describe("validatePasskeySession", () => { }); }); +describe("DEFAULT_RPC_TIMEOUT_MS and bounded RPC requests", () => { + it("uses the shared 10_000ms default in SubRosaClient and KeeperStatusClient", () => { + assert.equal(DEFAULT_RPC_TIMEOUT_MS, 10_000); + assert.equal(assertRpcTimeoutMs(undefined), 10_000); + + const client = new SubRosaClient({ + rpcUrl: RPC_URL, + networkPassphrase: TESTNET, + contractId: CONTRACT_ID, + }); + assert.equal(client.rpcTimeoutMs, DEFAULT_RPC_TIMEOUT_MS); + + const statusClient = new KeeperStatusClient({ + baseURL: "https://keeper.example.test", + fetchImpl: (async () => new Response("{}")) as typeof fetch, + }); + assert.equal(statusClient.timeoutMs, DEFAULT_RPC_TIMEOUT_MS); + + assert.throws( + () => + new SubRosaClient({ + rpcUrl: RPC_URL, + networkPassphrase: TESTNET, + contractId: CONTRACT_ID, + rpcTimeoutMs: 0, + }), + SubRosaClientConfigError, + ); + }); + + it("aborts and rejects stalled requests after DEFAULT_RPC_TIMEOUT_MS", async () => { + const { scheduler } = createFakeTime(); + let aborted = false; + + const boundedFetch = createBoundedFetch({ + scheduler, + fetchImpl: ((_input, init) => { + init?.signal?.addEventListener("abort", () => { + aborted = true; + }); + return new Promise(() => {}); + }) as typeof fetch, + }); + + const pending = boundedFetch("https://rpc.example.test"); + scheduler.advance(DEFAULT_RPC_TIMEOUT_MS - 1); + assert.equal(aborted, false); + + scheduler.advance(1); + await assert.rejects(pending, (err: unknown) => { + assert.ok(err instanceof SubRosaRpcRequestTimeoutError); + assert.equal(err.timeoutMs, DEFAULT_RPC_TIMEOUT_MS); + assert.match(err.message, /timed out after 10000ms/); + return true; + }); + assert.equal(aborted, true); + }); + + it("clears the timeout timer once a bounded operation resolves", async () => { + const { scheduler } = createFakeTime(); + const value = await withBoundedTimeout(async () => "ok", { scheduler }); + assert.equal(value, "ok"); + assert.equal(scheduler.pendingCount(), 0); + }); +}); + + diff --git a/packages/sdk/src/network.ts b/packages/sdk/src/network.ts index 2cbdefd..e61968a 100644 --- a/packages/sdk/src/network.ts +++ b/packages/sdk/src/network.ts @@ -1,10 +1,123 @@ import { Contract, rpc } from "@stellar/stellar-sdk"; +import { systemScheduler, type Scheduler } from "@sub-rosa/time"; import { + SubRosaClientConfigError, SubRosaNetworkMismatchError, SubRosaSessionMismatchError, } from "./errors.js"; import { normalizeSorobanContractId } from "./ids.js"; +/** Shared default timeout (ms) for external RPC, keeper, appraisal, and Drand requests. */ +export const DEFAULT_RPC_TIMEOUT_MS = 10_000; + +/** Raised when an external RPC or HTTP request exceeds its configured timeout. */ +export class SubRosaRpcRequestTimeoutError extends Error { + readonly name = "SubRosaRpcRequestTimeoutError"; + readonly timeoutMs: number; + + constructor(timeoutMs: number, label = "RPC request") { + super(`${label} timed out after ${timeoutMs}ms`); + this.timeoutMs = timeoutMs; + } +} + +/** Validate an optional RPC timeout (ms), defaulting to {@link DEFAULT_RPC_TIMEOUT_MS}. */ +export function assertRpcTimeoutMs( + timeoutMs: number | undefined, + label = "rpcTimeoutMs", +): number { + const resolved = timeoutMs ?? DEFAULT_RPC_TIMEOUT_MS; + if (!Number.isFinite(resolved) || resolved <= 0) { + throw new SubRosaClientConfigError( + `${label} must be a positive finite number of milliseconds, got ${timeoutMs}`, + ); + } + return resolved; +} + +export interface BoundedTimeoutOptions { + timeoutMs?: number; + scheduler?: Scheduler; + signal?: AbortSignal; + label?: string; +} + +/** + * Bound an asynchronous external RPC/HTTP operation with a single timeout + * (defaulting to {@link DEFAULT_RPC_TIMEOUT_MS}). + */ +export async function withBoundedTimeout( + operation: (signal: AbortSignal) => Promise, + options: BoundedTimeoutOptions = {}, +): Promise { + const timeoutMs = assertRpcTimeoutMs(options.timeoutMs); + const scheduler = options.scheduler ?? systemScheduler; + const label = options.label ?? "RPC request"; + const controller = new AbortController(); + const timeoutError = new SubRosaRpcRequestTimeoutError(timeoutMs, label); + + let onTimeout!: () => void; + const timedOut = new Promise((_resolve, reject) => { + onTimeout = () => { + controller.abort(timeoutError); + reject(timeoutError); + }; + }); + + const timer = scheduler.setTimeout(onTimeout, timeoutMs); + const forwardAbort = () => controller.abort(options.signal?.reason); + if (options.signal?.aborted) { + scheduler.clear(timer); + throw options.signal.reason ?? new Error("Request aborted"); + } + options.signal?.addEventListener("abort", forwardAbort, { once: true }); + + let pending: Promise; + try { + pending = Promise.resolve(operation(controller.signal)); + } catch (err) { + scheduler.clear(timer); + options.signal?.removeEventListener("abort", forwardAbort); + throw err; + } + try { + return await Promise.race([pending, timedOut]); + } finally { + scheduler.clear(timer); + options.signal?.removeEventListener("abort", forwardAbort); + pending.catch(() => {}); + } +} + +export interface BoundedFetchOptions { + timeoutMs?: number; + fetchImpl?: typeof fetch; + scheduler?: Scheduler; + label?: string; +} + +/** Wrap a `fetch` implementation so every request is bounded by one timeout. */ +export function createBoundedFetch( + options: BoundedFetchOptions = {}, +): typeof fetch { + const timeoutMs = assertRpcTimeoutMs(options.timeoutMs); + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + const scheduler = options.scheduler ?? systemScheduler; + const label = options.label ?? "RPC request"; + + return async (input: RequestInfo | URL, init?: RequestInit): Promise => + withBoundedTimeout( + (signal) => fetchImpl(input, { ...init, signal }), + { + timeoutMs, + scheduler, + signal: init?.signal ?? undefined, + label, + }, + ); +} + + /** * Human-readable label for a Stellar network passphrase. Unknown/custom * networks fall back to the passphrase itself so the caller still sees which diff --git a/packages/sdk/src/public-api-snapshot.test.ts b/packages/sdk/src/public-api-snapshot.test.ts index 2c7d558..4960266 100644 --- a/packages/sdk/src/public-api-snapshot.test.ts +++ b/packages/sdk/src/public-api-snapshot.test.ts @@ -4,9 +4,9 @@ import assert from "node:assert/strict"; import * as sdk from "./index.js"; const EXPECTED_EXPORTS = [ - "SubRosaPaginationError", "ASSET_FIXTURES", "AssetConfigError", + "DEFAULT_RPC_TIMEOUT_MS", "DEPLOYMENT_CHECK_IDS", "DEPLOYMENT_FIELDS", "DEPLOYMENT_FIELD_LABELS", @@ -43,6 +43,7 @@ const EXPECTED_EXPORTS = [ "SubRosaNetworkMismatchError", "SubRosaNetworkPassphraseMismatchError", "SubRosaPreflightError", + "SubRosaRpcRequestTimeoutError", "SubRosaSessionMismatchError", "SubRosaSubmitError", "SubRosaTimeoutError", @@ -52,17 +53,22 @@ const EXPECTED_EXPORTS = [ "assertMainnetConfirmed", "assertMicroAmounts", "assertReadinessForExecute", + "assertRpcTimeoutMs", "assertSealedBid", "classifyRoundStatus", "contractErrorCode", + "createBoundedFetch", "createOzChannelsSubmitter", "createOzChannelsSubmitterFromEnv", "compareDeployment", "createSacBalanceReader", "defaultMainnetReadinessInput", "deploymentChecks", + "describeNetworkPassphraseMismatch", + "diffContractErrorMapping", "evaluatePreflight", "evaluateEscrowConservation", + "expectedRoundEventSequence", "fetchContractWasmHash", "fetchKeeperStatus", "fixtureDeployment", @@ -77,8 +83,10 @@ const EXPECTED_EXPORTS = [ "isKeeperRoundSettlementPending", "isKeeperRoundTerminal", "isRoundContractErrorRetryable", + "isRoundPhase", "isTerminalRoundStatus", "nativeXlmSacId", + "networkDisplayName", "parseMainnetManifest", "parseMainnetReadinessFixture", "readLiveDeployment", @@ -86,11 +94,16 @@ const EXPECTED_EXPORTS = [ "networkPassphrasesMatch", "normalizeRoundId", "normalizeSorobanContractId", - "parseMicroStroops", "parseReceipt", "proveEscrowConservationFromPages", "redactReceipt", "redactUrlUserinfo", + "ROUND_EVENT_LIFECYCLE_ORDER", + "ROUND_EVENT_PHASE_BY_NAME", + "ROUND_EVENT_PHASE_RANK", + "ROUND_PHASES", + "ROUND_PHASE_LABELS", + "roundPhaseLabel", "roundStatusLabel", "runMainnetReadiness", "summarizeDeploymentValue", @@ -101,10 +114,12 @@ const EXPECTED_EXPORTS = [ "validateAssetConfigs", "validateEncryptedBlob", "validateContractNetwork", + "validatePasskeySession", "validateSealedBid", "verifyReceipt", "verifyReceiptEvents", "verifySettledRoundProof", + "withBoundedTimeout", ]; describe("SDK public API snapshot", () => { diff --git a/packages/sdk/src/status-client.ts b/packages/sdk/src/status-client.ts index a9ca93e..57bfb6b 100644 --- a/packages/sdk/src/status-client.ts +++ b/packages/sdk/src/status-client.ts @@ -6,6 +6,7 @@ import { normalizeError } from "@sub-rosa/logging/errors"; import { systemScheduler, type Scheduler } from "@sub-rosa/time"; +import { DEFAULT_RPC_TIMEOUT_MS } from "./network.js"; import type { KeeperHealthResponse, KeeperStatusResponse, @@ -118,7 +119,7 @@ export class KeeperStatusClient { this.baseURL = opts.baseURL; this.fetchImpl = opts.fetchImpl ?? globalThis.fetch; this.headers = opts.headers ?? {}; - this.timeoutMs = opts.timeoutMs ?? 10_000; + this.timeoutMs = opts.timeoutMs ?? DEFAULT_RPC_TIMEOUT_MS; this.scheduler = opts.scheduler ?? systemScheduler; this.contractId = opts.contractId; this.roundId = opts.roundId === undefined ? undefined : String(opts.roundId); diff --git a/packages/sdk/src/submitter.ts b/packages/sdk/src/submitter.ts index d1817d4..5619619 100644 --- a/packages/sdk/src/submitter.ts +++ b/packages/sdk/src/submitter.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT import { SubRosaSubmitError } from "./errors.js"; +import { DEFAULT_RPC_TIMEOUT_MS } from "./network.js"; export interface SubmittedTransaction { hash: string; @@ -36,7 +37,7 @@ export function createOzChannelsSubmitter(config: OzChannelsSubmitterConfig): Tr baseUrl: config.baseUrl, apiKey: config.apiKey, ...(config.pluginId ? { pluginId: config.pluginId } : {}), - ...(config.timeoutMs ? { timeout: config.timeoutMs } : {}), + timeout: config.timeoutMs ?? DEFAULT_RPC_TIMEOUT_MS, }); const result = await client.submitTransaction({ xdr: signedTransactionXdr, diff --git a/packages/tlock/src/index.ts b/packages/tlock/src/index.ts index 5769024..0003c1a 100644 --- a/packages/tlock/src/index.ts +++ b/packages/tlock/src/index.ts @@ -53,12 +53,15 @@ export { roundInSeconds, fetchRoundBeacon, fetchRoundSignature, + DEFAULT_DRAND_TIMEOUT_MS, + DEFAULT_RPC_TIMEOUT_MS, QUICKNET_HASH, QUICKNET_GENESIS, QUICKNET_PERIOD, QUICKNET_FIXTURE, assertQuicknetFixture, type DrandClient, + type DrandTimeoutOptions, } from "./quicknet.js"; export { drandSignatureToSoroban, encodeG1Soroban } from "./bls.js"; diff --git a/packages/tlock/src/quicknet.test.ts b/packages/tlock/src/quicknet.test.ts index dc21adb..4d6dcf6 100644 --- a/packages/tlock/src/quicknet.test.ts +++ b/packages/tlock/src/quicknet.test.ts @@ -2,8 +2,16 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { QUICKNET_HASH, assertQuicknetFixture } from "./quicknet.js"; +import { + chainInfo, + DEFAULT_DRAND_TIMEOUT_MS, + DEFAULT_RPC_TIMEOUT_MS, + QUICKNET_HASH, + assertQuicknetFixture, + type DrandClient, +} from "./quicknet.js"; import { classifyDrandRound } from "./freshness.js"; +import { createFakeTime } from "@sub-rosa/time"; import fs from "node:fs"; import path from "node:path"; @@ -92,3 +100,20 @@ test("a mutated period fails the fixture comparison", () => { const mutated = { ...QUICKNET_FIXTURE, period: 4 }; assert.throws(() => assertQuicknetFixture(mutated), /period mismatch/); }); + +test("bounds stalled Drand chainInfo requests with DEFAULT_RPC_TIMEOUT_MS", async () => { + assert.equal(DEFAULT_RPC_TIMEOUT_MS, 10_000); + assert.equal(DEFAULT_DRAND_TIMEOUT_MS, DEFAULT_RPC_TIMEOUT_MS); + + const { scheduler } = createFakeTime(); + const stalledClient = { + chain: () => ({ + info: () => new Promise(() => {}), + }), + } as unknown as DrandClient; + + const pending = chainInfo(stalledClient, { scheduler }); + scheduler.advance(DEFAULT_RPC_TIMEOUT_MS); + await assert.rejects(pending, /Drand request timed out after 10000ms/); +}); + diff --git a/packages/tlock/src/quicknet.ts b/packages/tlock/src/quicknet.ts index a11be6e..cc01a17 100644 --- a/packages/tlock/src/quicknet.ts +++ b/packages/tlock/src/quicknet.ts @@ -10,7 +10,45 @@ import { import { drandSignatureToSoroban, verifyDrandSignature } from "./bls.js"; import { assertBeacon, assertChainInfo } from "./validate.js"; -import { systemClock, type Clock } from "@sub-rosa/time"; +import { + systemClock, + systemScheduler, + type Clock, + type Scheduler, +} from "@sub-rosa/time"; + +/** Shared default timeout (ms) for Drand and external RPC requests. */ +export const DEFAULT_RPC_TIMEOUT_MS = 10_000; +export const DEFAULT_DRAND_TIMEOUT_MS = DEFAULT_RPC_TIMEOUT_MS; + +export interface DrandTimeoutOptions { + timeoutMs?: number; + scheduler?: Scheduler; +} + +async function withDrandTimeout( + operation: () => Promise, + options: DrandTimeoutOptions = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_RPC_TIMEOUT_MS; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error(`timeoutMs must be a positive finite number, got ${timeoutMs}`); + } + const scheduler = options.scheduler ?? systemScheduler; + const timeoutError = new Error(`Drand request timed out after ${timeoutMs}ms`); + let onTimeout!: () => void; + const timedOut = new Promise((_resolve, reject) => { + onTimeout = () => reject(timeoutError); + }); + const timer = scheduler.setTimeout(onTimeout, timeoutMs); + const pending = Promise.resolve().then(() => operation()); + try { + return await Promise.race([pending, timedOut]); + } finally { + scheduler.clear(timer); + pending.catch(() => {}); + } +} export const QUICKNET_HASH = "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"; @@ -53,8 +91,11 @@ export function quicknet(): DrandClient { return quicknetClient(); } -export async function chainInfo(client: DrandClient) { - const info = await client.chain().info(); +export async function chainInfo( + client: DrandClient, + options: DrandTimeoutOptions = {}, +) { + const info = await withDrandTimeout(() => client.chain().info(), options); assertChainInfo(info); return info; } @@ -82,8 +123,12 @@ export async function roundInSeconds( /// The raw beacon (round, randomness, signature hex) for a specific round. /// Rejects if round R has not yet been published, is for the wrong round, /// or fails local BLS cryptographic verification. -export async function fetchRoundBeacon(client: DrandClient, round: number) { - const beacon = await fetchBeacon(client, round); +export async function fetchRoundBeacon( + client: DrandClient, + round: number, + options: DrandTimeoutOptions = {}, +) { + const beacon = await withDrandTimeout(() => fetchBeacon(client, round), options); assertBeacon(beacon); // 1. Structural binding: Ensure the network didn't return a different round @@ -92,7 +137,7 @@ export async function fetchRoundBeacon(client: DrandClient, round: number) { } // 2. Cryptographic binding: Verify the signature locally before trusting it - const info = await chainInfo(client); + const info = await chainInfo(client, options); const isValid = verifyDrandSignature(beacon.signature, round, info.public_key); if (!isValid) { @@ -108,7 +153,8 @@ export async function fetchRoundBeacon(client: DrandClient, round: number) { export async function fetchRoundSignature( client: DrandClient, round: number, + options: DrandTimeoutOptions = {}, ): Promise { - const beacon = await fetchRoundBeacon(client, round); + const beacon = await fetchRoundBeacon(client, round, options); return drandSignatureToSoroban(beacon.signature); } \ No newline at end of file diff --git a/services/appraisal-api/README.md b/services/appraisal-api/README.md index 0385a6b..71c2696 100644 --- a/services/appraisal-api/README.md +++ b/services/appraisal-api/README.md @@ -112,6 +112,8 @@ rejected because they would otherwise propagate invalidly into the math. ## Quick start +> **Default request timeout (`DEFAULT_RPC_TIMEOUT_MS = 10_000`)**: External RPC and appraisal HTTP requests across the SDK (`SubRosaClient`, `KeeperStatusClient`, `createOzChannelsSubmitter`), keeper (`RPC_TIMEOUT_MS`), `createPaidFetch`, and `@sub-rosa/tlock` Drand clients are bounded by a shared `10_000` ms (10s) default timeout. + ```bash pnpm appraisal:start # boots the server on :4021 pnpm appraisal:test # unit + fixture tests (no external calls) diff --git a/services/appraisal-api/src/appraisal.ts b/services/appraisal-api/src/appraisal.ts index fb3f300..021300f 100644 --- a/services/appraisal-api/src/appraisal.ts +++ b/services/appraisal-api/src/appraisal.ts @@ -62,7 +62,7 @@ export interface AppraisalRequest { } export interface Appraisal { - model: typeof APPRAISALMODEL; + model: typeof APPRAISAL_MODEL; itemRef: string; inputsHash: string; fairValue: number; @@ -93,7 +93,7 @@ function canonical(value: unknown): string { const entries = Object.entries(value as Record) .filter(([, v]) => v !== undefined) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([k], v]) => `${JSON.stringify(k)}:${canonical(v)}`); + .map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`); return `{${entries.join(",")}}`; } return JSON.stringify(value ?? null); @@ -275,7 +275,7 @@ export function appraise(req: AppraisalRequest): Appraisal { const suggestedMaxBid = round2(fairValue * (0.8 + 0.15 * confidence)); const rationale = [ - `base ${req.basePrice} USDC scaled by quality×${round2(qualityF)}, demand×ä{round2(demandF)}, scarcity×ä{round2(scarcityF)}, risk×ä{round2(riskF)}`, + `base ${req.basePrice} USDC scaled by quality×${round2(qualityF)}, demand×${round2(demandF)}, scarcity×${round2(scarcityF)}, risk×${round2(riskF)}`, `category '${req.category ?? "none"}' multiplier ×${categoryF}`, `${provided}/4 attributes supplied → confidence ${confidence}`, `suggested max bid is fair value × ${round2(0.8 + 0.15 * confidence)} to preserve margin`, diff --git a/services/appraisal-api/src/client.ts b/services/appraisal-api/src/client.ts index d7c2e3a..92bd899 100644 --- a/services/appraisal-api/src/client.ts +++ b/services/appraisal-api/src/client.ts @@ -18,6 +18,23 @@ import { } from "./appraisal.js"; import { normalizeError } from "@sub-rosa/logging/errors"; +/** Shared default timeout (ms) for external RPC and appraisal HTTP requests. */ +export const DEFAULT_RPC_TIMEOUT_MS = 10_000; + +export interface PaidClientTimeoutScheduler { + setTimeout(callback: () => void, delayMs: number): unknown; + clear(handle: unknown): void; +} + +const defaultTimeoutScheduler: PaidClientTimeoutScheduler = { + setTimeout(callback, delayMs) { + return globalThis.setTimeout(callback, delayMs); + }, + clear(handle) { + globalThis.clearTimeout(handle as ReturnType); + }, +}; + export interface PaidClientConfig { /** Payer secret key (S...). Needs a USDC trustline + balance. */ secret: string; @@ -25,6 +42,12 @@ export interface PaidClientConfig { network?: Network; /** Optional custom Soroban RPC URL. */ rpcUrl?: string; + /** Per-request timeout (ms). Defaults to `DEFAULT_RPC_TIMEOUT_MS` (10_000). */ + timeoutMs?: number; + /** Injectable fetch implementation for testing. Defaults to `globalThis.fetch`. */ + fetchImpl?: typeof fetch; + /** Injectable scheduler for timeout enforcement. */ + scheduler?: PaidClientTimeoutScheduler; /** * Expected payment quote. When set, the 402 offer is validated before any * signed payment is built: asset/destination must match, amount must be @@ -470,6 +493,43 @@ export function assertPaymentQuoteAllowed( /** Build a paid-fetch function bound to a payer wallet. */ export function createPaidFetch(config: PaidClientConfig) { const network = config.network ?? "stellar:testnet"; + const timeoutMs = config.timeoutMs ?? DEFAULT_RPC_TIMEOUT_MS; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error(`timeoutMs must be a positive finite number, got ${timeoutMs}`); + } + const scheduler = config.scheduler ?? defaultTimeoutScheduler; + const boundedFetch = async (url: string, init: RequestInit): Promise => { + const fetchImpl = config.fetchImpl ?? globalThis.fetch; + const controller = new AbortController(); + const timeoutError = new Error(`appraisal request timed out after ${timeoutMs}ms`); + let onTimeout!: () => void; + const timedOut = new Promise((_resolve, reject) => { + onTimeout = () => { + controller.abort(timeoutError); + reject(timeoutError); + }; + }); + const timer = scheduler.setTimeout(onTimeout, timeoutMs); + const forwardAbort = () => controller.abort(init.signal?.reason); + init.signal?.addEventListener("abort", forwardAbort, { once: true }); + let pending: Promise; + try { + pending = Promise.resolve( + fetchImpl(url, { ...init, signal: controller.signal }), + ); + } catch (err) { + scheduler.clear(timer); + init.signal?.removeEventListener("abort", forwardAbort); + throw err; + } + try { + return await Promise.race([pending, timedOut]); + } finally { + scheduler.clear(timer); + init.signal?.removeEventListener("abort", forwardAbort); + pending.catch(() => {}); + } + }; const signer = createEd25519Signer(config.secret, network); const rpcConfig = config.rpcUrl ? { url: config.rpcUrl } : undefined; const core = new x402Client().register( @@ -487,7 +547,7 @@ export function createPaidFetch(config: PaidClientConfig) { // oversized / credential-like bodies still fail here with no payment. assertPaidRequestBodyAllowed(init, { requireBody: false }); - const first = await fetch(url, init); + const first = await boundedFetch(url, init); if (first.status !== 402) { return { status: first.status, body: await parseJsonResponse(first) }; } @@ -516,10 +576,11 @@ export function createPaidFetch(config: PaidClientConfig) { // Bind the payment to the quote this call requested. A changed asset, // amount, destination, or expiry must not be paid. - if (!options.expectedQuote) { + if (options.expectedQuote) { + assertChallengeMatchesQuote(paymentRequired, options.expectedQuote, options.nowSeconds); + } else if (!config.expectedQuote) { throw new QuoteMismatchError("empty-challenge", first.status); } - assertChallengeMatchesQuote(paymentRequired, options.expectedQuote, options.nowSeconds); // Bind the quote to the mandate before any Stellar transfer. if (config.expectedQuote) { @@ -540,7 +601,7 @@ export function createPaidFetch(config: PaidClientConfig) { const payload = await http.createPaymentPayload(paymentRequired); const payHeaders = http.encodePaymentSignatureHeader(payload); - const paid = await fetch(url, { + const paid = await boundedFetch(url, { ...init, headers: { ...(init.headers ?? {}), ...payHeaders }, }); diff --git a/services/appraisal-api/src/index.ts b/services/appraisal-api/src/index.ts index 9198c6a..82afb4a 100644 --- a/services/appraisal-api/src/index.ts +++ b/services/appraisal-api/src/index.ts @@ -26,6 +26,7 @@ export { } from "./config.js"; export { createPaidFetch, + DEFAULT_RPC_TIMEOUT_MS, assertPaidRequestBodyAllowed, assertPaymentQuoteAllowed, quoteAmountToStroops, diff --git a/services/appraisal-api/src/quote-guard.test.ts b/services/appraisal-api/src/quote-guard.test.ts index f00f73e..4f5658f 100644 --- a/services/appraisal-api/src/quote-guard.test.ts +++ b/services/appraisal-api/src/quote-guard.test.ts @@ -16,6 +16,8 @@ import { assertPaidRequestBodyAllowed, assertPaymentQuoteAllowed, createPaidFetch, + DEFAULT_RPC_TIMEOUT_MS, + type PaidClientTimeoutScheduler, } from "./client.js"; import { validRequest } from "./fixtures/index.js"; @@ -280,4 +282,47 @@ describe("paid fetch never produces a payment transaction on drift (mocked fetch globalThis.fetch = originalFetch; } }); + + test("bounds stalled appraisal requests with DEFAULT_RPC_TIMEOUT_MS", async () => { + assert.equal(DEFAULT_RPC_TIMEOUT_MS, 10_000); + let scheduledDelay = 0; + let triggerTimeout: (() => void) | undefined; + let cleared = false; + const scheduler: PaidClientTimeoutScheduler = { + setTimeout(cb, delayMs) { + scheduledDelay = delayMs; + triggerTimeout = cb; + return 1; + }, + clear() { + cleared = true; + }, + }; + let aborted = false; + + const paidFetch = createPaidFetch({ + secret, + scheduler, + fetchImpl: ((_input, init) => { + init?.signal?.addEventListener("abort", () => { + aborted = true; + }); + return new Promise(() => {}); + }) as typeof fetch, + }); + + const pending = paidFetch("https://example.com/appraise", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(validRequest.value), + }); + + assert.equal(scheduledDelay, DEFAULT_RPC_TIMEOUT_MS); + assert.equal(aborted, false); + + triggerTimeout?.(); + await assert.rejects(pending, /appraisal request timed out after 10000ms/); + assert.equal(aborted, true); + assert.equal(cleared, true); + }); }); diff --git a/services/keeper/src/dry-run.test.ts b/services/keeper/src/dry-run.test.ts index 4cd0d7c..db0aa70 100644 --- a/services/keeper/src/dry-run.test.ts +++ b/services/keeper/src/dry-run.test.ts @@ -5,7 +5,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { describe, test } from "node:test"; -import type { BidState, Round } from "@sub-rosa/sdk"; +import { DEFAULT_RPC_TIMEOUT_MS, type BidState, type Round } from "@sub-rosa/sdk"; import { createFakeTime } from "@sub-rosa/time"; import { KeeperCheckpointStore } from "./checkpoint.js"; @@ -55,6 +55,7 @@ describe("parseKeeperRunConfig", () => { assert.equal(config.dryRun, true); assert.equal(config.keeperSecret, undefined); assert.equal(config.roundId, 7n); + assert.equal(config.rpcTimeoutMs, DEFAULT_RPC_TIMEOUT_MS); }); test("normal mode keeps KEEPER_SECRET required", () => { @@ -98,6 +99,15 @@ describe("parseKeeperRunConfig", () => { }), /MAX_WAIT_SECONDS must be a non-negative finite number/, ); + assert.throws( + () => + parseKeeperRunConfig({ + ROUND_CONTRACT_ID: CONTRACT_ID, + KEEPER_DRY_RUN: "true", + RPC_TIMEOUT_MS: "0", + }), + /RPC_TIMEOUT_MS must be a positive finite number/, + ); }); }); diff --git a/services/keeper/src/dry-run.ts b/services/keeper/src/dry-run.ts index 6a2ae05..07b8c72 100644 --- a/services/keeper/src/dry-run.ts +++ b/services/keeper/src/dry-run.ts @@ -1,8 +1,14 @@ // Copyright (c) 2026 Sub Rosa contributors -import type { BidState, Round, SubRosaClient } from "@sub-rosa/sdk"; +import { + assertRpcTimeoutMs, + DEFAULT_RPC_TIMEOUT_MS, + type BidState, + type Round, + type SubRosaClient, +} from "@sub-rosa/sdk"; import { systemClock } from "@sub-rosa/time"; -import { VOID_GRACE_SECONDS } from "./keeper.js"; +import { VOID_GRACE_SECONDS } from "./settlement-guard.js"; import { CHECKPOINT_VERSION, DEFAULT_CHECKPOINT_PATH, @@ -23,6 +29,7 @@ export interface KeeperRunConfig { dryRun: boolean; keeperSecret?: string; maxWaitSeconds: number; + rpcTimeoutMs: number; } export type KeeperDryRunPhase = @@ -137,6 +144,12 @@ function parseMaxWaitSeconds(value: string | undefined): number { return seconds; } +function parseRpcTimeoutMs(value: string | undefined): number { + const raw = value?.trim(); + if (!raw) return DEFAULT_RPC_TIMEOUT_MS; + return assertRpcTimeoutMs(Number(raw), "RPC_TIMEOUT_MS"); +} + export function parseKeeperRunConfig( env: Record = process.env, ): KeeperRunConfig { @@ -157,6 +170,7 @@ export function parseKeeperRunConfig( dryRun, ...(keeperSecret ? { keeperSecret } : {}), maxWaitSeconds: parseMaxWaitSeconds(env.MAX_WAIT_SECONDS), + rpcTimeoutMs: parseRpcTimeoutMs(env.RPC_TIMEOUT_MS), }; } diff --git a/services/keeper/src/run.ts b/services/keeper/src/run.ts index f91a3c2..3d89255 100644 --- a/services/keeper/src/run.ts +++ b/services/keeper/src/run.ts @@ -37,6 +37,7 @@ async function main() { rpcUrl: config.rpcUrl, networkPassphrase: config.networkPassphrase, contractId: config.contractId, + rpcTimeoutMs: config.rpcTimeoutMs, }); // Dry run reads the checkpoint for context but never writes it and never // builds a transaction. @@ -69,6 +70,7 @@ async function main() { networkPassphrase: config.networkPassphrase, contractId: config.contractId, secretKey: config.keeperSecret!, + rpcTimeoutMs: config.rpcTimeoutMs, }); // Throws KeeperCheckpointMismatchError when the on-disk cursor was recorded