From b0561a5503c3104f3744b4565e901801c5eaabe8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 21 May 2026 13:17:56 -0500 Subject: [PATCH] Esplora REST failover with abort signals and timeouts Replace the single `esploraBaseUrl: string` with `esploraApis: string[]` and route every Esplora REST call through a new `esploraFetch` helper that handles ordered failover across multiple API endpoints. The failover client: - Tries URLs in order with a per-attempt 15s timeout. mempool.space has a shadowban-style rate-limit behaviour where requests are silently absorbed and never reply; the timeout converts that hang into a regular failover signal so the next URL is tried. - On `429` / `5xx` / network error / timeout, parks the URL in a module-level cool-down with exponential backoff (30s, 60s, 120s, 240s, 300s cap) and advances to the next. - Resets a URL's failure count on the first 2xx response, so the primary comes back into rotation as soon as it recovers. - Treats configurable `skipStatuses` (e.g. `404` on `/v1/prices`) as endpoint-capability mismatches: skip without penalising the endpoint. This lets non-mempool backends like Blockstream coexist in the list even though they don't expose the price extension. - Composes a caller-supplied AbortSignal with the per-attempt timeout via AbortSignal.any. Caller aborts (e.g. TanStack Query queryFn unmounts) propagate immediately; timeouts mark the endpoint failed and try the next URL. - Falls back to cooled-down endpoints when *every* URL is in cool-down, rather than failing outright. Default list is mempool.space \u2192 mempool.emzy.de \u2192 blockstream.info. Every helper in `src/lib/bitcoin.ts`, `src/lib/hdwallet/scan.ts`, and `verifyOnchainZap` now takes `(input, esploraApis: string[], signal?: AbortSignal)`. Every TanStack Query caller threads its `queryFn` signal through. Mutations (broadcasts, send/donate/onchain-zap flows) still call without an explicit signal but get the 15s per-attempt timeout. --- src/App.tsx | 6 +- src/components/DonateDialog.tsx | 20 +- src/components/HDSendBitcoinDialog.tsx | 8 +- src/components/OnchainZapContent.tsx | 14 +- src/components/SendBitcoinDialog.tsx | 12 +- src/components/ZapDialog.tsx | 6 +- src/contexts/AppContext.ts | 24 +- src/hooks/useBitcoinAddress.ts | 10 +- src/hooks/useBitcoinTx.ts | 10 +- src/hooks/useBitcoinWallet.ts | 14 +- src/hooks/useBtcPrice.ts | 6 +- src/hooks/useDonateCampaign.ts | 6 +- src/hooks/useFormatMoney.ts | 6 +- src/hooks/useHdWallet.ts | 10 +- src/hooks/useOnchainZap.ts | 8 +- src/hooks/useOnchainZaps.ts | 20 +- src/lib/bitcoin.ts | 99 ++++++-- src/lib/esplora.ts | 322 +++++++++++++++++++++++++ src/lib/hdwallet/scan.ts | 18 +- src/lib/schemas.ts | 10 +- src/test/TestApp.tsx | 2 +- 21 files changed, 514 insertions(+), 117 deletions(-) create mode 100644 src/lib/esplora.ts diff --git a/src/App.tsx b/src/App.tsx index 1e1df42c..a4fe383d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -147,7 +147,11 @@ const hardcodedConfig: AppConfig = { imageQuality: 'compressed', curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d', sandboxDomain: 'iframe.diy', - esploraBaseUrl: 'https://mempool.space/api', + esploraApis: [ + 'https://mempool.space/api', + 'https://mempool.emzy.de/api', + 'https://blockstream.info/api', + ], sidebarWidgets: [ { id: 'trends' }, { id: 'hot-posts' }, diff --git a/src/components/DonateDialog.tsx b/src/components/DonateDialog.tsx index 62b44c38..8dcd4763 100644 --- a/src/components/DonateDialog.tsx +++ b/src/components/DonateDialog.tsx @@ -354,22 +354,22 @@ function FormView({ const recipientCount = campaign.recipients.length; const { user } = useCurrentUser(); const { config } = useAppContext(); - const { esploraBaseUrl } = config; + const { esploraApis } = config; const senderAddress = user ? nostrPubkeyToBitcoinAddress(user.pubkey) : ''; const hasPrice = !!btcPrice && Number.isFinite(btcPrice) && btcPrice > 0; const validAmount = hasPrice && Number.isFinite(effectiveUsd) && effectiveUsd > 0 && effectiveAmount > 0; const canContinue = validAmount && !belowMin && !tooSmallSplit; const { data: utxos } = useQuery({ - queryKey: ['bitcoin-utxos', esploraBaseUrl, senderAddress], - queryFn: () => fetchUTXOs(senderAddress, esploraBaseUrl), + queryKey: ['bitcoin-utxos', esploraApis, senderAddress], + queryFn: ({ signal }) => fetchUTXOs(senderAddress, esploraApis, signal), enabled: !!senderAddress && validAmount, staleTime: 30_000, }); const { data: feeRates } = useQuery({ - queryKey: ['bitcoin-fee-rates', esploraBaseUrl], - queryFn: () => getFeeRates(esploraBaseUrl), + queryKey: ['bitcoin-fee-rates', esploraApis], + queryFn: ({ signal }) => getFeeRates(esploraApis, signal), enabled: validAmount, staleTime: 30_000, }); @@ -589,7 +589,7 @@ function ConfirmView({ }) { const { user } = useCurrentUser(); const { config } = useAppContext(); - const { esploraBaseUrl } = config; + const { esploraApis } = config; const senderAddress = user ? nostrPubkeyToBitcoinAddress(user.pubkey) : ''; const splits = useMemo(() => { try { @@ -600,15 +600,15 @@ function ConfirmView({ }, [campaign.recipients, amountSats, user?.pubkey]); const { data: utxos, isLoading: utxosLoading, isError: utxosError } = useQuery({ - queryKey: ['bitcoin-utxos', esploraBaseUrl, senderAddress], - queryFn: () => fetchUTXOs(senderAddress, esploraBaseUrl), + queryKey: ['bitcoin-utxos', esploraApis, senderAddress], + queryFn: ({ signal }) => fetchUTXOs(senderAddress, esploraApis, signal), enabled: !!senderAddress && amountSats > 0, staleTime: 30_000, }); const { data: feeRates, isLoading: feeRatesLoading, isError: feeRatesError } = useQuery({ - queryKey: ['bitcoin-fee-rates', esploraBaseUrl], - queryFn: () => getFeeRates(esploraBaseUrl), + queryKey: ['bitcoin-fee-rates', esploraApis], + queryFn: ({ signal }) => getFeeRates(esploraApis, signal), enabled: amountSats > 0, staleTime: 30_000, }); diff --git a/src/components/HDSendBitcoinDialog.tsx b/src/components/HDSendBitcoinDialog.tsx index 1baf1b1b..579f2929 100644 --- a/src/components/HDSendBitcoinDialog.tsx +++ b/src/components/HDSendBitcoinDialog.tsx @@ -166,7 +166,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin const availability = useHdWalletAccess(); const { scan, refetch: refetchWallet } = useHdWallet(); const { config } = useAppContext(); - const { esploraBaseUrl } = config; + const { esploraApis } = config; const { toast } = useToast(); const queryClient = useQueryClient(); @@ -188,8 +188,8 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin // ── Fee rates ──────────────────────────────────────────────── const { data: feeRates } = useQuery({ - queryKey: ['bitcoin-fee-rates', esploraBaseUrl], - queryFn: () => getFeeRates(esploraBaseUrl), + queryKey: ['bitcoin-fee-rates', esploraApis], + queryFn: ({ signal }) => getFeeRates(esploraApis, signal), enabled: isOpen && isReady, staleTime: 30_000, }); @@ -313,7 +313,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin const txHex = finalizeHdPsbt(signedHex); setProgress('broadcasting'); - const txid = await broadcastTransaction(txHex, esploraBaseUrl); + const txid = await broadcastTransaction(txHex, esploraApis); return { txid, amountSats, fee: built.fee }; }, diff --git a/src/components/OnchainZapContent.tsx b/src/components/OnchainZapContent.tsx index 880497d9..7afb2a5c 100644 --- a/src/components/OnchainZapContent.tsx +++ b/src/components/OnchainZapContent.tsx @@ -96,7 +96,7 @@ export function OnchainZapContent({ target, onSuccess, onClose }: OnchainZapCont const { capability } = useBitcoinSigner(); const { logins } = useNostrLogin(); const { config } = useAppContext(); - const { esploraBaseUrl } = config; + const { esploraApis } = config; const loginType = logins[0]?.type; const [usdAmount, setUsdAmount] = useState(5); @@ -117,21 +117,21 @@ export function OnchainZapContent({ target, onSuccess, onClose }: OnchainZapCont : ''; const { data: btcPrice } = useQuery({ - queryKey: ['btc-price', esploraBaseUrl], - queryFn: () => fetchBtcPrice(esploraBaseUrl), + queryKey: ['btc-price', esploraApis], + queryFn: ({ signal }) => fetchBtcPrice(esploraApis, signal), staleTime: 30_000, }); const { data: utxos } = useQuery({ - queryKey: ['bitcoin-utxos', esploraBaseUrl, senderAddress], - queryFn: () => fetchUTXOs(senderAddress, esploraBaseUrl), + queryKey: ['bitcoin-utxos', esploraApis, senderAddress], + queryFn: ({ signal }) => fetchUTXOs(senderAddress, esploraApis, signal), enabled: !!senderAddress && capability !== 'unsupported', staleTime: 30_000, }); const { data: feeRates } = useQuery({ - queryKey: ['bitcoin-fee-rates', esploraBaseUrl], - queryFn: () => getFeeRates(esploraBaseUrl), + queryKey: ['bitcoin-fee-rates', esploraApis], + queryFn: ({ signal }) => getFeeRates(esploraApis, signal), enabled: capability !== 'unsupported', staleTime: 30_000, }); diff --git a/src/components/SendBitcoinDialog.tsx b/src/components/SendBitcoinDialog.tsx index 2155a498..d77c952e 100644 --- a/src/components/SendBitcoinDialog.tsx +++ b/src/components/SendBitcoinDialog.tsx @@ -159,7 +159,7 @@ export function SendBitcoinDialog({ isOpen, onClose, btcPrice }: SendBitcoinDial const { mutateAsync: publishEvent } = useNostrPublish(); const { toast } = useToast(); const { config } = useAppContext(); - const { esploraBaseUrl } = config; + const { esploraApis } = config; const queryClient = useQueryClient(); // ── Form state ─────────────────────────────────────────────── @@ -179,15 +179,15 @@ export function SendBitcoinDialog({ isOpen, onClose, btcPrice }: SendBitcoinDial // ── Data fetching ──────────────────────────────────────────── const { data: utxos } = useQuery({ - queryKey: ['bitcoin-utxos', esploraBaseUrl, senderAddress], - queryFn: () => fetchUTXOs(senderAddress, esploraBaseUrl), + queryKey: ['bitcoin-utxos', esploraApis, senderAddress], + queryFn: ({ signal }) => fetchUTXOs(senderAddress, esploraApis, signal), enabled: !!senderAddress && isOpen && canSignPsbt, staleTime: 30_000, }); const { data: feeRates } = useQuery({ - queryKey: ['bitcoin-fee-rates', esploraBaseUrl], - queryFn: () => getFeeRates(esploraBaseUrl), + queryKey: ['bitcoin-fee-rates', esploraApis], + queryFn: ({ signal }) => getFeeRates(esploraApis, signal), enabled: isOpen && canSignPsbt, staleTime: 30_000, }); @@ -322,7 +322,7 @@ export function SendBitcoinDialog({ isOpen, onClose, btcPrice }: SendBitcoinDial const txHex = finalizePsbt(signedHex); setProgress('broadcasting'); - const txid = await broadcastTransaction(txHex, esploraBaseUrl); + const txid = await broadcastTransaction(txHex, esploraApis); // When the recipient is a Nostr identity, publish a kind 8333 profile zap // attesting the send. Per NIP.md, omitting `e`/`a` targets the recipient's diff --git a/src/components/ZapDialog.tsx b/src/components/ZapDialog.tsx index cd49d0c7..e9fb3e87 100644 --- a/src/components/ZapDialog.tsx +++ b/src/components/ZapDialog.tsx @@ -289,7 +289,7 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) { const { toast } = useToast(); const { webln, activeNWC } = useWallet(); const { config } = useAppContext(); - const { esploraBaseUrl } = config; + const { esploraApis } = config; // Success state: populated by either zap rail's onSuccess callback. // When set, we replace the tab UI with . @@ -323,8 +323,8 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) { const amountInputRef = useRef(null); const { data: btcPrice } = useQuery({ - queryKey: ['btc-price', esploraBaseUrl], - queryFn: () => fetchBtcPrice(esploraBaseUrl), + queryKey: ['btc-price', esploraApis], + queryFn: ({ signal }) => fetchBtcPrice(esploraApis, signal), staleTime: 30_000, }); diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index b0210cac..8741191a 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -271,12 +271,26 @@ export interface AppConfig { /** Wildcard domain used for iframe sandboxing (e.g. "iframe.diy"). Default: "iframe.diy". */ sandboxDomain: string; /** - * Base URL for the Esplora-compatible Bitcoin REST API. Used by the wallet, - * on-chain zap flows, and NIP-73 Bitcoin tx/address pages. The standard - * Esplora REST root (no version segment). The mempool.space `/v1/prices` - * extension is appended by the price call. Default: "https://mempool.space/api". + * Ordered list of base URLs for Esplora-compatible Bitcoin REST APIs. Used + * by the wallet, on-chain zap flows, and NIP-73 Bitcoin tx/address pages. + * Each URL is the standard Esplora REST root (no version segment, no + * trailing slash). The list is tried in order with exponential-backoff + * failover on `429` / `5xx` responses — see `src/lib/esplora.ts`. + * + * The first entry is treated as the primary. The mempool.space `/v1/prices` + * extension is appended by the price call; endpoints that don't speak it + * (e.g. Blockstream's Esplora) are silently skipped via a `404` soft-failover. + * + * Default: + * ``` + * [ + * 'https://mempool.space/api', + * 'https://mempool.emzy.de/api', + * 'https://blockstream.info/api', + * ] + * ``` */ - esploraBaseUrl: string; + esploraApis: string[]; /** * Display preference for monetary amounts (zap totals, balances, send forms). * - "usd" (default): convert sats to USD using the live BTC price. diff --git a/src/hooks/useBitcoinAddress.ts b/src/hooks/useBitcoinAddress.ts index 754f0b25..93b0bb91 100644 --- a/src/hooks/useBitcoinAddress.ts +++ b/src/hooks/useBitcoinAddress.ts @@ -10,18 +10,18 @@ import { useAppContext } from '@/hooks/useAppContext'; */ export function useBitcoinAddress(address: string) { const { config } = useAppContext(); - const { esploraBaseUrl } = config; + const { esploraApis } = config; const { data: addressDetail, isLoading, error, refetch } = useQuery({ - queryKey: ['bitcoin-address-detail', esploraBaseUrl, address], - queryFn: () => fetchAddressDetail(address, esploraBaseUrl), + queryKey: ['bitcoin-address-detail', esploraApis, address], + queryFn: ({ signal }) => fetchAddressDetail(address, esploraApis, signal), enabled: !!address, refetchInterval: 30_000, }); const { data: btcPrice } = useQuery({ - queryKey: ['btc-price', esploraBaseUrl], - queryFn: () => fetchBtcPrice(esploraBaseUrl), + queryKey: ['btc-price', esploraApis], + queryFn: ({ signal }) => fetchBtcPrice(esploraApis, signal), refetchInterval: 60_000, staleTime: 30_000, }); diff --git a/src/hooks/useBitcoinTx.ts b/src/hooks/useBitcoinTx.ts index 7370faaf..10bcced1 100644 --- a/src/hooks/useBitcoinTx.ts +++ b/src/hooks/useBitcoinTx.ts @@ -10,18 +10,18 @@ import { useAppContext } from '@/hooks/useAppContext'; */ export function useBitcoinTx(txid: string) { const { config } = useAppContext(); - const { esploraBaseUrl } = config; + const { esploraApis } = config; const { data: tx, isLoading, error } = useQuery({ - queryKey: ['bitcoin-tx-detail', esploraBaseUrl, txid], - queryFn: () => fetchTxDetail(txid, esploraBaseUrl), + queryKey: ['bitcoin-tx-detail', esploraApis, txid], + queryFn: ({ signal }) => fetchTxDetail(txid, esploraApis, signal), enabled: !!txid, staleTime: 60_000, }); const { data: btcPrice } = useQuery({ - queryKey: ['btc-price', esploraBaseUrl], - queryFn: () => fetchBtcPrice(esploraBaseUrl), + queryKey: ['btc-price', esploraApis], + queryFn: ({ signal }) => fetchBtcPrice(esploraApis, signal), refetchInterval: 60_000, staleTime: 30_000, }); diff --git a/src/hooks/useBitcoinWallet.ts b/src/hooks/useBitcoinWallet.ts index 8342a109..852bbab7 100644 --- a/src/hooks/useBitcoinWallet.ts +++ b/src/hooks/useBitcoinWallet.ts @@ -16,7 +16,7 @@ import { nostrPubkeyToBitcoinAddress, fetchAddressData, fetchBtcPrice, fetchTran export function useBitcoinWallet() { const { user } = useCurrentUser(); const { config } = useAppContext(); - const { esploraBaseUrl } = config; + const { esploraApis } = config; const bitcoinAddress = useMemo(() => { if (!user) return ''; @@ -29,15 +29,15 @@ export function useBitcoinWallet() { error, refetch, } = useQuery({ - queryKey: ['bitcoin-balance', esploraBaseUrl, bitcoinAddress], - queryFn: () => fetchAddressData(bitcoinAddress, esploraBaseUrl), + queryKey: ['bitcoin-balance', esploraApis, bitcoinAddress], + queryFn: ({ signal }) => fetchAddressData(bitcoinAddress, esploraApis, signal), enabled: !!bitcoinAddress, refetchInterval: 30_000, }); const { data: btcPrice } = useQuery({ - queryKey: ['btc-price', esploraBaseUrl], - queryFn: () => fetchBtcPrice(esploraBaseUrl), + queryKey: ['btc-price', esploraApis], + queryFn: ({ signal }) => fetchBtcPrice(esploraApis, signal), refetchInterval: 60_000, staleTime: 30_000, }); @@ -46,8 +46,8 @@ export function useBitcoinWallet() { data: transactions, isLoading: isLoadingTxs, } = useQuery({ - queryKey: ['bitcoin-txs', esploraBaseUrl, bitcoinAddress], - queryFn: () => fetchTransactions(bitcoinAddress, esploraBaseUrl), + queryKey: ['bitcoin-txs', esploraApis, bitcoinAddress], + queryFn: ({ signal }) => fetchTransactions(bitcoinAddress, esploraApis, signal), enabled: !!bitcoinAddress, refetchInterval: 30_000, }); diff --git a/src/hooks/useBtcPrice.ts b/src/hooks/useBtcPrice.ts index 20e8e611..1d5523bf 100644 --- a/src/hooks/useBtcPrice.ts +++ b/src/hooks/useBtcPrice.ts @@ -16,10 +16,10 @@ import { useAppContext } from '@/hooks/useAppContext'; */ export function useBtcPrice() { const { config } = useAppContext(); - const { esploraBaseUrl } = config; + const { esploraApis } = config; return useQuery({ - queryKey: ['btc-price', esploraBaseUrl], - queryFn: () => fetchBtcPrice(esploraBaseUrl), + queryKey: ['btc-price', esploraApis], + queryFn: ({ signal }) => fetchBtcPrice(esploraApis, signal), refetchInterval: 60_000, staleTime: 30_000, }); diff --git a/src/hooks/useDonateCampaign.ts b/src/hooks/useDonateCampaign.ts index 3b11d8d6..541ff883 100644 --- a/src/hooks/useDonateCampaign.ts +++ b/src/hooks/useDonateCampaign.ts @@ -76,7 +76,7 @@ export function useDonateCampaign() { const { mutateAsync: publishEvent } = useNostrPublish(); const queryClient = useQueryClient(); const { config } = useAppContext(); - const { esploraBaseUrl } = config; + const { esploraApis } = config; async function donateToCampaign({ campaign, @@ -116,7 +116,7 @@ export function useDonateCampaign() { return { address, amountSats: s.amountSats }; }); - const [utxos, rates] = await Promise.all([fetchUTXOs(senderAddress, esploraBaseUrl), getFeeRates(esploraBaseUrl)]); + const [utxos, rates] = await Promise.all([fetchUTXOs(senderAddress, esploraApis), getFeeRates(esploraApis)]); if (utxos.length === 0) { throw new Error('Your Bitcoin wallet has no spendable funds.'); } @@ -140,7 +140,7 @@ export function useDonateCampaign() { } const txHex = finalizePsbt(signedHex); - const txid = await broadcastTransaction(txHex, esploraBaseUrl); + const txid = await broadcastTransaction(txHex, esploraApis); // Publish a single kind 8333 receipt covering the whole transaction. The // event lists every recipient under its own `p` tag; the `amount` tag is diff --git a/src/hooks/useFormatMoney.ts b/src/hooks/useFormatMoney.ts index 775e78cf..75e8aa1f 100644 --- a/src/hooks/useFormatMoney.ts +++ b/src/hooks/useFormatMoney.ts @@ -34,7 +34,7 @@ export interface FormatMoneyResult { * failed, the function falls back to the sats representation so we never block * the UI on a network round-trip. * - * The BTC price is fetched via TanStack Query with a `['btc-price', esploraBaseUrl]` + * The BTC price is fetched via TanStack Query with a `['btc-price', esploraApis]` * key — the same key used by the wallet, zap dialogs, and on-chain zap flows — so * a single request is deduped across the whole app. */ @@ -44,8 +44,8 @@ export function useFormatMoney(): FormatMoneyResult { // Reuse the shared price query so all callers share one cached fetch. const { data: btcPrice } = useQuery({ - queryKey: ['btc-price', config.esploraBaseUrl], - queryFn: () => fetchBtcPrice(config.esploraBaseUrl), + queryKey: ['btc-price', config.esploraApis], + queryFn: ({ signal }) => fetchBtcPrice(config.esploraApis, signal), // Prices move; 60 s is fine for display formatting. staleTime: 60_000, // Don't pop a UI error if the price endpoint is down; we just fall back to sats. diff --git a/src/hooks/useHdWallet.ts b/src/hooks/useHdWallet.ts index 3c280541..0394d88a 100644 --- a/src/hooks/useHdWallet.ts +++ b/src/hooks/useHdWallet.ts @@ -86,7 +86,7 @@ export interface UseHdWalletResult { */ export function useHdWallet(): UseHdWalletResult { const { config } = useAppContext(); - const { esploraBaseUrl } = config; + const { esploraApis } = config; const availability = useHdWalletAccess(); const queryClient = useQueryClient(); @@ -101,7 +101,7 @@ export function useHdWallet(): UseHdWalletResult { ); // ── Scan query ─────────────────────────────────────────────── - const scanKey = ['hdwallet-scan', esploraBaseUrl, pubkey]; + const scanKey = ['hdwallet-scan', esploraApis, pubkey]; const { data: scan, isLoading: scanLoading, @@ -112,7 +112,7 @@ export function useHdWallet(): UseHdWalletResult { queryKey: scanKey, queryFn: async ({ signal }) => { if (!account) throw new Error('HD wallet account unavailable'); - return scanAccount(account, esploraBaseUrl, signal); + return scanAccount(account, esploraApis, signal); }, enabled: !!account, refetchInterval: REFRESH_INTERVAL_MS, @@ -125,10 +125,10 @@ export function useHdWallet(): UseHdWalletResult { isFetching: txFetching, refetch: refetchTxs, } = useQuery({ - queryKey: ['hdwallet-txs', esploraBaseUrl, pubkey, scan?.receive.used.length, scan?.change.used.length], + queryKey: ['hdwallet-txs', esploraApis, pubkey, scan?.receive.used.length, scan?.change.used.length], queryFn: async ({ signal }) => { if (!scan) return []; - return fetchHdTransactions(scan, esploraBaseUrl, signal); + return fetchHdTransactions(scan, esploraApis, signal); }, enabled: !!scan, refetchInterval: REFRESH_INTERVAL_MS, diff --git a/src/hooks/useOnchainZap.ts b/src/hooks/useOnchainZap.ts index 35e17e9f..757a72a9 100644 --- a/src/hooks/useOnchainZap.ts +++ b/src/hooks/useOnchainZap.ts @@ -74,7 +74,7 @@ export function useOnchainZap( const { mutateAsync: publishEvent } = useNostrPublish(); const { toast } = useToast(); const { config } = useAppContext(); - const { esploraBaseUrl } = config; + const { esploraApis } = config; const queryClient = useQueryClient(); const [isZapping, setIsZapping] = useState(false); @@ -104,8 +104,8 @@ export function useOnchainZap( // Fetch UTXOs and fee rates const [utxos, rates] = await Promise.all([ - fetchUTXOs(senderAddress, esploraBaseUrl), - getFeeRates(esploraBaseUrl), + fetchUTXOs(senderAddress, esploraApis), + getFeeRates(esploraApis), ]); if (utxos.length === 0) { @@ -137,7 +137,7 @@ export function useOnchainZap( // Broadcast setProgress('broadcasting'); - const txid = await broadcastTransaction(txHex, esploraBaseUrl); + const txid = await broadcastTransaction(txHex, esploraApis); // Publish kind 8333 event setProgress('publishing'); diff --git a/src/hooks/useOnchainZaps.ts b/src/hooks/useOnchainZaps.ts index b9acbee0..719bedcb 100644 --- a/src/hooks/useOnchainZaps.ts +++ b/src/hooks/useOnchainZaps.ts @@ -93,11 +93,13 @@ export function extractOnchainZapRecipient(event: NostrEvent): string { * any listed recipient — callers should discard such events. * * @param event The kind 8333 event to verify. - * @param esploraBaseUrl Esplora REST root used to fetch the tx detail. + * @param esploraApis Ordered list of Esplora REST roots tried with failover. + * @param signal Optional abort signal (e.g. from TanStack Query). */ export async function verifyOnchainZap( event: NostrEvent, - esploraBaseUrl: string, + esploraApis: string[], + signal?: AbortSignal, ): Promise { const txid = extractOnchainZapTxid(event); const recipientPubkeys = extractOnchainZapRecipients(event); @@ -120,7 +122,7 @@ export async function verifyOnchainZap( let detail; try { - detail = await fetchTxDetail(txid, esploraBaseUrl); + detail = await fetchTxDetail(txid, esploraApis, signal); } catch { return null; } @@ -155,7 +157,7 @@ export async function verifyOnchainZap( export function useOnchainZaps(target: NostrEvent | undefined) { const { nostr } = useNostr(); const { config } = useAppContext(); - const { esploraBaseUrl } = config; + const { esploraApis } = config; const isAddressable = target && target.kind >= 30000 && target.kind < 40000; const dTag = isAddressable ? target.tags.find(([n]) => n === 'd')?.[1] ?? '' @@ -204,8 +206,8 @@ export function useOnchainZaps(target: NostrEvent | undefined) { const events = eventsQuery.data ?? []; const verifications = useQueries({ queries: events.map((event) => ({ - queryKey: ['onchain-zaps', 'verify', esploraBaseUrl, event.id], - queryFn: () => verifyOnchainZap(event, esploraBaseUrl), + queryKey: ['onchain-zaps', 'verify', esploraApis, event.id], + queryFn: ({ signal }: { signal: AbortSignal }) => verifyOnchainZap(event, esploraApis, signal), staleTime: 60_000, })), }); @@ -239,13 +241,13 @@ export function useOnchainZaps(target: NostrEvent | undefined) { */ export function useVerifiedOnchainZap(event: NostrEvent | undefined): OnchainZapEntry | null | undefined { const { config } = useAppContext(); - const { esploraBaseUrl } = config; + const { esploraApis } = config; const txid = event ? extractOnchainZapTxid(event) : null; const hasRecipient = event ? extractOnchainZapRecipients(event).length > 0 : false; const { data } = useQuery({ - queryKey: ['onchain-zaps', 'verify', esploraBaseUrl, event?.id ?? ''], - queryFn: () => verifyOnchainZap(event!, esploraBaseUrl), + queryKey: ['onchain-zaps', 'verify', esploraApis, event?.id ?? ''], + queryFn: ({ signal }) => verifyOnchainZap(event!, esploraApis, signal), enabled: !!event && !!txid && hasRecipient, staleTime: 60_000, }); diff --git a/src/lib/bitcoin.ts b/src/lib/bitcoin.ts index e107efaf..23fc35bc 100644 --- a/src/lib/bitcoin.ts +++ b/src/lib/bitcoin.ts @@ -4,6 +4,8 @@ import { nip19 } from 'nostr-tools'; import * as ecc from '@bitcoinerlab/secp256k1'; import { ECPairFactory, type ECPairAPI } from 'ecpair'; +import { esploraFetch } from './esplora'; + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -117,10 +119,15 @@ export interface AddressData { * Esplora-compatible REST API (e.g. mempool.space, Blockstream). * * @param address The Bitcoin address to look up. - * @param baseUrl Esplora REST root, no trailing slash (e.g. `https://mempool.space/api`). + * @param baseUrls Ordered list of Esplora REST roots tried with failover. + * @param signal Optional abort signal (e.g. from TanStack Query). */ -export async function fetchAddressData(address: string, baseUrl: string): Promise { - const response = await fetch(`${baseUrl}/address/${address}`); +export async function fetchAddressData( + address: string, + baseUrls: string[], + signal?: AbortSignal, +): Promise { + const response = await esploraFetch(baseUrls, `/address/${address}`, { signal }); if (!response.ok) { throw new Error('Failed to fetch balance'); @@ -169,12 +176,20 @@ export function formatSats(sats: number): string { * * Note: the `/v1/prices` endpoint is a mempool.space extension to the * standard Esplora REST surface. Backends like Blockstream's Esplora do - * not expose it. + * not expose it — those endpoints return `404` and the failover client + * silently advances to the next URL (without penalising the endpoint). * - * @param baseUrl Esplora REST root, no trailing slash (e.g. `https://mempool.space/api`). + * @param baseUrls Ordered list of Esplora REST roots tried with failover. + * @param signal Optional abort signal (e.g. from TanStack Query). */ -export async function fetchBtcPrice(baseUrl: string): Promise { - const response = await fetch(`${baseUrl}/v1/prices`); +export async function fetchBtcPrice(baseUrls: string[], signal?: AbortSignal): Promise { + const response = await esploraFetch(baseUrls, `/v1/prices`, { + // /v1/prices is a mempool.space extension — 404 means "endpoint doesn't + // speak this path", not "the endpoint is dead". Soft-failover to the + // next URL without putting this one in cool-down. + skipStatuses: [404], + signal, + }); if (!response.ok) { throw new Error('Failed to fetch BTC price'); @@ -260,10 +275,15 @@ export interface Transaction { * Returns simplified transactions with net amount relative to the address. * * @param address The Bitcoin address to look up. - * @param baseUrl Esplora REST root, no trailing slash. + * @param baseUrls Ordered list of Esplora REST roots tried with failover. + * @param signal Optional abort signal (e.g. from TanStack Query). */ -export async function fetchTransactions(address: string, baseUrl: string): Promise { - const response = await fetch(`${baseUrl}/address/${address}/txs`); +export async function fetchTransactions( + address: string, + baseUrls: string[], + signal?: AbortSignal, +): Promise { + const response = await esploraFetch(baseUrls, `/address/${address}/txs`, { signal }); if (!response.ok) { throw new Error('Failed to fetch transactions'); @@ -350,10 +370,15 @@ export interface TxDetail { * Fetch full transaction details from an Esplora-compatible API. * * @param txid The transaction ID (hex). - * @param baseUrl Esplora REST root, no trailing slash. + * @param baseUrls Ordered list of Esplora REST roots tried with failover. + * @param signal Optional abort signal (e.g. from TanStack Query). */ -export async function fetchTxDetail(txid: string, baseUrl: string): Promise { - const response = await fetch(`${baseUrl}/tx/${txid}`); +export async function fetchTxDetail( + txid: string, + baseUrls: string[], + signal?: AbortSignal, +): Promise { + const response = await esploraFetch(baseUrls, `/tx/${txid}`, { signal }); if (!response.ok) throw new Error('Failed to fetch transaction'); const tx = await response.json(); @@ -429,12 +454,17 @@ export interface AddressDetail { * Fetch full address details (balance + recent txs) from an Esplora-compatible API. * * @param address The Bitcoin address to look up. - * @param baseUrl Esplora REST root, no trailing slash. + * @param baseUrls Ordered list of Esplora REST roots tried with failover. + * @param signal Optional abort signal (e.g. from TanStack Query). */ -export async function fetchAddressDetail(address: string, baseUrl: string): Promise { +export async function fetchAddressDetail( + address: string, + baseUrls: string[], + signal?: AbortSignal, +): Promise { const [addrData, txs] = await Promise.all([ - fetchAddressData(address, baseUrl), - fetchTransactions(address, baseUrl), + fetchAddressData(address, baseUrls, signal), + fetchTransactions(address, baseUrls, signal), ]); return { @@ -466,10 +496,15 @@ export interface UTXO { * Fetch UTXOs for a Bitcoin address from an Esplora-compatible API. * * @param address The Bitcoin address to look up. - * @param baseUrl Esplora REST root, no trailing slash. + * @param baseUrls Ordered list of Esplora REST roots tried with failover. + * @param signal Optional abort signal (e.g. from TanStack Query). */ -export async function fetchUTXOs(address: string, baseUrl: string): Promise { - const response = await fetch(`${baseUrl}/address/${address}/utxo`); +export async function fetchUTXOs( + address: string, + baseUrls: string[], + signal?: AbortSignal, +): Promise { + const response = await esploraFetch(baseUrls, `/address/${address}/utxo`, { signal }); if (!response.ok) throw new Error('Failed to fetch UTXOs'); return response.json(); } @@ -491,10 +526,11 @@ export interface FeeRates { /** * Fetch recommended fee rates (sat/vB) from an Esplora-compatible API. * - * @param baseUrl Esplora REST root, no trailing slash. + * @param baseUrls Ordered list of Esplora REST roots tried with failover. + * @param signal Optional abort signal (e.g. from TanStack Query). */ -export async function getFeeRates(baseUrl: string): Promise { - const response = await fetch(`${baseUrl}/fee-estimates`); +export async function getFeeRates(baseUrls: string[], signal?: AbortSignal): Promise { + const response = await esploraFetch(baseUrls, `/fee-estimates`, { signal }); if (!response.ok) throw new Error('Failed to fetch fee estimates'); const data = await response.json(); @@ -537,13 +573,24 @@ export function validateBitcoinAddress(address: string): boolean { * Broadcast a signed transaction hex to the Bitcoin network via an * Esplora-compatible API. Returns the txid. * + * Broadcast is idempotent at the Bitcoin protocol layer — re-broadcasting a + * tx that's already in mempool is harmless — so we let the failover client + * retry across endpoints normally. The first endpoint that accepts the tx + * wins. + * * @param txHex The signed transaction hex. - * @param baseUrl Esplora REST root, no trailing slash. + * @param baseUrls Ordered list of Esplora REST roots tried with failover. + * @param signal Optional abort signal (e.g. from TanStack Query). */ -export async function broadcastTransaction(txHex: string, baseUrl: string): Promise { - const response = await fetch(`${baseUrl}/tx`, { +export async function broadcastTransaction( + txHex: string, + baseUrls: string[], + signal?: AbortSignal, +): Promise { + const response = await esploraFetch(baseUrls, `/tx`, { method: 'POST', body: txHex, + signal, }); if (!response.ok) { diff --git a/src/lib/esplora.ts b/src/lib/esplora.ts new file mode 100644 index 00000000..874e3ff3 --- /dev/null +++ b/src/lib/esplora.ts @@ -0,0 +1,322 @@ +// --------------------------------------------------------------------------- +// Esplora REST failover client +// --------------------------------------------------------------------------- +// +// The Esplora REST surface is supported by many backends besides mempool.space: +// Blockstream's reference implementation, mempool.space community mirrors +// (mempool.emzy.de, mempool.bitaroo.net, geographic mirrors), and self-hosted +// instances. They all speak the same `/address/...`, `/tx/...`, +// `/fee-estimates`, etc. paths — but availability varies, and rate limits +// (HTTP 429) on public instances are real. +// +// This module turns the configured `esploraApis` (an ordered array of URLs) +// into a single `esploraFetch(urls, path, init)` call that: +// +// 1. Tries each URL in order, with a per-attempt timeout (default 15s) so +// a hung connection — common when mempool.space has rate-limited you and +// is silently dropping the request — kills the request and fails over to +// the next URL instead of leaking the inflight fetch forever. +// 2. On network error / timeout / HTTP 429 / 5xx, parks the URL in a +// module-level cool-down map with exponential backoff (30s → 60s → +// 120s → 240s → 300s), then advances to the next URL. +// 3. On any 2xx (or non-retryable 4xx like 400/404), returns the response. +// 4. Successful responses reset the URL's failure count to zero. +// 5. If the caller's `signal` aborts, the active request is cancelled and +// the `AbortError` is propagated — we do NOT continue to other endpoints. +// +// Cool-down state is in-memory only — it lives for the session and is +// transparent to callers. The list of URLs itself is never mutated; we just +// skip ones whose cool-down hasn't expired. +// --------------------------------------------------------------------------- + +/** Initial cool-down on first failure, in milliseconds. */ +const INITIAL_COOLDOWN_MS = 30_000; + +/** Maximum cool-down after repeated failures, in milliseconds. */ +const MAX_COOLDOWN_MS = 300_000; + +/** + * Default per-attempt timeout. Chosen to catch shadowban-style hangs + * (mempool.space's "absorb the request and never reply" rate-limit pattern) + * quickly, while still allowing genuinely slow responses on healthy endpoints + * to complete. A full address-with-paginated-txs response on a cold mempool + * is typically well under 10s. + */ +const DEFAULT_TIMEOUT_MS = 15_000; + +/** HTTP status codes that trigger failover + cool-down. */ +const RETRYABLE_STATUS = new Set([ + 408, // Request Timeout + 425, // Too Early + 429, // Too Many Requests + 500, // Internal Server Error + 502, // Bad Gateway + 503, // Service Unavailable + 504, // Gateway Timeout +]); + +/** Per-URL cool-down state. */ +interface EndpointState { + /** Earliest time (ms epoch) the endpoint may be retried. */ + retryAt: number; + /** Consecutive failure count. Drives backoff length. */ + failures: number; +} + +/** Module-level map of URL → cool-down state. */ +const state = new Map(); + +/** Has this endpoint's cool-down elapsed? */ +function isAvailable(url: string, now: number): boolean { + const s = state.get(url); + return !s || s.retryAt <= now; +} + +/** Mark an endpoint as failed, extending its cool-down with exponential backoff. */ +function markFailure(url: string, now: number): void { + const prev = state.get(url); + const failures = (prev?.failures ?? 0) + 1; + const backoff = Math.min( + INITIAL_COOLDOWN_MS * 2 ** (failures - 1), + MAX_COOLDOWN_MS, + ); + state.set(url, { retryAt: now + backoff, failures }); +} + +/** Mark an endpoint as healthy. Clears any prior cool-down / failure count. */ +function markSuccess(url: string): void { + if (state.has(url)) state.delete(url); +} + +/** Strip a trailing slash so callers don't have to think about it. */ +function normalize(url: string): string { + return url.endsWith('/') ? url.slice(0, -1) : url; +} + +/** + * Options that control failover behaviour for a single `esploraFetch` call. + */ +export interface EsploraFetchOptions extends Omit { + /** + * Caller-supplied abort signal. When this signal aborts (e.g. a TanStack + * Query unmount), the inflight request is cancelled and an `AbortError` + * propagates to the caller — we do not continue to other endpoints. + */ + signal?: AbortSignal; + /** + * Per-attempt timeout in milliseconds. After this elapses, the current + * request is aborted and the endpoint is marked as failed; the next URL + * in the list is tried. Defaults to {@link DEFAULT_TIMEOUT_MS}. + * + * Set to `0` to disable the timeout entirely (not recommended — this is + * the safety net against shadowbans). + */ + timeoutMs?: number; + /** + * Treat HTTP status `404` (and optionally others) as "this endpoint doesn't + * support this path" rather than "everything is broken". The endpoint stays + * healthy, but we still try the next one in the list. Used for the + * mempool.space-specific `/v1/prices` endpoint which is absent on backends + * like Blockstream Esplora. + * + * Defaults to `[]` — every non-retryable error response is returned to the + * caller as-is. + */ + skipStatuses?: number[]; +} + +/** Error thrown when every endpoint in the list is unreachable or cooled down. */ +export class EsploraAllEndpointsFailedError extends Error { + constructor( + /** Original URLs that were attempted. */ + public readonly urls: string[], + /** Per-URL failure reasons in attempt order. */ + public readonly causes: Array<{ url: string; reason: string }>, + ) { + const summary = causes.map((c) => `${c.url} → ${c.reason}`).join('; '); + super(`All Esplora endpoints failed: ${summary || '(none available)'}`); + this.name = 'EsploraAllEndpointsFailedError'; + } +} + +/** + * Build a single AbortSignal that fires when either the caller's signal + * aborts OR the per-attempt timeout elapses. Returns the merged signal plus + * a cleanup function to clear the timer once the attempt finishes. Uses + * `AbortSignal.any` when available (modern Chrome/Firefox/Safari and + * recent WKWebView/Android WebView via Capacitor); falls back to manual + * listener wiring on older runtimes. + */ +function buildAttemptSignal( + callerSignal: AbortSignal | undefined, + timeoutMs: number, +): { signal: AbortSignal; cleanup: () => void; timedOut: () => boolean } { + const timeoutController = new AbortController(); + let didTimeout = false; + const timer = timeoutMs > 0 + ? setTimeout(() => { + didTimeout = true; + timeoutController.abort(); + }, timeoutMs) + : undefined; + + // Compose timeout + caller signal. AbortSignal.any is the clean path. + const signals: AbortSignal[] = [timeoutController.signal]; + if (callerSignal) signals.push(callerSignal); + + let signal: AbortSignal; + let removeListener: (() => void) | undefined; + if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.any === 'function') { + signal = AbortSignal.any(signals); + } else if (callerSignal) { + // Manual composition: forward caller's abort onto the timeout controller + // so the timeout's signal is the single source of truth. + if (callerSignal.aborted) { + timeoutController.abort(); + } else { + const onAbort = () => timeoutController.abort(); + callerSignal.addEventListener('abort', onAbort, { once: true }); + removeListener = () => callerSignal.removeEventListener('abort', onAbort); + } + signal = timeoutController.signal; + } else { + signal = timeoutController.signal; + } + + return { + signal, + cleanup: () => { + if (timer !== undefined) clearTimeout(timer); + removeListener?.(); + }, + timedOut: () => didTimeout, + }; +} + +/** + * Fetch an Esplora REST path with ordered failover across `baseUrls`. + * + * Iterates the URL list in order, skipping any endpoint currently in + * cool-down. The first URL that returns a non-retryable response wins — + * callers handle 2xx and "expected" 4xx (400, 404 for genuine not-found, + * etc.) themselves. + * + * Each attempt is bounded by a timeout (default 15s) and the caller's + * abort signal. Timeouts count as endpoint failures (cool-down + try next); + * caller aborts propagate immediately. + * + * @param baseUrls Ordered list of Esplora REST roots, e.g. + * `['https://mempool.space/api', 'https://blockstream.info/api']`. + * Each should be a full URL with no trailing slash, but a + * trailing slash is tolerated. + * @param path Path beginning with `/`, e.g. `/address/bc1.../utxo`. + * @param options Standard `fetch` options plus `signal`, `timeoutMs`, and + * `skipStatuses` for soft failover on endpoint-capability + * mismatches. + */ +export async function esploraFetch( + baseUrls: string[], + path: string, + options: EsploraFetchOptions = {}, +): Promise { + if (baseUrls.length === 0) { + throw new EsploraAllEndpointsFailedError([], []); + } + + const { + skipStatuses = [], + signal: callerSignal, + timeoutMs = DEFAULT_TIMEOUT_MS, + ...fetchInit + } = options; + + // Caller already gave up before we even started. Honour that immediately. + if (callerSignal?.aborted) { + throw callerSignal.reason instanceof Error + ? callerSignal.reason + : new DOMException('Aborted', 'AbortError'); + } + + const skip = new Set(skipStatuses); + const causes: Array<{ url: string; reason: string }> = []; + const now = Date.now(); + + // Build the attempt order: available endpoints first, then cooled-down ones + // as a last-resort fallback. This way, when *every* endpoint is cooling + // down we still try them rather than dying instantly. + const normalized = baseUrls.map(normalize); + const available = normalized.filter((u) => isAvailable(u, now)); + const cooling = normalized.filter((u) => !isAvailable(u, now)); + const attemptOrder = available.length > 0 ? [...available, ...cooling] : cooling; + + for (const baseUrl of attemptOrder) { + const fullUrl = `${baseUrl}${path}`; + const attempt = buildAttemptSignal(callerSignal, timeoutMs); + + let response: Response; + try { + response = await fetch(fullUrl, { ...fetchInit, signal: attempt.signal }); + } catch (err) { + attempt.cleanup(); + + // Caller aborted: propagate. Don't continue to other endpoints, don't + // penalize this one. + if (callerSignal?.aborted) { + throw err; + } + + // Per-attempt timeout: treat as a soft failure for *this* endpoint + // and advance to the next URL. This is the shadowban defence — when + // mempool.space rate-limits, it sometimes just absorbs the connection + // and never responds; the timeout converts that hang into a regular + // failover signal. + if (attempt.timedOut()) { + markFailure(baseUrl, Date.now()); + causes.push({ url: baseUrl, reason: `timeout after ${timeoutMs}ms` }); + continue; + } + + // Generic network error / DNS failure / CORS error. + markFailure(baseUrl, Date.now()); + causes.push({ url: baseUrl, reason: err instanceof Error ? err.message : String(err) }); + continue; + } + attempt.cleanup(); + + if (response.ok) { + markSuccess(baseUrl); + return response; + } + + // "Endpoint capability mismatch" (e.g. /v1/prices on Blockstream). + // The endpoint is fine — it just doesn't speak that path. Try the + // next URL but don't penalize this one. + if (skip.has(response.status)) { + causes.push({ url: baseUrl, reason: `HTTP ${response.status} (skipped)` }); + continue; + } + + // 5xx / 429 / 408 → cool down and try the next URL. + if (RETRYABLE_STATUS.has(response.status)) { + markFailure(baseUrl, Date.now()); + causes.push({ url: baseUrl, reason: `HTTP ${response.status}` }); + continue; + } + + // Non-retryable 4xx (400, 404 for genuine "not found", etc.). + // Return as-is — this is a real answer that won't change by retrying. + markSuccess(baseUrl); + return response; + } + + throw new EsploraAllEndpointsFailedError(baseUrls, causes); +} + +/** + * Test-only: reset the in-memory cool-down map. Not exported from the public + * surface for production code — consumers should never need this. + */ +export function _resetEsploraStateForTests(): void { + state.clear(); +} + diff --git a/src/lib/hdwallet/scan.ts b/src/lib/hdwallet/scan.ts index 65b62950..e29a39b4 100644 --- a/src/lib/hdwallet/scan.ts +++ b/src/lib/hdwallet/scan.ts @@ -83,7 +83,7 @@ function isUsed(data: AddressData): boolean { async function scanChain( account: HdAccount, chain: 0 | 1, - esploraBaseUrl: string, + esploraApis: string[], signal?: AbortSignal, ): Promise { const chainNode = chain === RECEIVE_CHAIN ? account.receiveNode : account.changeNode; @@ -115,7 +115,7 @@ async function scanChain( const dataResults = await Promise.all( batch.map(async (d) => { signal?.throwIfAborted(); - const data = await fetchAddressData(d.address, esploraBaseUrl); + const data = await fetchAddressData(d.address, esploraApis, signal); return { d, data }; }), ); @@ -124,7 +124,7 @@ async function scanChain( if (isUsed(data)) { // Used — reset gap counter, fetch UTXOs for spending. signal?.throwIfAborted(); - const utxos = await fetchUTXOs(d.address, esploraBaseUrl); + const utxos = await fetchUTXOs(d.address, esploraApis, signal); const sa: ScannedAddress = { derived: d, data, utxos }; used.push(sa); if (utxos.length > 0 || data.totalBalance > 0) withBalance.push(sa); @@ -154,18 +154,18 @@ async function scanChain( * results. * * @param account The derived HD account. - * @param esploraBaseUrl Esplora REST root, no trailing slash. + * @param esploraApis Ordered list of Esplora REST roots tried with failover. * @param signal Optional abort signal. */ export async function scanAccount( account: HdAccount, - esploraBaseUrl: string, + esploraApis: string[], signal?: AbortSignal, ): Promise { // Both chains in parallel — they're independent of each other. const [receive, change] = await Promise.all([ - scanChain(account, RECEIVE_CHAIN, esploraBaseUrl, signal), - scanChain(account, CHANGE_CHAIN, esploraBaseUrl, signal), + scanChain(account, RECEIVE_CHAIN, esploraApis, signal), + scanChain(account, CHANGE_CHAIN, esploraApis, signal), ]); const addressMap = new Map(); @@ -222,7 +222,7 @@ export interface HdTransaction { */ export async function fetchHdTransactions( result: AccountScanResult, - esploraBaseUrl: string, + esploraApis: string[], signal?: AbortSignal, ): Promise { const allUsed = [...result.receive.used, ...result.change.used]; @@ -233,7 +233,7 @@ export async function fetchHdTransactions( const perAddress = await Promise.all( allUsed.map(async (sa) => { signal?.throwIfAborted(); - const txs = await fetchTransactions(sa.derived.address, esploraBaseUrl); + const txs = await fetchTransactions(sa.derived.address, esploraApis, signal); return txs.map((tx) => ({ ...tx, // `fetchTransactions` returns Math.abs(net); recover the signed value. diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index b4732123..9698adf1 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -256,7 +256,15 @@ export const AppConfigSchema = z.object({ imageQuality: z.enum(['compressed', 'original']), curatorPubkey: z.string().regex(/^[0-9a-f]{64}$/i).optional(), sandboxDomain: z.string().optional(), - esploraBaseUrl: z.string().url(), + /** + * Ordered list of Esplora REST roots tried in failover order. Accepts the + * legacy single-string form and normalizes it to a one-element array so + * existing localStorage configs keep working. + */ + esploraApis: z.union([ + z.string().url().transform((s) => [s]), + z.array(z.string().url()).min(1), + ]), currencyDisplay: z.enum(['usd', 'sats']).optional(), sidebarWidgets: z.array(z.object({ id: z.string(), diff --git a/src/test/TestApp.tsx b/src/test/TestApp.tsx index 839e25d8..e04ac0d9 100644 --- a/src/test/TestApp.tsx +++ b/src/test/TestApp.tsx @@ -117,7 +117,7 @@ export function TestApp({ children }: TestAppProps) { autoplayVideos: false, imageQuality: 'compressed', sandboxDomain: 'iframe.diy', - esploraBaseUrl: 'https://mempool.space/api', + esploraApis: ['https://mempool.space/api'], sidebarWidgets: [], aiBaseURL: 'https://ai.shakespeare.diy/v1', aiApiKey: '',