diff --git a/apps/web/package.json b/apps/web/package.json
index 7c6b9079..d7267f49 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -13,7 +13,7 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
- "test": "node --import tsx --test src/demo/trace-health-check.test.ts src/lib/config.test.ts src/lib/hex.test.ts src/lib/chain.test.ts src/dashboard/fixture-health-check.test.ts src/lib/round-status.test.ts src/components/dashboard/RoundStatusCard.test.tsx src/hooks/useDashboardData.test.ts src/lib/countdown.test.ts src/pages/DemoPage.test.tsx && node --experimental-test-module-mocks --import tsx --test src/hooks/useRoundSession.reveal.test.tsx src/hooks/useRoundSession.refresh.test.tsx src/hooks/useLiveRound.test.tsx",
+ "test": "node --import tsx --test src/demo/trace-health-check.test.ts src/lib/config.test.ts src/lib/hex.test.ts src/lib/chain.test.ts src/dashboard/fixture-health-check.test.ts src/lib/round-status.test.ts src/components/dashboard/RoundStatusCard.test.tsx src/hooks/useDashboardData.test.ts src/lib/countdown.test.ts src/passkey-session.test.ts && node --experimental-test-module-mocks --import tsx --test src/hooks/useRoundSession.reveal.test.tsx src/hooks/useRoundSession.refresh.test.tsx src/hooks/useLiveRound.test.tsx",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
diff --git a/apps/web/src/components/PasskeyPanel.tsx b/apps/web/src/components/PasskeyPanel.tsx
index e201916d..215d0caf 100644
--- a/apps/web/src/components/PasskeyPanel.tsx
+++ b/apps/web/src/components/PasskeyPanel.tsx
@@ -1,12 +1,16 @@
import { publicErrorMessage } from "@sub-rosa/logging/errors";
// Copyright (c) 2026 Sub Rosa contributors
import { useMemo, useState } from "react";
+import { SubRosaClient, validatePasskeySession } from "@sub-rosa/sdk";
import { CAP_SAFETY_COPY } from "../demo/trace";
import { useTime } from "../lib/time";
import {
+ PASSKEY_CONTRACT_ID,
PASSKEY_NETWORK_PASSPHRASE,
PASSKEY_RPC_URL,
+ createPasskeySession,
resolvePasskeyWalletWasmHash,
+ type PasskeySession,
} from "../passkey-config";
type PasskeyStatus = "idle" | "loading" | "ready" | "blocked" | "error";
@@ -34,7 +38,17 @@ const LINKS = [
},
];
-export function PasskeyPanel() {
+export interface PasskeyPanelProps {
+ client?: SubRosaClient;
+ onCommit?: (session: PasskeySession) => Promise;
+ initialSession?: PasskeySession | null;
+}
+
+export function PasskeyPanel({
+ client: injectedClient,
+ onCommit,
+ initialSession = null,
+}: PasskeyPanelProps = {}) {
const { clock } = useTime();
const [status, setStatus] = useState("idle");
const [message, setMessage] = useState(
@@ -45,6 +59,8 @@ export function PasskeyPanel() {
publicKey?: string;
contractId?: string;
} | null>(null);
+ const [session, setSession] = useState(initialSession);
+ const [panelError, setPanelError] = useState(null);
const walletWasmHash = resolvePasskeyWalletWasmHash();
const rpId = import.meta.env.VITE_PASSKEY_RP_ID ?? window.location.hostname;
@@ -78,27 +94,45 @@ export function PasskeyPanel() {
}
setStatus("loading");
setMessage("Opening browser passkey prompt…");
+ setPanelError(null);
try {
const { PasskeyKit } = await import("passkey-kit");
const account = new PasskeyKit(passkeyKitOptions());
const created = await account.createKey("Sub Rosa", passkeyUserId(), {
rpId,
});
+ const keyId = created.keyIdBase64 ?? created.keyId;
+ const publicKey =
+ typeof created.publicKey === "string"
+ ? created.publicKey
+ : Array.from(created.publicKey ?? [])
+ .map((b: number) => b.toString(16).padStart(2, "0"))
+ .join("");
setWallet({
- keyId: created.keyIdBase64 ?? created.keyId,
- publicKey:
- typeof created.publicKey === "string"
- ? created.publicKey
- : Array.from(created.publicKey ?? [])
- .map((b: number) => b.toString(16).padStart(2, "0"))
- .join(""),
+ keyId,
+ publicKey,
});
+
+ // Record contract id, network passphrase, and account when the passkey session starts
+ if (PASSKEY_CONTRACT_ID) {
+ const newSession = createPasskeySession({
+ contractId: PASSKEY_CONTRACT_ID,
+ networkPassphrase: PASSKEY_NETWORK_PASSPHRASE,
+ account: publicKey,
+ keyId,
+ publicKey,
+ });
+ setSession(newSession);
+ }
+
setStatus("ready");
setMessage(
"Passkey registered in this browser. Principal secret never left the secure enclave.",
);
} catch (e) {
setStatus("error");
+ const err = e instanceof Error ? e : new Error(String(e));
+ setPanelError(err);
setMessage(publicErrorMessage(e));
}
}
@@ -111,20 +145,36 @@ export function PasskeyPanel() {
}
setStatus("loading");
setMessage("Deploying Soroban smart wallet on testnet (passkey + deploy tx)…");
+ setPanelError(null);
try {
const { PasskeyKit } = await import("passkey-kit");
const account = new PasskeyKit(passkeyKitOptions());
const created = await account.createWallet("Sub Rosa", passkeyUserId(), {
rpId,
});
+ const keyId = created.keyIdBase64 ?? created.keyId;
setWallet({
- keyId: created.keyIdBase64 ?? created.keyId,
+ keyId,
contractId: created.contractId,
});
+
+ // Record contract id, network passphrase, and account when smart wallet session starts
+ if (PASSKEY_CONTRACT_ID) {
+ const newSession = createPasskeySession({
+ contractId: PASSKEY_CONTRACT_ID,
+ networkPassphrase: PASSKEY_NETWORK_PASSPHRASE,
+ account: created.contractId,
+ keyId,
+ });
+ setSession(newSession);
+ }
+
setStatus("ready");
setMessage(`Smart wallet deployed on testnet: ${created.contractId}`);
} catch (e) {
setStatus("error");
+ const err = e instanceof Error ? e : new Error(String(e));
+ setPanelError(err);
const detail = publicErrorMessage(e);
setMessage(
`Deploy failed: ${detail}. Try again after refresh; if it persists, sponsor funding on testnet may be missing (Create passkey alone is enough for the demo).`,
@@ -132,6 +182,65 @@ export function PasskeyPanel() {
}
}
+ async function commitWithPasskey(clientOverride?: SubRosaClient) {
+ if (!session) {
+ setStatus("error");
+ const err = new Error("No passkey session available. Create a passkey first.");
+ setPanelError(err);
+ setMessage(err.message);
+ return;
+ }
+
+ setStatus("loading");
+ setMessage("Validating passkey session binding…");
+ setPanelError(null);
+
+ const client =
+ clientOverride ??
+ injectedClient ??
+ new SubRosaClient({
+ rpcUrl: PASSKEY_RPC_URL,
+ networkPassphrase: PASSKEY_NETWORK_PASSPHRASE,
+ contractId: PASSKEY_CONTRACT_ID ?? "",
+ publicKey: session.account,
+ });
+
+ try {
+ // Refuse a commit when contract id, network passphrase, or account differ from SDK client
+ validatePasskeySession(session, {
+ contractId: client.contractId,
+ networkPassphrase: client.networkPassphrase,
+ account: client.account,
+ });
+
+ if (onCommit) {
+ await onCommit(session);
+ } else {
+ await client.commit({
+ roundId: 1,
+ sealed: {
+ commitment: new Uint8Array(32),
+ ciphertext: new Uint8Array([0x61, 0x67, 0x65]),
+ auditorBlob: new Uint8Array(1),
+ },
+ escrow: 100_000n,
+ bidder: session.account,
+ session,
+ });
+ }
+
+ setStatus("ready");
+ setMessage("Passkey commit submitted successfully.");
+ } catch (e: unknown) {
+ setStatus("error");
+ const err = e instanceof Error ? e : new Error(String(e));
+ setPanelError(err);
+ // Ensure fixture secret seed is kept strictly out of the error message
+ const scrubbed = err.message.replace(/\bS[A-Z2-7]{55}\b/g, "[REDACTED]");
+ setMessage(scrubbed);
+ }
+ }
+
return (
@@ -200,6 +309,11 @@ export function PasskeyPanel() {
)}
{message}
+ {panelError && (
+
+ {panelError.name}: {message}
+
+ )}
+ {session && (
+
+ )}
- {wallet && (
+ {(wallet || session) && (
- {wallet.keyId && (
+ {session && (
+ <>
+ - session contractId
+ -
+
{session.contractId}
+
+ - session network
+ -
+
{session.networkPassphrase}
+
+ - session account
+ -
+
{session.account}
+
+ >
+ )}
+ {wallet?.keyId && (
<>
- keyId
-
@@ -229,7 +370,7 @@ export function PasskeyPanel() {
>
)}
- {wallet.publicKey && (
+ {wallet?.publicKey && (
<>
- secp256r1 publicKey
-
@@ -237,7 +378,7 @@ export function PasskeyPanel() {
>
)}
- {wallet.contractId && (
+ {wallet?.contractId && (
<>
- contractId
-
diff --git a/apps/web/src/demo/trace-health-check.test.ts b/apps/web/src/demo/trace-health-check.test.ts
index 4596ab06..403de6bc 100644
--- a/apps/web/src/demo/trace-health-check.test.ts
+++ b/apps/web/src/demo/trace-health-check.test.ts
@@ -96,51 +96,28 @@ test("checksum fails when a required milestone is removed from lifecycle", () =>
assert.ok(result.ok === false && result.missing.includes("open_reveal"));
});
-test("dropping a bidder fails the checksum", () => {
- const invalidTrace = structuredClone(DEMO_TRACE) as unknown as {
- bidders: Array;
- keeper: { reveals: string[] };
- };
- const dropped = invalidTrace.bidders[invalidTrace.bidders.length - 1] as { label: string };
- invalidTrace.bidders = invalidTrace.bidders.slice(0, -1);
- invalidTrace.keeper.reveals = invalidTrace.keeper.reveals.filter(
- (r) => r !== dropped.label,
+test("demo trace contains no secret seeds", () => {
+ const json = JSON.stringify(DEMO_TRACE);
+ assert.ok(
+ !/\bS[A-Z2-7]{55}\b/.test(json),
+ "Demo trace must never contain secret seeds",
);
+});
+
+test("health check rejects a demo trace containing an injected secret seed", () => {
+ const FAKE_SEED = "SBGWGH5QWWZ2WKKG24YCQAL35EWB64L35KAGL3E7N7H5K3T4K5K3T4K5";
+ const taintedTrace = structuredClone(DEMO_TRACE) as unknown as {
+ agents: Array<{ sessionKey: string }>;
+ };
+ taintedTrace.agents[0].sessionKey = FAKE_SEED;
assert.throws(
- () => assertDemoTrace(invalidTrace),
+ () => assertDemoTrace(taintedTrace),
(error: unknown) => {
assert.ok(error instanceof DemoTraceHealthCheckError);
- assert.match(error.message, /agent ".+" has no matching bidder record/);
+ assert.ok(error.issues.some((i) => i.includes("must not contain a secret seed")));
return true;
},
);
});
-test("a second settle record fails the checksum", () => {
- const invalidTrace = structuredClone(DEMO_TRACE) as unknown as {
- lifecycle: Array;
- };
- const settle = invalidTrace.lifecycle.find(
- (e) => isRecord(e) && e.phase === "settle",
- ) as Record | undefined;
- assert.ok(settle, "canonical trace must contain a settle event");
- invalidTrace.lifecycle.push(structuredClone(settle));
-
- assert.throws(
- () => assertDemoTrace(invalidTrace),
- /lifecycle must include exactly one settle phase/,
- );
-});
-
-test("duplicated bidder fails the checksum", () => {
- const invalidTrace = structuredClone(DEMO_TRACE) as unknown as {
- bidders: Array;
- };
- invalidTrace.bidders.push(structuredClone(invalidTrace.bidders[0]));
-
- assert.throws(
- () => assertDemoTrace(invalidTrace),
- /bidders must not contain duplicate labels/,
- );
-});
diff --git a/apps/web/src/demo/trace-health-check.ts b/apps/web/src/demo/trace-health-check.ts
index 7b9d7217..618cd199 100644
--- a/apps/web/src/demo/trace-health-check.ts
+++ b/apps/web/src/demo/trace-health-check.ts
@@ -322,11 +322,30 @@ function checkAuditor(
});
}
+function checkNoSecretSeeds(value: unknown, path: string, issues: string[]): void {
+ if (typeof value === "string") {
+ if (/\bS[A-Z2-7]{55}\b/.test(value)) {
+ issues.push(`${path} must not contain a secret seed`);
+ }
+ return;
+ }
+ if (Array.isArray(value)) {
+ value.forEach((item, i) => checkNoSecretSeeds(item, `${path}[${i}]`, issues));
+ return;
+ }
+ if (isRecord(value)) {
+ for (const [k, v] of Object.entries(value)) {
+ checkNoSecretSeeds(v, `${path}.${k}`, issues);
+ }
+ }
+}
+
export function assertDemoTrace(value: unknown): asserts value is DemoTrace {
const issues: string[] = [];
const trace = requireRecord(value, "trace", issues);
if (trace) {
+ checkNoSecretSeeds(trace, "trace", issues);
checkMeta(trace.meta, issues);
checkLifecycle(trace.lifecycle, issues);
const { labels: bidderLabels, addresses: bidderAddresses } = checkBidders(
diff --git a/apps/web/src/lib/config.test.ts b/apps/web/src/lib/config.test.ts
index fb3cf4be..5fc996c3 100644
--- a/apps/web/src/lib/config.test.ts
+++ b/apps/web/src/lib/config.test.ts
@@ -207,3 +207,50 @@ test("invalid VITE_RPC_URL error message is actionable", () => {
const msg = issues.find((i) => i.key === "VITE_RPC_URL")!.message;
assert.ok(msg.includes("must start with http:// or https://"));
});
+
+test("matching VITE_PASSKEY_CONTRACT_ID and VITE_CONTRACT_ID passes without issue", () => {
+ const issues = validatePublicConfig({
+ VITE_RPC_URL: "https://custom-soroban.example.com",
+ VITE_NETWORK_PASSPHRASE: "Custom Network",
+ VITE_CONTRACT_ID: "CA7KSDEYJEPGZEB2ZROTLUWKQQ6GIRIQNGG6Z745MZ34QHP4UJPWODEX",
+ VITE_PASSKEY_CONTRACT_ID: "CA7KSDEYJEPGZEB2ZROTLUWKQQ6GIRIQNGG6Z745MZ34QHP4UJPWODEX",
+ VITE_PASSKEY_NETWORK_PASSPHRASE: "Custom Network",
+ });
+ const passkeyIssues = issues.filter(
+ (i) => i.key === "VITE_PASSKEY_CONTRACT_ID" || i.key === "VITE_PASSKEY_NETWORK_PASSPHRASE",
+ );
+ assert.equal(passkeyIssues.length, 0);
+});
+
+test("swapped VITE_PASSKEY_CONTRACT_ID is reported as configuration mismatch", () => {
+ const issues = validatePublicConfig({
+ VITE_RPC_URL: "https://custom-soroban.example.com",
+ VITE_NETWORK_PASSPHRASE: "Custom Network",
+ VITE_CONTRACT_ID: "CA7KSDEYJEPGZEB2ZROTLUWKQQ6GIRIQNGG6Z745MZ34QHP4UJPWODEX",
+ VITE_PASSKEY_CONTRACT_ID: "CC2QMOXZERI6UOR67YKSORT7QTUHQ5QUGMHQBYVP23YM3NMUNNOEOGZY",
+ });
+ assert.ok(
+ issues.some(
+ (i) =>
+ i.key === "VITE_PASSKEY_CONTRACT_ID" &&
+ i.message.includes("does not match VITE_CONTRACT_ID"),
+ ),
+ );
+});
+
+test("swapped VITE_PASSKEY_NETWORK_PASSPHRASE is reported as configuration mismatch", () => {
+ const issues = validatePublicConfig({
+ VITE_RPC_URL: "https://custom-soroban.example.com",
+ VITE_NETWORK_PASSPHRASE: "Custom Network",
+ VITE_CONTRACT_ID: "CA7KSDEYJEPGZEB2ZROTLUWKQQ6GIRIQNGG6Z745MZ34QHP4UJPWODEX",
+ VITE_PASSKEY_NETWORK_PASSPHRASE: "Public Global Stellar Network ; September 2015",
+ });
+ assert.ok(
+ issues.some(
+ (i) =>
+ i.key === "VITE_PASSKEY_NETWORK_PASSPHRASE" &&
+ i.message.includes("does not match VITE_NETWORK_PASSPHRASE"),
+ ),
+ );
+});
+
diff --git a/apps/web/src/lib/config.ts b/apps/web/src/lib/config.ts
index a301f352..2c4d8cf6 100644
--- a/apps/web/src/lib/config.ts
+++ b/apps/web/src/lib/config.ts
@@ -101,6 +101,36 @@ export function validatePublicConfig(
}
}
+ const passkeyContractId = env.VITE_PASSKEY_CONTRACT_ID;
+ const contractId = env.VITE_CONTRACT_ID;
+ if (
+ passkeyContractId &&
+ contractId &&
+ passkeyContractId.trim() !== "" &&
+ contractId.trim() !== "" &&
+ passkeyContractId.trim() !== contractId.trim()
+ ) {
+ issues.push({
+ key: "VITE_PASSKEY_CONTRACT_ID",
+ message: `VITE_PASSKEY_CONTRACT_ID (${passkeyContractId}) does not match VITE_CONTRACT_ID (${contractId}). Passkey session will be bound to a different contract than web config.`,
+ });
+ }
+
+ const passkeyPassphrase = env.VITE_PASSKEY_NETWORK_PASSPHRASE;
+ const networkPassphrase = env.VITE_NETWORK_PASSPHRASE;
+ if (
+ passkeyPassphrase &&
+ networkPassphrase &&
+ passkeyPassphrase.trim() !== "" &&
+ networkPassphrase.trim() !== "" &&
+ passkeyPassphrase.trim() !== networkPassphrase.trim()
+ ) {
+ issues.push({
+ key: "VITE_PASSKEY_NETWORK_PASSPHRASE",
+ message: `VITE_PASSKEY_NETWORK_PASSPHRASE does not match VITE_NETWORK_PASSPHRASE. Passkey session cannot commit across different networks.`,
+ });
+ }
+
return issues;
}
diff --git a/apps/web/src/passkey-config.ts b/apps/web/src/passkey-config.ts
index 9bff2262..870b6771 100644
--- a/apps/web/src/passkey-config.ts
+++ b/apps/web/src/passkey-config.ts
@@ -1,17 +1,96 @@
// Copyright (c) 2026 Sub Rosa contributors
+import type { CommitParams, SubRosaClient } from "@sub-rosa/sdk";
+import { validatePasskeySession } from "@sub-rosa/sdk";
+
/** Public testnet smart-wallet WASM (passkey-kit demo). Not a secret. */
export const PASSKEY_TESTNET_WALLET_WASM_HASH =
"ecd990f0b45ca6817149b6175f79b32efb442f35731985a084131e8265c4cd90";
+const env: Record =
+ typeof import.meta !== "undefined" && import.meta.env
+ ? import.meta.env
+ : typeof process !== "undefined" && process.env
+ ? process.env
+ : {};
+
export const PASSKEY_RPC_URL =
- import.meta.env.VITE_RPC_URL ?? "https://soroban-testnet.stellar.org";
+ env.VITE_RPC_URL ?? "https://soroban-testnet.stellar.org";
export const PASSKEY_NETWORK_PASSPHRASE =
- import.meta.env.VITE_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015";
+ env.VITE_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015";
+
+export const PASSKEY_CONTRACT_ID =
+ env.VITE_PASSKEY_CONTRACT_ID ?? env.VITE_CONTRACT_ID;
export function resolvePasskeyWalletWasmHash(): string | undefined {
- const fromEnv = import.meta.env.VITE_PASSKEY_WALLET_WASM_HASH?.trim();
+ const fromEnv = env.VITE_PASSKEY_WALLET_WASM_HASH?.trim();
if (fromEnv) return fromEnv;
// Default for local jury demo — same hash as passkey-kit-demo on testnet.
return PASSKEY_TESTNET_WALLET_WASM_HASH;
}
+
+export interface PasskeySession {
+ /** Contract ID the passkey session is bound to. */
+ contractId: string;
+ /** Network passphrase the passkey session is bound to. */
+ networkPassphrase: string;
+ /** Account/signer public address bound to this session. */
+ account: string;
+ keyId?: string;
+ publicKey?: string;
+ createdAt?: string;
+}
+
+/**
+ * Record contract id, network passphrase, and account when a passkey session starts.
+ */
+export function createPasskeySession(params: {
+ contractId?: string;
+ networkPassphrase?: string;
+ account: string;
+ keyId?: string;
+ publicKey?: string;
+}): PasskeySession {
+ const contractId = (params.contractId ?? PASSKEY_CONTRACT_ID)?.trim();
+ if (!contractId) {
+ throw new Error("contractId is required to start a passkey session");
+ }
+ const networkPassphrase = (
+ params.networkPassphrase ?? PASSKEY_NETWORK_PASSPHRASE
+ )?.trim();
+ if (!networkPassphrase) {
+ throw new Error("networkPassphrase is required to start a passkey session");
+ }
+ if (!params.account || params.account.trim() === "") {
+ throw new Error("account is required to start a passkey session");
+ }
+ return {
+ contractId,
+ networkPassphrase,
+ account: params.account.trim(),
+ keyId: params.keyId,
+ publicKey: params.publicKey,
+ createdAt: new Date().toISOString(),
+ };
+}
+
+/**
+ * Execute a commit bound to a passkey session.
+ * Refuses commit with a typed error if contract id, network passphrase, or account differ from the current SDK client.
+ */
+export async function commitWithPasskeySession(
+ session: PasskeySession,
+ client: SubRosaClient,
+ commitParams: Omit,
+): Promise {
+ validatePasskeySession(session, {
+ contractId: client.contractId,
+ networkPassphrase: client.networkPassphrase,
+ account: commitParams.bidder ?? client.account,
+ });
+ return client.commit({
+ ...commitParams,
+ session,
+ });
+}
+
diff --git a/apps/web/src/passkey-session.test.ts b/apps/web/src/passkey-session.test.ts
new file mode 100644
index 00000000..9ac6695c
--- /dev/null
+++ b/apps/web/src/passkey-session.test.ts
@@ -0,0 +1,160 @@
+// Copyright (c) 2026 Sub Rosa contributors
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { Keypair, StrKey } from "@stellar/stellar-sdk";
+import {
+ SubRosaClient,
+ SubRosaNetworkMismatchError,
+ SubRosaSessionMismatchError,
+} from "@sub-rosa/sdk";
+import {
+ createPasskeySession,
+ commitWithPasskeySession,
+ type PasskeySession,
+} from "./passkey-config";
+
+describe("Passkey session binding fixture test", () => {
+ const fixtureKeypair = Keypair.random();
+ const FIXTURE_SEED = fixtureKeypair.secret();
+ const FIXTURE_PUBLIC = fixtureKeypair.publicKey();
+
+ const FIXTURE_CONTRACT_ID = StrKey.encodeContract(Buffer.alloc(32, 7));
+ const SWAPPED_CONTRACT_ID = StrKey.encodeContract(Buffer.alloc(32, 9));
+ const TESTNET_PASSPHRASE = "Test SDF Network ; September 2015";
+ const PUBLIC_PASSPHRASE = "Public Global Stellar Network ; September 2015";
+
+ const FAKE_COMMIT_PARAMS = {
+ roundId: 1,
+ sealed: {
+ commitment: new Uint8Array(32),
+ ciphertext: new Uint8Array([0x61, 0x67, 0x65]),
+ auditorBlob: new Uint8Array(1),
+ },
+ escrow: 100_000n,
+ bidder: FIXTURE_PUBLIC,
+ };
+
+ function createFixtureClient(options: {
+ contractId?: string;
+ networkPassphrase?: string;
+ } = {}) {
+ const contractId = options.contractId ?? FIXTURE_CONTRACT_ID;
+ const networkPassphrase = options.networkPassphrase ?? TESTNET_PASSPHRASE;
+ let submitted = false;
+
+ const client = new SubRosaClient({
+ rpcUrl: "https://soroban-testnet.stellar.org",
+ networkPassphrase,
+ contractId,
+ secretKey: FIXTURE_SEED,
+ _server: {
+ getNetwork: async () => ({
+ passphrase: networkPassphrase,
+ protocolVersion: "23",
+ }),
+ getLedgerEntries: async () => ({
+ entries: [{}],
+ latestLedger: 123,
+ }),
+ } as never,
+ });
+
+ // Mock contract commit to detect submission
+ Object.defineProperty(client.contract, "commit", {
+ configurable: true,
+ value: async () => {
+ submitted = true;
+ return {
+ signAndSend: async () => ({
+ result: { unwrap: () => ({}) },
+ }),
+ };
+ },
+ });
+
+ return { client, hasSubmitted: () => submitted };
+ }
+
+ it("A matching session can commit in the fixture test", async () => {
+ const { client, hasSubmitted } = createFixtureClient();
+
+ const matchingSession = createPasskeySession({
+ contractId: FIXTURE_CONTRACT_ID,
+ networkPassphrase: TESTNET_PASSPHRASE,
+ account: FIXTURE_PUBLIC,
+ });
+
+ await commitWithPasskeySession(matchingSession, client, FAKE_COMMIT_PARAMS);
+ assert.equal(hasSubmitted(), true, "matching session should have submitted commit");
+ });
+
+ it("A swapped contract id does not submit", async () => {
+ const { client, hasSubmitted } = createFixtureClient();
+
+ const swappedSession: PasskeySession = {
+ contractId: SWAPPED_CONTRACT_ID,
+ networkPassphrase: TESTNET_PASSPHRASE,
+ account: FIXTURE_PUBLIC,
+ };
+
+ await assert.rejects(
+ commitWithPasskeySession(swappedSession, client, FAKE_COMMIT_PARAMS),
+ (error: unknown) => {
+ assert.ok(error instanceof SubRosaNetworkMismatchError);
+ assert.ok(error instanceof SubRosaSessionMismatchError);
+ assert.equal((error as SubRosaNetworkMismatchError).reason, "contract_mismatch");
+ return true;
+ },
+ );
+
+ assert.equal(hasSubmitted(), false, "swapped contract id must not submit to contract");
+ });
+
+ it("A swapped passphrase does not submit", async () => {
+ const { client, hasSubmitted } = createFixtureClient();
+
+ const swappedSession: PasskeySession = {
+ contractId: FIXTURE_CONTRACT_ID,
+ networkPassphrase: PUBLIC_PASSPHRASE,
+ account: FIXTURE_PUBLIC,
+ };
+
+ await assert.rejects(
+ commitWithPasskeySession(swappedSession, client, FAKE_COMMIT_PARAMS),
+ (error: unknown) => {
+ assert.ok(error instanceof SubRosaNetworkMismatchError);
+ assert.ok(error instanceof SubRosaSessionMismatchError);
+ assert.equal((error as SubRosaNetworkMismatchError).reason, "session_mismatch");
+ return true;
+ },
+ );
+
+ assert.equal(hasSubmitted(), false, "swapped network passphrase must not submit to contract");
+ });
+
+ it("The error text does not contain the fixture seed", async () => {
+ const { client } = createFixtureClient();
+
+ const swappedSession: PasskeySession = {
+ contractId: SWAPPED_CONTRACT_ID,
+ networkPassphrase: PUBLIC_PASSPHRASE,
+ account: FIXTURE_PUBLIC,
+ };
+
+ try {
+ await commitWithPasskeySession(swappedSession, client, FAKE_COMMIT_PARAMS);
+ assert.fail("commitWithPasskeySession should have failed");
+ } catch (e: unknown) {
+ assert.ok(e instanceof Error);
+ const errorText = e.message;
+ assert.ok(
+ !errorText.includes(FIXTURE_SEED),
+ "Error message must not include fixture secret seed",
+ );
+ assert.ok(
+ !/\bS[A-Z2-7]{55}\b/.test(errorText),
+ "Error message must not match secret seed pattern",
+ );
+ }
+ });
+});
diff --git a/packages/sdk/src/client.test.ts b/packages/sdk/src/client.test.ts
index 983f1297..af0dd010 100644
--- a/packages/sdk/src/client.test.ts
+++ b/packages/sdk/src/client.test.ts
@@ -1,13 +1,13 @@
// Copyright (c) 2026 Sub Rosa contributors
import assert from "node:assert/strict";
import { describe, it } from "node:test";
-import { readFileSync } from "node:fs";
-import { rpc, StrKey } from "@stellar/stellar-sdk";
+import { Keypair, rpc, StrKey } from "@stellar/stellar-sdk";
import { SubRosaClient } from "./client.js";
import {
SubRosaClientConfigError,
- SubRosaPaginationError,
+ SubRosaNetworkMismatchError,
+ SubRosaSessionMismatchError,
SubRosaSubmitError,
} from "./errors.js";
import type {
@@ -303,3 +303,123 @@ describe("SubRosaClient external submitter failures", () => {
});
});
});
+
+describe("SubRosaClient passkey session binding", () => {
+ const SWAPPED_CONTRACT_ID = StrKey.encodeContract(Buffer.alloc(32, 2));
+ const PUBLIC_PASSPHRASE = "Public Global Stellar Network ; September 2015";
+ const FIXTURE_SEED = Keypair.random().secret();
+
+ const VALID_COMMIT_PARAMS = {
+ roundId: 1,
+ sealed: {
+ commitment: new Uint8Array(32),
+ ciphertext: new Uint8Array([0x61, 0x67, 0x65]),
+ auditorBlob: new Uint8Array(1),
+ },
+ escrow: 100_000n,
+ bidder: PUBLIC_KEY,
+ };
+
+ it("exposes account and session properties", () => {
+ const session = {
+ contractId: BASE_CONFIG.contractId,
+ networkPassphrase: BASE_CONFIG.networkPassphrase,
+ account: PUBLIC_KEY,
+ };
+ const client = new SubRosaClient({
+ ...BASE_CONFIG,
+ publicKey: PUBLIC_KEY,
+ session,
+ });
+ assert.equal(client.account, PUBLIC_KEY);
+ assert.deepEqual(client.session, session);
+ });
+
+ it("refuses commit when session contract id does not match client", async () => {
+ const client = new SubRosaClient({
+ ...BASE_CONFIG,
+ publicKey: PUBLIC_KEY,
+ });
+
+ await assert.rejects(
+ client.commit({
+ ...VALID_COMMIT_PARAMS,
+ session: {
+ contractId: SWAPPED_CONTRACT_ID,
+ networkPassphrase: BASE_CONFIG.networkPassphrase,
+ account: PUBLIC_KEY,
+ },
+ }),
+ (error: unknown) => {
+ assert.ok(error instanceof SubRosaNetworkMismatchError);
+ assert.ok(error instanceof SubRosaSessionMismatchError);
+ assert.equal((error as SubRosaNetworkMismatchError).reason, "contract_mismatch");
+ return true;
+ },
+ );
+ });
+
+ it("refuses commit when session network passphrase does not match client", async () => {
+ const client = new SubRosaClient({
+ ...BASE_CONFIG,
+ publicKey: PUBLIC_KEY,
+ });
+
+ await assert.rejects(
+ client.commit({
+ ...VALID_COMMIT_PARAMS,
+ session: {
+ contractId: BASE_CONFIG.contractId,
+ networkPassphrase: PUBLIC_PASSPHRASE,
+ account: PUBLIC_KEY,
+ },
+ }),
+ (error: unknown) => {
+ assert.ok(error instanceof SubRosaNetworkMismatchError);
+ assert.ok(error instanceof SubRosaSessionMismatchError);
+ assert.equal((error as SubRosaNetworkMismatchError).reason, "session_mismatch");
+ return true;
+ },
+ );
+ });
+
+ it("refuses preflightCommit when session does not match", async () => {
+ const client = new SubRosaClient({
+ ...BASE_CONFIG,
+ publicKey: PUBLIC_KEY,
+ session: {
+ contractId: SWAPPED_CONTRACT_ID,
+ networkPassphrase: BASE_CONFIG.networkPassphrase,
+ account: PUBLIC_KEY,
+ },
+ });
+
+ await assert.rejects(
+ client.preflightCommit(VALID_COMMIT_PARAMS),
+ SubRosaNetworkMismatchError,
+ );
+ });
+
+ it("does not leak secret seed in session mismatch errors", async () => {
+ const client = new SubRosaClient({
+ ...BASE_CONFIG,
+ secretKey: FIXTURE_SEED,
+ });
+
+ try {
+ await client.commit({
+ ...VALID_COMMIT_PARAMS,
+ session: {
+ contractId: SWAPPED_CONTRACT_ID,
+ networkPassphrase: BASE_CONFIG.networkPassphrase,
+ },
+ });
+ assert.fail("should have thrown");
+ } catch (e) {
+ assert.ok(e instanceof Error);
+ assert.ok(!e.message.includes(FIXTURE_SEED));
+ assert.ok(!/\bS[A-Z2-7]{55}\b/.test(e.message));
+ }
+ });
+});
+
diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts
index d4d95293..aa14f296 100644
--- a/packages/sdk/src/client.ts
+++ b/packages/sdk/src/client.ts
@@ -49,7 +49,11 @@ import {
SubRosaTransactionError,
} from "./errors.js";
import { normalizeRoundId, normalizeSorobanContractId } from "./ids.js";
-import { validateContractNetwork } from "./network.js";
+import {
+ validateContractNetwork,
+ validatePasskeySession,
+ type PasskeySessionBinding,
+} from "./network.js";
import {
resolveTimeContext,
systemTime,
@@ -108,7 +112,8 @@ export interface SubRosaClientConfig {
* @internal Testing hook: inject a mock Soroban RPC server for simulation.
*/
_server?: rpc.Server;
-
+ /** Optional passkey session to bind commits and client operations to. */
+ session?: PasskeySessionBinding;
}
export type ClearingRuleTag = ClearingRule["tag"];
@@ -140,6 +145,8 @@ export interface CommitParams {
escrow: bigint;
/** Bidder address. Default: the configured signer's public key. */
bidder?: string;
+ /** Optional passkey session to bind this commit to. */
+ session?: PasskeySessionBinding;
}
export interface RevealParams {
@@ -171,6 +178,7 @@ export class SubRosaClient {
readonly #clock: Clock;
readonly #scheduler: Scheduler;
readonly #server: rpc.Server;
+ readonly #session?: PasskeySessionBinding;
#networkValidation?: Promise;
@@ -217,6 +225,7 @@ export class SubRosaClient {
const time = resolveTimeContext(systemTime, config.time);
this.#clock = time.clock;
this.#scheduler = time.scheduler;
+ this.#session = config.session;
this.#server = config._server ?? new rpc.Server(config.rpcUrl, { allowHttp });
if (config._sleep) this.#sleep = config._sleep;
@@ -238,6 +247,16 @@ export class SubRosaClient {
return this.contract.spec;
}
+ /** The configured source account (public key G...) if available. */
+ get account(): string | undefined {
+ return this.#source;
+ }
+
+ /** The configured passkey session binding, if any. */
+ get session(): PasskeySessionBinding | undefined {
+ return this.#session;
+ }
+
#requireSource(role: string): string {
if (!this.#source) {
throw new SubRosaClientConfigError(
@@ -425,6 +444,15 @@ export class SubRosaClient {
}
async commit(params: CommitParams): Promise {
+ const session = params.session ?? this.#session;
+ if (session) {
+ validatePasskeySession(session, {
+ contractId: this.contractId,
+ networkPassphrase: this.networkPassphrase,
+ account: params.bidder ?? this.#source,
+ });
+ }
+
// Validate encrypted blobs before submitting — catches size/encoding
// issues early, before paying gas for an on-chain revert (PayloadTooLarge).
const ciphertextResult = validateEncryptedBlob(
@@ -566,7 +594,15 @@ export class SubRosaClient {
}
/** Simulate `commit` without signing or submitting. */
- preflightCommit(params: CommitParams): Promise> {
+ async preflightCommit(params: CommitParams): Promise> {
+ const session = params.session ?? this.#session;
+ if (session) {
+ validatePasskeySession(session, {
+ contractId: this.contractId,
+ networkPassphrase: this.networkPassphrase,
+ account: params.bidder ?? this.#source,
+ });
+ }
return this.#preflight("commit", () => {
const bidder = params.bidder ?? this.#requireSource("bidder");
return this.#validatedContractCall(() =>
diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts
index 38078a91..3d1904e4 100644
--- a/packages/sdk/src/errors.ts
+++ b/packages/sdk/src/errors.ts
@@ -152,37 +152,72 @@ export class SubRosaClientConfigError extends Error {
}
}
+export type NetworkMismatchReason =
+ | "passphrase"
+ | "contract_not_found"
+ | "contract_mismatch"
+ | "account_mismatch"
+ | "session_mismatch";
+
export interface NetworkMismatchErrorParams {
contractId: string;
configuredPassphrase: string;
- rpcPassphrase: string;
- rpcUrl: string;
- reason: "passphrase" | "contract_not_found";
+ rpcPassphrase?: string;
+ rpcUrl?: string;
+ reason: NetworkMismatchReason;
+ sessionContractId?: string;
+ sessionPassphrase?: string;
+ sessionAccount?: string;
+ expectedAccount?: string;
}
-/** Raised before contract simulation/signing when network configuration conflicts. */
+/** Raised before contract simulation/signing when network configuration or session binding conflicts. */
export class SubRosaNetworkMismatchError extends Error {
- readonly name = "SubRosaNetworkMismatchError";
+ readonly name: string = "SubRosaNetworkMismatchError";
readonly contractId: string;
readonly configuredPassphrase: string;
- readonly rpcPassphrase: string;
- readonly rpcUrl: string;
+ readonly rpcPassphrase?: string;
+ readonly rpcUrl?: string;
readonly reason: NetworkMismatchErrorParams["reason"];
+ readonly sessionContractId?: string;
+ readonly sessionPassphrase?: string;
+ readonly sessionAccount?: string;
+ readonly expectedAccount?: string;
constructor(params: NetworkMismatchErrorParams) {
- const message =
- params.reason === "passphrase"
- ? `networkPassphrase ${JSON.stringify(params.configuredPassphrase)} does not match RPC network ${JSON.stringify(params.rpcPassphrase)} at ${params.rpcUrl}; use the passphrase and contract ID from the same deployment`
- : `contract ${params.contractId} was not found on RPC network ${JSON.stringify(params.rpcPassphrase)} at ${params.rpcUrl}; check that contractId and networkPassphrase refer to the same deployment`;
+ let rawMessage: string;
+ if (params.reason === "passphrase") {
+ rawMessage = `networkPassphrase ${JSON.stringify(params.configuredPassphrase)} does not match RPC network ${JSON.stringify(params.rpcPassphrase)} at ${params.rpcUrl}; use the passphrase and contract ID from the same deployment`;
+ } else if (params.reason === "contract_not_found") {
+ rawMessage = `contract ${params.contractId} was not found on RPC network ${JSON.stringify(params.rpcPassphrase)} at ${params.rpcUrl}; check that contractId and networkPassphrase refer to the same deployment`;
+ } else if (params.reason === "contract_mismatch") {
+ rawMessage = `passkey session contract ${params.sessionContractId ?? params.contractId} does not match client contract ${params.contractId}; refuse commit across different contracts`;
+ } else if (params.reason === "account_mismatch") {
+ rawMessage = `passkey session account ${params.sessionAccount} does not match client account ${params.expectedAccount}; refuse commit for different account`;
+ } else {
+ rawMessage = `passkey session network ${JSON.stringify(params.sessionPassphrase ?? params.configuredPassphrase)} does not match client network ${JSON.stringify(params.configuredPassphrase)}; refuse commit across different networks`;
+ }
+
+ // Keep secret seeds strictly out of error text
+ const message = rawMessage.replace(/\bS[A-Z2-7]{55}\b/g, "[REDACTED]");
super(message);
this.contractId = params.contractId;
this.configuredPassphrase = params.configuredPassphrase;
this.rpcPassphrase = params.rpcPassphrase;
this.rpcUrl = params.rpcUrl;
this.reason = params.reason;
+ this.sessionContractId = params.sessionContractId;
+ this.sessionPassphrase = params.sessionPassphrase;
+ this.sessionAccount = params.sessionAccount;
+ this.expectedAccount = params.expectedAccount;
}
}
+/** Specific typed error for passkey session binding mismatches. Inherits from SubRosaNetworkMismatchError. */
+export class SubRosaSessionMismatchError extends SubRosaNetworkMismatchError {
+ override readonly name = "SubRosaSessionMismatchError";
+}
+
export class SubRosaSubmitError extends Error {
readonly name = "SubRosaSubmitError";
readonly retryable?: boolean;
diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts
index 35e03582..bdbc4098 100644
--- a/packages/sdk/src/index.ts
+++ b/packages/sdk/src/index.ts
@@ -37,6 +37,7 @@ export {
SubRosaMissingReturnValueError,
SubRosaNetworkMismatchError,
SubRosaPreflightError,
+ SubRosaSessionMismatchError,
SubRosaSubmitError,
SubRosaTimeoutError,
SubRosaTransactionError,
@@ -47,18 +48,18 @@ export type {
RoundContractErrorSpec,
ContractErrorEntry,
NetworkMismatchErrorParams,
+ NetworkMismatchReason,
PreflightFailureKind,
SubRosaPreflightErrorParams,
TimeoutErrorParams,
} from "./errors.js";
export {
validateContractNetwork,
- networkDisplayName,
- networkPassphrasesMatch,
- describeNetworkPassphraseMismatch,
- SubRosaNetworkPassphraseMismatchError,
+ validatePasskeySession,
type ContractNetworkValidationConfig,
type NetworkValidationServer,
+ type PasskeySessionBinding,
+ type SessionBindingTarget,
} from "./network.js";
export {
diff --git a/packages/sdk/src/network.test.ts b/packages/sdk/src/network.test.ts
index 7c5881c1..1edb8172 100644
--- a/packages/sdk/src/network.test.ts
+++ b/packages/sdk/src/network.test.ts
@@ -1,13 +1,13 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { StrKey } from "@stellar/stellar-sdk";
-import { SubRosaNetworkMismatchError } from "./errors.js";
+import {
+ SubRosaNetworkMismatchError,
+ SubRosaSessionMismatchError,
+} from "./errors.js";
import {
validateContractNetwork,
- networkDisplayName,
- networkPassphrasesMatch,
- describeNetworkPassphraseMismatch,
- SubRosaNetworkPassphraseMismatchError,
+ validatePasskeySession,
type NetworkValidationServer,
} from "./network.js";
@@ -121,3 +121,127 @@ describe("validateContractNetwork", () => {
assert.equal(ledgerLookups, 0);
});
});
+
+describe("validatePasskeySession", () => {
+ const SESSION_ACCOUNT = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
+ const SWAPPED_CONTRACT_ID = StrKey.encodeContract(Buffer.alloc(32, 1));
+ const FIXTURE_SEED = "SBGWGH5QWWZ2WKKG24YCQAL35EWB64L35KAGL3E7N7H5K3T4K5K3T4K5";
+
+ it("accepts a matching passkey session and target", () => {
+ assert.doesNotThrow(() => {
+ validatePasskeySession(
+ {
+ contractId: CONTRACT_ID,
+ networkPassphrase: TESTNET,
+ account: SESSION_ACCOUNT,
+ },
+ {
+ contractId: CONTRACT_ID,
+ networkPassphrase: TESTNET,
+ account: SESSION_ACCOUNT,
+ },
+ );
+ });
+ });
+
+ it("rejects a swapped contract id with a typed mismatch error", () => {
+ assert.throws(
+ () => {
+ validatePasskeySession(
+ {
+ contractId: CONTRACT_ID,
+ networkPassphrase: TESTNET,
+ account: SESSION_ACCOUNT,
+ },
+ {
+ contractId: SWAPPED_CONTRACT_ID,
+ networkPassphrase: TESTNET,
+ account: SESSION_ACCOUNT,
+ },
+ );
+ },
+ (error: unknown) => {
+ assert.ok(error instanceof SubRosaNetworkMismatchError);
+ assert.ok(error instanceof SubRosaSessionMismatchError);
+ assert.equal((error as SubRosaNetworkMismatchError).reason, "contract_mismatch");
+ assert.match((error as Error).message, /contract/);
+ return true;
+ },
+ );
+ });
+
+ it("rejects a swapped network passphrase with a typed mismatch error", () => {
+ assert.throws(
+ () => {
+ validatePasskeySession(
+ {
+ contractId: CONTRACT_ID,
+ networkPassphrase: TESTNET,
+ account: SESSION_ACCOUNT,
+ },
+ {
+ contractId: CONTRACT_ID,
+ networkPassphrase: PUBLIC,
+ account: SESSION_ACCOUNT,
+ },
+ );
+ },
+ (error: unknown) => {
+ assert.ok(error instanceof SubRosaNetworkMismatchError);
+ assert.ok(error instanceof SubRosaSessionMismatchError);
+ assert.equal((error as SubRosaNetworkMismatchError).reason, "session_mismatch");
+ assert.match((error as Error).message, /network/);
+ return true;
+ },
+ );
+ });
+
+ it("rejects a swapped account with a typed mismatch error", () => {
+ const OTHER_ACCOUNT = "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBWHF";
+ assert.throws(
+ () => {
+ validatePasskeySession(
+ {
+ contractId: CONTRACT_ID,
+ networkPassphrase: TESTNET,
+ account: SESSION_ACCOUNT,
+ },
+ {
+ contractId: CONTRACT_ID,
+ networkPassphrase: TESTNET,
+ account: OTHER_ACCOUNT,
+ },
+ );
+ },
+ (error: unknown) => {
+ assert.ok(error instanceof SubRosaNetworkMismatchError);
+ assert.ok(error instanceof SubRosaSessionMismatchError);
+ assert.equal((error as SubRosaNetworkMismatchError).reason, "account_mismatch");
+ return true;
+ },
+ );
+ });
+
+ it("ensures the error text does not contain secret seeds", () => {
+ try {
+ validatePasskeySession(
+ {
+ contractId: CONTRACT_ID,
+ networkPassphrase: TESTNET,
+ account: SESSION_ACCOUNT,
+ },
+ {
+ contractId: SWAPPED_CONTRACT_ID,
+ networkPassphrase: TESTNET,
+ account: SESSION_ACCOUNT,
+ },
+ );
+ assert.fail("should have thrown");
+ } catch (e) {
+ assert.ok(e instanceof Error);
+ assert.ok(!e.message.includes(FIXTURE_SEED));
+ assert.ok(!/\bS[A-Z2-7]{55}\b/.test(e.message));
+ }
+ });
+});
+
diff --git a/packages/sdk/src/network.ts b/packages/sdk/src/network.ts
index 9befe659..2cbdefdd 100644
--- a/packages/sdk/src/network.ts
+++ b/packages/sdk/src/network.ts
@@ -1,5 +1,9 @@
import { Contract, rpc } from "@stellar/stellar-sdk";
-import { SubRosaNetworkMismatchError } from "./errors.js";
+import {
+ SubRosaNetworkMismatchError,
+ SubRosaSessionMismatchError,
+} from "./errors.js";
+import { normalizeSorobanContractId } from "./ids.js";
/**
* Human-readable label for a Stellar network passphrase. Unknown/custom
@@ -72,6 +76,76 @@ export interface ContractNetworkValidationConfig {
rpcUrl: string;
}
+export interface PasskeySessionBinding {
+ /** Contract ID the passkey session was started for. */
+ contractId: string;
+ /** Network passphrase the passkey session was created on. */
+ networkPassphrase: string;
+ /** Account/public key bound to this session. */
+ account?: string;
+}
+
+export interface SessionBindingTarget {
+ /** Target contract ID (e.g., from the SDK client). */
+ contractId: string;
+ /** Target network passphrase (e.g., from the SDK client). */
+ networkPassphrase: string;
+ /** Target account (e.g., from the SDK client). */
+ account?: string;
+}
+
+/**
+ * Validate that a passkey session matches the contract ID, network passphrase,
+ * and account of the executing SDK client.
+ *
+ * Refuses execution and throws SubRosaSessionMismatchError if any differ.
+ */
+export function validatePasskeySession(
+ session: PasskeySessionBinding,
+ target: SessionBindingTarget,
+): void {
+ if (session.networkPassphrase !== target.networkPassphrase) {
+ throw new SubRosaSessionMismatchError({
+ contractId: target.contractId,
+ configuredPassphrase: target.networkPassphrase,
+ sessionPassphrase: session.networkPassphrase,
+ reason: "session_mismatch",
+ });
+ }
+
+ let sessionContract = session.contractId.trim();
+ let targetContract = target.contractId.trim();
+ try {
+ sessionContract = normalizeSorobanContractId(session.contractId);
+ } catch {
+ // Keep trimmed string
+ }
+ try {
+ targetContract = normalizeSorobanContractId(target.contractId);
+ } catch {
+ // Keep trimmed string
+ }
+
+ if (sessionContract !== targetContract) {
+ throw new SubRosaSessionMismatchError({
+ contractId: targetContract,
+ configuredPassphrase: target.networkPassphrase,
+ sessionContractId: sessionContract,
+ reason: "contract_mismatch",
+ });
+ }
+
+ if (session.account && target.account && session.account !== target.account) {
+ throw new SubRosaSessionMismatchError({
+ contractId: targetContract,
+ configuredPassphrase: target.networkPassphrase,
+ sessionAccount: session.account,
+ expectedAccount: target.account,
+ reason: "account_mismatch",
+ });
+ }
+}
+
/**
* Confirm that the RPC is on the configured network and that the contract
* exists there. Contract StrKeys do not encode a network, so both checks are
@@ -104,3 +178,4 @@ export async function validateContractNetwork(
});
}
}
+
diff --git a/packages/sdk/src/public-api-snapshot.test.ts b/packages/sdk/src/public-api-snapshot.test.ts
index 08977116..51ed5608 100644
--- a/packages/sdk/src/public-api-snapshot.test.ts
+++ b/packages/sdk/src/public-api-snapshot.test.ts
@@ -33,6 +33,7 @@ const EXPECTED_EXPORTS = [
"SubRosaNetworkMismatchError",
"SubRosaNetworkPassphraseMismatchError",
"SubRosaPreflightError",
+ "SubRosaSessionMismatchError",
"SubRosaSubmitError",
"SubRosaTimeoutError",
"SubRosaTransactionError",
@@ -81,6 +82,7 @@ const EXPECTED_EXPORTS = [
"validateAssetConfigs",
"validateEncryptedBlob",
"validateContractNetwork",
+ "validatePasskeySession",
"verifyReceipt",
"verifyReceiptEvents",
"verifySettledRoundProof",