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}
}