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
11 changes: 6 additions & 5 deletions packages/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
} from "./errors.js";
import { normalizeRoundId, normalizeSorobanContractId } from "./ids.js";
import {
assertEndpointTransportSecurity,
validateContractNetwork,
validatePasskeySession,
type PasskeySessionBinding,
Expand Down Expand Up @@ -213,11 +214,11 @@ export class SubRosaClient {

constructor(config: SubRosaClientConfig) {
const allowHttp = config.allowHttp ?? false;
if (/^http:\/\//i.test(config.rpcUrl) && !allowHttp) {
throw new SubRosaClientConfigError(
"rpcUrl must use https unless allowHttp is explicitly enabled",
);
}
assertEndpointTransportSecurity(config.rpcUrl, {
networkPassphrase: config.networkPassphrase,
allowHttp,
label: "rpcUrl",
});

const confirmTimeout = config.confirmTimeout ?? 60_000;
if (!Number.isFinite(confirmTimeout) || confirmTimeout < 1_000) {
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
8 changes: 8 additions & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,17 @@ export {
type ProveEscrowConservationOptions,
} from "./conservation.js";
export {
assertEndpointTransportSecurity,
describeNetworkPassphraseMismatch,
isLoopbackHost,
isMainnetPassphrase,
networkDisplayName,
networkPassphrasesMatch,
SubRosaNetworkPassphraseMismatchError,
validateContractNetwork,
validatePasskeySession,
type ContractNetworkValidationConfig,
type EndpointTransportSecurityOptions,
type NetworkValidationServer,
type PasskeySessionBinding,
type SessionBindingTarget,
Expand Down
13 changes: 13 additions & 0 deletions packages/sdk/src/mainnet-readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,19 @@ describe("runMainnetReadiness — live comparison", () => {
);
});

it("blocks when rpcUrl uses http:// instead of https://", async () => {
const report = await runMainnetReadiness(
defaultMainnetReadinessInput({
live: false,
rpcUrl: "http://rpc.mainnet.example",
}),
);
const rpcCheck = report.checks.find((c) => c.id === "rpc-url");
assert.equal(rpcCheck?.status, "block");
assert.match(rpcCheck?.message ?? "", /must use HTTPS/);
assert.equal(hasBlockingFailures(report.checks), true);
});

it("blocks every deployment field when no read-only client is available", async () => {
const report = await runMainnetReadiness(defaultMainnetReadinessInput(), {
rpc: mockRpc("500000000"),
Expand Down
4 changes: 2 additions & 2 deletions packages/sdk/src/mainnet-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -822,8 +822,8 @@ function rpcUrlCheck(rpcUrl: string): ReadinessCheck {
return check(
"rpc-url",
"RPC URL",
"warn",
"uses HTTP — prefer HTTPS for mainnet",
"block",
"uses HTTP — mainnet RPC endpoints must use HTTPS",
);
}
return check(
Expand Down
75 changes: 75 additions & 0 deletions packages/sdk/src/network.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { StrKey } from "@stellar/stellar-sdk";
import {
SubRosaClientConfigError,
SubRosaNetworkMismatchError,
SubRosaSessionMismatchError,
} from "./errors.js";
import { SubRosaClient } from "./client.js";
import {
assertEndpointTransportSecurity,
isLoopbackHost,
isMainnetPassphrase,
validateContractNetwork,
validatePasskeySession,
type NetworkValidationServer,
Expand Down Expand Up @@ -245,3 +250,73 @@ describe("validatePasskeySession", () => {
});
});

describe("assertEndpointTransportSecurity", () => {
it("identifies mainnet passphrases and loopback hosts", () => {
assert.equal(isMainnetPassphrase(PUBLIC), true);
assert.equal(isMainnetPassphrase(TESTNET), false);
assert.equal(isLoopbackHost("localhost"), true);
assert.equal(isLoopbackHost("127.0.0.1"), true);
assert.equal(isLoopbackHost("[::1]"), true);
assert.equal(isLoopbackHost("rpc.example.com"), false);
});

it("requires https:// on mainnet even when allowHttp is true or host is localhost", () => {
assert.doesNotThrow(() =>
assertEndpointTransportSecurity("https://rpc.mainnet.example", {
networkPassphrase: PUBLIC,
}),
);

for (const url of [
"http://rpc.mainnet.example",
"http://localhost:8000",
]) {
assert.throws(
() =>
assertEndpointTransportSecurity(url, {
networkPassphrase: PUBLIC,
allowHttp: true,
}),
(err: unknown) => {
assert.ok(err instanceof SubRosaClientConfigError);
assert.match(err.message, /must use https:\/\/ when configured for mainnet/);
return true;
},
);
assert.throws(
() =>
new SubRosaClient({
rpcUrl: url,
networkPassphrase: PUBLIC,
contractId: CONTRACT_ID,
allowHttp: true,
}),
SubRosaClientConfigError,
);
}
});

it("allows http:// on testnet only when allowHttp is explicitly enabled", () => {
assert.throws(
() =>
new SubRosaClient({
rpcUrl: "http://localhost:8000",
networkPassphrase: TESTNET,
contractId: CONTRACT_ID,
}),
SubRosaClientConfigError,
);

assert.doesNotThrow(
() =>
new SubRosaClient({
rpcUrl: "http://localhost:8000",
networkPassphrase: TESTNET,
contractId: CONTRACT_ID,
allowHttp: true,
}),
);
});
});


Loading