diff --git a/frontend/src/ClaimRewards.jsx b/frontend/src/ClaimRewards.jsx
index 9e7cda6f..bb3c7546 100644
--- a/frontend/src/ClaimRewards.jsx
+++ b/frontend/src/ClaimRewards.jsx
@@ -1,11 +1,17 @@
import { useId, useState } from 'react';
-import { submitClaimTransaction, normalizeError, getStellarNetwork } from './stellar';
+import { submitClaimTransaction, getStellarNetwork } from './stellar';
import TransactionStatus from './components/TransactionStatus';
+import { useOptimisticAction } from './hooks/useOptimisticAction';
/**
* ClaimRewards — lets the user enter a points amount, sign a Soroban
* `claim(user, amount)` transaction via Freighter, and see the result.
*
+ * The submit is optimistic: the input clears and a pending indicator appears
+ * immediately; on confirmation the parent reconciles the on-chain balance, and
+ * on failure the entered amount is restored and a class-aware error is shown.
+ * A second submit while one is in flight is ignored (double-submit guard).
+ *
* Props
* ─────
* @param {string} walletAddress – Connected Stellar public key.
@@ -15,40 +21,35 @@ import TransactionStatus from './components/TransactionStatus';
*/
export default function ClaimRewards({ walletAddress, onClaimSuccess }) {
const [amount, setAmount] = useState('');
- const [isClaiming, setIsClaiming] = useState(false);
const [txHash, setTxHash] = useState('');
- const [claimError, setClaimError] = useState('');
const amountId = useId();
const headingId = useId();
const feedbackId = useId();
- const feedbackDescribedBy = txHash || claimError ? feedbackId : undefined;
const stellarNetwork = getStellarNetwork();
+ const { run, isPending, isError, error } = useOptimisticAction();
const parsedAmount = Number(amount);
const isValid = Number.isInteger(parsedAmount) && parsedAmount > 0;
+ const feedbackDescribedBy = txHash || isError ? feedbackId : undefined;
const handleClaim = async (event) => {
event.preventDefault();
if (!walletAddress || !isValid) return;
- setIsClaiming(true);
- setClaimError('');
setTxHash('');
+ const submittedAmount = amount;
- try {
- const { hash, newBalance } = await submitClaimTransaction(walletAddress, parsedAmount);
-
- setTxHash(hash);
- setAmount('');
-
- if (onClaimSuccess) {
- onClaimSuccess(newBalance);
- }
- } catch (error) {
- setClaimError(normalizeError(error));
- } finally {
- setIsClaiming(false);
- }
+ await run(() => submitClaimTransaction(walletAddress, parsedAmount), {
+ // Optimistic: clear the input right away so the action feels instant.
+ optimistic: () => setAmount(''),
+ // Rollback: restore the amount the user entered if the claim fails.
+ rollback: () => setAmount(submittedAmount),
+ // Reconcile: surface the tx + let the parent refresh the chain balance.
+ reconcile: ({ hash, newBalance }) => {
+ setTxHash(hash);
+ onClaimSuccess?.(newBalance);
+ },
+ });
};
return (
@@ -70,26 +71,32 @@ export default function ClaimRewards({ walletAddress, onClaimSuccess }) {
placeholder="e.g. 100"
className="claim-input"
value={amount}
- disabled={isClaiming || !walletAddress}
- aria-invalid={Boolean(claimError)}
+ disabled={isPending || !walletAddress}
+ aria-invalid={isError}
aria-describedby={feedbackDescribedBy}
onChange={(e) => setAmount(e.target.value)}
/>
- {txHash && }
+ {isPending && (
+
+ )}
+ {!isPending && txHash && (
+
+ )}
- {claimError && (
+ {isError && error && (
- {claimError}
+ {error.message}
+ {error.recovery ? ` ${error.recovery}.` : ''}
)}
diff --git a/frontend/src/RegisterCampaign.jsx b/frontend/src/RegisterCampaign.jsx
index 8a2122fa..92550886 100644
--- a/frontend/src/RegisterCampaign.jsx
+++ b/frontend/src/RegisterCampaign.jsx
@@ -7,11 +7,17 @@ import {
getStellarNetwork,
} from './stellar';
import TransactionStatus from './components/TransactionStatus';
+import { useOptimisticAction } from './hooks/useOptimisticAction';
/**
* RegisterCampaign — lets the connected wallet register as a campaign
* participant by calling the campaign contract's `register(participant)`.
*
+ * The submit flow is optimistic: the participant status flips to "Registered"
+ * the instant the user clicks, then either confirms on success or rolls back to
+ * the previous status on failure (with a class-aware error). A second click
+ * while a registration is in flight is ignored (double-submit guard).
+ *
* Props
* ─────
* @param {string} walletAddress – Connected Stellar public key.
@@ -19,27 +25,27 @@ import TransactionStatus from './components/TransactionStatus';
export default function RegisterCampaign({ walletAddress, onRegistered }) {
const [isRegistered, setIsRegistered] = useState(null);
const [isChecking, setIsChecking] = useState(false);
- const [isRegistering, setIsRegistering] = useState(false);
const [txHash, setTxHash] = useState('');
- const [error, setError] = useState('');
+ const [checkError, setCheckError] = useState('');
const [notice, setNotice] = useState('');
const headingId = useId();
const statusId = useId();
const campaignContractId = getCampaignContractId();
const stellarNetwork = getStellarNetwork();
+ const { run, isPending, isError, error } = useOptimisticAction();
/* On mount (and when the wallet changes), check participant status. */
useEffect(() => {
if (!walletAddress || !campaignContractId) {
setIsRegistered(null);
- setError('');
+ setCheckError('');
setNotice('');
return;
}
let cancelled = false;
setIsChecking(true);
- setError('');
+ setCheckError('');
setNotice('');
checkParticipantStatus(walletAddress)
@@ -47,7 +53,7 @@ export default function RegisterCampaign({ walletAddress, onRegistered }) {
if (!cancelled) setIsRegistered(registered);
})
.catch((err) => {
- if (!cancelled) setError(normalizeError(err));
+ if (!cancelled) setCheckError(normalizeError(err));
})
.finally(() => {
if (!cancelled) setIsChecking(false);
@@ -61,43 +67,45 @@ export default function RegisterCampaign({ walletAddress, onRegistered }) {
const handleRegister = async () => {
if (!walletAddress) return;
- setIsRegistering(true);
- setError('');
setNotice('');
setTxHash('');
-
- try {
- const { hash, alreadyRegistered } = await submitRegisterTransaction(walletAddress);
- setTxHash(hash);
- setIsRegistered(true);
-
- if (alreadyRegistered) {
- setNotice('You were already registered in this campaign.');
- } else {
- onRegistered?.();
- }
- } catch (err) {
- setError(normalizeError(err));
- } finally {
- setIsRegistering(false);
- }
+ setCheckError('');
+ const previousStatus = isRegistered;
+
+ await run(() => submitRegisterTransaction(walletAddress), {
+ // Optimistic: reflect "registered" immediately so the action feels instant.
+ optimistic: () => setIsRegistered(true),
+ // Rollback: restore the prior status if the transaction fails.
+ rollback: () => setIsRegistered(previousStatus),
+ // Reconcile with chain truth once confirmed.
+ reconcile: ({ hash, alreadyRegistered }) => {
+ setTxHash(hash);
+ if (alreadyRegistered) {
+ setNotice('You were already registered in this campaign.');
+ } else {
+ onRegistered?.();
+ }
+ },
+ });
};
if (!campaignContractId) return null;
const statusLabel = isChecking
? 'Checking…'
- : isRegistered === true
- ? '✓ Registered'
- : isRegistered === false
- ? 'Not registered'
- : '—';
+ : isPending
+ ? 'Registering…'
+ : isRegistered === true
+ ? '✓ Registered'
+ : isRegistered === false
+ ? 'Not registered'
+ : '—';
return (
Campaign registration
@@ -114,24 +122,35 @@ export default function RegisterCampaign({ walletAddress, onRegistered }) {
)}
- {txHash && }
+ {isPending && (
+
+ )}
+ {!isPending && txHash && (
+
+ )}
{notice && (
{notice}
)}
- {error && (
+ {isError && error && (
+
+ {error.message}
+ {error.recovery ? ` ${error.recovery}.` : ''}
+
+ )}
+ {checkError && (
- {error}
+ {checkError}
)}
diff --git a/frontend/src/components/TransactionStatus.css b/frontend/src/components/TransactionStatus.css
index 51437cb0..9186c744 100644
--- a/frontend/src/components/TransactionStatus.css
+++ b/frontend/src/components/TransactionStatus.css
@@ -131,3 +131,39 @@
.tx-explorer-link:hover svg {
transform: translate(1px, -1px);
}
+
+/* ── Lifecycle variants (optimistic UI) ─────────────────────────────────── */
+
+.tx-status--pending .tx-status-icon {
+ background: var(--warning, #d97706);
+ animation: txPulse 1.2s ease-in-out infinite;
+}
+
+.tx-status--pending .tx-status-label {
+ color: var(--warning, #d97706);
+}
+
+.tx-status--error .tx-status-icon {
+ background: var(--error, #dc2626);
+}
+
+.tx-status--error .tx-status-label {
+ color: var(--error, #dc2626);
+}
+
+.tx-status-message {
+ margin: 0 0 0.85rem;
+ font-size: 0.95rem;
+ line-height: 1.4;
+ color: var(--text-secondary, #4b5563);
+}
+
+@keyframes txPulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.45;
+ }
+}
diff --git a/frontend/src/components/TransactionStatus.jsx b/frontend/src/components/TransactionStatus.jsx
index 26ea484e..aa2ac242 100644
--- a/frontend/src/components/TransactionStatus.jsx
+++ b/frontend/src/components/TransactionStatus.jsx
@@ -1,19 +1,33 @@
import { useState } from 'react';
import './TransactionStatus.css';
+const VARIANT_ICON = { success: '✓', pending: '⏳', error: '✕' };
+const VARIANT_DEFAULT_LABEL = {
+ success: 'Success',
+ pending: 'Awaiting confirmation…',
+ error: 'Failed',
+};
+
/**
- * TransactionStatus — A premium component to display on-chain transaction results.
+ * TransactionStatus — displays the state of an on-chain action. Supports an
+ * optimistic lifecycle via `variant`: a `pending` card (shown immediately on
+ * submit, before a hash exists), a `success` card once confirmed, and an
+ * `error` card with a mapped message when the action is rolled back.
*
- * @param {string} hash - The full transaction hash.
+ * @param {string} [hash] - The full transaction hash (absent while pending).
* @param {string} network - The Stellar network (e.g., 'testnet', 'mainnet').
- * @param {string} [status='Success'] - The transaction status label.
+ * @param {'success'|'pending'|'error'} [variant='success'] - Lifecycle state.
+ * @param {string} [status] - Override label (defaults per variant).
+ * @param {string} [message] - Extra detail line (e.g. the rolled-back error).
*/
-export default function TransactionStatus({ hash, network, status = 'Success' }) {
+export default function TransactionStatus({ hash, network, variant = 'success', status, message }) {
const [copied, setCopied] = useState(false);
const shortenedHash = hash ? `${hash.substring(0, 8)}...${hash.substring(hash.length - 8)}` : '';
const explorerUrl = `https://stellar.expert/explorer/${network}/tx/${hash}`;
+ const label = status ?? VARIANT_DEFAULT_LABEL[variant] ?? VARIANT_DEFAULT_LABEL.success;
+ const isError = variant === 'error';
const handleCopy = async () => {
try {
@@ -25,86 +39,96 @@ export default function TransactionStatus({ hash, network, status = 'Success' })
}
};
- if (!hash) return null;
+ // Backward compatible: with no hash the card only renders for non-success
+ // (pending/error) lifecycle states.
+ if (!hash && variant === 'success') return null;
return (
-
+
- ✓
+ {VARIANT_ICON[variant] ?? VARIANT_ICON.success}
- {status}
+ {label}
-
-
-
Transaction Hash
-
-
- {shortenedHash}
-
-
+ {message &&
{message}
}
+
+ {hash && (
+
+
+
Transaction Hash
+
+
+ {shortenedHash}
+
+
+
-
-
- View on Stellar Expert
-
-
-
+ View on Stellar Expert
+
+
+
+ )}
);
}
diff --git a/frontend/src/hooks/useOptimisticAction.js b/frontend/src/hooks/useOptimisticAction.js
new file mode 100644
index 00000000..9bfd2c75
--- /dev/null
+++ b/frontend/src/hooks/useOptimisticAction.js
@@ -0,0 +1,93 @@
+import { useCallback, useRef, useState } from 'react';
+import { mapError as defaultMapError } from '../lib/errorMapping';
+
+/**
+ * useOptimisticAction — drives an "optimistic UI" lifecycle for an async,
+ * chain-backed action (register, claim, …):
+ *
+ * submit → apply optimistic state immediately
+ * → await the real action
+ * → success: reconcile with chain truth
+ * → failure: roll back the optimistic state + surface a mapped error
+ *
+ * It also guards against double-submit (a second `run` while one is in flight
+ * is ignored) so a double-click can't fire two transactions.
+ *
+ * State machine: 'idle' → 'pending' → 'success' | 'error'.
+ *
+ * @param {{ mapError?: (error: unknown) => object }} [options]
+ * `mapError` converts a thrown error into the structured shape from
+ * lib/errorMapping (overridable for testing). Defaults to the shared mapper.
+ */
+export function useOptimisticAction({ mapError = defaultMapError } = {}) {
+ const [status, setStatus] = useState('idle');
+ const [error, setError] = useState(null);
+ // Synchronous in-flight latch: state updates are async, so a ref is what
+ // actually prevents a same-tick double-submit.
+ const inFlight = useRef(false);
+
+ /**
+ * @template T
+ * @param {() => Promise
} action The real async action (e.g. submit tx).
+ * @param {{
+ * optimistic?: () => void, // apply expected state right away
+ * rollback?: () => void, // undo the optimistic state on failure
+ * reconcile?: (result: T) => void, // align local state with chain truth
+ * }} [handlers]
+ * @returns {Promise<{ ok: boolean, skipped?: boolean, result?: T, error?: object }>}
+ */
+ const run = useCallback(
+ async (action, { optimistic, rollback, reconcile } = {}) => {
+ if (inFlight.current) {
+ return { ok: false, skipped: true };
+ }
+ inFlight.current = true;
+ setStatus('pending');
+ setError(null);
+
+ let appliedOptimistic = false;
+ try {
+ if (optimistic) {
+ optimistic();
+ appliedOptimistic = true;
+ }
+
+ const result = await action();
+
+ reconcile?.(result);
+ setStatus('success');
+ return { ok: true, result };
+ } catch (err) {
+ // Only undo what we actually applied — keeps rollback idempotent and
+ // correct even if the failure happened before the optimistic step.
+ if (appliedOptimistic) {
+ rollback?.();
+ }
+ const mapped = mapError(err);
+ setError(mapped);
+ setStatus('error');
+ return { ok: false, error: mapped };
+ } finally {
+ inFlight.current = false;
+ }
+ },
+ [mapError],
+ );
+
+ const reset = useCallback(() => {
+ setStatus('idle');
+ setError(null);
+ }, []);
+
+ return {
+ status,
+ error,
+ isPending: status === 'pending',
+ isSuccess: status === 'success',
+ isError: status === 'error',
+ run,
+ reset,
+ };
+}
+
+export default useOptimisticAction;
diff --git a/frontend/src/hooks/useOptimisticAction.test.jsx b/frontend/src/hooks/useOptimisticAction.test.jsx
new file mode 100644
index 00000000..3ddd21e0
--- /dev/null
+++ b/frontend/src/hooks/useOptimisticAction.test.jsx
@@ -0,0 +1,167 @@
+// Tests for the optimistic-action lifecycle hook and the error classification
+// that powers register/claim's rollback messaging (#627).
+//
+// Located under src/hooks/ to match the vitest `include` glob
+// (src/hooks/**/*.test.{js,jsx}) so it runs in CI.
+
+import { renderHook, act } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import { useOptimisticAction } from './useOptimisticAction';
+import { classifyError, mapError, ERROR_CLASS } from '../lib/errorMapping';
+
+describe('useOptimisticAction', () => {
+ it('applies the optimistic update, then reconciles on success', async () => {
+ const { result } = renderHook(() => useOptimisticAction());
+ const optimistic = vi.fn();
+ const reconcile = vi.fn();
+ const rollback = vi.fn();
+
+ await act(async () => {
+ const outcome = await result.current.run(async () => 'BALANCE_42', {
+ optimistic,
+ rollback,
+ reconcile,
+ });
+ expect(outcome).toEqual({ ok: true, result: 'BALANCE_42' });
+ });
+
+ expect(optimistic).toHaveBeenCalledTimes(1);
+ expect(reconcile).toHaveBeenCalledWith('BALANCE_42');
+ expect(rollback).not.toHaveBeenCalled();
+ expect(result.current.isSuccess).toBe(true);
+ expect(result.current.error).toBeNull();
+ });
+
+ it('rolls back and surfaces a mapped error on failure', async () => {
+ const { result } = renderHook(() => useOptimisticAction());
+ const optimistic = vi.fn();
+ const rollback = vi.fn();
+ const reconcile = vi.fn();
+
+ await act(async () => {
+ const outcome = await result.current.run(
+ async () => {
+ throw new Error('Error(Contract, #103)');
+ },
+ { optimistic, rollback, reconcile },
+ );
+ expect(outcome.ok).toBe(false);
+ expect(outcome.error.class).toBe(ERROR_CLASS.CONTRACT);
+ });
+
+ expect(optimistic).toHaveBeenCalledTimes(1);
+ expect(rollback).toHaveBeenCalledTimes(1);
+ expect(reconcile).not.toHaveBeenCalled();
+ expect(result.current.isError).toBe(true);
+ expect(result.current.error.code).toBe(103);
+ expect(result.current.error.message).toMatch(/not active/i);
+ });
+
+ it('does not roll back if the failure happens before the optimistic step', async () => {
+ const { result } = renderHook(() => useOptimisticAction());
+ const rollback = vi.fn();
+
+ await act(async () => {
+ await result.current.run(
+ async () => {
+ throw new Error('boom');
+ },
+ {
+ optimistic: () => {
+ throw new Error('optimistic failed');
+ },
+ rollback,
+ },
+ );
+ });
+
+ // The optimistic callback threw, so nothing was applied → nothing to undo.
+ expect(rollback).not.toHaveBeenCalled();
+ expect(result.current.isError).toBe(true);
+ });
+
+ it('ignores a second submit while one is in flight (double-submit guard)', async () => {
+ const { result } = renderHook(() => useOptimisticAction());
+
+ let resolveFirst;
+ const action = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveFirst = resolve;
+ }),
+ );
+
+ let firstPromise;
+ act(() => {
+ firstPromise = result.current.run(action);
+ });
+ expect(result.current.isPending).toBe(true);
+
+ // Second call while pending must be skipped and must not invoke the action.
+ await act(async () => {
+ const second = await result.current.run(action);
+ expect(second).toEqual({ ok: false, skipped: true });
+ });
+ expect(action).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ resolveFirst('ok');
+ await firstPromise;
+ });
+ expect(result.current.isSuccess).toBe(true);
+
+ // After settling, a fresh submit is allowed again.
+ await act(async () => {
+ await result.current.run(async () => 'again');
+ });
+ expect(action).toHaveBeenCalledTimes(1);
+ });
+
+ it('reset returns the hook to its idle state', async () => {
+ const { result } = renderHook(() => useOptimisticAction());
+
+ await act(async () => {
+ await result.current.run(async () => {
+ throw new Error('nope');
+ });
+ });
+ expect(result.current.isError).toBe(true);
+
+ act(() => {
+ result.current.reset();
+ });
+ expect(result.current.status).toBe('idle');
+ expect(result.current.error).toBeNull();
+ });
+});
+
+describe('error classification (distinct messaging)', () => {
+ it('classifies a contract revert by its decoded code', () => {
+ expect(classifyError(new Error('HostError: Error(Contract, #102)'))).toBe(ERROR_CLASS.CONTRACT);
+ const mapped = mapError(new Error('Error(Contract, #102)'));
+ expect(mapped.code).toBe(102);
+ expect(mapped.message).toMatch(/participant limit/i);
+ });
+
+ it('classifies a wallet rejection separately from a system failure', () => {
+ expect(classifyError(new Error('User declined the transaction in Freighter'))).toBe(
+ ERROR_CLASS.WALLET,
+ );
+ expect(mapError(new Error('User rejected request')).message).toMatch(/cancelled/i);
+ });
+
+ it('distinguishes network failures from contract reverts', () => {
+ expect(classifyError(new TypeError('Failed to fetch'))).toBe(ERROR_CLASS.NETWORK);
+ const net = mapError(new Error('network timeout while submitting'));
+ expect(net.class).toBe(ERROR_CLASS.NETWORK);
+ expect(net.retryable).toBe(true);
+ expect(net.message).toMatch(/not submitted/i);
+ });
+
+ it('classifies RPC/Horizon outages as their own class', () => {
+ const rpc = mapError(new Error('Soroban RPC simulate failed: 503 service unavailable'));
+ expect(rpc.class).toBe(ERROR_CLASS.RPC);
+ expect(rpc.retryable).toBe(true);
+ expect(rpc.message).toMatch(/temporarily unavailable/i);
+ });
+});
diff --git a/frontend/src/lib/errorMapping.js b/frontend/src/lib/errorMapping.js
index 59869c81..2779ab82 100644
--- a/frontend/src/lib/errorMapping.js
+++ b/frontend/src/lib/errorMapping.js
@@ -31,6 +31,128 @@ export const ERROR_MESSAGES = {
503: 'The blockchain service is temporarily unavailable',
};
+/**
+ * Coarse error classes used to give register/claim failures distinct messaging
+ * and to decide how the optimistic UI should recover (see useOptimisticAction).
+ *
+ * - `contract` — the contract reverted with a known/mapped error code.
+ * - `wallet` — the user rejected/closed the signing prompt.
+ * - `network` — request never reached the chain (offline, DNS, timeout).
+ * - `rpc` — Soroban RPC / Horizon reachable but failing (5xx, simulate).
+ * - `validation` — client-side input problem.
+ * - `unknown` — anything we can't confidently bucket.
+ */
+export const ERROR_CLASS = {
+ CONTRACT: 'contract',
+ WALLET: 'wallet',
+ NETWORK: 'network',
+ RPC: 'rpc',
+ VALIDATION: 'validation',
+ UNKNOWN: 'unknown',
+};
+
+/** Per-class fallback copy used when no specific contract message applies. */
+const CLASS_MESSAGES = {
+ [ERROR_CLASS.WALLET]: 'Signing was cancelled in your wallet. Nothing was submitted.',
+ [ERROR_CLASS.NETWORK]:
+ 'Network problem reaching the blockchain — your action was not submitted. Check your connection and try again.',
+ [ERROR_CLASS.RPC]:
+ 'The Soroban network is temporarily unavailable. Your action was not applied; please try again in a moment.',
+ [ERROR_CLASS.VALIDATION]: 'Please check your input and try again.',
+ [ERROR_CLASS.UNKNOWN]: 'An unexpected error occurred. Your action was not applied.',
+};
+
+/**
+ * Extract a numeric contract/HTTP error code from any error shape.
+ * @param {Error|number|string|object} error
+ * @returns {number|null}
+ */
+export function extractErrorCode(error) {
+ if (error === null || error === undefined) return null;
+ if (typeof error === 'number') return Number.isFinite(error) ? error : null;
+ if (typeof error === 'string') {
+ const fromString = error.match(/(?:Error\(Contract,\s*#|contract.*?#|code[:\s]+)(\d+)/i);
+ return fromString ? Number(fromString[1]) : null;
+ }
+ if (typeof error.code === 'number') return error.code;
+ if (typeof error.status === 'number') return error.status;
+ const text = error.message || error.toString?.() || '';
+ const match = text.match(/(?:Error\(Contract,\s*#|contract.*?#|code[:\s]+)(\d+)/i);
+ return match ? Number(match[1]) : null;
+}
+
+/**
+ * Classify an error so the UI can show class-specific messaging and decide
+ * recovery. Order matters: a contract revert with a real code is the most
+ * specific signal, a rejected wallet prompt next, then transport problems.
+ * @param {Error|number|string|object} error
+ * @returns {string} one of ERROR_CLASS
+ */
+export function classifyError(error) {
+ if (!error) return ERROR_CLASS.UNKNOWN;
+
+ const code = extractErrorCode(error);
+ const text = (
+ typeof error === 'string' ? error : error.message || error.toString?.() || ''
+ ).toLowerCase();
+
+ // A decoded contract revert (e.g. `Error(Contract, #103)`) is unambiguous.
+ if (/error\(contract|contract.*?#\d+/i.test(text)) return ERROR_CLASS.CONTRACT;
+
+ // User-driven wallet rejections must not look like a system failure.
+ if (
+ /user (rejected|declined)|reject|declin|denied|cancel|freighter|wallet|not allowed/.test(text)
+ )
+ return ERROR_CLASS.WALLET;
+
+ // Transport: never reached the chain.
+ if (
+ /failed to fetch|networkerror|offline|timeout|timed out|enotfound|econnrefused|etimedout|dns/.test(
+ text,
+ )
+ )
+ return ERROR_CLASS.NETWORK;
+
+ // RPC/Horizon reachable but erroring.
+ if (/rpc|simulat|soroban|horizon|gateway|bad gateway|service unavailable|50[023]/.test(text))
+ return ERROR_CLASS.RPC;
+
+ if (/invalid|required|must be|too large|not a number/.test(text)) return ERROR_CLASS.VALIDATION;
+
+ // A bare contract code with no transport hints is still a contract error.
+ if (typeof code === 'number' && code >= 1 && code <= 199) return ERROR_CLASS.CONTRACT;
+
+ return ERROR_CLASS.UNKNOWN;
+}
+
+/**
+ * Map any error to a structured, user-facing shape for the optimistic UI.
+ * @param {Error|number|string|object} error
+ * @returns {{ class: string, code: number|null, message: string, recovery: string|null, retryable: boolean }}
+ */
+export function mapError(error) {
+ const errorClass = classifyError(error);
+ const code = extractErrorCode(error);
+
+ // Contract reverts get the precise per-code copy; everything else uses the
+ // class-level message so network/RPC failures read differently from reverts.
+ const message =
+ errorClass === ERROR_CLASS.CONTRACT && code && ERROR_MESSAGES[code]
+ ? getErrorMessage(code)
+ : CLASS_MESSAGES[errorClass] || getErrorMessage(error);
+
+ return {
+ class: errorClass,
+ code: code ?? null,
+ message,
+ recovery: code ? getRecoverySuggestion(code) : null,
+ retryable:
+ errorClass === ERROR_CLASS.NETWORK ||
+ errorClass === ERROR_CLASS.RPC ||
+ (code ? isRetryableError(code) : false),
+ };
+}
+
/**
* Get user-friendly error message from error object or code
* @param {Error|number|string} error - Error object, code, or status