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
30 changes: 16 additions & 14 deletions frontend/app/holder/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ import {
submitProofs,
MAX_BATCH_SIZE,
parseContractError,
type ContractError,
ContractError,
isRetryableContractError,
type ProofSubmissionParams,
} from "@/lib/contracts";
import {
Expand Down Expand Up @@ -1009,18 +1010,19 @@ function ProofFlow({
// ProofTimeoutError gets a distinct user-visible message — half the
// point is that stalled provers fail visibly, not as a generic error.
if (e instanceof ProofTimeoutError) {
setError({
code: null,
friendly:
setError(
new ContractError(
"Proof generation timed out. The prover took too long — this can happen on slow devices or with large circuits. Please try again.",
raw: e.message,
});
null,
e.message,
),
);
setErrorPhase("timeout");
setStage("error");
toast.error("Proof timed out — please try again.");
return;
}
const parsed = parseContractError((e as Error).message);
const parsed = e instanceof ContractError ? e : parseContractError((e as Error).message);
setError(parsed);
setErrorPhase("proving");
setStage("error");
Expand Down Expand Up @@ -1073,7 +1075,7 @@ function ProofFlow({
addEvent("verified", { txHash: hash });
toast.success(`Proof confirmed on-chain for ${cred.title}`, { txHash: hash });
} catch (e) {
const parsed = parseContractError((e as Error).message);
const parsed = e instanceof ContractError ? e : parseContractError((e as Error).message);
setError(parsed);
setErrorPhase("submitting");
setStage("error");
Expand Down Expand Up @@ -1287,8 +1289,8 @@ function ProofFlow({
)}
</div>
)}
{/* Retry submission without re-proving when the proof exists */}
{errorPhase === "submitting" && proof && (
{/* Retry submission without re-proving when the proof exists and error is retryable */}
{errorPhase === "submitting" && proof && error && isRetryableContractError(error) && (
<button
className="btn btn-primary"
style={{ marginTop: "1rem", width: "100%" }}
Expand Down Expand Up @@ -1406,7 +1408,7 @@ function BatchProofFlow({
return next;
});
setBatchStage("error");
const parsed = parseContractError((e as Error).message);
const parsed = e instanceof ContractError ? e : parseContractError((e as Error).message);
setBatchError(parsed);
toast.error(`Proof generation failed for ${cred.title}: ${parsed.friendly}`);
return;
Expand Down Expand Up @@ -1443,7 +1445,7 @@ function BatchProofFlow({
return next;
});
setBatchStage("error");
const parsed = parseContractError((e as Error).message);
const parsed = e instanceof ContractError ? e : parseContractError((e as Error).message);
setBatchError(parsed);
toast.error(`Proof generation failed for ${cred.title}: ${parsed.friendly}`);
return;
Expand Down Expand Up @@ -1512,8 +1514,8 @@ function BatchProofFlow({

toast.success(`Confirmed ${creds.length} proofs on-chain`, { txHash: hash });
})
.catch((e: any) => {
const parsed = parseContractError((e as Error).message);
.catch((e: unknown) => {
const parsed = e instanceof ContractError ? e : parseContractError((e as Error).message);
setBatchError(parsed);
setBatchStage("error");
toast.error(`Batch submission failed: ${parsed.friendly}`);
Expand Down
39 changes: 37 additions & 2 deletions frontend/lib/__tests__/contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from "vitest";

vi.mock("../wallet", () => ({ signTx: vi.fn() }));

import { parseContractError, PROOF_REGISTRY_ERRORS } from "../contracts";
import { parseContractError, PROOF_REGISTRY_ERRORS, ContractError, isRetryableContractError } from "../contracts";

// ── ProofRegistry Error enum coverage ────────────────────────────────────────
//
Expand Down Expand Up @@ -49,17 +49,23 @@ describe("parseContractError", () => {
const raw = `Error(Contract, #${code})`;
const result = parseContractError(raw);

// Typed error instance — callers can instanceof-check
expect(result).toBeInstanceOf(ContractError);
expect(result).toBeInstanceOf(Error);
expect(result.name).toBe("ContractError");

expect(result.code).toBe(code);
expect(result.raw).toBe(raw);
expect(result.friendly).toBe(PROOF_REGISTRY_ERRORS[code]);
// Friendly message must never fall back to the raw "Contract error #N." default.
expect(result.friendly).not.toMatch(/^Contract error #\d+\.$/);
expect(result.friendly).not.toMatch(/^Contract error #\d+\.$/)
});

it("returns code and a fallback message for an unknown contract error code", () => {
const raw = "Error(Contract, #99)";
const result = parseContractError(raw);

expect(result).toBeInstanceOf(ContractError);
expect(result.code).toBe(99);
expect(result.friendly).toBe("Contract error #99.");
expect(result.raw).toBe(raw);
Expand Down Expand Up @@ -163,3 +169,32 @@ describe("parseContractError", () => {
});
});
});

// ── isRetryableContractError ──────────────────────────────────────────────────

describe("isRetryableContractError", () => {
it("returns true for code 11 (SubmissionsPaused) — admin may un-pause", () => {
const e = parseContractError("Error(Contract, #11)");
expect(isRetryableContractError(e)).toBe(true);
});

it("returns true when code is null (network / transient)", () => {
const e = parseContractError("some unexpected rpc error");
expect(isRetryableContractError(e)).toBe(true);
});

it.each([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12] as const)(
"returns false for deterministic code %i",
(code) => {
const e = parseContractError(`Error(Contract, #${code})`);
expect(isRetryableContractError(e)).toBe(false);
},
);

it("ContractError is instanceof Error", () => {
const e = parseContractError("Error(Contract, #2)");
expect(e).toBeInstanceOf(Error);
expect(e).toBeInstanceOf(ContractError);
expect(e.name).toBe("ContractError");
});
});
85 changes: 71 additions & 14 deletions frontend/lib/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,29 +72,84 @@ export const PROOF_REGISTRY_ERRORS: Record<number, string> = {
12: "Invalid expiry — the credential expiry is either in the past or too far in the future. Re-issue with a valid validity window.",
};

export interface ContractError {
friendly: string;
code: number | null;
raw: string;
/**
* A typed, instanceof-checkable error thrown whenever a Soroban contract panic
* is detected during proof submission. Callers can:
*
* - Branch on `e instanceof ContractError` to distinguish contract panics
* from network / wallet errors.
* - Read `e.code` to handle specific error codes programmatically.
* - Use `isRetryableContractError(e)` to decide whether to show a retry
* button or a terminal failure state.
*/
export class ContractError extends Error {
/** Numeric discriminant from the Soroban Error enum, or null for non-contract errors. */
readonly code: number | null;
/** Human-readable message looked up from PROOF_REGISTRY_ERRORS, or a generic fallback. */
readonly friendly: string;
/** The original raw error string from the RPC / SDK. */
readonly raw: string;

constructor(friendly: string, code: number | null, raw: string) {
super(friendly);
this.name = "ContractError";
this.code = code;
this.friendly = friendly;
this.raw = raw;
// Restore the prototype chain in envs that transpile classes to ES5.
Object.setPrototypeOf(this, new.target.prototype);
}
}

/**
* Returns `true` when the error condition may resolve on its own and the
* caller is safe to retry the same submission:
*
* - SubmissionsPaused (#11): the admin may un-pause; retry later.
* - Unknown codes / network-level errors (no code): transient RPC blips.
*
* Returns `false` for deterministic failures where re-submitting the same
* proof will always produce the same error:
* - VerificationFailed (#2): the ZK proof is cryptographically invalid.
* - NotAuthorized (#3): wrong wallet signature.
* - IssuerNotTrusted (#4): issuer not registered for this type.
* - IssuerKeyMismatch (#5): credential signed with a key no longer on-chain.
* - NotInitialized (#1), BatchTooLarge (#7), BatchEmpty (#8),
* DuplicateCredentialType (#9), AggregateLayoutInvalid (#10),
* InvalidExpiry (#12): caller must fix the submission before retrying.
*/
export function isRetryableContractError(e: ContractError): boolean {
// SubmissionsPaused: retryable — the admin may un-pause.
if (e.code === 11) return true;
// Deterministic contract errors: retrying will always fail.
if (e.code !== null) return false;
// No code: network / auth-level transient error — allow retry.
return true;
}

/**
* Parse a raw RPC/SDK error string into a typed {@link ContractError}.
*
* This is the single conversion point between the wire representation
* (`Error(Contract, #N)`) and the typed error taxonomy.
*/
export function parseContractError(raw: string): ContractError {
const match = raw.match(/Error\(Contract,\s*#(\d+)\)/);
if (match) {
const code = parseInt(match[1]);
return {
return new ContractError(
PROOF_REGISTRY_ERRORS[code] ?? `Contract error #${code}.`,
code,
friendly: PROOF_REGISTRY_ERRORS[code] ?? `Contract error #${code}.`,
raw,
};
);
}
if (raw.includes("Error(Auth")) {
return { code: null, friendly: "Wallet authorisation failed — approve the transaction in your wallet.", raw };
return new ContractError("Wallet authorisation failed — approve the transaction in your wallet.", null, raw);
}
if (raw.includes("Error(WasmVm")) {
return { code: null, friendly: "Contract execution failed — the proof or inputs were malformed.", raw };
return new ContractError("Contract execution failed — the proof or inputs were malformed.", null, raw);
}
return { code: null, friendly: raw, raw };
return new ContractError(raw, null, raw);
}

export interface VerificationStatus {
Expand Down Expand Up @@ -148,7 +203,7 @@ async function sendAndConfirm(
typeof (sent.errorResult as { toXDR?: (f: string) => string }).toXDR === "function"
? (sent.errorResult as { toXDR: (f: string) => string }).toXDR("hex")
: String(sent.errorResult);
throw new Error(`${label} rejected: ${errHex}`);
throw parseContractError(`${label} rejected: ${errHex}`);
}

const start = Date.now();
Expand All @@ -169,7 +224,8 @@ async function sendAndConfirm(
}
}
if (result.status !== "SUCCESS") {
throw new Error(`Transaction ${sent.hash} did not succeed (${result.status}).`);
const raw = `Transaction ${sent.hash} did not succeed (${result.status}).`;
throw parseContractError(raw);
}
return sent.hash;
}
Expand Down Expand Up @@ -318,7 +374,7 @@ export async function submitProofs(params: {
typeof (sent.errorResult as { toXDR?: (f: string) => string }).toXDR === "function"
? (sent.errorResult as { toXDR: (f: string) => string }).toXDR("hex")
: String(sent.errorResult);
throw new Error(`Batch submission rejected: ${errHex}`);
throw parseContractError(`Batch submission rejected: ${errHex}`);
}

function isBadUnionSwitch(e: unknown): boolean {
Expand All @@ -343,7 +399,8 @@ export async function submitProofs(params: {
}
}
if (result.status !== "SUCCESS") {
throw new Error(`Batch transaction ${sent.hash} did not succeed (${result.status}).`);
const raw = `Batch transaction ${sent.hash} did not succeed (${result.status}).`;
throw parseContractError(raw);
}
return sent.hash;
}
Expand Down
24 changes: 24 additions & 0 deletions frontend/packages/sdk/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ import {
InvalidAddressError,
RpcError,
TimeoutError,
ContractError,
PROOF_REGISTRY_ERRORS,
isRetryableContractError,
StellarCred,
withRetry,
} from "./index";
Expand All @@ -42,9 +45,30 @@ describe("error taxonomy exports", () => {
expect(StellarCred.RpcError).toBe(RpcError);
expect(StellarCred.TimeoutError).toBe(TimeoutError);
expect(StellarCred.InvalidAddressError).toBe(InvalidAddressError);
expect(StellarCred.ContractError).toBe(ContractError);
expect(StellarCred.PROOF_REGISTRY_ERRORS).toBe(PROOF_REGISTRY_ERRORS);
expect(StellarCred.isRetryableContractError).toBe(isRetryableContractError);
expect(new ConfigError().name).toBe("ConfigError");
expect(new RpcError().name).toBe("RpcError");
expect(new InvalidAddressError().name).toBe("InvalidAddressError");
const contractErr = new ContractError("Proof verification failed", 2, "Error(Contract, #2)");
expect(contractErr).toBeInstanceOf(Error);
expect(contractErr).toBeInstanceOf(ContractError);
expect(contractErr.name).toBe("ContractError");
expect(contractErr.code).toBe(2);
expect(contractErr.friendly).toBe("Proof verification failed");
expect(contractErr.raw).toBe("Error(Contract, #2)");
});

it("isRetryableContractError distinguishes retryable vs terminal codes", () => {
const pausedErr = new ContractError("Submissions paused", 11, "Error(Contract, #11)");
expect(isRetryableContractError(pausedErr)).toBe(true);

const netErr = new ContractError("network failed", null, "raw network error");
expect(isRetryableContractError(netErr)).toBe(true);

const verifyErr = new ContractError("Verification failed", 2, "Error(Contract, #2)");
expect(isRetryableContractError(verifyErr)).toBe(false);
});
});

Expand Down
Loading
Loading