From ed7b7b66f252b04f3817bb416744ecb2f6ea037e Mon Sep 17 00:00:00 2001 From: Ajibose Date: Tue, 18 Aug 2026 21:49:55 +0300 Subject: [PATCH 1/3] feat(sdk): add typed error handling with Soroban error code mapping (#223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces src/errors.ts: SdkError (base) and ContractError extends SdkError, a ContractErrorType const-object/union pairing (mirroring validation.ts's ErrorCode convention), a CONTRACT_ERROR_MAP lookup table, and parseContractError() which extracts a numeric Soroban error code from `Error(Contract, #N)`-shaped failures and returns a typed ContractError with a recovery suggestion, falling back to UNKNOWN when the code is unmapped or unextractable. Includes an opt-in, dependency-free setErrorReporter() hook for analytics/observability integrations. client.ts's three throw sites (invokeContract's simulation/submit/status failures, readContract's simulation failure) now throw parseContractError(...) results instead of plain Error, giving callers .errorType/.recovery/.rawCode while preserving the original descriptive message and chaining the raw failure as .cause. Adds SdkErrorBoundary, a reusable React class-component error boundary (apps/frontend/src/components/common/) that surfaces ContractError recovery suggestions in its fallback UI, complementing (not replacing) the existing route-level src/app/error.tsx. IMPORTANT: CONTRACT_ERROR_MAP's numeric codes are a placeholder/starter set inferred from this SDK's own method surface, not sourced from the real common/src/errors.rs in Stellar-VaultLink/invofi-contracts (a separate repo not available in this workspace). They must be reconciled against that enum before relying on them against live contracts — see the file-level comment in src/errors.ts. Closes #223 --- .../common/SdkErrorBoundary.test.tsx | 132 +++++++ .../components/common/SdkErrorBoundary.tsx | 101 ++++++ invofi/apps/frontend/vitest.config.ts | 14 + invofi/apps/sdk/src/client.ts | 9 +- invofi/apps/sdk/src/errors.ts | 327 ++++++++++++++++++ invofi/apps/sdk/src/index.ts | 21 ++ invofi/apps/sdk/tests/errors.test.ts | 199 +++++++++++ 7 files changed, 799 insertions(+), 4 deletions(-) create mode 100644 invofi/apps/frontend/src/components/common/SdkErrorBoundary.test.tsx create mode 100644 invofi/apps/frontend/src/components/common/SdkErrorBoundary.tsx create mode 100644 invofi/apps/sdk/src/errors.ts create mode 100644 invofi/apps/sdk/tests/errors.test.ts diff --git a/invofi/apps/frontend/src/components/common/SdkErrorBoundary.test.tsx b/invofi/apps/frontend/src/components/common/SdkErrorBoundary.test.tsx new file mode 100644 index 000000000..fc175efb8 --- /dev/null +++ b/invofi/apps/frontend/src/components/common/SdkErrorBoundary.test.tsx @@ -0,0 +1,132 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { ContractError, ContractErrorType } from '@invofi/sdk'; +import { SdkErrorBoundary } from './SdkErrorBoundary'; + +/** Throws once on mount, then renders normally after `SdkErrorBoundary` resets it. */ +function Bomb({ error, shouldThrow = true }: { error: Error; shouldThrow?: boolean }) { + if (shouldThrow) throw error; + return
recovered
; +} + +describe('SdkErrorBoundary', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders children when nothing throws', () => { + render( + +
all good
+
, + ); + expect(screen.getByText('all good')).toBeInTheDocument(); + }); + + it('shows the recovery message and action when a ContractError with recovery is thrown', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const err = new ContractError( + 11, + ContractErrorType.INSUFFICIENT_BALANCE, + 'The account does not have sufficient balance to complete this transaction.', + { message: 'Add funds to your wallet and try again.', action: 'Add funds' }, + ); + + render( + + + , + ); + + expect(screen.getByText('Add funds to your wallet and try again.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument(); + }); + + it('shows a link when the recovery suggestion includes a url', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const err = new ContractError( + 14, + ContractErrorType.NO_TRUSTLINE, + 'The recipient does not have a trustline for the position token.', + { message: 'Add a trustline first.', action: 'Add trustline', url: 'https://example.com/trustlines' }, + ); + + render( + + + , + ); + + const link = screen.getByRole('link', { name: 'Add trustline' }); + expect(link).toHaveAttribute('href', 'https://example.com/trustlines'); + }); + + it('falls back to the error message when a ContractError has no recovery suggestion', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const err = new ContractError(999999, ContractErrorType.UNKNOWN, 'Contract call failed: mystery error'); + + render( + + + , + ); + + expect(screen.getByText('Contract call failed: mystery error')).toBeInTheDocument(); + }); + + it('renders a graceful generic fallback for a plain (non-SDK) error', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + + render( + + + , + ); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect(screen.getByText('boom, totally unrelated to the SDK')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument(); + }); + + it('calls onReset and clears the error state when "Try again" is clicked', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const onReset = vi.fn(); + + function Wrapper() { + return ( + + + + ); + } + + render(); + // Force the boundary into an error state via a custom fallback-free bomb + // is awkward without remounting, so this test instead verifies the reset + // wiring directly: render already-recovered content and ensure no crash. + expect(screen.getByText('recovered')).toBeInTheDocument(); + }); + + it('supports a custom fallback render prop', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const err = new ContractError(1, ContractErrorType.INVOICE_NOT_FOUND, 'No invoice found.'); + + render( + ( +
+ custom fallback: {error.message} + +
+ )} + > + +
, + ); + + expect(screen.getByText('custom fallback: No invoice found.')).toBeInTheDocument(); + }); +}); diff --git a/invofi/apps/frontend/src/components/common/SdkErrorBoundary.tsx b/invofi/apps/frontend/src/components/common/SdkErrorBoundary.tsx new file mode 100644 index 000000000..fa0b00378 --- /dev/null +++ b/invofi/apps/frontend/src/components/common/SdkErrorBoundary.tsx @@ -0,0 +1,101 @@ +'use client'; + +// ── SdkErrorBoundary — reusable error boundary for @invofi/sdk errors (#223) ── +// +// A narrower, reusable class-component error boundary for wrapping specific +// data-fetching / contract-interaction sections of the UI (a card, a form, a +// table) — NOT a replacement for `src/app/error.tsx`, which remains the +// route-level Next.js error boundary. +// +// When the caught error is a `ContractError` (or `SdkError`) from +// `@invofi/sdk`, the fallback UI surfaces its recovery suggestion +// (`message` / `action` / `url`) so the user gets an actionable next step +// instead of a raw stack trace. Any other error still renders a safe, +// generic fallback rather than crashing the surrounding page. + +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { AlertTriangle } from 'lucide-react'; +import { ContractError, SdkError } from '@invofi/sdk'; +import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; + +interface SdkErrorBoundaryProps { + children: ReactNode; + /** Optional custom fallback renderer, given the caught error and a reset callback. */ + fallback?: (error: Error, reset: () => void) => ReactNode; + /** Called when the user retries, before the boundary clears its error state. */ + onReset?: () => void; +} + +interface SdkErrorBoundaryState { + error: Error | null; +} + +/** Default fallback UI shown when no custom `fallback` render prop is supplied. */ +function DefaultFallback({ error, onReset }: { error: Error; onReset: () => void }) { + const recovery = error instanceof ContractError ? error.recovery : undefined; + const description = recovery?.message ?? error.message ?? 'An unexpected error occurred. Please try again.'; + + return ( + + + Something went wrong + +

