Skip to content
Merged
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
19 changes: 19 additions & 0 deletions apps/web/src/lib/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,3 +254,22 @@ test("swapped VITE_PASSKEY_NETWORK_PASSPHRASE is reported as configuration misma
);
});

test("VITE_RPC_URL with embedded credentials is rejected without echoing credentials", () => {
for (const url of [
"https://webuser:websecret@custom-soroban.example.com",
"https://webuser@custom-soroban.example.com",
"https://:websecret@custom-soroban.example.com",
]) {
const issues = validatePublicConfig({
VITE_RPC_URL: url,
VITE_NETWORK_PASSPHRASE: "Custom Network",
VITE_CONTRACT_ID: "CA7KSDEYJEPGZEB2ZROTLUWKQQ6GIRIQNGG6Z745MZ34QHP4UJPWODEX",
});
const credIssue = issues.find(
(i) => i.key === "VITE_RPC_URL" && i.message.includes("must not contain credentials"),
);
assert.ok(credIssue, `expected credential issue for ${url}`);
assert.doesNotMatch(credIssue.message, /webuser|websecret/);
}
});

19 changes: 19 additions & 0 deletions apps/web/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,25 @@ export function validatePublicConfig(
key: "VITE_RPC_URL",
message: `VITE_RPC_URL is not a valid URL (${rpcUrl}). It must start with http:// or https://.`,
});
} else {
try {
const parsed = new URL(normalized);
if (parsed.username !== "" || parsed.password !== "") {
issues.push({
key: "VITE_RPC_URL",
message:
"VITE_RPC_URL must not contain credentials (username or password).",
});
}
} catch {
if (/^https?:\/\/[^/\s?#]*@/i.test(normalized)) {
issues.push({
key: "VITE_RPC_URL",
message:
"VITE_RPC_URL must not contain credentials (username or password).",
});
}
}
}
}

Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/passkey-config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) 2026 Sub Rosa contributors
import type { CommitParams, SubRosaClient } from "@sub-rosa/sdk";
import { validatePasskeySession } from "@sub-rosa/sdk";
import { assertNoUrlCredentials, validatePasskeySession } from "@sub-rosa/sdk";

/** Public testnet smart-wallet WASM (passkey-kit demo). Not a secret. */
export const PASSKEY_TESTNET_WALLET_WASM_HASH =
Expand All @@ -16,6 +16,11 @@ const env: Record<string, string | undefined> =
export const PASSKEY_RPC_URL =
env.VITE_RPC_URL ?? "https://soroban-testnet.stellar.org";

export function resolvePasskeyRpcUrl(rawUrl: string = PASSKEY_RPC_URL): string {
assertNoUrlCredentials(rawUrl, "VITE_RPC_URL");
return rawUrl.trim();
}

export const PASSKEY_NETWORK_PASSPHRASE =
env.VITE_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015";

