From 6f66b0c39b96adfecd23e6b0964addde62473ead Mon Sep 17 00:00:00 2001 From: Obiefuna Theophilus Date: Fri, 28 Aug 2026 22:07:57 +0100 Subject: [PATCH] feat: map contract error codes 1-5 to human-readable messages in the SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds describeContractError(code) to packages/client/src/errors.ts, covering the 5 stable error codes documented in contracts/sharibo/src/lib.rs (CircleNotFound, RoundNotFunded, WrongRoundTag, AlreadyClaimed, InvalidProof), each with a name, a user-facing sentence, and a hint. parseContractErrorCode() extracts the numeric code from a signAndSend() failure by matching the `Error(Contract, #N)` string @stellar/stellar-sdk's own AssembledTransaction embeds in a simulation-failure Error's message (traced through node_modules/@stellar/stellar-sdk's assembled_transaction.js and utils.js's `contractErrorPattern`, and cross-checked against the existing raw-string checks in scripts/e2e.ts's replay path and scripts/smoke.ts). describeError() ties the two together and falls back to the raw message for unrecognized codes or non-contract errors. Wires this into the app's claimAgain deliberate-replay demo (previously displaying the raw Error(Contract, #4)) via a new getErrorMessage() helper in App.tsx — filling in a function that was already referenced there and in fundMember's Freighter path but never defined. Also re-exports errors.js from packages/client/src/index.ts, which App.tsx and scripts/e2e.ts already import ContractError/RpcError from but which wasn't actually re-exported. Closes #53 --- app/src/App.tsx | 14 ++++ packages/client/src/errors.test.ts | 98 ++++++++++++++++++++++ packages/client/src/errors.ts | 127 +++++++++++++++++++++++++++++ packages/client/src/index.ts | 1 + 4 files changed, 240 insertions(+) create mode 100644 packages/client/src/errors.test.ts diff --git a/app/src/App.tsx b/app/src/App.tsx index 8a959af..fba40ce 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -27,6 +27,7 @@ import { RpcError, ProvingError, InvalidInputError, + describeError, } from "@sharibo/client"; import { config, configError } from "./config"; import { useI18n } from "./i18n"; @@ -106,6 +107,19 @@ function toUiError(error: unknown): string { return "Something went wrong. Please retry."; } +// Same shape as toUiError, but additionally recognizes Sharibo contract +// rejections — the raw `Error(Contract, #4)` Soroban surfaces gets rendered +// as "AlreadyClaimed: this proof's nullifier was already used; ..." via +// describeError() (packages/client/src/errors.ts) instead of the bare error +// code. Falls back to the same Friendbot special-case and raw-message +// behavior as toUiError for anything that isn't a recognized contract error. +function getErrorMessage(error: unknown): string { + if (error instanceof FriendbotRetryableError) { + return FRIEND_BOT_RATE_LIMIT_MESSAGE; + } + return describeError(error); +} + function explorerAccount(address: string): string { return `https://stellar.expert/explorer/testnet/account/${address}`; } diff --git a/packages/client/src/errors.test.ts b/packages/client/src/errors.test.ts new file mode 100644 index 0000000..1aa76d8 --- /dev/null +++ b/packages/client/src/errors.test.ts @@ -0,0 +1,98 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + ContractError, + describeContractError, + parseContractErrorCode, + describeError, +} from "./errors.js"; + +// Issue #53: contracts/sharibo/src/lib.rs's `Error` enum, discriminants 1-5. +test("describeContractError returns the right name for each of the 5 known codes", () => { + assert.equal(describeContractError(1)?.name, "CircleNotFound"); + assert.equal(describeContractError(2)?.name, "RoundNotFunded"); + assert.equal(describeContractError(3)?.name, "WrongRoundTag"); + assert.equal(describeContractError(4)?.name, "AlreadyClaimed"); + assert.equal(describeContractError(5)?.name, "InvalidProof"); +}); + +test("describeContractError includes a user-facing sentence and a hint for every known code", () => { + for (const code of [1, 2, 3, 4, 5]) { + const description = describeContractError(code); + assert.ok(description, `expected a description for code ${code}`); + assert.equal(description!.code, code); + assert.ok(description!.message.length > 0); + assert.ok(description!.hint.length > 0); + } +}); + +test("describeContractError(4) matches AlreadyClaimed's documented semantics (nullifier reuse)", () => { + const description = describeContractError(4); + assert.match(description!.message, /nullifier/i); + assert.match(description!.message, /already/i); +}); + +test("describeContractError returns undefined for unknown codes", () => { + assert.equal(describeContractError(0), undefined); + assert.equal(describeContractError(6), undefined); // RoundFull — real code, out of Issue #53's scope + assert.equal(describeContractError(7), undefined); // Overflow + assert.equal(describeContractError(8), undefined); // CircleCancelled + assert.equal(describeContractError(999), undefined); +}); + +// The exact shape signAndSend()'s underlying @stellar/stellar-sdk throws: +// traced through node_modules/@stellar/stellar-sdk's AssembledTransaction +// (simulationData getter -> SimulationFailed with the RPC's raw diagnostic +// string embedded) and cross-checked against contract/utils.js's own +// `contractErrorPattern = /Error\(Contract, #(\d+)\)/`, plus the existing +// raw-string checks in scripts/e2e.ts and scripts/smoke.ts. +test("parseContractErrorCode extracts the code from a signAndSend-shaped Error", () => { + const error = new Error( + 'Transaction simulation failed: "HostError: Error(Contract, #4)\n\nEvent log (newest first):\n 0: [Diagnostic Event] contract:abc, topics:[error, Error(Contract, #4)], data:[\"claim\", \"AlreadyClaimed\"]"', + ); + assert.equal(parseContractErrorCode(error), 4); +}); + +test("parseContractErrorCode matches the exact minimal shape used in scripts/e2e.ts and scripts/smoke.ts", () => { + assert.equal(parseContractErrorCode(new Error("Error(Contract, #4)")), 4); + assert.equal(parseContractErrorCode(new Error("Error(Contract, #1)")), 1); +}); + +test("parseContractErrorCode reads the code straight off a ContractError instance", () => { + const error = new ContractError("claim rejected", 5); + assert.equal(parseContractErrorCode(error), 5); +}); + +test("parseContractErrorCode returns undefined for errors that aren't contract rejections", () => { + assert.equal(parseContractErrorCode(new Error("RPC Error 429 Too Many Requests")), undefined); + assert.equal(parseContractErrorCode(new Error("network timeout")), undefined); + assert.equal(parseContractErrorCode("not an error at all"), undefined); + assert.equal(parseContractErrorCode(null), undefined); + assert.equal(parseContractErrorCode(undefined), undefined); +}); + +// Acceptance criterion: the replay demo shows "AlreadyClaimed: ..." prose +// instead of the raw `Error(Contract, #4)`. +test("describeError renders a known contract rejection as 'Name: sentence hint' prose", () => { + const error = new Error("Error(Contract, #4)"); + const text = describeError(error); + assert.match(text, /^AlreadyClaimed:/); + assert.match(text, /nullifier/i); + assert.doesNotMatch(text, /Error\(Contract/); +}); + +test("describeError falls back to the raw message for an unrecognized contract error code", () => { + const error = new Error("Error(Contract, #6)"); // RoundFull — real, but out of scope + assert.equal(describeError(error), "Error(Contract, #6)"); +}); + +test("describeError falls back to the raw message for a non-contract error", () => { + const error = new Error("RPC Error 503 Service Unavailable"); + assert.equal(describeError(error), "RPC Error 503 Service Unavailable"); +}); + +test("describeError never throws on a non-Error, non-string throw", () => { + assert.equal(describeError({ weird: "shape" }), "Something went wrong. Please retry."); + assert.equal(describeError(null), "Something went wrong. Please retry."); + assert.equal(describeError(undefined), "Something went wrong. Please retry."); +}); diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts index 5914ea2..c658126 100644 --- a/packages/client/src/errors.ts +++ b/packages/client/src/errors.ts @@ -32,3 +32,130 @@ export class ContractError extends ShariboError { this.code = code; } } + +/** + * A human-readable description of a contract error code. + * + * @property code - The numeric error discriminant (matches + * `contracts/sharibo/src/lib.rs`'s `Error` enum). + * @property name - The `Error` enum variant name (e.g. `"AlreadyClaimed"`). + * @property message - A user-facing sentence describing what happened. + * @property hint - A short explanation of why, or what the user can do next. + */ +export interface ContractErrorDescription { + code: number; + name: string; + message: string; + hint: string; +} + +// Mirrors contracts/sharibo/src/lib.rs's `Error` enum discriminants 1-5 — +// the ones `Contract::claim` (and, for #1, `get_circle`/`cancel_circle`) can +// panic with. Codes 6-8 (RoundFull, Overflow, CircleCancelled) aren't +// covered by Issue #53's scope and fall through to the raw-string fallback +// below like any other unrecognized code. +const CONTRACT_ERROR_DESCRIPTIONS: Record> = { + 1: { + name: "CircleNotFound", + message: "No circle exists with this ID.", + hint: "Double-check the circle ID and that you're pointed at the right network/contract — it may never have been created, or the contract was redeployed.", + }, + 2: { + name: "RoundNotFunded", + message: "This round hasn't been fully funded yet.", + hint: "The pot must reach exactly contribution × size before anyone can claim — keep funding until the round is complete.", + }, + 3: { + name: "WrongRoundTag", + message: "This proof doesn't match the circle's current round.", + hint: "Proofs are bound to a specific circle and round; generate a fresh proof for the current round rather than reusing one from an earlier round.", + }, + 4: { + name: "AlreadyClaimed", + message: "This proof's nullifier was already used; each member can claim only once per circle.", + hint: "If you believe this is wrong, confirm you're using the identity that hasn't claimed yet — every member gets exactly one claim across all rounds of this circle.", + }, + 5: { + name: "InvalidProof", + message: "The zero-knowledge proof failed verification.", + hint: "The proof doesn't match the circle's committed membership root — make sure you're proving with the correct identity and Merkle path for this circle.", + }, +}; + +/** + * Maps a Sharibo contract error code (1-5) to a human-readable description. + * + * The five codes are stable and documented in + * `contracts/sharibo/src/lib.rs`'s `Error` enum: 1 CircleNotFound, + * 2 RoundNotFunded, 3 WrongRoundTag, 4 AlreadyClaimed, 5 InvalidProof. + * + * @param code - The numeric error discriminant from a `Error(Contract, #N)` + * failure. + * @returns The description for a known code, or `undefined` for anything + * else (codes 6+ or a number that isn't a Sharibo error code at all) — the + * caller should fall back to displaying the raw error string in that case. + */ +export function describeContractError(code: number): ContractErrorDescription | undefined { + const entry = CONTRACT_ERROR_DESCRIPTIONS[code]; + if (!entry) return undefined; + return { code, ...entry }; +} + +// Soroban surfaces a rejected host function as a diagnostic string embedding +// `Error(Contract, #)` (see soroban-sdk's Error Display impl). The +// stellar-sdk contract Client's `signAndSend()` throws a plain `Error` whose +// `.message` contains this string — confirmed by the existing raw-string +// checks in scripts/e2e.ts's replay-rejection path (`message.includes( +// "Error(Contract, #4)")`) and scripts/smoke.ts (`msg.includes( +// "Error(Contract, #1)")`), both matching against real signAndSend() +// rejections. No SDK-level structured error is exposed here — this pattern +// is the only stable extraction point. +const CONTRACT_ERROR_PATTERN = /Error\(Contract,\s*#(\d+)\)/; + +/** + * Extracts the numeric Sharibo contract error code out of a failure thrown + * by `signAndSend()` (or anything wrapping one), if it's a contract + * rejection at all. + * + * @param error - Anything caught from a contract call, typically the + * `Error` thrown by the stellar-sdk contract Client's `signAndSend()`. + * @returns The parsed code, or `undefined` if `error` isn't a contract + * rejection in the recognized `Error(Contract, #N)` shape. + */ +export function parseContractErrorCode(error: unknown): number | undefined { + if (error instanceof ContractError && typeof error.code === "number") { + return error.code; + } + + const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined; + if (!message) return undefined; + + const match = message.match(CONTRACT_ERROR_PATTERN); + if (!match) return undefined; + + return Number(match[1]); +} + +/** + * Turns any error caught from a contract call into display-ready text. + * + * Tries to parse a Sharibo contract error code out of `error` and, if it's + * one of the five known codes, renders `": "`. Falls + * back to the error's raw message (or a generic string) for anything else — + * an unrecognized code, a network/RPC error, or a non-Error throw — so the + * UI never breaks on an error it doesn't specifically know about. + * + * @param error - Anything caught from a contract call. + * @returns Human-readable text safe to show directly in the UI. + */ +export function describeError(error: unknown): string { + const code = parseContractErrorCode(error); + const description = code !== undefined ? describeContractError(code) : undefined; + if (description) { + return `${description.name}: ${description.message} ${description.hint}`; + } + + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + return "Something went wrong. Please retry."; +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 40bf7a1..c864f03 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -3,6 +3,7 @@ export * from "./tree.js"; export * from "./prove.js"; export * from "./contract.js"; export * from "./config.js"; +export * from "./errors.js"; // Re-exported for convenience so consumers can import from "@sharibo/client" // rather than digging into the contract module.