Skip to content
Draft
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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@stellar/freighter-api": "^6.0.1",
"@stellar/stellar-sdk": "^15.1.0",
"@sub-rosa/agent": "workspace:*",
"@sub-rosa/errors": "workspace:*",
"@sub-rosa/sdk": "workspace:*",
"@sub-rosa/time": "workspace:*",
"@sub-rosa/tlock": "workspace:*",
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/AttackDemo.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) 2026 Sub Rosa contributors
import { useState } from "react";
import { getErrorMessage } from "@sub-rosa/errors";
import type { AttackStep } from "../lib/demoTypes";
import { useToast } from "../ui/Toast";

Expand Down Expand Up @@ -44,7 +45,7 @@ export function AttackDemo() {
`Seal-off leaks early · seal-on waits for R=${res.revealRound.toLocaleString()}`,
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const msg = getErrorMessage(e);
setErr(msg);
toast.dismiss(workingId);
toast.push("error", "Attack demo failed", msg);
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/components/AuditorView.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) 2026 Sub Rosa contributors
import { useMemo, useState } from "react";
import { getErrorMessage } from "@sub-rosa/errors";
import type { DemoTrace } from "../demo/trace";
import { shortAddr } from "../lib/format";
import { hexToBytes } from "../lib/hex";
Expand Down Expand Up @@ -53,13 +54,13 @@ export function AuditorView({ trace }: { trace: DemoTrace }) {
label,
address,
identity: null,
error: e instanceof Error ? e.message : String(e),
error: getErrorMessage(e),
};
}
});
setRows(decoded);
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
setErr(getErrorMessage(e));
} finally {
setBusy(false);
}
Expand All @@ -80,7 +81,7 @@ export function AuditorView({ trace }: { trace: DemoTrace }) {
if (opened.value !== value) throw new Error("opened value mismatch");
setBidDemo({ value: (Number(value) / 1e7).toFixed(2), round });
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
setErr(getErrorMessage(e));
} finally {
setBusy(false);
}
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/PasskeyPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) 2026 Sub Rosa contributors
import { useMemo, useState } from "react";
import { getErrorMessage } from "@sub-rosa/errors";
import { CAP_SAFETY_COPY } from "../demo/trace";
import { useTime } from "../lib/time";
import {
Expand Down Expand Up @@ -98,7 +99,7 @@ export function PasskeyPanel() {
);
} catch (e) {
setStatus("error");
setMessage(e instanceof Error ? e.message : String(e));
setMessage(getErrorMessage(e));
}
}

