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.
This commit is contained in:
Alex Gleason
2026-05-21 13:17:56 -05:00
parent 522c265041
commit b0561a5503
21 changed files with 514 additions and 117 deletions
+5 -1
View File
@@ -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' },
+10 -10
View File
@@ -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,
});
+4 -4
View File
@@ -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 };
},
+7 -7
View File
@@ -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<number | string>(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,
});
+6 -6
View File
@@ -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
+3 -3
View File
@@ -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 <ZapSuccessScreen />.
@@ -323,8 +323,8 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
const amountInputRef = useRef<HTMLInputElement>(null);
const { data: btcPrice } = useQuery({
queryKey: ['btc-price', esploraBaseUrl],
queryFn: () => fetchBtcPrice(esploraBaseUrl),
queryKey: ['btc-price', esploraApis],
queryFn: ({ signal }) => fetchBtcPrice(esploraApis, signal),
staleTime: 30_000,
});
+19 -5
View File
@@ -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.
+5 -5
View File
@@ -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,
});
+5 -5
View File
@@ -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,
});
+7 -7
View File
@@ -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,
});
+3 -3
View File
@@ -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,
});
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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.
+5 -5
View File
@@ -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<HdTransaction[]>({
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,
+4 -4
View File
@@ -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');
+11 -9
View File
@@ -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<OnchainZapEntry | null> {
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,
});
+73 -26
View File
@@ -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<AddressData> {
const response = await fetch(`${baseUrl}/address/${address}`);
export async function fetchAddressData(
address: string,
baseUrls: string[],
signal?: AbortSignal,
): Promise<AddressData> {
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<number> {
const response = await fetch(`${baseUrl}/v1/prices`);
export async function fetchBtcPrice(baseUrls: string[], signal?: AbortSignal): Promise<number> {
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<Transaction[]> {
const response = await fetch(`${baseUrl}/address/${address}/txs`);
export async function fetchTransactions(
address: string,
baseUrls: string[],
signal?: AbortSignal,
): Promise<Transaction[]> {
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<TxDetail> {
const response = await fetch(`${baseUrl}/tx/${txid}`);
export async function fetchTxDetail(
txid: string,
baseUrls: string[],
signal?: AbortSignal,
): Promise<TxDetail> {
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<AddressDetail> {
export async function fetchAddressDetail(
address: string,
baseUrls: string[],
signal?: AbortSignal,
): Promise<AddressDetail> {
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<UTXO[]> {
const response = await fetch(`${baseUrl}/address/${address}/utxo`);
export async function fetchUTXOs(
address: string,
baseUrls: string[],
signal?: AbortSignal,
): Promise<UTXO[]> {
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<FeeRates> {
const response = await fetch(`${baseUrl}/fee-estimates`);
export async function getFeeRates(baseUrls: string[], signal?: AbortSignal): Promise<FeeRates> {
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<string> {
const response = await fetch(`${baseUrl}/tx`, {
export async function broadcastTransaction(
txHex: string,
baseUrls: string[],
signal?: AbortSignal,
): Promise<string> {
const response = await esploraFetch(baseUrls, `/tx`, {
method: 'POST',
body: txHex,
signal,
});
if (!response.ok) {
+322
View File
@@ -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<number>([
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<string, EndpointState>();
/** 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<RequestInit, 'signal'> {
/**
* 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<Response> {
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();
}
+9 -9
View File
@@ -83,7 +83,7 @@ function isUsed(data: AddressData): boolean {
async function scanChain(
account: HdAccount,
chain: 0 | 1,
esploraBaseUrl: string,
esploraApis: string[],
signal?: AbortSignal,
): Promise<ChainScanResult> {
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<AccountScanResult> {
// 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<string, DerivedAddress>();
@@ -222,7 +222,7 @@ export interface HdTransaction {
*/
export async function fetchHdTransactions(
result: AccountScanResult,
esploraBaseUrl: string,
esploraApis: string[],
signal?: AbortSignal,
): Promise<HdTransaction[]> {
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.
+9 -1
View File
@@ -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(),
+1 -1
View File
@@ -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: '',