Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ import {
} from "./errors.js";
import { normalizeRoundId, normalizeSorobanContractId } from "./ids.js";
import {
assertRpcTimeoutMs,
DEFAULT_RPC_TIMEOUT_MS,
validateContractNetwork,
validatePasskeySession,
type PasskeySessionBinding,
Expand Down Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
146 changes: 146 additions & 0 deletions packages/sdk/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<number, RoundContractErrorSpec>> = 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<Record<string, RoundContractErrorSpec>> = 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";

Expand Down
11 changes: 11 additions & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
80 changes: 80 additions & 0 deletions packages/sdk/src/network.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<Response>(() => {});
}) 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);
});
});


Loading