diff --git a/app/src/app/deposit/page.tsx b/app/src/app/deposit/page.tsx index a151aba..02d14a8 100644 --- a/app/src/app/deposit/page.tsx +++ b/app/src/app/deposit/page.tsx @@ -8,15 +8,11 @@ import { createIntent, beginSubmission, markAwaitingSignature, - markSubmitted, - markPending, - markConfirmed, - markFailed, - markExpired, findActiveIntent, type PaymentIntent, } from '@/lib/paymentIntent'; -import { waitForTransaction, TransactionTimedOutError } from '@/services/rpc'; +import { toast } from '@/lib/toast'; +import { runPaymentSubmission, defaultPaymentSubmissionDeps } from '@/lib/paymentFlow'; const TIERS = [ { id: 'flex', label: 'Flex', lock: 'None', lockMonths: 0, multiplier: 1.0, multiplierLabel: '1.00x', exitFee: '0%', minDeposit: 1, apy: 4.2 }, @@ -45,8 +41,9 @@ function lockExpiry(lockMonths: number): string { * regenerate `app/pnpm-lock.yaml` correctly — this environment can't * safely run that (see the memory-constrained-box note in the repo's * contribution history). Everything downstream of the hash this returns - * (`waitForTransaction` below) is real and already wired for the day this - * returns a genuine submission hash instead. + * (submit -> wait -> classify, in lib/paymentFlow.ts's `runPaymentSubmission`) + * is real and already wired for the day this returns a genuine submission + * hash instead. * * Format-valid (64 hex chars, like a real Stellar transaction hash) so it * exercises the real confirmation poller end-to-end against the live RPC @@ -137,42 +134,22 @@ function DepositFlow() { const awaiting = markAwaitingSignature(started); setIntent(awaiting); - try { - const txHash = await placeholderSubmissionHash(awaiting.key); - const submitted = markSubmitted(awaiting, txHash); - setIntent(submitted); + // Real submit -> sign -> wait -> classify pipeline (issue #142), shared + // with EarlyExitModal via lib/paymentFlow.ts so its failure paths are + // reachable via real, injected failures in tests — not just this + // manual UI trigger. + const result = await runPaymentSubmission(awaiting, { + ...defaultPaymentSubmissionDeps, + submit: placeholderSubmissionHash, + }); - const pending = markPending(submitted); - setIntent(pending); - setTxStatus('pending'); - - // Real polling against the live Soroban RPC endpoint (issue #141) — - // not a timer. A 20s cap keeps manual testing bearable; production - // callers get waitForTransaction's full default (60s). - await waitForTransaction(txHash, { timeoutMs: 20_000 }); - - const confirmed = markConfirmed(pending); - setIntent(confirmed); - setTxStatus('confirmed'); - } catch (error) { - if (error instanceof TransactionTimedOutError) { - const expired = markExpired( - awaiting, - 'Could not confirm this transaction in time. It may still complete — check back before retrying.', - ); - setIntent(expired); - setTxStatus('expired'); - setTxError(expired.error ?? ''); - } else { - const message = error instanceof Error ? error.message : 'Transaction failed'; - const failed = markFailed(awaiting, message); - setIntent(failed); - setTxStatus('failed'); - setTxError(failed.error ?? 'An error occurred. Please try again.'); - } - } finally { - setSubmitting(false); + setIntent(result.intent); + setTxStatus(result.status); + if (result.message) { + setTxError(result.message); + toast.failed(result.message); } + setSubmitting(false); } function handleRetry() { diff --git a/app/src/components/EarlyExitModal.tsx b/app/src/components/EarlyExitModal.tsx index 4d99d57..8063136 100644 --- a/app/src/components/EarlyExitModal.tsx +++ b/app/src/components/EarlyExitModal.tsx @@ -6,17 +6,28 @@ import { createIntent, beginSubmission, markAwaitingSignature, - markSubmitted, - markConfirmed, - markFailed, findActiveIntent, type PaymentIntent, } from "@/lib/paymentIntent"; +import { runPaymentSubmission, defaultPaymentSubmissionDeps } from "@/lib/paymentFlow"; +import { toast } from "@/lib/toast"; function fixed7(n: number) { return Math.round(n * 1e7) / 1e7; } +/** + * TODO(#141 follow-up): stands in for `sdk.earlyExit({ tier, idempotencyKey + * })` (sdks/typescript's YieldLadder) until the app depends on the SDK + * directly — see the identical TODO on app/src/app/deposit/page.tsx's + * placeholderSubmissionHash for why that wiring is deferred. + */ +async function placeholderEarlyExitHash(idempotencyKey: string): Promise { + return new Promise((resolve) => + setTimeout(() => resolve(`mock-tx-${idempotencyKey}`), 2000), + ); +} + export default function EarlyExitModal({ open, onClose, @@ -37,7 +48,9 @@ export default function EarlyExitModal({ }) { const [intent, setIntent] = useState(null); const [submitting, setSubmitting] = useState(false); - const [status, setStatus] = useState<'idle' | 'pending' | 'confirmed' | 'failed'>('idle'); + const [status, setStatus] = useState< + 'idle' | 'pending' | 'confirmed' | 'failed' | 'expired' + >('idle'); const [error, setError] = useState(''); const address = loadSession()?.account.publicKey ?? 'anonymous'; @@ -80,27 +93,22 @@ export default function EarlyExitModal({ const awaiting = markAwaitingSignature(started); setIntent(awaiting); - try { - // sdk.earlyExit({ tier, idempotencyKey: awaiting.key }) would go here - const txHash = await new Promise((resolve) => - setTimeout(() => resolve(`mock-tx-${awaiting.key}`), 2000), - ); - const submitted = markSubmitted(awaiting, txHash); - setIntent(submitted); - const confirmed = markConfirmed(submitted); - setIntent(confirmed); - setStatus('confirmed'); - } catch (err) { - const failed = markFailed( - awaiting, - err instanceof Error ? err.message : 'Transaction failed', - ); - setIntent(failed); - setStatus('failed'); - setError(failed.error ?? 'An error occurred. Please try again.'); - } finally { - setSubmitting(false); + // Real submit -> sign -> wait -> classify pipeline (issue #142), shared + // with the deposit flow via lib/paymentFlow.ts — see that module's docs + // for why real failures (network/wallet/on-chain) are reachable here + // via injected test doubles rather than only this manual UI trigger. + const result = await runPaymentSubmission(awaiting, { + ...defaultPaymentSubmissionDeps, + submit: placeholderEarlyExitHash, + }); + + setIntent(result.intent); + setStatus(result.status); + if (result.message) { + setError(result.message); + toast.failed(result.message); } + setSubmitting(false); } const calc = useMemo(() => { @@ -161,6 +169,11 @@ export default function EarlyExitModal({ {status === 'pending' &&

Submitting transaction…

} {status === 'confirmed' &&

Exit confirmed.

} {status === 'failed' &&

{error || 'An error occurred. Please try again.'}

} + {status === 'expired' && ( +

+ {error || 'Could not confirm this transaction in time.'} +

+ )}
- {submitting ? 'Submitting…' : 'Exit Early'} + {submitting + ? 'Submitting…' + : status === 'failed' || status === 'expired' + ? 'Try Again' + : 'Exit Early'}
diff --git a/app/src/lib/paymentFlow.test.ts b/app/src/lib/paymentFlow.test.ts new file mode 100644 index 0000000..51742f9 --- /dev/null +++ b/app/src/lib/paymentFlow.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + classifyPaymentError, + requestWalletSignature, + runPaymentSubmission, +} from './paymentFlow'; +import { + RpcTimeoutError, + RpcUnavailableError, + TransactionFailedError, + TransactionTimedOutError, +} from '../services/rpc'; +import { + WalletDisconnectedError, + WalletRejectedError, + WalletUnavailableError, +} from './wallet/errors'; +import { createIntent, markAwaitingSignature, beginSubmission, type StorageLike } from './paymentIntent'; + +describe('classifyPaymentError (issue #142)', () => { + it('classifies a wallet rejection as retryable-safely with the wallet message', () => { + const result = classifyPaymentError(new WalletRejectedError()); + expect(result.retryClassification).toBe('retryable-safely'); + expect(result.message).toMatch(/declined/i); + }); + + it('classifies a dropped wallet connection as retryable-safely', () => { + const result = classifyPaymentError(new WalletDisconnectedError()); + expect(result.retryClassification).toBe('retryable-safely'); + expect(result.message).toMatch(/disconnect/i); + }); + + it('classifies a missing wallet extension as retryable-safely', () => { + const result = classifyPaymentError(new WalletUnavailableError('Freighter')); + expect(result.retryClassification).toBe('retryable-safely'); + expect(result.message).toMatch(/freighter/i); + }); + + it('classifies an unreachable RPC endpoint as retryable-safely', () => { + const result = classifyPaymentError(new RpcUnavailableError('fetch failed')); + expect(result.retryClassification).toBe('retryable-safely'); + expect(result.message).toMatch(/reach/i); + }); + + it('classifies an RPC timeout as retryable-safely', () => { + const result = classifyPaymentError(new RpcTimeoutError('timed out')); + expect(result.retryClassification).toBe('retryable-safely'); + expect(result.message).toMatch(/respond/i); + }); + + it('classifies a confirmed on-chain failure as retryable-with-new-intent', () => { + const result = classifyPaymentError(new TransactionFailedError('abc123')); + expect(result.retryClassification).toBe('retryable-with-new-intent'); + expect(result.message).toMatch(/abc123/); + }); + + it('classifies an ambiguous confirmation timeout as retryable-with-new-intent', () => { + const result = classifyPaymentError(new TransactionTimedOutError('abc123')); + expect(result.retryClassification).toBe('retryable-with-new-intent'); + }); + + it('falls back to a generic message and retryable-safely for an unrecognized Error', () => { + const result = classifyPaymentError(new Error('something unexpected')); + expect(result.message).toBe('something unexpected'); + expect(result.retryClassification).toBe('retryable-safely'); + }); + + it('falls back gracefully for a non-Error throw', () => { + const result = classifyPaymentError('a string, not an Error'); + expect(result.message).toBe('Transaction failed'); + expect(result.retryClassification).toBe('retryable-safely'); + }); +}); + +describe('requestWalletSignature', () => { + it('resolves (placeholder) so callers can await it like a real signing step', async () => { + await expect(requestWalletSignature()).resolves.toBeUndefined(); + }); +}); + +function makeMemoryStorage(): StorageLike { + const store = new Map(); + return { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => void store.set(key, value), + removeItem: (key) => void store.delete(key), + }; +} + +function makeAwaitingIntent(storage: StorageLike) { + const intent = createIntent('deposit', 'GADDR', 'flex', 'USDC', '10', storage); + return markAwaitingSignature(intent, storage); +} + +describe('runPaymentSubmission (issue #142 — real injected failures)', () => { + let storage: StorageLike; + + beforeEach(() => { + storage = makeMemoryStorage(); + }); + + it('reaches the confirmed state on the happy path', async () => { + const awaiting = makeAwaitingIntent(storage); + + const result = await runPaymentSubmission(awaiting, { + requestWalletSignature: vi.fn().mockResolvedValue(undefined), + submit: vi.fn().mockResolvedValue('tx-hash-1'), + waitForTransaction: vi.fn().mockResolvedValue(undefined), + storage, + }); + + expect(result.status).toBe('confirmed'); + expect(result.intent.txHash).toBe('tx-hash-1'); + expect(result.message).toBeNull(); + }); + + it('reaches the failed state with a wallet-specific message when the wallet rejects signing', async () => { + const awaiting = makeAwaitingIntent(storage); + + const result = await runPaymentSubmission(awaiting, { + requestWalletSignature: vi.fn().mockRejectedValue(new WalletRejectedError()), + submit: vi.fn(), + waitForTransaction: vi.fn(), + storage, + }); + + expect(result.status).toBe('failed'); + expect(result.message).toMatch(/declined/i); + expect(result.intent.error).toMatch(/declined/i); + // The signing step never resolved, so submission must never be attempted. + expect(result.intent.txHash).toBeNull(); + }); + + it('reaches the failed state with a network-specific message when submission cannot reach the RPC', async () => { + const awaiting = makeAwaitingIntent(storage); + + const result = await runPaymentSubmission(awaiting, { + requestWalletSignature: vi.fn().mockResolvedValue(undefined), + submit: vi.fn().mockRejectedValue(new RpcUnavailableError('fetch failed')), + waitForTransaction: vi.fn(), + storage, + }); + + expect(result.status).toBe('failed'); + expect(result.message).toMatch(/reach/i); + }); + + it('reaches the failed state when the transaction is included but reverts on-chain', async () => { + const awaiting = makeAwaitingIntent(storage); + + const result = await runPaymentSubmission(awaiting, { + requestWalletSignature: vi.fn().mockResolvedValue(undefined), + submit: vi.fn().mockResolvedValue('tx-hash-2'), + waitForTransaction: vi + .fn() + .mockRejectedValue(new TransactionFailedError('tx-hash-2')), + storage, + }); + + expect(result.status).toBe('failed'); + expect(result.message).toMatch(/tx-hash-2/); + }); + + it('reaches the distinct expired state (not failed) when confirmation times out ambiguously', async () => { + const awaiting = makeAwaitingIntent(storage); + + const result = await runPaymentSubmission(awaiting, { + requestWalletSignature: vi.fn().mockResolvedValue(undefined), + submit: vi.fn().mockResolvedValue('tx-hash-3'), + waitForTransaction: vi + .fn() + .mockRejectedValue(new TransactionTimedOutError('tx-hash-3')), + storage, + }); + + expect(result.status).toBe('expired'); + expect(result.intent.status).toBe('expired'); + }); + + it('releases the operation lock on failure so a retry is not blocked', async () => { + const awaiting = makeAwaitingIntent(storage); + + await runPaymentSubmission(awaiting, { + requestWalletSignature: vi.fn().mockRejectedValue(new WalletRejectedError()), + submit: vi.fn(), + waitForTransaction: vi.fn(), + storage, + }); + + const retryIntent = createIntent('deposit', 'GADDR', 'flex', 'USDC', '10', storage); + // beginSubmission would refuse if the previous attempt's lock were still held. + expect(beginSubmission(retryIntent, storage)).not.toBeNull(); + }); +}); diff --git a/app/src/lib/paymentFlow.ts b/app/src/lib/paymentFlow.ts new file mode 100644 index 0000000..d5543db --- /dev/null +++ b/app/src/lib/paymentFlow.ts @@ -0,0 +1,149 @@ +// Shared payment-flow helpers used by both app/src/app/deposit/page.tsx and +// app/src/components/EarlyExitModal.tsx (issue #142): the error taxonomy/ +// classification, the submit -> sign -> wait orchestration, and the +// (still-placeholder, see requestWalletSignature's doc) wallet-signing +// step — "give every payment operation a complete, typed failure taxonomy +// and well-defined retry/recovery behavior." + +import { + RpcTimeoutError, + RpcUnavailableError, + TransactionFailedError, + TransactionTimedOutError, + waitForTransaction, +} from '../services/rpc'; +import { + WalletDisconnectedError, + WalletRejectedError, + WalletUnavailableError, +} from './wallet/errors'; +import type { RetryClassification } from './retryClassification'; +import { + markConfirmed, + markExpired, + markFailed, + markPending, + markSubmitted, + type PaymentIntent, + type StorageLike, +} from './paymentIntent'; + +export interface ClassifiedPaymentError { + message: string; + retryClassification: RetryClassification; +} + +/** + * Maps any error a payment submission can throw to a user-facing message + * and a retry classification, so the UI branches on one typed decision + * instead of ad hoc `instanceof`/string checks scattered per component. + */ +export function classifyPaymentError(error: unknown): ClassifiedPaymentError { + if ( + error instanceof WalletRejectedError || + error instanceof WalletDisconnectedError || + error instanceof WalletUnavailableError || + error instanceof RpcUnavailableError || + error instanceof RpcTimeoutError || + error instanceof TransactionFailedError || + error instanceof TransactionTimedOutError + ) { + return { + message: error.message, + retryClassification: error.retryClassification, + }; + } + + return { + message: error instanceof Error ? error.message : 'Transaction failed', + // Unknown shape — safest default is to let the user try again rather + // than silently blocking retry on an error we don't recognize. + retryClassification: 'retryable-safely', + }; +} + +/** + * TODO(#141 follow-up, same as placeholderSubmissionHash in + * app/src/app/deposit/page.tsx): stands in for asking the connected wallet + * (via sdks/typescript's `Signer` interface) to sign the built transaction, + * until the app depends on the SDK directly — see that file's comment for + * why that wiring is deferred. A real implementation can throw + * WalletRejectedError/WalletDisconnectedError/WalletUnavailableError; this + * placeholder never does, but is a distinct, separately-mockable function + * specifically so tests can inject those failures through the real + * call site and prove the classifier + UI react correctly (issue #142 + * acceptance criteria: failure states reachable via real, injected + * failures, not just hardcoded/manual triggers). + */ +export async function requestWalletSignature(): Promise { + // No-op placeholder — nothing to await yet. +} + +export interface PaymentSubmissionDeps { + requestWalletSignature: () => Promise; + /** Submits the payment and resolves with the transaction hash. */ + submit: (idempotencyKey: string) => Promise; + waitForTransaction: typeof waitForTransaction; + storage?: StorageLike; +} + +export const defaultPaymentSubmissionDeps: Pick< + PaymentSubmissionDeps, + 'requestWalletSignature' | 'waitForTransaction' +> = { + requestWalletSignature, + waitForTransaction, +}; + +export interface PaymentSubmissionResult { + intent: PaymentIntent; + status: 'confirmed' | 'failed' | 'expired'; + message: string | null; +} + +/** + * Runs one payment attempt — deposit, withdraw, or early-exit; the + * orchestration is identical for all three — from `awaiting` (an intent + * already transitioned to `awaiting_signature`) through to a terminal + * outcome, persisting each transition via paymentIntent.ts exactly as the + * two components' previous separate inline versions did. + * + * `deps` is injected so tests can supply a signer/submit/wait that throws a + * specific, real error and assert the resulting classification — see + * paymentFlow.test.ts. This is what makes the deposit and early-exit + * failure UI states reachable via real, injected failures rather than only + * a hardcoded/manual trigger (issue #142 acceptance criteria). + */ +export async function runPaymentSubmission( + awaiting: PaymentIntent, + deps: PaymentSubmissionDeps, + timeoutMs = 20_000, +): Promise { + try { + await deps.requestWalletSignature(); + + const txHash = await deps.submit(awaiting.key); + const submitted = markSubmitted(awaiting, txHash, deps.storage); + const pending = markPending(submitted, deps.storage); + + await deps.waitForTransaction(txHash, { timeoutMs }); + + const confirmed = markConfirmed(pending, deps.storage); + return { intent: confirmed, status: 'confirmed', message: null }; + } catch (error) { + if (error instanceof TransactionTimedOutError) { + // Friendlier than the raw classified message ("Timed out waiting for + // transaction to confirm") — this is the ambiguous, ongoing- + // uncertainty case (see TransactionTimedOutError's own doc comment), + // worth explaining rather than just restating the hash. + const message = + 'Could not confirm this transaction in time. It may still complete — check back before retrying.'; + const expired = markExpired(awaiting, message, deps.storage); + return { intent: expired, status: 'expired', message }; + } + + const classified = classifyPaymentError(error); + const failed = markFailed(awaiting, classified.message, deps.storage); + return { intent: failed, status: 'failed', message: classified.message }; + } +} diff --git a/app/src/lib/retryClassification.ts b/app/src/lib/retryClassification.ts new file mode 100644 index 0000000..5ad5ae9 --- /dev/null +++ b/app/src/lib/retryClassification.ts @@ -0,0 +1,18 @@ +/** + * How safe/appropriate it is to retry after a given payment-flow error + * (issue #142). Mirrors the identical three-way taxonomy in the TypeScript + * SDK (sdks/typescript/src/errors.ts) — kept in sync by convention, the + * same way IntentStatus/PaymentStatus are (see paymentIntent.ts). + * + * - 'retryable-safely': nothing was submitted/accepted, so retrying (even + * automatically) carries no risk of a duplicate operation. + * - 'retryable-with-new-intent': the outcome is ambiguous, or the original + * attempt is now stale — the caller must check on-chain state and/or + * mint a fresh intent before retrying, never blindly resubmit. + * - 'not-retryable': retrying with the same input won't succeed, or + * requires external state to change first. + */ +export type RetryClassification = + | 'retryable-safely' + | 'retryable-with-new-intent' + | 'not-retryable'; diff --git a/app/src/lib/wallet/errors.ts b/app/src/lib/wallet/errors.ts new file mode 100644 index 0000000..b1f58d5 --- /dev/null +++ b/app/src/lib/wallet/errors.ts @@ -0,0 +1,36 @@ +import type { RetryClassification } from '../retryClassification'; + +/** + * The connected wallet declined to sign — a user action, not a network or + * contract failure. Trivially retryable: nothing was built that can't be + * rebuilt, the user can simply approve next time (issue #142). + */ +export class WalletRejectedError extends Error { + readonly retryClassification: RetryClassification = 'retryable-safely'; + constructor() { + super('You declined the request in your wallet.'); + this.name = 'WalletRejectedError'; + } +} + +/** + * The wallet dropped its connection partway through signing (extension + * locked, session expired) — distinct from a rejection: the user didn't + * decline, the connection itself was lost. + */ +export class WalletDisconnectedError extends Error { + readonly retryClassification: RetryClassification = 'retryable-safely'; + constructor() { + super('Wallet disconnected before it could sign. Reconnect and try again.'); + this.name = 'WalletDisconnectedError'; + } +} + +/** No supported wallet extension was detected in the browser. */ +export class WalletUnavailableError extends Error { + readonly retryClassification: RetryClassification = 'retryable-safely'; + constructor(provider = 'wallet') { + super(`No ${provider} extension detected. Install it and reconnect.`); + this.name = 'WalletUnavailableError'; + } +} diff --git a/app/src/services/rpc.test.ts b/app/src/services/rpc.test.ts index f24b296..052dfbd 100644 --- a/app/src/services/rpc.test.ts +++ b/app/src/services/rpc.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { + callRpc, waitForTransaction, + RpcTimeoutError, + RpcUnavailableError, TransactionFailedError, TransactionTimedOutError, } from './rpc'; @@ -68,3 +71,62 @@ describe('waitForTransaction (issue #141)', () => { expect(statuses[statuses.length - 1]).toBe('expired'); }); }); + +describe('callRpc retry-with-backoff (issue #142)', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + }); + + it('retries a thrown network failure and succeeds without surfacing an error', async () => { + fetchMock + .mockRejectedValueOnce(new TypeError('fetch failed')) + .mockResolvedValueOnce(jsonRpcResponse({ sequence: 42 })); + + const result = await callRpc<{ sequence: number }>('getLatestLedger'); + + expect(result).toEqual({ sequence: 42 }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('retries a transient 503 response and succeeds', async () => { + fetchMock + .mockResolvedValueOnce({ ok: false, status: 503, json: async () => ({}) } as Response) + .mockResolvedValueOnce(jsonRpcResponse({ sequence: 42 })); + + const result = await callRpc<{ sequence: number }>('getLatestLedger'); + + expect(result).toEqual({ sequence: 42 }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('surfaces RpcUnavailableError once retries are exhausted on a persistent network failure', async () => { + fetchMock.mockRejectedValue(new TypeError('fetch failed')); + + await expect(callRpc('getLatestLedger')).rejects.toBeInstanceOf( + RpcUnavailableError, + ); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('surfaces RpcTimeoutError (not RpcUnavailableError) for a timeout-shaped failure', async () => { + fetchMock.mockRejectedValue(new Error('request timed out')); + + await expect(callRpc('getLatestLedger')).rejects.toBeInstanceOf( + RpcTimeoutError, + ); + }); + + it('does not retry and does not relabel a JSON-RPC error the server deliberately returned', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ jsonrpc: '2.0', id: 1, error: { message: 'bad params' } }), + } as Response); + + await expect(callRpc('getLatestLedger')).rejects.toThrow('bad params'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/services/rpc.ts b/app/src/services/rpc.ts index 3d39a35..906d259 100644 --- a/app/src/services/rpc.ts +++ b/app/src/services/rpc.ts @@ -1,4 +1,5 @@ import type { IntentStatus } from '../lib/paymentIntent'; +import type { RetryClassification } from '../lib/retryClassification'; const RPC_URL = process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ?? @@ -6,10 +7,71 @@ const RPC_URL = let _seq = 0; -export async function callRpc( - method: string, - params: unknown[] = [], -): Promise { +/** + * The RPC endpoint could not be reached at all (DNS failure, connection + * refused, offline) or responded with a transient server-side error + * (429/5xx) — distinct from a timeout, where a connection was established + * but no response arrived in time. Raised only after `callRpc`'s own + * retry-with-backoff (issue #142) has exhausted its attempts. + */ +export class RpcUnavailableError extends Error { + readonly retryClassification: RetryClassification = 'retryable-safely'; + constructor(readonly rawError: string) { + super('Could not reach the Soroban RPC endpoint.'); + this.name = 'RpcUnavailableError'; + } +} + +/** An RPC request was sent but no response arrived before the deadline. Raised only after retries are exhausted. */ +export class RpcTimeoutError extends Error { + readonly retryClassification: RetryClassification = 'retryable-safely'; + constructor(readonly rawError: string) { + super('The Soroban RPC endpoint did not respond in time.'); + this.name = 'RpcTimeoutError'; + } +} + +class TransientHttpError extends Error { + constructor(readonly status: number) { + super(`Soroban RPC HTTP ${status}`); + this.name = 'TransientHttpError'; + } +} + +function isTimeoutMessage(error: unknown): boolean { + if (typeof DOMException !== 'undefined' && error instanceof DOMException) { + return error.name === 'AbortError' || error.name === 'TimeoutError'; + } + const message = error instanceof Error ? error.message : String(error); + return /timeout|timed out|ETIMEDOUT/i.test(message); +} + +/** + * Distinguishes a transient transport/server failure — worth retrying — + * from everything else (a JSON-RPC error the server deliberately returned, + * a malformed request, etc.), which must fail immediately instead of being + * silently retried into a misleading "RPC unreachable" message. + */ +function isRetryableRpcError(error: unknown): boolean { + if (error instanceof TransientHttpError) return true; + if (error instanceof TypeError) return true; // fetch's own network-failure signature + if (typeof DOMException !== 'undefined' && error instanceof DOMException) { + return error.name === 'AbortError' || error.name === 'TimeoutError'; + } + const message = error instanceof Error ? error.message : String(error); + return /network|timeout|timed out|ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|fetch failed|socket hang up/i.test( + message, + ); +} + +const MAX_RPC_ATTEMPTS = 3; +const RETRY_BASE_DELAY_MS = 300; +const RETRY_MAX_DELAY_MS = 3_000; + +async function callRpcOnce(method: string, params: unknown[]): Promise { + // A thrown `fetch` rejection (network failure, no response at all) + // propagates as-is — isRetryableRpcError below classifies it (a + // TypeError) the same way it classifies a transient HTTP status. const res = await fetch(RPC_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -17,6 +79,9 @@ export async function callRpc( cache: 'no-store', }); + if (res.status === 429 || res.status >= 500) { + throw new TransientHttpError(res.status); + } if (!res.ok) throw new Error(`Soroban RPC HTTP ${res.status}`); const json = (await res.json()) as { @@ -29,6 +94,43 @@ export async function callRpc( return json.result; } +/** + * Retries a transient transport/server failure with exponential backoff + * and full jitter (issue #142) before giving up with a typed + * RpcUnavailableError/RpcTimeoutError. A non-transient failure (a JSON-RPC + * error, a malformed request) is never retried and passes through + * unmodified — retrying it would just fail again for the same reason. + */ +export async function callRpc( + method: string, + params: unknown[] = [], +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < MAX_RPC_ATTEMPTS; attempt++) { + try { + return await callRpcOnce(method, params); + } catch (error) { + lastError = error; + if (!isRetryableRpcError(error) || attempt === MAX_RPC_ATTEMPTS - 1) { + break; + } + const exponential = Math.min( + RETRY_BASE_DELAY_MS * 2 ** attempt, + RETRY_MAX_DELAY_MS, + ); + await sleep(Math.random() * exponential); + } + } + + if (!isRetryableRpcError(lastError)) { + throw lastError; + } + const raw = lastError instanceof Error ? lastError.message : String(lastError); + throw isTimeoutMessage(lastError) + ? new RpcTimeoutError(raw) + : new RpcUnavailableError(raw); +} + export interface LedgerInfo { sequence: number; closedAt: string; @@ -81,6 +183,11 @@ export function getTransaction(hash: string): Promise { * included"), so no separate XDR inspection is needed to catch this case. */ export class TransactionFailedError extends Error { + // Definitive and final (Soroban's single-host-function-invocation + // atomicity — see contracts/vault_router/src/lib.rs's withdraw comment), + // but the signed envelope that got here is now stale (its sequence + // number is consumed either way), so retrying means a fresh intent. + readonly retryClassification: RetryClassification = 'retryable-with-new-intent'; constructor(readonly hash: string) { super(`Transaction ${hash} was included but failed on-chain`); this.name = 'TransactionFailedError'; @@ -96,6 +203,9 @@ export class TransactionFailedError extends Error { * up watching). */ export class TransactionTimedOutError extends Error { + // Ambiguous, not a confirmed failure — the caller must re-query chain + // state by this hash before assuming anything, never resubmit blindly. + readonly retryClassification: RetryClassification = 'retryable-with-new-intent'; constructor(readonly hash: string) { super(`Timed out waiting for transaction ${hash} to confirm`); this.name = 'TransactionTimedOutError'; diff --git a/sdks/typescript/src/__tests__/index.test.ts b/sdks/typescript/src/__tests__/index.test.ts index 398b0ba..4025d03 100644 --- a/sdks/typescript/src/__tests__/index.test.ts +++ b/sdks/typescript/src/__tests__/index.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Address, Keypair, nativeToScVal, xdr } from '@stellar/stellar-sdk'; import { YieldLadder } from '../index'; import { + AmountExceedsBalanceError, AssetNotAllowedError, BelowMinDepositError, LockNotExpiredError, @@ -190,16 +191,30 @@ describe('YieldLadder', () => { ).rejects.toBeInstanceOf(BelowMinDepositError); }); - it('falls back to a generic VaultContractError for an unmapped code', async () => { + it('maps a simulated AmountExceedsBalance rejection (code 7) to a typed error', async () => { + // issue #142: code 7 used to fall back to the generic + // VaultContractError — VaultRouter's own code-7 variant (Unauthorized) + // is never actually raised anywhere in the contract, so this is + // unambiguously VaultL3's AmountExceedsBalance. mockSimulateTransaction.mockResolvedValueOnce( errorSim('HostError: Error(Contract, #7)'), ); + await expect( + sdk.deposit({ tier: 'Flex', amount: '10' }), + ).rejects.toBeInstanceOf(AmountExceedsBalanceError); + }); + + it('falls back to a generic VaultContractError for a genuinely unmapped code', async () => { + mockSimulateTransaction.mockResolvedValueOnce( + errorSim('HostError: Error(Contract, #99)'), + ); + const error = await sdk .deposit({ tier: 'Flex', amount: '10' }) .catch((e: unknown) => e); expect(error).toBeInstanceOf(VaultContractError); - expect((error as VaultContractError).code).toBe(7); + expect((error as VaultContractError).code).toBe(99); }); it('throws TransactionExpiredError when submission reports txTooLate', async () => { diff --git a/sdks/typescript/src/__tests__/transactions.test.ts b/sdks/typescript/src/__tests__/transactions.test.ts new file mode 100644 index 0000000..9bead00 --- /dev/null +++ b/sdks/typescript/src/__tests__/transactions.test.ts @@ -0,0 +1,242 @@ +import { randomBytes } from 'crypto'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Address, Keypair, xdr } from '@stellar/stellar-sdk'; +import { TransactionPipeline } from '../transactions'; +import type { Signer } from '../types'; +import { + AmbiguousSubmissionError, + AmountExceedsBalanceError, + AssetNotAllowedError, + NotYetMaturedError, + RpcTimeoutError, + RpcUnavailableError, + WalletDisconnectedError, + WalletRejectedError, + WalletSigningFailedError, +} from '../errors'; + +const mockGetAccount = vi.fn(); +const mockSimulateTransaction = vi.fn(); +const mockSendTransaction = vi.fn(); +const mockAssembleTransaction = vi.fn(); + +// Same mocking approach as index.test.ts: only network I/O and +// assembleTransaction are faked, everything else (Address, ScVal encoding, +// Transaction parsing) is the real @stellar/stellar-sdk implementation. +vi.mock('@stellar/stellar-sdk', async () => { + const actual = await vi.importActual( + '@stellar/stellar-sdk', + ); + return { + ...actual, + SorobanRpc: { + ...actual.SorobanRpc, + Server: vi.fn().mockImplementation(() => ({ + getAccount: (...args: unknown[]) => mockGetAccount(...args), + simulateTransaction: (...args: unknown[]) => + mockSimulateTransaction(...args), + sendTransaction: (...args: unknown[]) => mockSendTransaction(...args), + })), + assembleTransaction: (...args: unknown[]) => + mockAssembleTransaction(...args), + }, + }; +}); + +const contractId = Address.contract(randomBytes(32)).toString(); +const userKeypair = Keypair.random(); + +function fakeAccount(publicKey: string) { + return { + accountId: () => publicKey, + sequenceNumber: () => '1', + incrementSequenceNumber: () => undefined, + }; +} + +function successSim() { + return { + id: '1', + latestLedger: 100, + events: [], + _parsed: true, + transactionData: {}, + minResourceFee: '100', + cost: { cpuInsns: '0', memBytes: '0' }, + result: { auth: [], retval: xdr.ScVal.scvVoid() }, + }; +} + +function errorSim(error: string) { + return { id: '1', latestLedger: 100, events: [], _parsed: true, error }; +} + +function makeSigner(impl: Signer['signTransaction'] = async (x) => x) { + return { signTransaction: vi.fn(impl) }; +} +type MockSigner = ReturnType; + +function makePipeline(signer: MockSigner) { + return new TransactionPipeline({ + rpcUrl: 'https://rpc.example', + network: 'testnet', + publicKey: userKeypair.publicKey(), + signer, + }); +} + +describe('TransactionPipeline', () => { + let signer: MockSigner; + let pipeline: TransactionPipeline; + + beforeEach(() => { + vi.clearAllMocks(); + mockGetAccount.mockImplementation(async (pk: string) => fakeAccount(pk)); + mockAssembleTransaction.mockImplementation((rawTx: unknown) => ({ + build: () => rawTx, + })); + signer = makeSigner(); + pipeline = makePipeline(signer); + }); + + describe('contract error mapping (issue #142)', () => { + it('maps code 4 to AssetNotAllowedError for a deposit call', async () => { + mockSimulateTransaction.mockResolvedValueOnce( + errorSim('HostError: Error(Contract, #4)'), + ); + + await expect( + pipeline.invoke({ contractId, method: 'deposit', args: [] }), + ).rejects.toBeInstanceOf(AssetNotAllowedError); + }); + + it('maps the SAME code 4 to NotYetMaturedError for a relock call', async () => { + mockSimulateTransaction.mockResolvedValueOnce( + errorSim('HostError: Error(Contract, #4)'), + ); + + await expect( + pipeline.invoke({ contractId, method: 'relock', args: [] }), + ).rejects.toBeInstanceOf(NotYetMaturedError); + }); + + it('maps code 7 to AmountExceedsBalanceError', async () => { + mockSimulateTransaction.mockResolvedValueOnce( + errorSim('HostError: Error(Contract, #7)'), + ); + + await expect( + pipeline.invoke({ contractId, method: 'withdraw', args: [] }), + ).rejects.toBeInstanceOf(AmountExceedsBalanceError); + }); + }); + + describe('wallet-level signing failures', () => { + it('classifies a rejected signature as WalletRejectedError', async () => { + mockSimulateTransaction.mockResolvedValueOnce(successSim()); + signer.signTransaction.mockRejectedValueOnce( + new Error('User declined access'), + ); + + const error = await pipeline + .invoke({ contractId, method: 'deposit', args: [] }) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(WalletRejectedError); + expect((error as WalletRejectedError).retryClassification).toBe( + 'retryable-safely', + ); + }); + + it('classifies a dropped wallet connection as WalletDisconnectedError', async () => { + mockSimulateTransaction.mockResolvedValueOnce(successSim()); + signer.signTransaction.mockRejectedValueOnce( + new Error('Wallet is not connected'), + ); + + await expect( + pipeline.invoke({ contractId, method: 'deposit', args: [] }), + ).rejects.toBeInstanceOf(WalletDisconnectedError); + }); + + it('falls back to WalletSigningFailedError for an unrecognized signer error', async () => { + mockSimulateTransaction.mockResolvedValueOnce(successSim()); + signer.signTransaction.mockRejectedValueOnce( + new Error('something internal broke'), + ); + + await expect( + pipeline.invoke({ contractId, method: 'deposit', args: [] }), + ).rejects.toBeInstanceOf(WalletSigningFailedError); + }); + }); + + describe('ambiguous submission failures', () => { + it('throws AmbiguousSubmissionError with a locally-computed hash when sendTransaction itself throws', async () => { + mockSimulateTransaction.mockResolvedValueOnce(successSim()); + mockSendTransaction.mockRejectedValueOnce(new TypeError('fetch failed')); + + const error = await pipeline + .invoke({ contractId, method: 'deposit', args: [] }) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(AmbiguousSubmissionError); + const ambiguous = error as AmbiguousSubmissionError; + expect(ambiguous.retryClassification).toBe('retryable-with-new-intent'); + expect(typeof ambiguous.hash).toBe('string'); + expect(ambiguous.hash).toMatch(/^[0-9a-f]{64}$/); + }); + }); + + describe('RPC transport retry (issue #142)', () => { + it('retries a transient getAccount failure and succeeds without surfacing an error', async () => { + mockGetAccount + .mockRejectedValueOnce(new TypeError('fetch failed')) + .mockImplementationOnce(async (pk: string) => fakeAccount(pk)); + mockSimulateTransaction.mockResolvedValueOnce(successSim()); + mockSendTransaction.mockResolvedValueOnce({ + status: 'PENDING', + hash: 'retried-hash', + }); + + const hash = await pipeline.invoke({ + contractId, + method: 'deposit', + args: [], + }); + + expect(hash).toBe('retried-hash'); + expect(mockGetAccount).toHaveBeenCalledTimes(2); + }); + + it('surfaces RpcUnavailableError once retries are exhausted on a persistent network failure', async () => { + mockGetAccount.mockRejectedValue(new TypeError('fetch failed')); + + await expect( + pipeline.invoke({ contractId, method: 'deposit', args: [] }), + ).rejects.toBeInstanceOf(RpcUnavailableError); + // maxAttempts is 3 by default for the RPC transport retry. + expect(mockGetAccount).toHaveBeenCalledTimes(3); + }); + + it('surfaces RpcTimeoutError (not RpcUnavailableError) for a timeout-shaped failure', async () => { + mockGetAccount.mockRejectedValue(new Error('request timed out')); + + await expect( + pipeline.invoke({ contractId, method: 'deposit', args: [] }), + ).rejects.toBeInstanceOf(RpcTimeoutError); + }); + + it('does not retry and does not relabel a non-network getAccount failure', async () => { + const notFound = new Error('Account not found'); + mockGetAccount.mockRejectedValueOnce(notFound); + + const error = await pipeline + .invoke({ contractId, method: 'deposit', args: [] }) + .catch((e: unknown) => e); + + expect(error).toBe(notFound); + expect(mockGetAccount).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/sdks/typescript/src/errors.ts b/sdks/typescript/src/errors.ts index 0a332ba..ff77bd1 100644 --- a/sdks/typescript/src/errors.ts +++ b/sdks/typescript/src/errors.ts @@ -1,55 +1,131 @@ -export class YieldLadderError extends Error { - constructor(message: string) { +/** + * How safe/appropriate it is to retry after a given error (issue #142). + * + * - 'retryable-safely': nothing was submitted/accepted on-chain, so + * retrying — even automatically — carries no risk of a duplicate + * operation. Covers pre-submission failures (contract-revert-in- + * simulation is the one exception, see 'not-retryable' below), wallet + * rejections, and RPC/network failures that never reached the network. + * - 'retryable-with-new-intent': the outcome is ambiguous (we don't know + * whether the transaction landed) or the original attempt is now + * provably stale (e.g. a definitive on-chain revert already consumed its + * sequence number). The caller must check chain state and/or build a + * fresh intent (new simulate/build/sign) before retrying — never blindly + * resubmit the same attempt. + * - 'not-retryable': retrying with the same input won't succeed (e.g. a + * BelowMinDeposit revert) or requires external state to change first + * (e.g. ProtocolPaused). Nothing stops the user from changing their + * input and trying again — that's a new action, not a "retry". + */ +export type RetryClassification = + | 'retryable-safely' + | 'retryable-with-new-intent' + | 'not-retryable'; + +export abstract class YieldLadderError extends Error { + protected constructor( + message: string, + readonly retryClassification: RetryClassification, + ) { super(message); this.name = 'YieldLadderError'; } } export class LockNotExpiredError extends YieldLadderError { - constructor() { super('Lock period has not expired yet'); this.name = 'LockNotExpiredError'; } + constructor() { + super('Lock period has not expired yet', 'not-retryable'); + this.name = 'LockNotExpiredError'; + } } export class BelowMinDepositError extends YieldLadderError { - constructor() { super('Amount is below the minimum deposit'); this.name = 'BelowMinDepositError'; } + constructor() { + super('Amount is below the minimum deposit', 'not-retryable'); + this.name = 'BelowMinDepositError'; + } } -// The rest mirror contracts/vault_router/src/error.rs's VaultError. Codes 2 -// (BelowMinDeposit), 4 (AssetNotAllowed), and 6 (ProtocolPaused) are checked -// at the very top of every VaultRouter entrypoint before it ever forwards to -// a tier vault, so a simulation error carrying one of these codes is always -// unambiguously from the router itself — never from the tier vault's own -// (differently-numbered) error enum. +// The rest mirror contracts/vault_router/src/error.rs's VaultError and +// contracts/vault_l3(/l6/l12)/src/lib.rs's VaultError. VaultRouter validates +// InvalidTier/BelowMinDeposit/AssetNotAllowed/DepositCapExceeded/ +// ProtocolPaused at the very top of every entrypoint, before ever forwarding +// to a tier vault — so a simulation error carrying one of those codes is +// always from the router itself. +// +// Code 4 is the one genuine cross-contract collision that matters today: +// VaultRouter's AssetNotAllowed=4 (raised on `deposit`) and VaultL3/L6/L12's +// NotYetMatured=4 (raised on `relock`) share a discriminant but mean +// different things. They're only reachable from different operations +// though (relock never runs router's asset check; deposit never reaches +// relock's maturity check), so `mapContractError` disambiguates by the +// method being simulated, not the code alone. +// +// Code 7 (VaultRouter's Unauthorized=7, VaultL3/L6/L12's +// AmountExceedsBalance=7) would be a similar collision, but VaultRouter's +// own Unauthorized variant is never actually raised anywhere in the +// contract today (auth is enforced via `require_auth()`, not this custom +// error) — verified against contracts/vault_router/src/lib.rs. So code 7 +// unambiguously means AmountExceedsBalance in the current contracts. If +// VaultRouter ever starts raising Unauthorized via `panic_with_error`, this +// mapping needs the same method-based disambiguation code 4 already has. export class InvalidTierError extends YieldLadderError { - constructor() { super('Unknown or unsupported tier'); this.name = 'InvalidTierError'; } + constructor() { + super('Unknown or unsupported tier', 'not-retryable'); + this.name = 'InvalidTierError'; + } } export class AssetNotAllowedError extends YieldLadderError { - constructor() { super('Asset is not on the deposit allowlist'); this.name = 'AssetNotAllowedError'; } + constructor() { + super('Asset is not on the deposit allowlist', 'not-retryable'); + this.name = 'AssetNotAllowedError'; + } } export class DepositCapExceededError extends YieldLadderError { - constructor() { super('Deposit would exceed the tier vault\'s TVL cap'); this.name = 'DepositCapExceededError'; } + constructor() { + super("Deposit would exceed the tier vault's TVL cap", 'not-retryable'); + this.name = 'DepositCapExceededError'; + } } export class ProtocolPausedError extends YieldLadderError { - constructor() { super('Protocol is currently paused'); this.name = 'ProtocolPausedError'; } + constructor() { + super('Protocol is currently paused', 'not-retryable'); + this.name = 'ProtocolPausedError'; + } +} + +/** Raised by `relock` on a position that hasn't matured yet — see the code-4 note above. */ +export class NotYetMaturedError extends YieldLadderError { + constructor() { + super('Position has not matured yet', 'not-retryable'); + this.name = 'NotYetMaturedError'; + } +} + +/** Raised by `withdraw`/`earlyExit` when the requested amount exceeds the caller's balance. */ +export class AmountExceedsBalanceError extends YieldLadderError { + constructor() { + super('Amount exceeds the available balance', 'not-retryable'); + this.name = 'AmountExceedsBalanceError'; + } } /** * A contract-level rejection whose numeric code didn't map to one of the - * typed errors above — most commonly code 7, which is genuinely ambiguous - * between VaultRouter's own (rarely-raised) Unauthorized and a tier vault's - * AmountExceedsBalance, since the two contracts' error enums don't share a - * numbering scheme for every code. Still a real, specific contract - * rejection — just not one this SDK can name with certainty. + * typed errors above — a real, specific contract rejection, just not one + * this SDK version can name with certainty (e.g. a contract upgrade adds a + * new error variant this SDK predates). */ export class VaultContractError extends YieldLadderError { constructor( readonly code: number, readonly rawError: string, ) { - super(`Vault contract rejected the call (code ${code})`); + super(`Vault contract rejected the call (code ${code})`, 'not-retryable'); this.name = 'VaultContractError'; } } @@ -60,7 +136,7 @@ export class SimulationFailedError extends YieldLadderError { message: string, readonly rawError: string, ) { - super(message); + super(message, 'not-retryable'); this.name = 'SimulationFailedError'; } } @@ -69,22 +145,31 @@ export class SimulationFailedError extends YieldLadderError { * The transaction's time-bounds expired — most often because the user took * too long at their wallet's signing prompt. Distinct from a contract-level * rejection: nothing about the transaction itself was invalid, it just - * arrived too late to be included. + * arrived too late to be included. Nothing was accepted on-chain, so a + * fresh attempt is unconditionally safe. */ export class TransactionExpiredError extends YieldLadderError { constructor() { - super('Transaction expired before it could be submitted — please try again'); + super( + 'Transaction expired before it could be submitted — please try again', + 'retryable-safely', + ); this.name = 'TransactionExpiredError'; } } -/** Submission failed for a reason other than expiration or a mapped contract error. */ +/** + * Submission failed with a definitive, synchronous rejection from the RPC + * node (e.g. a bad sequence number) — the transaction was never broadcast, + * so retrying is safe, though it needs a fresh build (new sequence/fee), + * not a resubmission of the same signed envelope. + */ export class SubmissionFailedError extends YieldLadderError { constructor( message: string, readonly rawResponse?: unknown, ) { - super(message); + super(message, 'retryable-safely'); this.name = 'SubmissionFailedError'; } } @@ -94,11 +179,15 @@ export class SubmissionFailedError extends YieldLadderError { * covers both an outright tx-level rejection and a contract call that * reverted after inclusion (Soroban surfaces both as a non-SUCCESS * `getTransaction` status, unlike chains where a reverted call can still be - * "successfully included"). + * "successfully included"). Soroban's single-host-function-invocation + * atomicity means this is a definitive, all-or-nothing outcome — never a + * partially-applied one — but the signed envelope that got here is now + * stale (its sequence number is consumed either way), so retrying means + * building a fresh intent, not resubmitting it. */ export class TransactionFailedError extends YieldLadderError { constructor(readonly hash: string) { - super(`Transaction ${hash} was included but failed on-chain`); + super(`Transaction ${hash} was included but failed on-chain`, 'retryable-with-new-intent'); this.name = 'TransactionFailedError'; } } @@ -108,11 +197,92 @@ export class TransactionFailedError extends YieldLadderError { * the hash stayed NOT_FOUND past the poll deadline. This can mean the * transaction is still propagating somewhere slow, or that it was quietly * dropped; either way, the caller must stop waiting rather than poll - * forever, and should treat this as "unknown," not "confirmed failure." + * forever, and should treat this as "unknown," not "confirmed failure." A + * caller must re-check chain state by this hash before deciding to retry — + * never assume local retry-exhaustion alone proves the transaction failed. */ export class TransactionTimedOutError extends YieldLadderError { constructor(readonly hash: string) { - super(`Timed out waiting for transaction ${hash} to confirm`); + super(`Timed out waiting for transaction ${hash} to confirm`, 'retryable-with-new-intent'); this.name = 'TransactionTimedOutError'; } } + +/** + * The RPC endpoint could not be reached at all (DNS failure, connection + * refused, offline) — distinct from a timeout, where a connection was + * established but no response arrived in time. Raised only after the + * pipeline's own retry-with-backoff (see transactions.ts) has already + * exhausted its attempts. + */ +export class RpcUnavailableError extends YieldLadderError { + constructor(readonly rawError: string) { + super('Could not reach the Soroban RPC endpoint', 'retryable-safely'); + this.name = 'RpcUnavailableError'; + } +} + +/** An RPC request was sent but no response arrived before the deadline. Raised only after retries are exhausted. */ +export class RpcTimeoutError extends YieldLadderError { + constructor(readonly rawError: string) { + super('The Soroban RPC endpoint did not respond in time', 'retryable-safely'); + this.name = 'RpcTimeoutError'; + } +} + +/** + * The connected wallet declined to sign — a user action (clicked "Reject"), + * not a network or contract failure. Trivially retryable: nothing was + * built that can't be rebuilt, and the user can simply approve next time. + */ +export class WalletRejectedError extends YieldLadderError { + constructor() { + super('You declined the request in your wallet', 'retryable-safely'); + this.name = 'WalletRejectedError'; + } +} + +/** + * The wallet was disconnected (extension locked, session expired, tab + * lost its connection) partway through signing. Distinct from a rejection: + * the user didn't decline, the connection itself dropped. + */ +export class WalletDisconnectedError extends YieldLadderError { + constructor() { + super('Wallet disconnected before it could sign — please reconnect and try again', 'retryable-safely'); + this.name = 'WalletDisconnectedError'; + } +} + +/** + * The wallet's `signTransaction` rejected for a reason that's neither a + * recognized decline nor a recognized disconnect (e.g. the wallet itself + * errored internally). Nothing was ever submitted, so retrying is safe. + */ +export class WalletSigningFailedError extends YieldLadderError { + constructor(readonly rawError: string) { + super('Signing failed', 'retryable-safely'); + this.name = 'WalletSigningFailedError'; + } +} + +/** + * `sendTransaction` itself threw (a network/RPC-transport failure, not a + * response the RPC returned) — genuinely ambiguous whether the transaction + * reached the network. `hash` is the LOCALLY-computed hash of the signed + * envelope (available even though nothing confirms it was ever received), + * so a caller can poll `getTransaction(hash)` to resolve the ambiguity + * before deciding whether to build and submit a fresh attempt. + */ +export class AmbiguousSubmissionError extends YieldLadderError { + constructor( + readonly hash: string | null, + readonly rawError: string, + ) { + super( + 'Submission may or may not have reached the network — check status before retrying', + 'retryable-with-new-intent', + ); + this.name = 'AmbiguousSubmissionError'; + } +} diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 32fbf19..a65e720 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -7,6 +7,30 @@ export type { PaymentStatus, } from './types'; export type { WaitForTransactionOptions } from './transactions'; +export type { RetryClassification } from './errors'; +export { + YieldLadderError, + LockNotExpiredError, + BelowMinDepositError, + InvalidTierError, + AssetNotAllowedError, + DepositCapExceededError, + ProtocolPausedError, + NotYetMaturedError, + AmountExceedsBalanceError, + VaultContractError, + SimulationFailedError, + TransactionExpiredError, + SubmissionFailedError, + TransactionFailedError, + TransactionTimedOutError, + RpcUnavailableError, + RpcTimeoutError, + WalletRejectedError, + WalletDisconnectedError, + WalletSigningFailedError, + AmbiguousSubmissionError, +} from './errors'; import type { Tier, YieldLadderOptions, Position, Network } from './types'; import { BelowMinDepositError } from './errors'; import { diff --git a/sdks/typescript/src/transactions.ts b/sdks/typescript/src/transactions.ts index 3cedab5..46cd001 100644 --- a/sdks/typescript/src/transactions.ts +++ b/sdks/typescript/src/transactions.ts @@ -12,21 +12,34 @@ import { } from '@stellar/stellar-sdk'; import type { Network, PaymentStatus, Signer, Tier } from './types'; import { + AmbiguousSubmissionError, + AmountExceedsBalanceError, AssetNotAllowedError, BelowMinDepositError, DepositCapExceededError, InvalidTierError, LockNotExpiredError, + NotYetMaturedError, ProtocolPausedError, + RpcTimeoutError, + RpcUnavailableError, SimulationFailedError, SubmissionFailedError, TransactionExpiredError, TransactionFailedError, TransactionTimedOutError, VaultContractError, + WalletDisconnectedError, + WalletRejectedError, + WalletSigningFailedError, YieldLadderError, } from './errors'; -import { sleep } from './utils'; +import { + isTimeoutError, + isTransientNetworkError, + retryWithBackoff, + sleep, +} from './utils'; const NETWORK_PASSPHRASES: Record = { mainnet: Networks.PUBLIC, @@ -54,6 +67,34 @@ const DEFAULT_POLL_TIMEOUT_MS = 60_000; */ const CONTRACT_ERROR_PATTERN = /Error\(Contract,\s*#(\d+)\)/; +/** + * Retry ceiling for transient network/timeout failures on the read-only, + * pre-submission RPC calls (`getAccount`, `simulateTransaction`) — issue + * #142. Deliberately NOT applied to the signing step (a wallet round-trip + * isn't a network call to retry) nor, at this layer, to `sendTransaction` + * itself — see `doInvoke`'s own handling of that call's failure modes. + */ +const RPC_RETRY_OPTIONS = { + maxAttempts: 3, + baseDelayMs: 300, + maxDelayMs: 3_000, + isRetryable: isTransientNetworkError, +}; + +/** + * Only rewraps an error that's actually a transient-network failure (the + * kind `retryWithBackoff` just gave up retrying) into the typed RPC error — + * a non-network error (e.g. a malformed request) must pass through + * unchanged, not get mislabeled as "RPC unreachable". + */ +function toRpcErrorIfTransient(error: unknown): unknown { + if (!isTransientNetworkError(error)) { + return error; + } + const raw = error instanceof Error ? error.message : String(error); + return isTimeoutError(error) ? new RpcTimeoutError(raw) : new RpcUnavailableError(raw); +} + export interface TransactionPipelineConfig { rpcUrl: string; network: Network; @@ -118,7 +159,7 @@ export class TransactionPipeline { /** Simulates a read-only call (no signing, no submission) and returns the raw retval, decoded. */ async simulateRead(params: InvokeContractParams): Promise { const tx = await this.buildTransaction(params); - const sim = await this.server.simulateTransaction(tx); + const sim = await this.simulateWithRetry(tx); this.throwIfSimulationFailed(sim, params.method); if (!SorobanRpc.Api.isSimulationSuccess(sim) || sim.result === undefined) { @@ -219,7 +260,7 @@ export class TransactionPipeline { // Always simulate fresh for this specific call — never reuse a fee or // resource footprint estimated for a different transaction. - const sim = await this.server.simulateTransaction(rawTx); + const sim = await this.simulateWithRetry(rawTx); this.throwIfSimulationFailed(sim, params.method); let prepared: Transaction; @@ -233,23 +274,43 @@ export class TransactionPipeline { } // The wallet round-trip happens here — this is where time-bounds can - // expire while the user is still looking at the signing prompt. - const signedXdr = await this.config.signer.signTransaction( - prepared.toXDR(), - { network: this.config.network, accountToSign: this.config.publicKey }, - ); + // expire while the user is still looking at the signing prompt. Not + // retried: asking the wallet to sign again after it just rejected + // would either re-prompt confusingly or hang, not recover anything. + let signedXdr: string; + try { + signedXdr = await this.config.signer.signTransaction(prepared.toXDR(), { + network: this.config.network, + accountToSign: this.config.publicKey, + }); + } catch (error) { + throw TransactionPipeline.classifySignerError(error); + } const signedTx = TransactionBuilder.fromXDR( signedXdr, this.networkPassphrase, ); + // Computed locally, before ever calling the network — if `sendTransaction` + // below throws ambiguously (network/timeout, no response at all), the + // caller still has a hash to independently poll for instead of no way + // to look the transaction up at all. + const localHash = TransactionPipeline.tryComputeHash(signedTx); + let sendResult: SorobanRpc.Api.SendTransactionResponse; try { sendResult = await this.server.sendTransaction(signedTx); } catch (error) { - throw new SubmissionFailedError( - `Submitting "${params.method}" failed`, + // Soroban RPC's sendTransaction is idempotent by hash (a resubmission + // of the identical signed envelope comes back DUPLICATE, not a double + // application) — but this exception means we don't even know whether + // THIS attempt reached the network, so it's still ambiguous from the + // caller's point of view: they must check status by hash (or accept + // the SDK's own retry, which is safe precisely because it resends the + // same envelope) rather than build and submit a brand new one. + throw new AmbiguousSubmissionError( + localHash, error instanceof Error ? error.message : String(error), ); } @@ -270,7 +331,12 @@ export class TransactionPipeline { private async buildTransaction( params: InvokeContractParams, ): Promise { - const sourceAccount = await this.server.getAccount(this.config.publicKey); + const sourceAccount = await retryWithBackoff( + () => this.server.getAccount(this.config.publicKey), + RPC_RETRY_OPTIONS, + ).catch((error) => { + throw toRpcErrorIfTransient(error); + }); const contract = new Contract(params.contractId); const timeoutSeconds = this.config.transactionTimeoutSeconds ?? DEFAULT_TX_TIMEOUT_SECONDS; @@ -284,6 +350,22 @@ export class TransactionPipeline { .build(); } + /** + * Retries only the transport failure (unreachable/timed-out RPC) — + * never retries a normal response that happens to encode a simulation + * *error* (that's not transient, retrying it changes nothing). + */ + private async simulateWithRetry( + tx: Transaction, + ): Promise { + return retryWithBackoff( + () => this.server.simulateTransaction(tx), + RPC_RETRY_OPTIONS, + ).catch((error) => { + throw toRpcErrorIfTransient(error); + }); + } + private throwIfSimulationFailed( sim: SorobanRpc.Api.SimulateTransactionResponse, method: string, @@ -294,7 +376,11 @@ export class TransactionPipeline { const match = CONTRACT_ERROR_PATTERN.exec(sim.error); if (match) { - throw TransactionPipeline.mapContractError(Number(match[1]), sim.error); + throw TransactionPipeline.mapContractError( + method, + Number(match[1]), + sim.error, + ); } throw new SimulationFailedError( `Simulating "${method}" failed: ${sim.error}`, @@ -302,7 +388,44 @@ export class TransactionPipeline { ); } + /** + * Best-effort local hash computation — must never itself throw and mask + * the real error it's meant to accompany. Accepts either envelope type + * `TransactionBuilder.fromXDR` can return; both expose `.hash()`. + */ + private static tryComputeHash(tx: { hash(): Buffer }): string | null { + try { + return tx.hash().toString('hex'); + } catch { + return null; + } + } + + /** + * Distinguishes a wallet declining to sign (a deliberate user action) + * from the wallet dropping the connection mid-flow — neither is a + * network or contract failure, so neither should be reported as one. + * No single error shape is shared across wallets (Freighter/Albedo/ + * LOBSTR/...), so this matches on common phrasing rather than a type. + */ + private static classifySignerError(error: unknown): YieldLadderError { + const message = ( + error instanceof Error ? error.message : String(error) + ).toLowerCase(); + + if (/disconnect|not connected|no wallet|no active/.test(message)) { + return new WalletDisconnectedError(); + } + if (/reject|declin|deni|cancel|user closed/.test(message)) { + return new WalletRejectedError(); + } + return new WalletSigningFailedError( + error instanceof Error ? error.message : String(error), + ); + } + private static mapContractError( + method: string, code: number, rawError: string, ): YieldLadderError { @@ -314,11 +437,18 @@ export class TransactionPipeline { case 3: return new LockNotExpiredError(); case 4: - return new AssetNotAllowedError(); + // See the code-4 note atop errors.ts: AssetNotAllowed (router, + // `deposit`) and NotYetMatured (tier vault, `relock`) collide on + // this discriminant — only the calling method disambiguates them. + return method === 'relock' + ? new NotYetMaturedError() + : new AssetNotAllowedError(); case 5: return new DepositCapExceededError(); case 6: return new ProtocolPausedError(); + case 7: + return new AmountExceedsBalanceError(); default: return new VaultContractError(code, rawError); } diff --git a/sdks/typescript/src/utils.ts b/sdks/typescript/src/utils.ts index 0bd3059..4fc52a6 100644 --- a/sdks/typescript/src/utils.ts +++ b/sdks/typescript/src/utils.ts @@ -15,3 +15,77 @@ export function formatUSDC(amount: string): string { export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } + +/** + * Heuristically distinguishes a transport/network-level failure (RPC + * unreachable, connection reset, request timeout) from every other kind of + * rejection (issue #142) — the RPC layer only auto-retries the former. + * There's no single error shape shared by every environment's `fetch` + * (browser vs Node's undici) or every possible network stack failure, so + * this matches on the common signatures rather than a specific type. + */ +export function isTransientNetworkError(error: unknown): boolean { + if (error instanceof TypeError) { + // fetch's own network-failure signature in both the browser ("Failed to + // fetch") and Node/undici ("fetch failed"). + return true; + } + if (typeof DOMException !== 'undefined' && error instanceof DOMException) { + return error.name === 'AbortError' || error.name === 'TimeoutError'; + } + const message = error instanceof Error ? error.message : String(error); + return /network|timeout|timed out|ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|fetch failed|socket hang up/i.test( + message, + ); +} + +/** Narrower check for specifically a timeout, vs. an outright unreachable endpoint — used to pick RpcTimeoutError vs RpcUnavailableError. */ +export function isTimeoutError(error: unknown): boolean { + if (typeof DOMException !== 'undefined' && error instanceof DOMException) { + return error.name === 'AbortError' || error.name === 'TimeoutError'; + } + const message = error instanceof Error ? error.message : String(error); + return /timeout|timed out|ETIMEDOUT/i.test(message); +} + +export interface RetryWithBackoffOptions { + /** Total number of attempts, including the first — default 3. */ + maxAttempts?: number; + baseDelayMs?: number; + maxDelayMs?: number; + /** Only errors this returns true for trigger a retry; anything else rejects immediately. Defaults to retrying everything. */ + isRetryable?: (error: unknown) => boolean; +} + +/** + * Retries `fn` with exponential backoff and full jitter (issue #142). + * Non-retryable errors (per `isRetryable`) and the final attempt's error + * both reject immediately — this never swallows a permanent failure. + */ +export async function retryWithBackoff( + fn: () => Promise, + opts: RetryWithBackoffOptions = {}, +): Promise { + const maxAttempts = opts.maxAttempts ?? 3; + const baseDelayMs = opts.baseDelayMs ?? 300; + const maxDelayMs = opts.maxDelayMs ?? 3_000; + const isRetryable = opts.isRetryable ?? (() => true); + + let lastError: unknown; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error; + if (!isRetryable(error) || attempt === maxAttempts - 1) { + throw error; + } + const exponential = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs); + // Full jitter: uniform in [0, exponential] rather than a fixed + // fraction of it, so concurrent callers retrying the same transient + // outage don't stay synchronized on each other's backoff schedule. + await sleep(Math.random() * exponential); + } + } + throw lastError; +}