Expand Down
2 changes: 2 additions & 0 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 {
assertNoUrlCredentials,
validateContractNetwork,
validatePasskeySession,
type PasskeySessionBinding,
Expand Down Expand Up @@ -212,6 +213,7 @@ export class SubRosaClient {


constructor(config: SubRosaClientConfig) {
assertNoUrlCredentials(config.rpcUrl, "rpcUrl");
const allowHttp = config.allowHttp ?? false;
if (/^http:\/\//i.test(config.rpcUrl) && !allowHttp) {
throw new SubRosaClientConfigError(
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
6 changes: 6 additions & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ export {
type ProveEscrowConservationOptions,
} from "./conservation.js";
export {
assertNoUrlCredentials,
describeNetworkPassphraseMismatch,
hasUrlCredentials,
networkDisplayName,
networkPassphrasesMatch,
SubRosaNetworkPassphraseMismatchError,
validateContractNetwork,
validatePasskeySession,
type ContractNetworkValidationConfig,
Expand Down
63 changes: 63 additions & 0 deletions packages/sdk/src/network.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,19 @@ 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 { KeeperStatusClient } from "./status-client.js";
import {
assertNoUrlCredentials,
describeNetworkPassphraseMismatch,
hasUrlCredentials,
networkDisplayName,
networkPassphrasesMatch,
SubRosaNetworkPassphraseMismatchError,
validateContractNetwork,
validatePasskeySession,
type NetworkValidationServer,
Expand Down Expand Up @@ -245,3 +254,57 @@ describe("validatePasskeySession", () => {
});
});

describe("assertNoUrlCredentials", () => {
it("accepts clean HTTP and HTTPS RPC URLs", () => {
assert.equal(hasUrlCredentials("https://soroban-testnet.stellar.org"), false);
assert.equal(hasUrlCredentials("http://localhost:8000/rpc"), false);
assert.doesNotThrow(() =>
assertNoUrlCredentials("https://soroban-testnet.stellar.org", "rpcUrl"),
);
});

it("rejects username and password URLs in SDK and status client without echoing credentials", () => {
for (const url of [
"https://alice:secret123@rpc.example.test",
"https://alice@rpc.example.test",
"https://:secret123@rpc.example.test",
"http://admin:pass@localhost:8000",
]) {
assert.equal(hasUrlCredentials(url), true);
assert.throws(
() => assertNoUrlCredentials(url, "rpcUrl"),
(err: unknown) => {
assert.ok(err instanceof SubRosaClientConfigError);
assert.match(err.message, /must not contain credentials/);
assert.doesNotMatch(err.message, /alice|secret123|admin|pass/);
return true;
},
);
assert.throws(
() =>
new SubRosaClient({
rpcUrl: url,
networkPassphrase: TESTNET,
contractId: CONTRACT_ID,
allowHttp: true,
}),
(err: unknown) => {
assert.ok(err instanceof SubRosaClientConfigError);
assert.match(err.message, /must not contain credentials/);
assert.doesNotMatch(err.message, /alice|secret123|admin|pass/);
return true;
},
);
assert.throws(
() => new KeeperStatusClient({ baseURL: url }),
(err: unknown) => {
assert.ok(err instanceof SubRosaClientConfigError);
assert.match(err.message, /must not contain credentials/);
assert.doesNotMatch(err.message, /alice|secret123|admin|pass/);
return true;
},
);
}
});
});

47 changes: 47 additions & 0 deletions packages/sdk/src/network.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,57 @@
import { Contract, rpc } from "@stellar/stellar-sdk";
import {
SubRosaClientConfigError,
SubRosaNetworkMismatchError,
SubRosaSessionMismatchError,
} from "./errors.js";
import { normalizeSorobanContractId } from "./ids.js";

/**
* Return true when `rawUrl` parses as a URL with a non-empty `username` or
* `password` component (or contains an authority `@` before the path).
*/
export function hasUrlCredentials(rawUrl: string): boolean {
const trimmed = rawUrl.trim();
if (!trimmed) return false;
try {
const parsed = new URL(trimmed);
return parsed.username !== "" || parsed.password !== "";
} catch {
return /^[a-z][a-z0-9+.-]*:\/\/[^/\s?#]*@/i.test(trimmed);
}
}

/**
* Validate that an RPC or service endpoint URL uses HTTP(S) and does not embed
* a `username` or `password`. Throws {@link SubRosaClientConfigError} without
* echoing any credential fragments.
*/
export function assertNoUrlCredentials(
rawUrl: string,
label = "rpcUrl",
): URL {
const trimmed = typeof rawUrl === "string" ? rawUrl.trim() : "";
if (!trimmed) {
throw new SubRosaClientConfigError(`${label}: must be a non-empty HTTP or HTTPS URL`);
}
if (hasUrlCredentials(trimmed)) {
throw new SubRosaClientConfigError(`${label}: must not contain credentials`);
}
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
throw new SubRosaClientConfigError(`${label}: must be a valid HTTP or HTTPS URL`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new SubRosaClientConfigError(`${label}: must use http:// or https://`);
}
if (parsed.username !== "" || parsed.password !== "") {
throw new SubRosaClientConfigError(`${label}: must not contain credentials`);
}
return parsed;
}

/**
* Human-readable label for a Stellar network passphrase. Unknown/custom
* networks fall back to the passphrase itself so the caller still sees which
Expand Down
Loading