From 3a027011a9f313aead151e542ee3d4afe0a949ee Mon Sep 17 00:00:00 2001 From: anuoluwaponiorimi <287817511+anuoluwaponiorimi@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:36:25 +0100 Subject: [PATCH] Add optimistic UI with rollback for register/claim (#627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register and claim now reflect the expected state the instant the user submits, then reconcile with chain truth on success or roll back on failure — so successful actions feel instant and failures recover cleanly. - useOptimisticAction hook (src/hooks): drives the idle → pending → success | error lifecycle. Applies an optimistic update, awaits the action, reconciles on success, and rolls back on failure. A synchronous in-flight latch guards against double-submit (a second click is ignored, never fires a second transaction). Rollback only runs if the optimistic update was actually applied. - errorMapping: add classifyError + mapError giving each failure a class (contract | wallet | network | rpc | validation | unknown) with distinct messaging — a network/RPC outage now reads differently from a contract revert, and contract reverts keep their precise per-code copy. - RegisterCampaign: status flips to "Registered" optimistically on submit; rolls back to the prior status on failure with a mapped error. - ClaimRewards: clears the input optimistically + shows a pending indicator; restores the amount on failure; reconciles the balance via onClaimSuccess. - TransactionStatus: new pending/success/error variant (backward compatible) so the pending state shows before a hash exists and errors render inline. Tests (src/hooks/useOptimisticAction.test.jsx, runs under the vitest include): optimistic-apply + reconcile, rollback on failure, no rollback when the optimistic step itself throws, double-submit guard, reset, and error classification (contract vs wallet vs network vs rpc messaging). A live register/claim E2E that forces a failure needs the docker-compose backend (the CI playwright run has none and skips the lifecycle suite), so rollback is covered here at the hook/integration level; the E2E can follow in that environment. --- frontend/src/ClaimRewards.jsx | 61 ++++--- frontend/src/RegisterCampaign.jsx | 87 +++++---- frontend/src/components/TransactionStatus.css | 36 ++++ frontend/src/components/TransactionStatus.jsx | 172 ++++++++++-------- frontend/src/hooks/useOptimisticAction.js | 93 ++++++++++ .../src/hooks/useOptimisticAction.test.jsx | 167 +++++++++++++++++ frontend/src/lib/errorMapping.js | 122 +++++++++++++ 7 files changed, 603 insertions(+), 135 deletions(-) create mode 100644 frontend/src/hooks/useOptimisticAction.js create mode 100644 frontend/src/hooks/useOptimisticAction.test.jsx 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 && ( )} 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 ( -
+
- {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