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..8ab6ff035
--- /dev/null
+++ b/invofi/apps/frontend/src/components/common/SdkErrorBoundary.test.tsx
@@ -0,0 +1,148 @@
+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';
+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(
+ 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' },
+ );
+
+ 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(
+ 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(
+
+
+ ,
+ );
+
+ 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', () => {
+ 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 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();
+
+ 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(2, ContractErrorType.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}
+
+
+
+ );
+}
+
+/**
+ * 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 3b3217ecf..0311097fb 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,7 +37,15 @@ 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'),
+ // 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'),
},
},
});
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..b86924d94
--- /dev/null
+++ b/invofi/apps/sdk/src/errors.ts
@@ -0,0 +1,266 @@
+// ── 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.
+//
+// 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';
+// 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 = {
+ // ── 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',
+
+ // ── Fallback ─────────────────────────────────────────────────────────────────
+ UNKNOWN: 'UNKNOWN',
+} as const;
+
+export type ContractErrorType = typeof ContractErrorType[keyof typeof ContractErrorType];
+
+// ── Contract error → typed mapping table ─────────────────────────────────────
+//
+// 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;
+ message: string;
+ recovery?: RecoverySuggestion;
+}
+
+export const CONTRACT_ERROR_MAP: Record = {
+ 1: {
+ 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.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_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.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.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' },
+ },
+ 6: {
+ type: ContractErrorType.INVALID_INPUT,
+ message: 'One or more input values were invalid.',
+ recovery: { message: 'Check the submitted values and try again.' },
+ },
+ 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.' },
+ },
+ 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.' },
+ },
+};
+
+// ── 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..7d970a393
--- /dev/null
+++ b/invofi/apps/sdk/tests/errors.test.ts
@@ -0,0 +1,234 @@
+/**
+ * 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 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.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 (NotFound, code 2)', () => {
+ const raw = 'Error(Contract, #2)';
+ const err = parseContractError(raw);
+
+ 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');
+ 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.UNAUTHORIZED);
+ });
+
+ 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();
+ });
+});