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:
+5
-1
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
Reference in New Issue
Block a user