diff --git a/package.json b/package.json index 0400ce4..11fbb36 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.5.0", + "@vitest/coverage-v8": "^4.1.9", "autoprefixer": "^10.4.20", "concurrently": "^9.2.1", "http-server": "^14.1.1", @@ -70,6 +71,7 @@ "tailwindcss": "^3.4.0", "typescript": "^5.7.0", "vite": "^6.3.0", + "vitest": "^4.1.9", "wait-on": "^9.0.10" }, "msw": { diff --git a/src/components/StellarMatchCard.tsx b/src/components/StellarMatchCard.tsx index 58ee6f4..1bf8aa6 100644 --- a/src/components/StellarMatchCard.tsx +++ b/src/components/StellarMatchCard.tsx @@ -13,6 +13,7 @@ export interface StellarMatchCardProps { withdrawHash: string | null; feeBumpHash: string | null; error: string; + retryStatus?: string; showKey: boolean; showSponsorPrompt: boolean; onDestChange: (value: string) => void; @@ -39,6 +40,7 @@ export function StellarMatchCard({ withdrawHash, feeBumpHash, error, + retryStatus = '', showKey, showSponsorPrompt, onDestChange, @@ -404,6 +406,7 @@ export function StellarMatchCard({ )} + {retryStatus &&

{retryStatus}

} {error &&

{error}

} {withdrawHash && ( diff --git a/src/components/StellarReceive.tsx b/src/components/StellarReceive.tsx index 04d0030..a0874cb 100644 --- a/src/components/StellarReceive.tsx +++ b/src/components/StellarReceive.tsx @@ -27,6 +27,7 @@ import { StellarReceiveView } from '@/components/StellarReceiveView'; import { useStealthLabels } from '@/hooks/useStealthLabels'; import { useStellarNotifications } from '@/hooks/useStellarNotifications'; import { STELLAR_NETWORK } from '@/config'; +import { fetchWithRetry, withRetry, RetryExhaustedError } from '@/lib/stellar/retry'; import { useActivityStore } from '@/stores/activityStore'; import type { ImportResult } from '@/lib/stealthLabels'; import { KeyVault } from '@/vault'; @@ -65,6 +66,7 @@ function StellarMatchCardContainer({ const [withdrawHash, setWithdrawHash] = useState(null); const [feeBumpHash, setFeeBumpHash] = useState(null); const [error, setError] = useState(''); + const [retryStatus, setRetryStatus] = useState(''); const [showKey, setShowKey] = useState(false); const [showSponsorPrompt, setShowSponsorPrompt] = useState(false); @@ -73,7 +75,12 @@ function StellarMatchCardContainer({ useEffect(() => { (async () => { try { - const res = await fetch(`${STELLAR_NETWORK.horizonUrl}/accounts/${match.stealthAddress}`); + const res = await fetchWithRetry( + `${STELLAR_NETWORK.horizonUrl}/accounts/${match.stealthAddress}`, + {}, + { onRetry: (attempt) => setRetryStatus(`Retrying (${attempt}/3)…`) }, + ); + setRetryStatus(''); if (!res.ok) { setBalance('0'); return; @@ -82,6 +89,7 @@ function StellarMatchCardContainer({ const xlm = data.balances?.find((b: { asset_type: string }) => b.asset_type === 'native'); setBalance(xlm?.balance ?? '0'); } catch { + setRetryStatus(''); setBalance('0'); } finally { setBalanceState('loaded'); @@ -92,13 +100,21 @@ function StellarMatchCardContainer({ const handleWithdraw = async () => { if (!dest) return; setError(''); + setRetryStatus(''); setWithdrawing(true); + const onRetry = (attempt: number) => setRetryStatus(`Retrying (${attempt}/3)…`); + try { const horizonUrl = STELLAR_NETWORK.horizonUrl; const networkPassphrase = STELLAR_NETWORK.networkPassphrase; - const res = await fetch(`${horizonUrl}/accounts/${match.stealthAddress}`); + const res = await fetchWithRetry( + `${horizonUrl}/accounts/${match.stealthAddress}`, + {}, + { onRetry }, + ); + setRetryStatus(''); if (!res.ok) throw new Error('Account not found'); const account = await res.json(); @@ -181,8 +197,10 @@ function StellarMatchCardContainer({ updateActivity(txHashHex, 'confirmed'); onWithdrawn(); } catch (err) { - setError(err instanceof Error ? err.message : 'Withdraw failed'); - // In a real robust implementation we'd check if we submitted and mark failed + setRetryStatus(''); + setError( + err instanceof RetryExhaustedError ? err.message : err instanceof Error ? err.message : 'Withdraw failed', + ); } finally { setWithdrawing(false); } @@ -191,15 +209,22 @@ function StellarMatchCardContainer({ const handleSponsoredWithdraw = async () => { if (!dest || !address) return; setError(''); + setRetryStatus(''); setWithdrawing(true); setShowSponsorPrompt(false); + const onRetry = (attempt: number) => setRetryStatus(`Retrying (${attempt}/3)…`); + try { const horizonUrl = STELLAR_NETWORK.horizonUrl; const networkPassphrase = STELLAR_NETWORK.networkPassphrase; - // Fetch stealth account - const stealthRes = await fetch(`${horizonUrl}/accounts/${match.stealthAddress}`); + const stealthRes = await fetchWithRetry( + `${horizonUrl}/accounts/${match.stealthAddress}`, + {}, + { onRetry }, + ); + setRetryStatus(''); if (!stealthRes.ok) throw new Error('Stealth account not found'); const stealthAccount = await stealthRes.json(); @@ -233,7 +258,8 @@ function StellarMatchCardContainer({ innerTx.addSignature(match.stealthAddress, innerSignatureBase64); // Fetch sponsor account for fee-bump - const sponsorRes = await fetch(`${horizonUrl}/accounts/${address}`); + const sponsorRes = await fetchWithRetry(`${horizonUrl}/accounts/${address}`, {}, { onRetry }); + setRetryStatus(''); if (!sponsorRes.ok) throw new Error('Sponsor account not found'); // Build fee-bump transaction @@ -282,7 +308,10 @@ function StellarMatchCardContainer({ updateActivity(txHashHex, 'confirmed'); onWithdrawn(); } catch (err) { - setError(err instanceof Error ? err.message : 'Sponsored withdraw failed'); + setRetryStatus(''); + setError( + err instanceof RetryExhaustedError ? err.message : err instanceof Error ? err.message : 'Sponsored withdraw failed', + ); } finally { setWithdrawing(false); } @@ -299,6 +328,7 @@ function StellarMatchCardContainer({ withdrawHash={withdrawHash} feeBumpHash={feeBumpHash} error={error} + retryStatus={retryStatus} showKey={showKey} showSponsorPrompt={showSponsorPrompt} onDestChange={setDest} @@ -339,6 +369,7 @@ export function StellarReceive() { }, []); const [hasScanned, setHasScanned] = useState(false); const [error, setError] = useState(''); + const [retryStatus, setRetryStatus] = useState(''); const [isRegistering, setIsRegistering] = useState(false); const [isRegSuccess, setIsRegSuccess] = useState(false); const [regHash, setRegHash] = useState(null); @@ -407,7 +438,9 @@ export function StellarReceive() { const soroban = new rpcMod.Server(STELLAR_NETWORK.rpcUrl); const networkPassphrase = STELLAR_NETWORK.networkPassphrase; - const accountResponse = await soroban.getAccount(address); + const onRetry = (attempt: number) => setRetryStatus(`Retrying (${attempt}/3)…`); + const accountResponse = await withRetry(() => soroban.getAccount(address), { onRetry }); + setRetryStatus(''); const sourceAccount = new Account( accountResponse.accountId(), accountResponse.sequenceNumber(), @@ -425,11 +458,13 @@ export function StellarReceive() { .setTimeout(30) .build(); - const simulated = await soroban.simulateTransaction(tx); + const simulated = await withRetry(() => soroban.simulateTransaction(tx), { onRetry }); + setRetryStatus(''); if (!('error' in simulated) && 'result' in simulated) { setIsAlreadyRegistered(true); } } catch { + setRetryStatus(''); // Not registered or contract not available } })(); @@ -636,12 +671,15 @@ export function StellarReceive() { if (!stellarKeys || !address) return; setIsRegistering(true); setError(''); + setRetryStatus(''); + const onRetryReg = (attempt: number) => setRetryStatus(`Retrying (${attempt}/3)…`); try { const { rpc: rpcMod } = await import('@stellar/stellar-sdk'); const soroban = new rpcMod.Server(STELLAR_NETWORK.rpcUrl); const networkPassphrase = STELLAR_NETWORK.networkPassphrase; - const accountResponse = await soroban.getAccount(address); + const accountResponse = await withRetry(() => soroban.getAccount(address), { onRetry: onRetryReg }); + setRetryStatus(''); const sourceAccount = new Account( accountResponse.accountId(), accountResponse.sequenceNumber(), @@ -664,7 +702,8 @@ export function StellarReceive() { .setTimeout(30) .build(); - const simulated = await soroban.simulateTransaction(tx); + const simulated = await withRetry(() => soroban.simulateTransaction(tx), { onRetry: onRetryReg }); + setRetryStatus(''); if ('error' in simulated) { throw new Error((simulated as { error: string }).error || 'Simulation failed'); } @@ -719,7 +758,10 @@ export function StellarReceive() { } } } catch (err) { - setError(err instanceof Error ? err.message : 'Registration failed'); + setRetryStatus(''); + setError( + err instanceof RetryExhaustedError ? err.message : err instanceof Error ? err.message : 'Registration failed', + ); } finally { setIsRegistering(false); } @@ -877,6 +919,7 @@ export function StellarReceive() { hasScanned={hasScanned} matchCount={matched.length} error={error} + retryStatus={retryStatus} onDeriveKeys={deriveKeysFromWallet} onRegister={registerOnChain} onScan={scanPayments} diff --git a/src/components/StellarReceiveView.tsx b/src/components/StellarReceiveView.tsx index 6aaa68f..b54e95e 100644 --- a/src/components/StellarReceiveView.tsx +++ b/src/components/StellarReceiveView.tsx @@ -19,6 +19,7 @@ export interface StellarReceiveViewProps { matchCount: number; matches: ReactNode; error: string; + retryStatus?: string; onDeriveKeys: () => void; onRegister: () => void; onScan: () => void; @@ -59,6 +60,7 @@ export function StellarReceiveView({ matchCount, matches, error, + retryStatus = '', onDeriveKeys, onRegister, onScan, @@ -122,6 +124,7 @@ export function StellarReceiveView({ > {isDerivingKeys ? 'Sign in wallet...' : 'Derive Keys'} + {retryStatus &&

{retryStatus}

} {error &&

{error}

} {vaultPanel} @@ -253,6 +256,7 @@ export function StellarReceiveView({ )} + {retryStatus &&

{retryStatus}

} {error &&

{error}

} {/* Search, filter, and toolbar */} diff --git a/src/components/StellarSend.tsx b/src/components/StellarSend.tsx index c608744..dca5cfd 100644 --- a/src/components/StellarSend.tsx +++ b/src/components/StellarSend.tsx @@ -25,6 +25,7 @@ import { simulateStellarSendAnnouncement, type StellarSendSimulationState, } from '@/lib/stellarSimulation'; +import { fetchWithRetry, withRetry, RetryExhaustedError } from '@/lib/stellar/retry'; const ANNOUNCER_CONTRACT = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; const STELLAR_BASE_FEE_XLM = 0.00001; @@ -93,6 +94,7 @@ export function StellarSend() { const [balanceLookupError, setBalanceLookupError] = useState(''); const [isPending, setIsPending] = useState(false); const [isExpired, setIsExpired] = useState(false); + const [retryStatus, setRetryStatus] = useState(''); const [simulation, setSimulation] = useState( emptyStellarSendSimulation(), ); @@ -155,10 +157,15 @@ export function StellarSend() { simulationTimeoutRef.current = globalThis.setTimeout(async () => { try { - const result = await simulateStellarSendAnnouncement({ - address, - recipient: metaAddress, - }); + const result = await simulateStellarSendAnnouncement( + { address, recipient: metaAddress }, + { onRetry: (attempt, _, err) => { + const msg = err instanceof Error ? err.message : ''; + setRetryStatus(`Retrying (${attempt}/3)…${msg ? ` (${msg})` : ''}`); + }, + }, + ); + setRetryStatus(''); setSimulation({ status: 'success', error: '', @@ -167,6 +174,7 @@ export function StellarSend() { events: result.events, }); } catch (err) { + setRetryStatus(''); if (err instanceof DOMException && err.name === 'AbortError') return; setSimulation({ status: 'error', @@ -199,9 +207,15 @@ export function StellarSend() { setIsBalanceLoading(true); try { - const accountRes = await fetch(`${STELLAR_NETWORK.horizonUrl}/accounts/${address}`, { - signal: controller.signal, - }); + const accountRes = await fetchWithRetry( + `${STELLAR_NETWORK.horizonUrl}/accounts/${address}`, + { signal: controller.signal }, + { + signal: controller.signal, + onRetry: (attempt) => setRetryStatus(`Retrying (${attempt}/3)…`), + }, + ); + setRetryStatus(''); if (!accountRes.ok) throw new Error('Failed to load sender account'); const accountData = (await accountRes.json()) as HorizonAccount; @@ -211,8 +225,15 @@ export function StellarSend() { setSourceBalance(parsedBalance); } catch (err) { + setRetryStatus(''); if (!controller.signal.aborted) { - setBalanceLookupError(err instanceof Error ? err.message : 'Failed to check XLM balance'); + setBalanceLookupError( + err instanceof RetryExhaustedError + ? err.message + : err instanceof Error + ? err.message + : 'Failed to check XLM balance', + ); } } finally { if (!controller.signal.aborted) { @@ -243,8 +264,11 @@ export function StellarSend() { setError(''); setIsPending(true); + setRetryStatus(''); let txHashHex = ''; + const onRetry = (attempt: number) => setRetryStatus(`Retrying (${attempt}/3)…`); + try { const decoded = decodeStealthMetaAddress(metaAddress); const result = generateStealthAddress(decoded.spendingPubKey, decoded.viewingPubKey); @@ -253,14 +277,25 @@ export function StellarSend() { const horizonUrl = STELLAR_NETWORK.horizonUrl; const networkPassphrase = STELLAR_NETWORK.networkPassphrase; - const accountRes = await fetch(`${horizonUrl}/accounts/${address}`); + const accountRes = await fetchWithRetry(`${horizonUrl}/accounts/${address}`, {}, { onRetry }); + setRetryStatus(''); if (!accountRes.ok) throw new Error('Failed to load sender account'); const accountData = (await accountRes.json()) as HorizonAccount; const sourceAccount = new Account(address, accountData.sequence); - const stealthExists = await fetch(`${horizonUrl}/accounts/${result.stealthAddress}`).then( - (r) => r.ok, - ); + let stealthExists = false; + try { + const stealthCheckRes = await fetchWithRetry( + `${horizonUrl}/accounts/${result.stealthAddress}`, + {}, + { onRetry }, + ); + stealthExists = stealthCheckRes.ok; + } catch { + // Transient network error on existence check — assume not created yet + } finally { + setRetryStatus(''); + } let builder = new TransactionBuilder(sourceAccount, { fee: '100', networkPassphrase }); @@ -326,7 +361,8 @@ 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 fetchWithRetry(`${horizonUrl}/accounts/${address}`, {}, { onRetry }); + setRetryStatus(''); const freshData = await freshRes.json(); const freshAccount = new Account(address, freshData.sequence); @@ -343,7 +379,8 @@ export function StellarSend() { .setTimeout(30) .build(); - const simulated = await soroban.simulateTransaction(announceTx); + const simulated = await withRetry(() => soroban.simulateTransaction(announceTx), { onRetry }); + setRetryStatus(''); if (!('error' in simulated)) { const assembled = rpcMod .assembleTransaction( @@ -359,17 +396,16 @@ export function StellarSend() { } } catch { // Announcement is best-effort — payment already succeeded + } finally { + setRetryStatus(''); } setIsSuccess(true); updateActivity(txHashHex, 'confirmed'); } catch (err) { + setRetryStatus(''); if (txHashHex) updateActivity(txHashHex, 'failed'); - if (err instanceof Error) { - setError(err.message); - } else { - setError('Transaction failed'); - } + setError(err instanceof Error ? err.message : 'Transaction failed'); } finally { setIsPending(false); } @@ -428,6 +464,7 @@ export function StellarSend() { } simulationEvents={simulation.status === 'success' ? simulation.events : []} error={error} + retryStatus={retryStatus} canSubmit={canSubmit && simulation.status === 'success'} isPending={isPending} stealthResult={stealthResult} diff --git a/src/components/StellarSendView.tsx b/src/components/StellarSendView.tsx index 97fc685..c84a6a0 100644 --- a/src/components/StellarSendView.tsx +++ b/src/components/StellarSendView.tsx @@ -18,6 +18,7 @@ export interface StellarSendViewProps { simulationReturnValue: string | null; simulationEvents: string[]; error: string; + retryStatus?: string; canSubmit: boolean; isPending: boolean; stealthResult: { stealthAddress: string } | null; @@ -56,6 +57,7 @@ export function StellarSendView({ simulationReturnValue, simulationEvents, error, + retryStatus = '', canSubmit, isPending, stealthResult, @@ -278,6 +280,7 @@ export function StellarSendView({

{simulationError}

)} + {retryStatus &&

{retryStatus}

} {error &&

{error}

}