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
14 changes: 14 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
RpcError,
ProvingError,
InvalidInputError,
describeError,
} from "@sharibo/client";
import { config, configError } from "./config";
import { useI18n } from "./i18n";
Expand Down Expand Up @@ -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}`;
}
Expand Down
98 changes: 98 additions & 0 deletions packages/client/src/errors.test.ts
Original file line number Diff line number Diff line change
@@ -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.");
});
127 changes: 127 additions & 0 deletions packages/client/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, Omit<ContractErrorDescription, "code">> = {
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, #<code>)` (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 `"<Name>: <message> <hint>"`. 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.";
}
1 change: 1 addition & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down