From eac24e95cf437e10e97d052eb6e7572c4c88dd8e Mon Sep 17 00:00:00 2001 From: gregemax Date: Wed, 27 May 2026 19:08:59 +0100 Subject: [PATCH] feat(stellar): retry + exponential backoff for Soroban RPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add withRetry(fn, opts) helper with 4 attempts, exponential backoff (200/400/800/1600ms ±25% jitter), and AbortSignal support - Retry on HTTP 408/429/502/503/504, TypeError (network), SyntaxError (JSON) - Skip retry on other 4xx, AbortError, and StellarRetryExhaustedError - Emit onRetry telemetry hook for future PostHog integration - Wire withRetry into all Stellar fetches in StellarSend and StellarReceive - Show 'Network unstable — try again.' + retry button on exhaustion - 16 unit tests covering all retry/no-retry paths --- src/components/StellarReceive.tsx | 93 ++++++++++++++------- src/components/StellarSend.tsx | 44 +++++++--- src/lib/stellar/retry.test.ts | 133 ++++++++++++++++++++++++++++++ src/lib/stellar/retry.ts | 85 +++++++++++++++++++ 4 files changed, 316 insertions(+), 39 deletions(-) create mode 100644 src/lib/stellar/retry.test.ts create mode 100644 src/lib/stellar/retry.ts diff --git a/src/components/StellarReceive.tsx b/src/components/StellarReceive.tsx index d279a52..8b873c6 100644 --- a/src/components/StellarReceive.tsx +++ b/src/components/StellarReceive.tsx @@ -24,6 +24,7 @@ import { useStellarWallet } from '@/context/StellarWalletContext'; import { CopyButton } from '@/components/CopyButton'; import { stellarTxUrl, stellarAddrUrl } from '@/lib/explorer'; import { STELLAR_NETWORK } from '@/config'; +import { withRetry, StellarRetryExhaustedError } from '@/lib/stellar/retry'; const ANNOUNCER_CONTRACT = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; const REGISTRY_CONTRACT = 'CC2LAUCXYOPJ4DV4CYXNXYAXRDVOTMAWFF76W4WFD5OVQBD6TN4PYYJ5'; @@ -36,20 +37,22 @@ async function fetchAnnouncementEvents( try { let startLedger = 1; - const probeRes = await fetch(rpcUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 0, - method: 'getEvents', - params: { - startLedger: 1, - filters: [{ type: 'contract', contractIds: [contractId] }], - pagination: { limit: 1 }, - }, + const probeRes = await withRetry(() => + fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 0, + method: 'getEvents', + params: { + startLedger: 1, + filters: [{ type: 'contract', contractIds: [contractId] }], + pagination: { limit: 1 }, + }, + }), }), - }); + ); const probeData = await probeRes.json(); if (probeData.error?.message) { @@ -78,11 +81,13 @@ async function fetchAnnouncementEvents( params.startLedger = startLedger; } - const res = await fetch(rpcUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'getEvents', params }), - }); + const res = await withRetry(() => + fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'getEvents', params }), + }), + ); const data = await res.json(); const events = data.result?.events ?? []; @@ -150,6 +155,7 @@ function StellarStealthRow({ const [withdrawing, setWithdrawing] = useState(false); const [withdrawHash, setWithdrawHash] = useState(null); const [error, setError] = useState(''); + const [retryExhausted, setRetryExhausted] = useState(false); const [showKey, setShowKey] = useState(false); const scalarHex = match.stealthPrivateScalar.toString(16).padStart(64, '0'); @@ -157,7 +163,9 @@ function StellarStealthRow({ useEffect(() => { (async () => { try { - const res = await fetch(`${STELLAR_NETWORK.horizonUrl}/accounts/${match.stealthAddress}`); + const res = await withRetry(() => + fetch(`${STELLAR_NETWORK.horizonUrl}/accounts/${match.stealthAddress}`), + ); if (!res.ok) { setBalance('0'); return; @@ -176,13 +184,16 @@ function StellarStealthRow({ const handleWithdraw = async () => { if (!dest) return; setError(''); + setRetryExhausted(false); setWithdrawing(true); try { const horizonUrl = STELLAR_NETWORK.horizonUrl; const networkPassphrase = STELLAR_NETWORK.networkPassphrase; - const res = await fetch(`${horizonUrl}/accounts/${match.stealthAddress}`); + const res = await withRetry(() => + fetch(`${horizonUrl}/accounts/${match.stealthAddress}`), + ); if (!res.ok) throw new Error('Account not found'); const account = await res.json(); @@ -213,11 +224,13 @@ function StellarStealthRow({ const signatureBase64 = Buffer.from(signature).toString('base64'); tx.addSignature(match.stealthAddress, signatureBase64); - const submitRes = await fetch(`${horizonUrl}/transactions`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `tx=${encodeURIComponent(tx.toXDR())}`, - }); + const submitRes = await withRetry(() => + fetch(`${horizonUrl}/transactions`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `tx=${encodeURIComponent(tx.toXDR())}`, + }), + ); const submitData = await submitRes.json(); if (!submitRes.ok) { @@ -229,7 +242,12 @@ function StellarStealthRow({ setWithdrawHash(submitData.hash); onWithdrawn(); } catch (err) { - setError(err instanceof Error ? err.message : 'Withdraw failed'); + if (err instanceof StellarRetryExhaustedError) { + setRetryExhausted(true); + setError('Network unstable — try again.'); + } else { + setError(err instanceof Error ? err.message : 'Withdraw failed'); + } } finally { setWithdrawing(false); } @@ -292,7 +310,19 @@ function StellarStealthRow({ )} - {error &&

{error}

} + {error && ( +
+

{error}

+ {retryExhausted && ( + + )} +
+ )} {withdrawHash && (
@@ -345,6 +375,7 @@ export function StellarReceive() { const [matched, setMatched] = useState([]); const [hasScanned, setHasScanned] = useState(false); const [error, setError] = useState(''); + const [scanRetryExhausted, setScanRetryExhausted] = useState(false); const [isRegistering, setIsRegistering] = useState(false); const [isRegSuccess, setIsRegSuccess] = useState(false); const [regHash, setRegHash] = useState(null); @@ -502,7 +533,13 @@ export function StellarReceive() { setMatched(results); setHasScanned(true); } catch (err) { - setError(err instanceof Error ? err.message : 'Scan failed'); + setError( + err instanceof StellarRetryExhaustedError + ? 'Network unstable — try again.' + : err instanceof Error + ? err.message + : 'Scan failed', + ); } finally { setIsScanning(false); } diff --git a/src/components/StellarSend.tsx b/src/components/StellarSend.tsx index 6626d1f..5554a05 100644 --- a/src/components/StellarSend.tsx +++ b/src/components/StellarSend.tsx @@ -18,6 +18,7 @@ import { useStellarWallet } from '@/context/StellarWalletContext'; import { stellarTxUrl, stellarAddrUrl } from '@/lib/explorer'; import { STELLAR_NETWORK } from '@/config'; import { CopyButton } from '@/components/CopyButton'; +import { withRetry, StellarRetryExhaustedError } from '@/lib/stellar/retry'; const ANNOUNCER_CONTRACT = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; @@ -27,6 +28,7 @@ export function StellarSend() { const [amount, setAmount] = useState(''); const [error, setError] = useState(''); const [isPending, setIsPending] = useState(false); + const [retryExhausted, setRetryExhausted] = useState(false); const [stealthResult, setStealthResult] = useState<{ stealthAddress: string; ephemeralPubKey: Uint8Array; @@ -42,6 +44,7 @@ export function StellarSend() { } setError(''); + setRetryExhausted(false); setIsPending(true); try { @@ -59,13 +62,13 @@ export function StellarSend() { const horizonUrl = STELLAR_NETWORK.horizonUrl; const networkPassphrase = STELLAR_NETWORK.networkPassphrase; - const accountRes = await fetch(`${horizonUrl}/accounts/${address}`); + const accountRes = await withRetry(() => fetch(`${horizonUrl}/accounts/${address}`)); if (!accountRes.ok) throw new Error('Failed to load sender account'); const accountData = await accountRes.json(); const sourceAccount = new Account(address, accountData.sequence); - const stealthExists = await fetch(`${horizonUrl}/accounts/${result.stealthAddress}`).then( - (r) => r.ok, + const stealthExists = await withRetry(() => + fetch(`${horizonUrl}/accounts/${result.stealthAddress}`).then((r) => r.ok), ); let classicTx; @@ -94,11 +97,13 @@ export function StellarSend() { const signedXdr = await signTransaction(classicTx.toXDR()); - const submitRes = await fetch(`${horizonUrl}/transactions`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `tx=${encodeURIComponent(signedXdr)}`, - }); + const submitRes = await withRetry(() => + fetch(`${horizonUrl}/transactions`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `tx=${encodeURIComponent(signedXdr)}`, + }), + ); const submitData = await submitRes.json(); if (!submitRes.ok) { @@ -115,7 +120,7 @@ export function StellarSend() { const soroban = new rpcMod.Server(STELLAR_NETWORK.rpcUrl); const announcerContract = new Contract(ANNOUNCER_CONTRACT); - const freshRes = await fetch(`${horizonUrl}/accounts/${address}`); + const freshRes = await withRetry(() => fetch(`${horizonUrl}/accounts/${address}`)); const freshData = await freshRes.json(); const freshAccount = new Account(address, freshData.sequence); @@ -152,7 +157,12 @@ export function StellarSend() { setIsSuccess(true); } catch (err) { - setError(err instanceof Error ? err.message : 'Transaction failed'); + if (err instanceof StellarRetryExhaustedError) { + setRetryExhausted(true); + setError('Network unstable — try again.'); + } else { + setError(err instanceof Error ? err.message : 'Transaction failed'); + } } finally { setIsPending(false); } @@ -263,7 +273,19 @@ export function StellarSend() {
- {error &&

{error}

} + {error && ( +
+

{error}

+ {retryExhausted && ( + + )} +
+ )}