{description}

+
+ + {recovery?.url && ( + + {recovery.action ?? 'Learn more'} + + )} +
+
+
+ ); +} + +/** + * Class-based error boundary for wrapping SDK-driven sections of the UI. + * + * Usage: + * + * + * + */ +export class SdkErrorBoundary extends Component { + state: SdkErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): SdkErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + // Non-SDK errors (render bugs, etc.) are still contained by the boundary + // — but always logged so they aren't silently swallowed. + const kind = error instanceof SdkError ? error.name : 'Error'; + // eslint-disable-next-line no-console + console.error(`[SdkErrorBoundary] caught ${kind}:`, error, errorInfo.componentStack); + } + + reset = (): void => { + this.props.onReset?.(); + this.setState({ error: null }); + }; + + render(): ReactNode { + const { error } = this.state; + if (error) { + if (this.props.fallback) return this.props.fallback(error, this.reset); + return ; + } + return this.props.children; + } +} diff --git a/invofi/apps/frontend/vitest.config.ts b/invofi/apps/frontend/vitest.config.ts index bbe15e82f..c712b50ba 100644 --- a/invofi/apps/frontend/vitest.config.ts +++ b/invofi/apps/frontend/vitest.config.ts @@ -5,6 +5,16 @@ import { defineConfig } from 'vitest/config'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ + // The rest of the codebase writes components with the automatic JSX + // runtime (no `import React from 'react'` needed — see e.g. + // src/components/common/EmptyState.tsx), matching Next.js's default. Vite's + // esbuild otherwise falls back to the classic transform (`React.createElement` + // with React expected in scope) since tsconfig.json's `"jsx": "preserve"` + // isn't one of the react-jsx/react-jsxdev values esbuild auto-detects. + // Pin it explicitly so component tests (#223) don't need React imports. + esbuild: { + jsx: 'automatic', + }, test: { // Unit tests only — the e2e/ directory is Playwright, not Vitest. include: ['src/**/*.test.{ts,tsx}', 'scripts/**/*.test.mjs'], @@ -27,6 +37,10 @@ export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, 'src'), + // @invofi/sdk is consumed from source via tsconfig paths (see + // tsconfig.json + next.config.mjs); mirror that here so Vitest can + // resolve it too (#223). + '@invofi/sdk': path.resolve(__dirname, '../sdk/src/index.ts'), }, }, }); diff --git a/invofi/apps/sdk/src/client.ts b/invofi/apps/sdk/src/client.ts index 0d5fb5da1..a67184fca 100644 --- a/invofi/apps/sdk/src/client.ts +++ b/invofi/apps/sdk/src/client.ts @@ -31,6 +31,7 @@ import { validateAssetString, validateConfigField, } from './validation'; +import { parseContractError } from './errors'; export { SdkValidationError, ErrorCode }; @@ -145,7 +146,7 @@ export function createInvofiClient(cfg: InvofiClientConfig) { const simResult = await rpc.simulateTransaction(tx); if (SorobanRpc.Api.isSimulationError(simResult)) { - throw new Error(`Simulation failed: ${simResult.error}`); + throw parseContractError(simResult.error, 'Simulation failed'); } tx = SorobanRpc.assembleTransaction(tx, simResult).build(); @@ -154,7 +155,7 @@ export function createInvofiClient(cfg: InvofiClientConfig) { const sendResult = await rpc.sendTransaction(signedTx); if (sendResult.status === 'ERROR') { - throw new Error(`Transaction failed: ${JSON.stringify(sendResult.errorResult)}`); + throw parseContractError(sendResult.errorResult, 'Transaction failed'); } let getResult = await rpc.getTransaction(sendResult.hash); @@ -164,7 +165,7 @@ export function createInvofiClient(cfg: InvofiClientConfig) { } if (getResult.status !== 'SUCCESS') { - throw new Error(`Transaction did not succeed: ${getResult.status}`); + throw parseContractError(getResult, `Transaction did not succeed (status: ${getResult.status})`); } return getResult.returnValue ?? xdr.ScVal.scvVoid(); @@ -203,7 +204,7 @@ export function createInvofiClient(cfg: InvofiClientConfig) { const sim = await rpc.simulateTransaction(tx); if (SorobanRpc.Api.isSimulationError(sim)) { - throw new Error(`Read failed: ${sim.error}`); + throw parseContractError(sim.error, 'Read failed'); } if (!SorobanRpc.Api.isSimulationSuccess(sim) || !sim.result) { throw new Error('Read simulation returned no result'); diff --git a/invofi/apps/sdk/src/errors.ts b/invofi/apps/sdk/src/errors.ts new file mode 100644 index 000000000..cec3662b1 --- /dev/null +++ b/invofi/apps/sdk/src/errors.ts @@ -0,0 +1,327 @@ +// ── SDK typed error handling & Soroban error code mapping (#223) ──────────── +// +// Every RPC-facing failure in client.ts (simulation failure, submit failure, +// non-SUCCESS transaction status) is funneled through `parseContractError` +// so callers get a typed `ContractError` with a stable `.errorType`, a +// human-readable `.message`, an optional `.recovery` suggestion, and the +// original failure preserved as `.cause` — instead of a plain `new Error(...)` +// with an interpolated string that can only be handled by matching text. +// +// ⚠️ IMPORTANT — PLACEHOLDER ERROR CODES ⚠️ +// The numeric codes in `CONTRACT_ERROR_MAP` below are illustrative starter +// values inferred from this SDK's own method surface (client.ts) and +// validation constants (validation.ts). They are NOT sourced from the real +// `common/src/errors.rs` enum in the `Stellar-VaultLink/invofi-contracts` +// repository — that repo is a separate codebase not available in this +// workspace. The numeric ordering of a Rust `#[contracterror]` enum is +// whatever `common/src/errors.rs` declares, and Soroban error codes are +// positional (first variant = 1, second = 2, ...), so a mismatch here would +// silently mislabel real on-chain errors. Before this ships against a +// network where the real contracts are live, a maintainer MUST reconcile +// every entry in `CONTRACT_ERROR_MAP` against `common/src/errors.rs` and +// correct the numeric codes (and add any missing variants) accordingly. +// +// Usage: +// import { parseContractError, ContractError, ContractErrorType } from './errors'; +// try { ... } catch (err) { throw parseContractError(err); } + +// ── Recovery suggestions ───────────────────────────────────────────────────── + +/** + * A user-facing hint for how to recover from a given error, surfaced by + * consumers (e.g. the frontend's `SdkErrorBoundary`) alongside the error + * message. + */ +export interface RecoverySuggestion { + /** Human-readable recovery hint, e.g. "Fund your wallet with more XLM." */ + message: string; + /** Optional short action label for a UI button, e.g. "Add funds". */ + action?: string; + /** Optional URL for more information (docs, faucet, support). */ + url?: string; +} + +// ── Base SDK error ─────────────────────────────────────────────────────────── + +/** + * Base class for all errors thrown by @invofi/sdk beyond input validation + * (see `SdkValidationError` in validation.ts for pre-RPC argument checks). + * + * Supports error chaining (contract error → SDK error → UI error): pass the + * originating error as `cause` and it is preserved for callers that want to + * inspect the full failure chain (e.g. `err.cause`). + * + * Note: this SDK targets ES2020 (see tsconfig.json), which predates the + * ES2022 `Error` `cause` option in TypeScript's lib types. We therefore + * carry our own `cause` field and wire it through manually rather than + * relying on `super(message, { cause })`. + */ +export class SdkError extends Error { + /** The original error this one was constructed from, if any. */ + readonly cause?: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'SdkError'; + this.cause = cause; + // Maintain proper prototype chain for compiled ES5 targets. + Object.setPrototypeOf(this, SdkError.prototype); + } +} + +// ── Contract error codes ───────────────────────────────────────────────────── +// `const` object + derived union type, mirroring the `ErrorCode` convention +// in validation.ts. One entry per mapped Soroban contract error, plus +// UNKNOWN for anything not (yet) in CONTRACT_ERROR_MAP. + +export const ContractErrorType = { + // ── Invoice lifecycle (registry contract) ────────────────────────────────── + INVOICE_NOT_FOUND: 'INVOICE_NOT_FOUND', + INVOICE_ALREADY_CANCELLED: 'INVOICE_ALREADY_CANCELLED', + INVALID_STATUS_TRANSITION: 'INVALID_STATUS_TRANSITION', + + // ── Financing offers (financing contract) ────────────────────────────────── + OFFER_NOT_FOUND: 'OFFER_NOT_FOUND', + OFFER_EXPIRED: 'OFFER_EXPIRED', + OFFER_ALREADY_ACCEPTED: 'OFFER_ALREADY_ACCEPTED', + OFFER_ALREADY_REJECTED: 'OFFER_ALREADY_REJECTED', + INTEREST_RATE_OUT_OF_RANGE: 'INTEREST_RATE_OUT_OF_RANGE', + DURATION_OUT_OF_RANGE: 'DURATION_OUT_OF_RANGE', + + // ── Repayment / reclaim (repayment contract) ──────────────────────────────── + ALREADY_REPAID: 'ALREADY_REPAID', + INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', + NOT_YET_OVERDUE: 'NOT_YET_OVERDUE', + GRACE_PERIOD_NOT_ELAPSED: 'GRACE_PERIOD_NOT_ELAPSED', + + // ── Position tokens ────────────────────────────────────────────────────────── + NO_TRUSTLINE: 'NO_TRUSTLINE', + + // ── Cross-cutting ───────────────────────────────────────────────────────────── + UNAUTHORIZED: 'UNAUTHORIZED', + + // ── Fallback ─────────────────────────────────────────────────────────────────── + UNKNOWN: 'UNKNOWN', +} as const; + +export type ContractErrorType = typeof ContractErrorType[keyof typeof ContractErrorType]; + +// ── Contract error → typed mapping table ───────────────────────────────────── +// +// ⚠️ PLACEHOLDER / STARTER SET — see the file-level banner above. The +// numeric `code` values here are illustrative guesses at positional +// `#[contracterror]` ordering and MUST be reconciled against the real +// `common/src/errors.rs` in `Stellar-VaultLink/invofi-contracts` before +// relying on them against live contracts. Treat this table as the single +// place to update once the real codes are known — it is trivially +// extensible: add a `{ type, message, recovery }` entry keyed by the real +// numeric code. + +interface ContractErrorEntry { + type: ContractErrorType; + message: string; + recovery?: RecoverySuggestion; +} + +export const CONTRACT_ERROR_MAP: Record = { + 1: { + type: ContractErrorType.INVOICE_NOT_FOUND, + message: 'No invoice was found with the given ID.', + recovery: { message: 'Double-check the invoice ID and that it was registered successfully.' }, + }, + 2: { + type: ContractErrorType.INVOICE_ALREADY_CANCELLED, + message: 'This invoice has already been cancelled and cannot be modified.', + recovery: { message: 'Register a new invoice if you need to submit these terms again.' }, + }, + 3: { + type: ContractErrorType.INVALID_STATUS_TRANSITION, + message: 'This action is not valid for the invoice/offer in its current status.', + recovery: { message: 'Refresh the invoice/offer status and confirm the action is still applicable.' }, + }, + 4: { + type: ContractErrorType.OFFER_NOT_FOUND, + message: 'No financing offer was found with the given ID.', + recovery: { message: 'Double-check the offer ID and that it was created successfully.' }, + }, + 5: { + type: ContractErrorType.OFFER_EXPIRED, + message: 'This financing offer has expired and can no longer be accepted.', + recovery: { message: 'Ask the lender to submit a new offer.' }, + }, + 6: { + type: ContractErrorType.OFFER_ALREADY_ACCEPTED, + message: 'This financing offer has already been accepted.', + recovery: { message: 'Refresh the offer status — no further action is needed.' }, + }, + 7: { + type: ContractErrorType.OFFER_ALREADY_REJECTED, + message: 'This financing offer has already been rejected.', + recovery: { message: 'Ask the lender to submit a new offer if terms are still needed.' }, + }, + 8: { + type: ContractErrorType.INTEREST_RATE_OUT_OF_RANGE, + message: 'The requested interest rate is outside the protocol-allowed range.', + recovery: { message: 'Use an interest rate between 1 and 10,000 basis points (0.01%–100%).' }, + }, + 9: { + type: ContractErrorType.DURATION_OUT_OF_RANGE, + message: 'The requested offer duration is outside the protocol-allowed range.', + recovery: { message: 'Use a duration between 1 second and 365 days.' }, + }, + 10: { + type: ContractErrorType.ALREADY_REPAID, + message: 'This invoice has already been fully repaid.', + recovery: { message: 'Refresh the invoice status — no further repayment is needed.' }, + }, + 11: { + type: ContractErrorType.INSUFFICIENT_BALANCE, + message: 'The account does not have sufficient balance to complete this transaction.', + recovery: { message: 'Add funds to your wallet and try again.', action: 'Add funds' }, + }, + 12: { + type: ContractErrorType.NOT_YET_OVERDUE, + message: 'This invoice is not yet past its due date and cannot be marked overdue.', + recovery: { message: 'Wait until the due date has passed before calling this again.' }, + }, + 13: { + type: ContractErrorType.GRACE_PERIOD_NOT_ELAPSED, + message: 'The overdue grace period has not yet elapsed, so this invoice cannot be reclaimed.', + recovery: { message: 'Wait for the grace period to elapse before reclaiming.' }, + }, + 14: { + type: ContractErrorType.NO_TRUSTLINE, + message: 'The recipient does not have a trustline for the position token.', + recovery: { + message: 'Add a trustline for the position token asset before this transfer/mint can succeed.', + action: 'Add trustline', + }, + }, + 15: { + type: ContractErrorType.UNAUTHORIZED, + message: 'The calling address is not authorized to perform this action.', + recovery: { message: 'Sign this transaction with the address that owns/originated this resource.' }, + }, +}; + +// ── Analytics hook (opt-in, dependency-free) ───────────────────────────────── +// +// Consuming apps can opt in to reporting SDK errors to their own analytics/ +// observability pipeline without the SDK taking a dependency on any specific +// analytics package. No-op until `setErrorReporter` is called. + +let errorReporter: ((err: SdkError) => void) | undefined; + +/** + * Register a callback invoked with every `SdkError` (including + * `ContractError`) constructed via `parseContractError`. Optional — if never + * called, no reporting happens. Pass `undefined` to unregister. + */ +export function setErrorReporter(fn: ((err: SdkError) => void) | undefined): void { + errorReporter = fn; +} + +/** Internal: report an error to the registered reporter, if any. Never throws. */ +function reportError(err: SdkError): void { + if (!errorReporter) return; + try { + errorReporter(err); + } catch { + // Reporting must never break the caller's error-handling flow. + } +} + +// ── Contract error ──────────────────────────────────────────────────────────── + +/** + * A typed error representing a failed Soroban contract call — simulation + * failure, submit failure, or a transaction that did not reach SUCCESS + * status. Constructed by `parseContractError`. + */ +export class ContractError extends SdkError { + /** The raw numeric Soroban contract error code, or -1 if none could be extracted. */ + readonly rawCode: number; + /** The typed classification of this error (UNKNOWN if rawCode is unmapped). */ + readonly errorType: ContractErrorType; + /** Optional recovery suggestion for this error type. */ + readonly recovery?: RecoverySuggestion; + + constructor(rawCode: number, errorType: ContractErrorType, message: string, recovery?: RecoverySuggestion, cause?: unknown) { + super(message, cause); + this.name = 'ContractError'; + this.rawCode = rawCode; + this.errorType = errorType; + this.recovery = recovery; + // Maintain proper prototype chain for compiled ES5 targets. + Object.setPrototypeOf(this, ContractError.prototype); + } +} + +// ── Error code extraction & mapping ────────────────────────────────────────── + +/** + * Soroban simulation/transaction failures typically stringify as something + * like `HostError: Error(Contract, #4)` or embed `Error(Contract, #4)` + * within a larger JSON/diagnostic payload. This pattern extracts the + * trailing `#` contract error code from any such string. + */ +const CONTRACT_ERROR_CODE_RE = /Error\(Contract,\s*#(\d+)\)/; +/** Fallback: a bare `#` anywhere in the string. */ +const BARE_ERROR_CODE_RE = /#(\d+)/; + +/** Best-effort extraction of a raw error message string from an unknown thrown value. */ +function stringifyRawError(rawError: unknown): string { + if (typeof rawError === 'string') return rawError; + if (rawError instanceof Error) return rawError.message; + if (rawError && typeof rawError === 'object') { + try { + return JSON.stringify(rawError); + } catch { + return String(rawError); + } + } + return String(rawError); +} + +/** Extracts a numeric Soroban contract error code from a raw error value, if present. */ +function extractErrorCode(rawError: unknown): number | undefined { + const text = stringifyRawError(rawError); + const match = CONTRACT_ERROR_CODE_RE.exec(text) ?? BARE_ERROR_CODE_RE.exec(text); + if (!match) return undefined; + const code = Number(match[1]); + return Number.isFinite(code) ? code : undefined; +} + +/** + * Parses a raw error (as thrown/returned by a failed `simulateTransaction`, + * `sendTransaction`, or `getTransaction` call) into a typed `ContractError`. + * + * - Extracts a numeric Soroban error code (`Error(Contract, #N)`) when present. + * - Looks it up in `CONTRACT_ERROR_MAP`; unmapped codes fall back to + * `ContractErrorType.UNKNOWN` with the raw code preserved. + * - When no code can be extracted at all, falls back to `UNKNOWN` with + * `rawCode: -1`, preserving the original message. + * - Always attaches the original `rawError` as `.cause` for chaining. + * - Reports the constructed error via the opt-in analytics hook. + * + * This never throws — it always returns a `ContractError` for the caller to throw. + */ +export function parseContractError(rawError: unknown, contextMessage?: string): ContractError { + const originalMessage = stringifyRawError(rawError); + const code = extractErrorCode(rawError); + + if (code !== undefined && code in CONTRACT_ERROR_MAP) { + const entry = CONTRACT_ERROR_MAP[code]; + const message = contextMessage ? `${contextMessage}: ${entry.message}` : entry.message; + const err = new ContractError(code, entry.type, message, entry.recovery, rawError); + reportError(err); + return err; + } + + const fallbackMessage = contextMessage + ? `${contextMessage}: ${originalMessage}` + : `Contract call failed: ${originalMessage}`; + const err = new ContractError(code ?? -1, ContractErrorType.UNKNOWN, fallbackMessage, undefined, rawError); + reportError(err); + return err; +} diff --git a/invofi/apps/sdk/src/index.ts b/invofi/apps/sdk/src/index.ts index 36a782633..a5afadc99 100644 --- a/invofi/apps/sdk/src/index.ts +++ b/invofi/apps/sdk/src/index.ts @@ -21,6 +21,27 @@ export { VALID_CURRENCIES, } from './validation'; +// ── Typed error handling & Soroban error code mapping (#223) ──────────────── +// `SdkError` is the base class for all non-validation SDK errors; +// `ContractError extends SdkError` wraps a failed contract call with a typed +// `errorType`, an optional `recovery` suggestion, and the raw Soroban error +// code. `parseContractError` is the mapping entry point client.ts funnels +// every simulate/send/getTransaction failure through. `setErrorReporter` is +// an optional, dependency-free analytics/observability hook. +// +// NOTE: `CONTRACT_ERROR_MAP`'s numeric codes are a placeholder/starter set — +// see the banner comment at the top of `src/errors.ts` for details on why, +// and what must be reconciled before relying on them against live contracts. +export { + SdkError, + ContractError, + ContractErrorType, + CONTRACT_ERROR_MAP, + parseContractError, + setErrorReporter, + type RecoverySuggestion, +} from './errors'; + // Stellar primitives the client surface needs — re-exported so consumers // don't need a direct @stellar/stellar-sdk dependency for common cases. export { Contract, Networks, xdr, nativeToScVal, scValToNative } from '@stellar/stellar-sdk'; diff --git a/invofi/apps/sdk/tests/errors.test.ts b/invofi/apps/sdk/tests/errors.test.ts new file mode 100644 index 000000000..ec5e0b2de --- /dev/null +++ b/invofi/apps/sdk/tests/errors.test.ts @@ -0,0 +1,199 @@ +/** + * Unit tests — SDK typed error handling (#223) + * + * Covers: parseContractError's numeric-code extraction and mapping, + * fallback behavior for unmapped/unparseable errors, cause chaining, + * the opt-in analytics reporter hook, and prototype-chain correctness for + * SdkError/ContractError. + * + * Strategy: all tests are pure in-process — no network calls, no Soroban RPC. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { + SdkError, + ContractError, + ContractErrorType, + CONTRACT_ERROR_MAP, + parseContractError, + setErrorReporter, +} from '../src/errors'; + +afterEach(() => { + // Always reset the opt-in reporter between tests so it doesn't leak. + setErrorReporter(undefined); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// parseContractError — code extraction & mapping +// ───────────────────────────────────────────────────────────────────────────── + +describe('parseContractError', () => { + it('extracts a mapped code from a realistic `Error(Contract, #N)` string and returns the typed error', () => { + // Code 1 is mapped to INVOICE_NOT_FOUND in CONTRACT_ERROR_MAP. + const raw = 'HostError: Error(Contract, #1)\n\nEvent log (newest first):...'; + const err = parseContractError(raw); + + expect(err).toBeInstanceOf(ContractError); + expect(err.rawCode).toBe(1); + expect(err.errorType).toBe(ContractErrorType.INVOICE_NOT_FOUND); + expect(err.message).toContain(CONTRACT_ERROR_MAP[1].message); + expect(err.recovery).toBeDefined(); + expect(err.recovery?.message).toBe(CONTRACT_ERROR_MAP[1].recovery?.message); + }); + + it('extracts a different mapped code correctly (INSUFFICIENT_BALANCE, code 11)', () => { + const raw = 'Error(Contract, #11)'; + const err = parseContractError(raw); + + expect(err.rawCode).toBe(11); + expect(err.errorType).toBe(ContractErrorType.INSUFFICIENT_BALANCE); + expect(err.recovery?.action).toBe('Add funds'); + }); + + it('prefixes the mapped message with an optional context message', () => { + const raw = 'Error(Contract, #1)'; + const err = parseContractError(raw, 'Simulation failed'); + expect(err.message.startsWith('Simulation failed:')).toBe(true); + }); + + it('falls back to UNKNOWN for a code not present in CONTRACT_ERROR_MAP, without throwing', () => { + const raw = 'Error(Contract, #999999)'; + let err: ContractError | undefined; + expect(() => { + err = parseContractError(raw); + }).not.toThrow(); + + expect(err).toBeInstanceOf(ContractError); + expect(err!.errorType).toBe(ContractErrorType.UNKNOWN); + expect(err!.rawCode).toBe(999999); + expect(err!.message).toContain('999999'); + }); + + it('falls back to UNKNOWN with rawCode -1 when no code can be extracted at all', () => { + const raw = 'network request timed out'; + const err = parseContractError(raw); + + expect(err.errorType).toBe(ContractErrorType.UNKNOWN); + expect(err.rawCode).toBe(-1); + expect(err.message).toContain('network request timed out'); + }); + + it('handles a plain Error instance as input and preserves its message', () => { + const original = new Error('Error(Contract, #1)'); + const err = parseContractError(original); + expect(err.errorType).toBe(ContractErrorType.INVOICE_NOT_FOUND); + }); + + it('handles a non-string, non-Error object payload (e.g. sendResult.errorResult-shaped)', () => { + const raw = { status: 'ERROR', errorResultXdr: 'AAAAAAAAAGT////+AAAAAA==' }; + let err: ContractError | undefined; + expect(() => { + err = parseContractError(raw); + }).not.toThrow(); + expect(err).toBeInstanceOf(ContractError); + expect(err!.errorType).toBe(ContractErrorType.UNKNOWN); + }); + + it('chains the original raw error as `cause`', () => { + const original = new Error('Error(Contract, #1)'); + const err = parseContractError(original); + expect(err.cause).toBe(original); + }); + + it('chains a non-Error raw value as `cause` too', () => { + const raw = 'Error(Contract, #1)'; + const err = parseContractError(raw); + expect(err.cause).toBe(raw); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// setErrorReporter — opt-in analytics hook +// ───────────────────────────────────────────────────────────────────────────── + +describe('setErrorReporter', () => { + it('is not invoked when no reporter is registered', () => { + // No reporter set (afterEach clears it) — this must not throw. + expect(() => parseContractError('Error(Contract, #1)')).not.toThrow(); + }); + + it('is invoked with the constructed error once a reporter is registered', () => { + const seen: SdkError[] = []; + setErrorReporter(err => seen.push(err)); + + const err = parseContractError('Error(Contract, #1)'); + + expect(seen).toHaveLength(1); + expect(seen[0]).toBe(err); + }); + + it('is invoked for UNKNOWN/unmapped errors too', () => { + const seen: SdkError[] = []; + setErrorReporter(err => seen.push(err)); + + parseContractError('some unparseable failure'); + + expect(seen).toHaveLength(1); + }); + + it('a reporter that throws does not break parseContractError', () => { + setErrorReporter(() => { + throw new Error('reporter boom'); + }); + + expect(() => parseContractError('Error(Contract, #1)')).not.toThrow(); + }); + + it('stops being invoked once unregistered with undefined', () => { + const seen: SdkError[] = []; + setErrorReporter(err => seen.push(err)); + setErrorReporter(undefined); + + parseContractError('Error(Contract, #1)'); + + expect(seen).toHaveLength(0); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// SdkError / ContractError — class hierarchy & prototype chain +// ───────────────────────────────────────────────────────────────────────────── + +describe('SdkError / ContractError class hierarchy', () => { + it('ContractError is an instance of SdkError and Error', () => { + const err = parseContractError('Error(Contract, #1)'); + expect(err).toBeInstanceOf(ContractError); + expect(err).toBeInstanceOf(SdkError); + expect(err).toBeInstanceOf(Error); + }); + + it('instanceof checks survive when caught from a thrown value (ES5-target prototype chain)', () => { + try { + throw parseContractError('Error(Contract, #1)'); + } catch (e) { + expect(e instanceof ContractError).toBe(true); + expect(e instanceof SdkError).toBe(true); + expect(e instanceof Error).toBe(true); + } + }); + + it('SdkError sets name to "SdkError" and ContractError overrides it to "ContractError"', () => { + const base = new SdkError('base message'); + expect(base.name).toBe('SdkError'); + + const contractErr = parseContractError('Error(Contract, #1)'); + expect(contractErr.name).toBe('ContractError'); + }); + + it('SdkError carries an optional cause', () => { + const cause = new Error('root cause'); + const err = new SdkError('wrapped', cause); + expect(err.cause).toBe(cause); + }); + + it('SdkError without a cause leaves it undefined', () => { + const err = new SdkError('no cause here'); + expect(err.cause).toBeUndefined(); + }); +}); From 4b7d0c188102fe7bf8b0d9bdca4585164af5fb14 Mon Sep 17 00:00:00 2001 From: Ajibose Date: Wed, 19 Aug 2026 09:18:20 +0300 Subject: [PATCH 2/3] fix(frontend): alias @stellar/stellar-sdk for Vitest, matching webpack The SDK's own node_modules isn't installed in CI (only apps/frontend's is), so @invofi/sdk's transitive `@stellar/stellar-sdk` import needs to resolve to this app's copy. next.config.mjs already aliases this for webpack; vitest.config.ts didn't, so `npm test` failed in CI with "Failed to resolve import '@stellar/stellar-sdk' from '../sdk/src/index.ts'" once SdkErrorBoundary.test.tsx started exercising that import path. Refs #223 --- invofi/apps/frontend/vitest.config.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/invofi/apps/frontend/vitest.config.ts b/invofi/apps/frontend/vitest.config.ts index c712b50ba..0311097fb 100644 --- a/invofi/apps/frontend/vitest.config.ts +++ b/invofi/apps/frontend/vitest.config.ts @@ -41,6 +41,11 @@ export default defineConfig({ // tsconfig.json + next.config.mjs); mirror that here so Vitest can // resolve it too (#223). '@invofi/sdk': path.resolve(__dirname, '../sdk/src/index.ts'), + // The SDK's own node_modules isn't installed in CI, so its + // `@stellar/stellar-sdk` import must resolve to this app's copy — + // same reasoning as the webpack alias in next.config.mjs, mirrored + // here for Vitest. + '@stellar/stellar-sdk': path.resolve(__dirname, 'node_modules/@stellar/stellar-sdk'), }, }, }); From e17b33dfa3914c293221e9dc4f137dc39f688549 Mon Sep 17 00:00:00 2001 From: Ajibose Date: Wed, 19 Aug 2026 12:14:26 +0300 Subject: [PATCH 3/3] fix(sdk): use canonical error discriminants, add real reset-path test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit's second round of review on #235: - CONTRACT_ERROR_MAP now uses the canonical common/src/errors.rs discriminants for codes 1-8 (Unauthorized, NotFound, InvalidTransition, Paused, InsufficientBalance, InvalidInput, AlreadyExists, Blacklisted) instead of the previous invented, domain-specific guesses (INVOICE_NOT_FOUND, OFFER_EXPIRED, etc.) for codes 1-15. Removed the mappings for codes 9-15 entirely — they were never sourced from the real contract enum, so a real code in that range now correctly falls back to UNKNOWN rather than being silently mislabeled. Updated errors.test.ts to match, including a test that all 8 canonical codes map correctly and that 9+ is unmapped. - SdkErrorBoundary.test.tsx: replaced the reset test that only asserted on pre-recovered content (it never actually exercised the reset click) with one that lets Bomb throw for real, clicks "Try again", and asserts onReset was called and the boundary re-renders recovered children instead of the fallback. Also updated two other tests' ContractError codes/types to the new canonical set. Refs #223 --- .../common/SdkErrorBoundary.test.tsx | 44 +++-- invofi/apps/sdk/src/errors.ts | 157 ++++++------------ invofi/apps/sdk/tests/errors.test.ts | 47 +++++- 3 files changed, 119 insertions(+), 129 deletions(-) diff --git a/invofi/apps/frontend/src/components/common/SdkErrorBoundary.test.tsx b/invofi/apps/frontend/src/components/common/SdkErrorBoundary.test.tsx index fc175efb8..8ab6ff035 100644 --- a/invofi/apps/frontend/src/components/common/SdkErrorBoundary.test.tsx +++ b/invofi/apps/frontend/src/components/common/SdkErrorBoundary.test.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { ContractError, ContractErrorType } from '@invofi/sdk'; @@ -27,7 +28,7 @@ describe('SdkErrorBoundary', () => { vi.spyOn(console, 'error').mockImplementation(() => {}); const err = new ContractError( - 11, + 5, ContractErrorType.INSUFFICIENT_BALANCE, 'The account does not have sufficient balance to complete this transaction.', { message: 'Add funds to your wallet and try again.', action: 'Add funds' }, @@ -47,10 +48,10 @@ describe('SdkErrorBoundary', () => { vi.spyOn(console, 'error').mockImplementation(() => {}); const err = new ContractError( - 14, - ContractErrorType.NO_TRUSTLINE, - 'The recipient does not have a trustline for the position token.', - { message: 'Add a trustline first.', action: 'Add trustline', url: 'https://example.com/trustlines' }, + 7, + ContractErrorType.ALREADY_EXISTS, + 'A resource with this ID already exists.', + { message: 'Use a different ID.', action: 'View existing', url: 'https://example.com/lookup' }, ); render( @@ -59,8 +60,8 @@ describe('SdkErrorBoundary', () => { , ); - const link = screen.getByRole('link', { name: 'Add trustline' }); - expect(link).toHaveAttribute('href', 'https://example.com/trustlines'); + const link = screen.getByRole('link', { name: 'View existing' }); + expect(link).toHaveAttribute('href', 'https://example.com/lookup'); }); it('falls back to the error message when a ContractError has no recovery suggestion', () => { @@ -91,29 +92,44 @@ describe('SdkErrorBoundary', () => { expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument(); }); - it('calls onReset and clears the error state when "Try again" is clicked', () => { + it('calls onReset and shows recovered content when "Try again" is clicked after a real error', () => { vi.spyOn(console, 'error').mockImplementation(() => {}); const onReset = vi.fn(); + // Bomb throws on its first render; clicking "Try again" must call + // onReset (which flips shouldThrow to false here, simulating a caller + // that clears whatever caused the error) and then re-render children + // instead of the fallback. function Wrapper() { + const [shouldThrow, setShouldThrow] = useState(true); return ( - - + { + onReset(); + setShouldThrow(false); + }} + > + ); } render(); - // Force the boundary into an error state via a custom fallback-free bomb - // is awkward without remounting, so this test instead verifies the reset - // wiring directly: render already-recovered content and ensure no crash. + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect(screen.queryByText('recovered')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + + expect(onReset).toHaveBeenCalledOnce(); expect(screen.getByText('recovered')).toBeInTheDocument(); + expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument(); }); it('supports a custom fallback render prop', () => { vi.spyOn(console, 'error').mockImplementation(() => {}); - const err = new ContractError(1, ContractErrorType.INVOICE_NOT_FOUND, 'No invoice found.'); + const err = new ContractError(2, ContractErrorType.NOT_FOUND, 'No invoice found.'); render( ( diff --git a/invofi/apps/sdk/src/errors.ts b/invofi/apps/sdk/src/errors.ts index cec3662b1..b86924d94 100644 --- a/invofi/apps/sdk/src/errors.ts +++ b/invofi/apps/sdk/src/errors.ts @@ -7,19 +7,16 @@ // original failure preserved as `.cause` — instead of a plain `new Error(...)` // with an interpolated string that can only be handled by matching text. // -// ⚠️ IMPORTANT — PLACEHOLDER ERROR CODES ⚠️ -// The numeric codes in `CONTRACT_ERROR_MAP` below are illustrative starter -// values inferred from this SDK's own method surface (client.ts) and -// validation constants (validation.ts). They are NOT sourced from the real -// `common/src/errors.rs` enum in the `Stellar-VaultLink/invofi-contracts` -// repository — that repo is a separate codebase not available in this -// workspace. The numeric ordering of a Rust `#[contracterror]` enum is -// whatever `common/src/errors.rs` declares, and Soroban error codes are -// positional (first variant = 1, second = 2, ...), so a mismatch here would -// silently mislabel real on-chain errors. Before this ships against a -// network where the real contracts are live, a maintainer MUST reconcile -// every entry in `CONTRACT_ERROR_MAP` against `common/src/errors.rs` and -// correct the numeric codes (and add any missing variants) accordingly. +// Error codes 1–8 in `CONTRACT_ERROR_MAP` are the canonical, shared +// `common/src/errors.rs` discriminants from `Stellar-VaultLink/invofi-contracts` +// (positional Soroban `#[contracterror]` ordering: first variant = 1, second +// = 2, ...): Unauthorized, NotFound, InvalidTransition, Paused, +// InsufficientBalance, InvalidInput, AlreadyExists, Blacklisted. These are +// cross-cutting, contract-agnostic discriminants — e.g. code 2 (`NotFound`) +// covers a missing invoice, offer, or any other by-ID lookup, not a +// per-resource variant. If `common/src/errors.rs` gains variants beyond 8, +// they are not yet mapped here and fall back to `ContractErrorType.UNKNOWN` +// until added. // // Usage: // import { parseContractError, ContractError, ContractErrorType } from './errors'; @@ -75,47 +72,27 @@ export class SdkError extends Error { // UNKNOWN for anything not (yet) in CONTRACT_ERROR_MAP. export const ContractErrorType = { - // ── Invoice lifecycle (registry contract) ────────────────────────────────── - INVOICE_NOT_FOUND: 'INVOICE_NOT_FOUND', - INVOICE_ALREADY_CANCELLED: 'INVOICE_ALREADY_CANCELLED', - INVALID_STATUS_TRANSITION: 'INVALID_STATUS_TRANSITION', + // ── common/src/errors.rs canonical discriminants (codes 1–8) ─────────────── + UNAUTHORIZED: 'UNAUTHORIZED', + NOT_FOUND: 'NOT_FOUND', + INVALID_TRANSITION: 'INVALID_TRANSITION', + PAUSED: 'PAUSED', + INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', + INVALID_INPUT: 'INVALID_INPUT', + ALREADY_EXISTS: 'ALREADY_EXISTS', + BLACKLISTED: 'BLACKLISTED', - // ── Financing offers (financing contract) ────────────────────────────────── - OFFER_NOT_FOUND: 'OFFER_NOT_FOUND', - OFFER_EXPIRED: 'OFFER_EXPIRED', - OFFER_ALREADY_ACCEPTED: 'OFFER_ALREADY_ACCEPTED', - OFFER_ALREADY_REJECTED: 'OFFER_ALREADY_REJECTED', - INTEREST_RATE_OUT_OF_RANGE: 'INTEREST_RATE_OUT_OF_RANGE', - DURATION_OUT_OF_RANGE: 'DURATION_OUT_OF_RANGE', - - // ── Repayment / reclaim (repayment contract) ──────────────────────────────── - ALREADY_REPAID: 'ALREADY_REPAID', - INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', - NOT_YET_OVERDUE: 'NOT_YET_OVERDUE', - GRACE_PERIOD_NOT_ELAPSED: 'GRACE_PERIOD_NOT_ELAPSED', - - // ── Position tokens ────────────────────────────────────────────────────────── - NO_TRUSTLINE: 'NO_TRUSTLINE', - - // ── Cross-cutting ───────────────────────────────────────────────────────────── - UNAUTHORIZED: 'UNAUTHORIZED', - - // ── Fallback ─────────────────────────────────────────────────────────────────── - UNKNOWN: 'UNKNOWN', + // ── Fallback ───────────────────────────────────────────────────────────────── + UNKNOWN: 'UNKNOWN', } as const; export type ContractErrorType = typeof ContractErrorType[keyof typeof ContractErrorType]; // ── Contract error → typed mapping table ───────────────────────────────────── // -// ⚠️ PLACEHOLDER / STARTER SET — see the file-level banner above. The -// numeric `code` values here are illustrative guesses at positional -// `#[contracterror]` ordering and MUST be reconciled against the real -// `common/src/errors.rs` in `Stellar-VaultLink/invofi-contracts` before -// relying on them against live contracts. Treat this table as the single -// place to update once the real codes are known — it is trivially -// extensible: add a `{ type, message, recovery }` entry keyed by the real -// numeric code. +// Codes 1–8 are the canonical `common/src/errors.rs` discriminants — see the +// file-level banner above. This table is the single place to extend if +// `common/src/errors.rs` ever gains variants beyond 8. interface ContractErrorEntry { type: ContractErrorType; @@ -125,82 +102,44 @@ interface ContractErrorEntry { export const CONTRACT_ERROR_MAP: Record = { 1: { - type: ContractErrorType.INVOICE_NOT_FOUND, - message: 'No invoice was found with the given ID.', - recovery: { message: 'Double-check the invoice ID and that it was registered successfully.' }, + type: ContractErrorType.UNAUTHORIZED, + message: 'The calling address is not authorized to perform this action.', + recovery: { message: 'Sign this transaction with the address that owns/originated this resource.' }, }, 2: { - type: ContractErrorType.INVOICE_ALREADY_CANCELLED, - message: 'This invoice has already been cancelled and cannot be modified.', - recovery: { message: 'Register a new invoice if you need to submit these terms again.' }, + type: ContractErrorType.NOT_FOUND, + message: 'No resource was found with the given ID.', + recovery: { message: 'Double-check the ID and that it was created successfully.' }, }, 3: { - type: ContractErrorType.INVALID_STATUS_TRANSITION, - message: 'This action is not valid for the invoice/offer in its current status.', - recovery: { message: 'Refresh the invoice/offer status and confirm the action is still applicable.' }, + type: ContractErrorType.INVALID_TRANSITION, + message: 'This action is not valid for the resource in its current status.', + recovery: { message: 'Refresh the resource’s status and confirm the action is still applicable.' }, }, 4: { - type: ContractErrorType.OFFER_NOT_FOUND, - message: 'No financing offer was found with the given ID.', - recovery: { message: 'Double-check the offer ID and that it was created successfully.' }, + type: ContractErrorType.PAUSED, + message: 'This contract is currently paused and not accepting this action.', + recovery: { message: 'Try again later, or check protocol announcements for details.' }, }, 5: { - type: ContractErrorType.OFFER_EXPIRED, - message: 'This financing offer has expired and can no longer be accepted.', - recovery: { message: 'Ask the lender to submit a new offer.' }, - }, - 6: { - type: ContractErrorType.OFFER_ALREADY_ACCEPTED, - message: 'This financing offer has already been accepted.', - recovery: { message: 'Refresh the offer status — no further action is needed.' }, - }, - 7: { - type: ContractErrorType.OFFER_ALREADY_REJECTED, - message: 'This financing offer has already been rejected.', - recovery: { message: 'Ask the lender to submit a new offer if terms are still needed.' }, - }, - 8: { - type: ContractErrorType.INTEREST_RATE_OUT_OF_RANGE, - message: 'The requested interest rate is outside the protocol-allowed range.', - recovery: { message: 'Use an interest rate between 1 and 10,000 basis points (0.01%–100%).' }, - }, - 9: { - type: ContractErrorType.DURATION_OUT_OF_RANGE, - message: 'The requested offer duration is outside the protocol-allowed range.', - recovery: { message: 'Use a duration between 1 second and 365 days.' }, - }, - 10: { - type: ContractErrorType.ALREADY_REPAID, - message: 'This invoice has already been fully repaid.', - recovery: { message: 'Refresh the invoice status — no further repayment is needed.' }, - }, - 11: { type: ContractErrorType.INSUFFICIENT_BALANCE, message: 'The account does not have sufficient balance to complete this transaction.', recovery: { message: 'Add funds to your wallet and try again.', action: 'Add funds' }, }, - 12: { - type: ContractErrorType.NOT_YET_OVERDUE, - message: 'This invoice is not yet past its due date and cannot be marked overdue.', - recovery: { message: 'Wait until the due date has passed before calling this again.' }, - }, - 13: { - type: ContractErrorType.GRACE_PERIOD_NOT_ELAPSED, - message: 'The overdue grace period has not yet elapsed, so this invoice cannot be reclaimed.', - recovery: { message: 'Wait for the grace period to elapse before reclaiming.' }, + 6: { + type: ContractErrorType.INVALID_INPUT, + message: 'One or more input values were invalid.', + recovery: { message: 'Check the submitted values and try again.' }, }, - 14: { - type: ContractErrorType.NO_TRUSTLINE, - message: 'The recipient does not have a trustline for the position token.', - recovery: { - message: 'Add a trustline for the position token asset before this transfer/mint can succeed.', - action: 'Add trustline', - }, + 7: { + type: ContractErrorType.ALREADY_EXISTS, + message: 'A resource with this ID already exists.', + recovery: { message: 'Use a different ID, or look up the existing resource instead.' }, }, - 15: { - type: ContractErrorType.UNAUTHORIZED, - message: 'The calling address is not authorized to perform this action.', - recovery: { message: 'Sign this transaction with the address that owns/originated this resource.' }, + 8: { + type: ContractErrorType.BLACKLISTED, + message: 'This address has been blacklisted and cannot perform this action.', + recovery: { message: 'Contact support if you believe this is a mistake.' }, }, }; diff --git a/invofi/apps/sdk/tests/errors.test.ts b/invofi/apps/sdk/tests/errors.test.ts index ec5e0b2de..7d970a393 100644 --- a/invofi/apps/sdk/tests/errors.test.ts +++ b/invofi/apps/sdk/tests/errors.test.ts @@ -30,27 +30,62 @@ afterEach(() => { describe('parseContractError', () => { it('extracts a mapped code from a realistic `Error(Contract, #N)` string and returns the typed error', () => { - // Code 1 is mapped to INVOICE_NOT_FOUND in CONTRACT_ERROR_MAP. + // Code 1 is the canonical common/src/errors.rs discriminant for Unauthorized. const raw = 'HostError: Error(Contract, #1)\n\nEvent log (newest first):...'; const err = parseContractError(raw); expect(err).toBeInstanceOf(ContractError); expect(err.rawCode).toBe(1); - expect(err.errorType).toBe(ContractErrorType.INVOICE_NOT_FOUND); + expect(err.errorType).toBe(ContractErrorType.UNAUTHORIZED); expect(err.message).toContain(CONTRACT_ERROR_MAP[1].message); expect(err.recovery).toBeDefined(); expect(err.recovery?.message).toBe(CONTRACT_ERROR_MAP[1].recovery?.message); }); - it('extracts a different mapped code correctly (INSUFFICIENT_BALANCE, code 11)', () => { - const raw = 'Error(Contract, #11)'; + it('extracts a different mapped code correctly (NotFound, code 2)', () => { + const raw = 'Error(Contract, #2)'; const err = parseContractError(raw); - expect(err.rawCode).toBe(11); + expect(err.rawCode).toBe(2); + expect(err.errorType).toBe(ContractErrorType.NOT_FOUND); + }); + + it('extracts InsufficientBalance (code 5), which carries an actionable recovery', () => { + const raw = 'Error(Contract, #5)'; + const err = parseContractError(raw); + + expect(err.rawCode).toBe(5); expect(err.errorType).toBe(ContractErrorType.INSUFFICIENT_BALANCE); expect(err.recovery?.action).toBe('Add funds'); }); + it('maps all eight canonical codes (1-8) to their expected discriminant', () => { + const expected: Record = { + 1: ContractErrorType.UNAUTHORIZED, + 2: ContractErrorType.NOT_FOUND, + 3: ContractErrorType.INVALID_TRANSITION, + 4: ContractErrorType.PAUSED, + 5: ContractErrorType.INSUFFICIENT_BALANCE, + 6: ContractErrorType.INVALID_INPUT, + 7: ContractErrorType.ALREADY_EXISTS, + 8: ContractErrorType.BLACKLISTED, + }; + + for (const [code, type] of Object.entries(expected)) { + const err = parseContractError(`Error(Contract, #${code})`); + expect(err.errorType).toBe(type); + } + }); + + it('does not map code 9 or above — those fall back to UNKNOWN', () => { + expect(CONTRACT_ERROR_MAP[9]).toBeUndefined(); + expect(CONTRACT_ERROR_MAP[15]).toBeUndefined(); + + const err = parseContractError('Error(Contract, #9)'); + expect(err.errorType).toBe(ContractErrorType.UNKNOWN); + expect(err.rawCode).toBe(9); + }); + it('prefixes the mapped message with an optional context message', () => { const raw = 'Error(Contract, #1)'; const err = parseContractError(raw, 'Simulation failed'); @@ -82,7 +117,7 @@ describe('parseContractError', () => { it('handles a plain Error instance as input and preserves its message', () => { const original = new Error('Error(Contract, #1)'); const err = parseContractError(original); - expect(err.errorType).toBe(ContractErrorType.INVOICE_NOT_FOUND); + expect(err.errorType).toBe(ContractErrorType.UNAUTHORIZED); }); it('handles a non-string, non-Error object payload (e.g. sendResult.errorResult-shaped)', () => {