Expand All @@ -124,7 +125,7 @@ export function PasskeyPanel() {
setMessage(`Smart wallet deployed on testnet: ${created.contractId}`);
} catch (e) {
setStatus("error");
const detail = e instanceof Error ? e.message : String(e);
const detail = getErrorMessage(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).`,
);
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/hooks/useDashboardData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react";
import type { DashboardData } from "../dashboard/types";
import { DASHBOARD_FIXTURE } from "../dashboard/fixture";
import { assertDashboardData } from "../dashboard/fixture-health-check";
import { getErrorMessage } from "@sub-rosa/errors";
import { useTime } from "../lib/time";

const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
Expand Down Expand Up @@ -84,7 +85,7 @@ export function useDashboardData(): UseDashboardDataResult {
stale: isStale(json.meta.fetchedAt, clock.nowMs()),
}));
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
const message = getErrorMessage(e);
setState((s) => ({
...s,
loading: false,
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/hooks/useDrandCountdown.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2026 Sub Rosa contributors
import { useEffect, useState } from "react";
import { quicknet } from "@sub-rosa/tlock";
import { getErrorMessage } from "@sub-rosa/errors";
import { useTime } from "../lib/time";

const QUICKNET_GENESIS = 1_692_803_367;
Expand Down Expand Up @@ -80,7 +81,7 @@ export function useDrandCountdown(targetRound: number, pollMs = 1000): DrandCoun
setState({
...fallback,
loading: false,
error: e instanceof Error ? e.message : String(e),
error: getErrorMessage(e),
});
}
}
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/hooks/useLiveRound.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2026 Sub Rosa contributors
import { useEffect, useState } from "react";
import type { Round, BidState } from "@sub-rosa/sdk";
import { getErrorMessage } from "@sub-rosa/errors";
import { useTime } from "../lib/time";

const RPC = import.meta.env.VITE_RPC_URL ?? "https://soroban-testnet.stellar.org";
Expand Down Expand Up @@ -48,7 +49,7 @@ export function useLiveRound(enabled: boolean, pollMs = 12_000) {
setError(null);
}
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
if (!cancelled) setError(getErrorMessage(e));
}
}

Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/hooks/useRoundSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
requestAccess,
} from "@stellar/freighter-api";
import type { BidState, Round } from "@sub-rosa/sdk";
import { getErrorMessage } from "@sub-rosa/errors";
import {
fetchRoundSignature,
generateAuditorKeypair,
Expand Down Expand Up @@ -163,7 +164,7 @@ export function useRoundSession(active: UseCase) {
toast.dismiss(workingId);
toast.push("success", "Wallet connected", netMsg);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
const msg = getErrorMessage(error);
setWalletStatus(msg);
setStatus("error");
toast.dismiss(workingId);
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/lib/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
signTransaction,
} from "@stellar/freighter-api";
import { RoundContract } from "@sub-rosa/sdk";
import { getErrorMessage } from "@sub-rosa/errors";
import { useMemo } from "react";

import { formatEscrowAmount } from "./amount";
Expand Down Expand Up @@ -51,7 +52,7 @@ export function freighterError(result: { error?: unknown }) {
}

export function displayError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
const message = getErrorMessage(error);
if (message.includes("Contract, #10")) {
return "Commit window closed. Create a fresh round, then commit before Drand reaches reveal.";
}
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/lib/demoActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
MandateCapError,
usdcToStroops,
} from "@sub-rosa/agent/mandate";
import { getErrorMessage } from "@sub-rosa/errors";
import {
commitment,
currentRound,
Expand Down Expand Up @@ -55,7 +56,7 @@ export function runCapSafetyDemos(): CapDemoResult[] {
title: "Appraisal price above mandate (0.20 > cap 0.10)",
layer: "agent (off-chain)",
expected: "reject",
outcome: e instanceof MandateCapError ? e.message : String(e),
outcome: getErrorMessage(e),
pass: e instanceof MandateCapError,
});
}
Expand Down
3 changes: 2 additions & 1 deletion coverage.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"services/keeper",
"services/auction-template",
"services/appraisal-api",
"services/agent"
"services/agent",
"packages/errors"
]
}
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@
"receipt:typecheck": "pnpm --filter @sub-rosa/receipt-cli typecheck",
"time:test": "pnpm --filter @sub-rosa/time test",
"time:guard": "node scripts/check-direct-time-access.mjs",
"time:guard:test": "node --test scripts/check-direct-time-access.test.mjs"
"time:guard:test": "node --test scripts/check-direct-time-access.test.mjs",
"errors-pkg:test": "pnpm --filter @sub-rosa/errors test",
"errors-pkg:typecheck": "pnpm --filter @sub-rosa/errors typecheck",
"errors:guard": "node scripts/check-error-normalization.mjs",
"errors:guard:test": "node --test scripts/check-error-normalization.test.mjs"
}
}
25 changes: 25 additions & 0 deletions packages/errors/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "@sub-rosa/errors",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Standardized unknown-error normalization, recursive redaction, and diagnostics for Sub Rosa.",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"import": "./src/index.ts",
"require": "./src/index.cjs",
"default": "./src/index.ts"
}
},
"scripts": {
"test": "node --import tsx --test src/normalize.test.ts src/redact.test.ts src/classify.test.ts",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"devDependencies": {
"@types/node": "^25.9.1",
"tsx": "^4.22.4",
"typescript": "^6.0.3"
}
}
101 changes: 101 additions & 0 deletions packages/errors/src/classify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
extractErrorCode,
getSafePublicMessage,
isRetryable,
} from "./classify.js";

const VALID_STELLAR_SECRET = "S" + "B".repeat(55);

describe("classify - extractErrorCode", () => {
it("extracts direct code property", () => {
assert.equal(extractErrorCode({ code: "ECONNRESET" }), "ECONNRESET");
assert.equal(extractErrorCode({ code: 404 }), 404);
});

it("extracts status or statusCode property", () => {
assert.equal(extractErrorCode({ status: 503 }), 503);
assert.equal(extractErrorCode({ statusCode: 400 }), 400);
});

it("extracts kind property", () => {
assert.equal(extractErrorCode({ kind: "rpc_error" }), "rpc_error");
});

it("extracts nested rpc/error code", () => {
assert.equal(extractErrorCode({ error: { code: -32603 } }), -32603);
assert.equal(extractErrorCode({ response: { status: 502 } }), 502);
});

it("returns undefined for values without codes", () => {
assert.equal(extractErrorCode(null), undefined);
assert.equal(extractErrorCode("simple string"), undefined);
assert.equal(extractErrorCode({}), undefined);
});
});

describe("classify - isRetryable", () => {
it("respects explicit retryable property", () => {
assert.equal(isRetryable({ retryable: true }), true);
assert.equal(isRetryable({ retryable: false, status: 503 }), false);
assert.equal(isRetryable({ isRetryable: true }), true);
});

it("classifies HTTP statuses correctly", () => {
assert.equal(isRetryable(null, 429), true);
assert.equal(isRetryable(null, 502), true);
assert.equal(isRetryable(null, 503), true);
assert.equal(isRetryable(null, 504), true);
assert.equal(isRetryable(null, 425), true);
assert.equal(isRetryable(null, 400), false);
assert.equal(isRetryable(null, 404), false);
});

it("classifies network error codes correctly", () => {
assert.equal(isRetryable(null, "ECONNRESET"), true);
assert.equal(isRetryable(null, "ETIMEDOUT"), true);
assert.equal(isRetryable(null, "UND_ERR_CONNECT_TIMEOUT"), true);
assert.equal(isRetryable(null, "ENOENT"), false);
});

it("detects retryable keywords in messages", () => {
assert.equal(isRetryable(null, undefined, "drand round 1234 not servable yet"), true);
assert.equal(isRetryable(null, undefined, "got 425 too early"), true);
assert.equal(isRetryable(null, undefined, "rate limit exceeded"), true);
assert.equal(isRetryable(null, undefined, "invalid signature"), false);
});
});

describe("classify - getSafePublicMessage", () => {
it("translates Soroban contract codes into clear user instructions", () => {
const msg10 = getSafePublicMessage("Error", "Transaction failed with Contract, #10");
assert.match(msg10, /Commit window closed/);

const msg15 = getSafePublicMessage("Error", "Contract, #15 execution failed");
assert.match(msg15, /Reveal window closed/);

const msg425 = getSafePublicMessage("Error", "got 425 from Drand provider");
assert.match(msg425, /Drand R is not published yet/);

const msgTrustline = getSafePublicMessage("Error", "op_no_trustline: trustline entry is missing");
assert.match(msgTrustline, /Wallet is missing the escrow asset trustline/);

const msgNotFound = getSafePublicMessage("Error", "RoundNotFound at key");
assert.equal(msgNotFound, "Round not found.");
});

it("sanitizes file paths and stack traces from unhandled errors", () => {
const raw = "Crash in /Users/secretuser/project/file.ts:42\n at Object.run (/Users/secretuser/project/file.ts:42:10)";
const publicMsg = getSafePublicMessage("Error", raw);
assert.equal(publicMsg.includes("/Users"), false);
assert.equal(publicMsg.includes("at Object.run"), false);
});

it("redacts credentials and private keys from public messages", () => {
const raw = `Failed to sign with secret ${VALID_STELLAR_SECRET}`;
const publicMsg = getSafePublicMessage("Error", raw);
assert.equal(publicMsg.includes(VALID_STELLAR_SECRET), false);
assert.match(publicMsg, /\[REDACTED\]/);
});
});
Loading
Loading