From 474ec6cc996a4ef94d8b2c9d4c09e66e2e82dfb6 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 13 May 2026 11:23:36 -0500 Subject: [PATCH] Make the Esplora API base URL configurable Hardcoded MEMPOOL_API constant in src/lib/bitcoin.ts becomes a baseUrl parameter on every fetch helper, sourced from a new `esploraBaseUrl` field on AppConfig (default `https://mempool.space/api`). The wallet, zap dialogs, on-chain zap verification, and NIP-73 Bitcoin tx/address pages now read the URL from useAppContext and pass it through, so self-hosted Esplora deployments (or Blockstream's) work without code changes. The mempool.space-specific `/v1/prices` extension is still appended by fetchBtcPrice. --- WALLET.md | 26 ++++---- package-lock.json | 4 +- src/App.tsx | 1 + src/components/OnchainZapContent.tsx | 15 +++-- src/components/SendBitcoinDialog.tsx | 13 ++-- src/components/ZapDialog.tsx | 7 ++- src/contexts/AppContext.ts | 7 +++ src/hooks/useBitcoinAddress.ts | 17 +++-- src/hooks/useBitcoinTx.ts | 17 +++-- src/hooks/useBitcoinWallet.ts | 18 +++--- src/hooks/useOnchainZap.ts | 9 ++- src/hooks/useOnchainZaps.ts | 23 +++++-- src/lib/bitcoin.ts | 94 +++++++++++++++++++--------- src/lib/schemas.ts | 1 + src/test/TestApp.tsx | 1 + 15 files changed, 168 insertions(+), 85 deletions(-) diff --git a/WALLET.md b/WALLET.md index fb2a7bce..19ae0aa4 100644 --- a/WALLET.md +++ b/WALLET.md @@ -91,15 +91,15 @@ bitcoin.initEccLib(ecc); ## Balance & Transaction APIs -All Bitcoin data is fetched from the public [mempool.space](https://mempool.space) Esplora-compatible API: +All Bitcoin data is fetched from an [Esplora](https://github.com/Blockstream/esplora/blob/master/API.md)-compatible REST API. The base URL is read from `AppConfig.esploraBaseUrl` (default: `https://mempool.space/api`) and can be overridden in `ditto.json` at build time or in Settings at runtime. Any Esplora-compatible backend works (mempool.space, Blockstream, self-hosted). -| Endpoint | Purpose | +| Endpoint (relative to `esploraBaseUrl`) | Purpose | |---|---| -| `GET https://mempool.space/api/address/{address}` | Balance stats (funded/spent sums, tx counts) | -| `GET https://mempool.space/api/address/{address}/txs` | Transaction history for an address | -| `GET https://mempool.space/api/tx/{txid}` | Full transaction detail (inputs, outputs, fee, block) | +| `GET /address/{address}` | Balance stats (funded/spent sums, tx counts) | +| `GET /address/{address}/txs` | Transaction history for an address | +| `GET /tx/{txid}` | Full transaction detail (inputs, outputs, fee, block) | -The wallet page polls balance and transaction data every 30 seconds. BTC/USD price is fetched from mempool.space every 60 seconds. +The wallet page polls balance and transaction data every 30 seconds. BTC/USD price is fetched every 60 seconds via `GET /v1/prices` — a mempool.space-specific extension that is not part of the standard Esplora REST surface. ## NIP-73 Integration @@ -140,9 +140,9 @@ The signer classes (`NSecSignerBtc`, `NBrowserSignerBtc`, `NConnectSignerBtc`) e The send flow constructs a standard Taproot (P2TR) key-path spend: -1. **Fetch UTXOs** -- All unspent outputs for the sender's address are retrieved from `mempool.space/api/address/{address}/utxo`. +1. **Fetch UTXOs** -- All unspent outputs for the sender's address are retrieved from `GET {esploraBaseUrl}/address/{address}/utxo`. -2. **Fetch fee rates** -- Recommended fee rates (sat/vB) for four confirmation targets are retrieved from `mempool.space/api/fee-estimates`: +2. **Fetch fee rates** -- Recommended fee rates (sat/vB) for four confirmation targets are retrieved from `GET {esploraBaseUrl}/fee-estimates`: | Speed | Block target | Typical wait | |---|---|---| @@ -170,7 +170,7 @@ The send flow constructs a standard Taproot (P2TR) key-path spend: For extension and bunker signers, the tweaking is handled by the external signer. -8. **Finalize and broadcast** -- The signed PSBT is finalized, the raw transaction hex is extracted, and POSTed to `mempool.space/api/tx`, which returns the txid on success. +8. **Finalize and broadcast** -- The signed PSBT is finalized, the raw transaction hex is extracted, and POSTed to `{esploraBaseUrl}/tx`, which returns the txid on success. ### User Flow @@ -182,11 +182,11 @@ The send dialog has three steps: ### Additional API Endpoints -| Endpoint | Purpose | +| Endpoint (relative to `esploraBaseUrl`) | Purpose | |---|---| -| `GET https://mempool.space/api/address/{address}/utxo` | Unspent transaction outputs | -| `GET https://mempool.space/api/fee-estimates` | Recommended fee rates by block target | -| `POST https://mempool.space/api/tx` | Broadcast signed transaction (raw hex body) | +| `GET /address/{address}/utxo` | Unspent transaction outputs | +| `GET /fee-estimates` | Recommended fee rates by block target | +| `POST /tx` | Broadcast signed transaction (raw hex body) | ### Dependencies (sending-specific) diff --git a/package-lock.json b/package-lock.json index ff190103..ac965080 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ditto", - "version": "2.14.3", + "version": "2.14.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ditto", - "version": "2.14.3", + "version": "2.14.4", "dependencies": { "@bitcoinerlab/secp256k1": "^1.2.0", "@capacitor/app": "^8.0.0", diff --git a/src/App.tsx b/src/App.tsx index 33d222ab..1b630fb6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -153,6 +153,7 @@ const hardcodedConfig: AppConfig = { imageQuality: 'compressed', curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d', sandboxDomain: 'iframe.diy', + esploraBaseUrl: 'https://mempool.space/api', sidebarWidgets: [ { id: 'trends' }, { id: 'hot-posts' }, diff --git a/src/components/OnchainZapContent.tsx b/src/components/OnchainZapContent.tsx index f258c258..880497d9 100644 --- a/src/components/OnchainZapContent.tsx +++ b/src/components/OnchainZapContent.tsx @@ -17,6 +17,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useBitcoinSigner } from '@/hooks/useBitcoinSigner'; import { useOnchainZap, type OnchainFeeSpeed } from '@/hooks/useOnchainZap'; import { useToast } from '@/hooks/useToast'; +import { useAppContext } from '@/hooks/useAppContext'; import { useNostrLogin } from '@nostrify/react/login'; import { nostrPubkeyToBitcoinAddress, @@ -94,6 +95,8 @@ export function OnchainZapContent({ target, onSuccess, onClose }: OnchainZapCont const { user } = useCurrentUser(); const { capability } = useBitcoinSigner(); const { logins } = useNostrLogin(); + const { config } = useAppContext(); + const { esploraBaseUrl } = config; const loginType = logins[0]?.type; const [usdAmount, setUsdAmount] = useState(5); @@ -114,21 +117,21 @@ export function OnchainZapContent({ target, onSuccess, onClose }: OnchainZapCont : ''; const { data: btcPrice } = useQuery({ - queryKey: ['btc-price'], - queryFn: fetchBtcPrice, + queryKey: ['btc-price', esploraBaseUrl], + queryFn: () => fetchBtcPrice(esploraBaseUrl), staleTime: 30_000, }); const { data: utxos } = useQuery({ - queryKey: ['bitcoin-utxos', senderAddress], - queryFn: () => fetchUTXOs(senderAddress), + queryKey: ['bitcoin-utxos', esploraBaseUrl, senderAddress], + queryFn: () => fetchUTXOs(senderAddress, esploraBaseUrl), enabled: !!senderAddress && capability !== 'unsupported', staleTime: 30_000, }); const { data: feeRates } = useQuery({ - queryKey: ['bitcoin-fee-rates'], - queryFn: getFeeRates, + queryKey: ['bitcoin-fee-rates', esploraBaseUrl], + queryFn: () => getFeeRates(esploraBaseUrl), enabled: capability !== 'unsupported', staleTime: 30_000, }); diff --git a/src/components/SendBitcoinDialog.tsx b/src/components/SendBitcoinDialog.tsx index fc5c226c..453c3f43 100644 --- a/src/components/SendBitcoinDialog.tsx +++ b/src/components/SendBitcoinDialog.tsx @@ -33,6 +33,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useBitcoinSigner } from '@/hooks/useBitcoinSigner'; import { useToast } from '@/hooks/useToast'; +import { useAppContext } from '@/hooks/useAppContext'; import { nostrPubkeyToBitcoinAddress, npubToBitcoinAddress, @@ -108,6 +109,8 @@ export function SendBitcoinDialog({ isOpen, onClose, btcPrice }: SendBitcoinDial const { user } = useCurrentUser(); const { canSignPsbt, signPsbt } = useBitcoinSigner(); const { toast } = useToast(); + const { config } = useAppContext(); + const { esploraBaseUrl } = config; const queryClient = useQueryClient(); // Form state @@ -126,15 +129,15 @@ export function SendBitcoinDialog({ isOpen, onClose, btcPrice }: SendBitcoinDial // ── Data fetching ────────────────────────────────────────────── const { data: utxos, isLoading: isLoadingUtxos } = useQuery({ - queryKey: ['bitcoin-utxos', senderAddress], - queryFn: () => fetchUTXOs(senderAddress), + queryKey: ['bitcoin-utxos', esploraBaseUrl, senderAddress], + queryFn: () => fetchUTXOs(senderAddress, esploraBaseUrl), enabled: !!senderAddress && isOpen, staleTime: 30_000, }); const { data: feeRates, isLoading: isLoadingFees } = useQuery({ - queryKey: ['bitcoin-fee-rates'], - queryFn: getFeeRates, + queryKey: ['bitcoin-fee-rates', esploraBaseUrl], + queryFn: () => getFeeRates(esploraBaseUrl), enabled: isOpen, staleTime: 30_000, }); @@ -202,7 +205,7 @@ export function SendBitcoinDialog({ isOpen, onClose, btcPrice }: SendBitcoinDial // 3. Finalize and extract raw tx const txHex = finalizePsbt(signedHex); - const id = await broadcastTransaction(txHex); + const id = await broadcastTransaction(txHex, esploraBaseUrl); return { txId: id, fee }; }, onSuccess: ({ txId: id, fee }) => { diff --git a/src/components/ZapDialog.tsx b/src/components/ZapDialog.tsx index 7c1f5570..ca159cf0 100644 --- a/src/components/ZapDialog.tsx +++ b/src/components/ZapDialog.tsx @@ -23,6 +23,7 @@ import { useBitcoinSigner } from '@/hooks/useBitcoinSigner'; import { useToast } from '@/hooks/useToast'; import { useZaps } from '@/hooks/useZaps'; import { useWallet } from '@/hooks/useWallet'; +import { useAppContext } from '@/hooks/useAppContext'; import { canZap } from '@/lib/canZap'; import { fetchBtcPrice, @@ -287,6 +288,8 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) { const { data: author } = useAuthor(target.pubkey); const { toast } = useToast(); const { webln, activeNWC } = useWallet(); + const { config } = useAppContext(); + const { esploraBaseUrl } = config; // Success state: populated by either zap rail's onSuccess callback. // When set, we replace the tab UI with . @@ -320,8 +323,8 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) { const amountInputRef = useRef(null); const { data: btcPrice } = useQuery({ - queryKey: ['btc-price'], - queryFn: fetchBtcPrice, + queryKey: ['btc-price', esploraBaseUrl], + queryFn: () => fetchBtcPrice(esploraBaseUrl), staleTime: 30_000, }); diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index 41a90962..e81edbf9 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -278,6 +278,13 @@ export interface AppConfig { curatorPubkey?: string; /** 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". + */ + esploraBaseUrl: string; /** Ordered list of right sidebar widget configs. Each entry is a widget type ID with optional display settings. */ sidebarWidgets: WidgetConfig[]; } diff --git a/src/hooks/useBitcoinAddress.ts b/src/hooks/useBitcoinAddress.ts index 56830f40..754f0b25 100644 --- a/src/hooks/useBitcoinAddress.ts +++ b/src/hooks/useBitcoinAddress.ts @@ -1,22 +1,27 @@ import { useQuery } from '@tanstack/react-query'; import { fetchAddressDetail, fetchBtcPrice } from '@/lib/bitcoin'; +import { useAppContext } from '@/hooks/useAppContext'; /** - * Fetch full address details (balance + recent txs) via the mempool.space API. - * Also fetches the current BTC/USD price for display. + * Fetch full address details (balance + recent txs) via the configured + * Esplora-compatible API (default: mempool.space). Also fetches the + * current BTC/USD price for display. */ export function useBitcoinAddress(address: string) { + const { config } = useAppContext(); + const { esploraBaseUrl } = config; + const { data: addressDetail, isLoading, error, refetch } = useQuery({ - queryKey: ['bitcoin-address-detail', address], - queryFn: () => fetchAddressDetail(address), + queryKey: ['bitcoin-address-detail', esploraBaseUrl, address], + queryFn: () => fetchAddressDetail(address, esploraBaseUrl), enabled: !!address, refetchInterval: 30_000, }); const { data: btcPrice } = useQuery({ - queryKey: ['btc-price'], - queryFn: fetchBtcPrice, + queryKey: ['btc-price', esploraBaseUrl], + queryFn: () => fetchBtcPrice(esploraBaseUrl), refetchInterval: 60_000, staleTime: 30_000, }); diff --git a/src/hooks/useBitcoinTx.ts b/src/hooks/useBitcoinTx.ts index 6063e423..7370faaf 100644 --- a/src/hooks/useBitcoinTx.ts +++ b/src/hooks/useBitcoinTx.ts @@ -1,22 +1,27 @@ import { useQuery } from '@tanstack/react-query'; import { fetchTxDetail, fetchBtcPrice } from '@/lib/bitcoin'; +import { useAppContext } from '@/hooks/useAppContext'; /** - * Fetch full transaction details for a Bitcoin txid via the mempool.space API. - * Also fetches the current BTC/USD price for display. + * Fetch full transaction details for a Bitcoin txid via the configured + * Esplora-compatible API (default: mempool.space). Also fetches the + * current BTC/USD price for display. */ export function useBitcoinTx(txid: string) { + const { config } = useAppContext(); + const { esploraBaseUrl } = config; + const { data: tx, isLoading, error } = useQuery({ - queryKey: ['bitcoin-tx-detail', txid], - queryFn: () => fetchTxDetail(txid), + queryKey: ['bitcoin-tx-detail', esploraBaseUrl, txid], + queryFn: () => fetchTxDetail(txid, esploraBaseUrl), enabled: !!txid, staleTime: 60_000, }); const { data: btcPrice } = useQuery({ - queryKey: ['btc-price'], - queryFn: fetchBtcPrice, + queryKey: ['btc-price', esploraBaseUrl], + queryFn: () => fetchBtcPrice(esploraBaseUrl), refetchInterval: 60_000, staleTime: 30_000, }); diff --git a/src/hooks/useBitcoinWallet.ts b/src/hooks/useBitcoinWallet.ts index 2d7bf0b1..8342a109 100644 --- a/src/hooks/useBitcoinWallet.ts +++ b/src/hooks/useBitcoinWallet.ts @@ -2,17 +2,21 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useAppContext } from '@/hooks/useAppContext'; import { nostrPubkeyToBitcoinAddress, fetchAddressData, fetchBtcPrice, fetchTransactions } from '@/lib/bitcoin'; /** * Hook that derives a Bitcoin Taproot address from the current user's Nostr - * pubkey and fetches the on-chain balance from the Blockstream API. + * pubkey and fetches the on-chain balance from the configured Esplora-compatible + * API (default: mempool.space). * * Balance auto-refreshes every 30 seconds while the component is mounted. * BTC/USD price refreshes every 60 seconds. */ export function useBitcoinWallet() { const { user } = useCurrentUser(); + const { config } = useAppContext(); + const { esploraBaseUrl } = config; const bitcoinAddress = useMemo(() => { if (!user) return ''; @@ -25,15 +29,15 @@ export function useBitcoinWallet() { error, refetch, } = useQuery({ - queryKey: ['bitcoin-balance', bitcoinAddress], - queryFn: () => fetchAddressData(bitcoinAddress), + queryKey: ['bitcoin-balance', esploraBaseUrl, bitcoinAddress], + queryFn: () => fetchAddressData(bitcoinAddress, esploraBaseUrl), enabled: !!bitcoinAddress, refetchInterval: 30_000, }); const { data: btcPrice } = useQuery({ - queryKey: ['btc-price'], - queryFn: fetchBtcPrice, + queryKey: ['btc-price', esploraBaseUrl], + queryFn: () => fetchBtcPrice(esploraBaseUrl), refetchInterval: 60_000, staleTime: 30_000, }); @@ -42,8 +46,8 @@ export function useBitcoinWallet() { data: transactions, isLoading: isLoadingTxs, } = useQuery({ - queryKey: ['bitcoin-txs', bitcoinAddress], - queryFn: () => fetchTransactions(bitcoinAddress), + queryKey: ['bitcoin-txs', esploraBaseUrl, bitcoinAddress], + queryFn: () => fetchTransactions(bitcoinAddress, esploraBaseUrl), enabled: !!bitcoinAddress, refetchInterval: 30_000, }); diff --git a/src/hooks/useOnchainZap.ts b/src/hooks/useOnchainZap.ts index 06f9ff0a..35e17e9f 100644 --- a/src/hooks/useOnchainZap.ts +++ b/src/hooks/useOnchainZap.ts @@ -6,6 +6,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useBitcoinSigner, isSignerCapabilityError, reportSignerUnsupported } from '@/hooks/useBitcoinSigner'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useToast } from '@/hooks/useToast'; +import { useAppContext } from '@/hooks/useAppContext'; import { notificationSuccess } from '@/lib/haptics'; import { nostrPubkeyToBitcoinAddress, @@ -72,6 +73,8 @@ export function useOnchainZap( const { canSignPsbt, signPsbt } = useBitcoinSigner(); const { mutateAsync: publishEvent } = useNostrPublish(); const { toast } = useToast(); + const { config } = useAppContext(); + const { esploraBaseUrl } = config; const queryClient = useQueryClient(); const [isZapping, setIsZapping] = useState(false); @@ -101,8 +104,8 @@ export function useOnchainZap( // Fetch UTXOs and fee rates const [utxos, rates] = await Promise.all([ - fetchUTXOs(senderAddress), - getFeeRates(), + fetchUTXOs(senderAddress, esploraBaseUrl), + getFeeRates(esploraBaseUrl), ]); if (utxos.length === 0) { @@ -134,7 +137,7 @@ export function useOnchainZap( // Broadcast setProgress('broadcasting'); - const txid = await broadcastTransaction(txHex); + const txid = await broadcastTransaction(txHex, esploraBaseUrl); // Publish kind 8333 event setProgress('publishing'); diff --git a/src/hooks/useOnchainZaps.ts b/src/hooks/useOnchainZaps.ts index 661aa528..b90e5273 100644 --- a/src/hooks/useOnchainZaps.ts +++ b/src/hooks/useOnchainZaps.ts @@ -3,6 +3,7 @@ import { useNostr } from '@nostrify/react'; import type { NostrEvent } from '@nostrify/nostrify'; import { fetchTxDetail, nostrPubkeyToBitcoinAddress } from '@/lib/bitcoin'; +import { useAppContext } from '@/hooks/useAppContext'; /** A single verified on-chain zap, with the amount that actually paid the recipient on-chain. */ export interface OnchainZapEntry { /** The kind 8333 event. */ @@ -57,8 +58,14 @@ export function extractOnchainZapRecipient(event: NostrEvent): string { * * A verified amount of 0 means the transaction exists but does not pay * the claimed 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. */ -export async function verifyOnchainZap(event: NostrEvent): Promise { +export async function verifyOnchainZap( + event: NostrEvent, + esploraBaseUrl: string, +): Promise { const txid = extractOnchainZapTxid(event); const recipientPubkey = extractOnchainZapRecipient(event); if (!txid || !recipientPubkey) return null; @@ -73,7 +80,7 @@ export async function verifyOnchainZap(event: NostrEvent): Promise= 30000 && target.kind < 40000; const dTag = isAddressable ? target.tags.find(([n]) => n === 'd')?.[1] ?? '' @@ -155,8 +164,8 @@ export function useOnchainZaps(target: NostrEvent | undefined) { const events = eventsQuery.data ?? []; const verifications = useQueries({ queries: events.map((event) => ({ - queryKey: ['onchain-zaps', 'verify', extractOnchainZapTxid(event), extractOnchainZapRecipient(event)], - queryFn: () => verifyOnchainZap(event), + queryKey: ['onchain-zaps', 'verify', esploraBaseUrl, extractOnchainZapTxid(event), extractOnchainZapRecipient(event)], + queryFn: () => verifyOnchainZap(event, esploraBaseUrl), staleTime: 60_000, })), }); @@ -189,12 +198,14 @@ export function useOnchainZaps(target: NostrEvent | undefined) { * (invalid tx, wrong recipient, self-zap, etc.), or the entry. */ export function useVerifiedOnchainZap(event: NostrEvent | undefined): OnchainZapEntry | null | undefined { + const { config } = useAppContext(); + const { esploraBaseUrl } = config; const txid = event ? extractOnchainZapTxid(event) : null; const recipient = event ? extractOnchainZapRecipient(event) : ''; const { data } = useQuery({ - queryKey: ['onchain-zaps', 'verify', txid, recipient], - queryFn: () => verifyOnchainZap(event!), + queryKey: ['onchain-zaps', 'verify', esploraBaseUrl, txid, recipient], + queryFn: () => verifyOnchainZap(event!, esploraBaseUrl), enabled: !!event && !!txid && !!recipient, staleTime: 60_000, }); diff --git a/src/lib/bitcoin.ts b/src/lib/bitcoin.ts index 0c958a12..8c24aea3 100644 --- a/src/lib/bitcoin.ts +++ b/src/lib/bitcoin.ts @@ -8,9 +8,6 @@ import { ECPairFactory, type ECPairAPI } from 'ecpair'; // Constants // --------------------------------------------------------------------------- -/** Base URL for the mempool.space Esplora-compatible REST API. */ -const MEMPOOL_API = 'https://mempool.space/api'; - /** Standard Bitcoin dust limit in satoshis. */ const DUST_LIMIT = 546; @@ -108,11 +105,14 @@ export interface AddressData { } /** - * Fetch balance and transaction stats for a Bitcoin address from the - * mempool.space Esplora API. + * Fetch balance and transaction stats for a Bitcoin address from an + * 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`). */ -export async function fetchAddressData(address: string): Promise { - const response = await fetch(`${MEMPOOL_API}/address/${address}`); +export async function fetchAddressData(address: string, baseUrl: string): Promise { + const response = await fetch(`${baseUrl}/address/${address}`); if (!response.ok) { throw new Error('Failed to fetch balance'); @@ -156,9 +156,17 @@ export function formatSats(sats: number): string { return sats.toLocaleString(); } -/** Fetch the current BTC price in USD from the mempool.space API. */ -export async function fetchBtcPrice(): Promise { - const response = await fetch(`${MEMPOOL_API}/v1/prices`); +/** + * Fetch the current BTC price in USD from a mempool.space-compatible API. + * + * 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. + * + * @param baseUrl Esplora REST root, no trailing slash (e.g. `https://mempool.space/api`). + */ +export async function fetchBtcPrice(baseUrl: string): Promise { + const response = await fetch(`${baseUrl}/v1/prices`); if (!response.ok) { throw new Error('Failed to fetch BTC price'); @@ -222,11 +230,14 @@ export interface Transaction { } /** - * Fetch transactions for a Bitcoin address from the mempool.space Esplora API. + * Fetch transactions for a Bitcoin address from an Esplora-compatible API. * 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. */ -export async function fetchTransactions(address: string): Promise { - const response = await fetch(`${MEMPOOL_API}/address/${address}/txs`); +export async function fetchTransactions(address: string, baseUrl: string): Promise { + const response = await fetch(`${baseUrl}/address/${address}/txs`); if (!response.ok) { throw new Error('Failed to fetch transactions'); @@ -309,9 +320,14 @@ export interface TxDetail { totalOutput: number; } -/** Fetch full transaction details from mempool.space. */ -export async function fetchTxDetail(txid: string): Promise { - const response = await fetch(`${MEMPOOL_API}/tx/${txid}`); +/** + * Fetch full transaction details from an Esplora-compatible API. + * + * @param txid The transaction ID (hex). + * @param baseUrl Esplora REST root, no trailing slash. + */ +export async function fetchTxDetail(txid: string, baseUrl: string): Promise { + const response = await fetch(`${baseUrl}/tx/${txid}`); if (!response.ok) throw new Error('Failed to fetch transaction'); const tx = await response.json(); @@ -383,11 +399,16 @@ export interface AddressDetail { recentTxs: Transaction[]; } -/** Fetch full address details (balance + recent txs) from mempool.space. */ -export async function fetchAddressDetail(address: string): Promise { +/** + * 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. + */ +export async function fetchAddressDetail(address: string, baseUrl: string): Promise { const [addrData, txs] = await Promise.all([ - fetchAddressData(address), - fetchTransactions(address), + fetchAddressData(address, baseUrl), + fetchTransactions(address, baseUrl), ]); return { @@ -415,9 +436,14 @@ export interface UTXO { }; } -/** Fetch UTXOs for a Bitcoin address from mempool.space. */ -export async function fetchUTXOs(address: string): Promise { - const response = await fetch(`${MEMPOOL_API}/address/${address}/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. + */ +export async function fetchUTXOs(address: string, baseUrl: string): Promise { + const response = await fetch(`${baseUrl}/address/${address}/utxo`); if (!response.ok) throw new Error('Failed to fetch UTXOs'); return response.json(); } @@ -436,9 +462,13 @@ export interface FeeRates { minimumFee: number; } -/** Fetch recommended fee rates (sat/vB) from mempool.space. */ -export async function getFeeRates(): Promise { - const response = await fetch(`${MEMPOOL_API}/fee-estimates`); +/** + * Fetch recommended fee rates (sat/vB) from an Esplora-compatible API. + * + * @param baseUrl Esplora REST root, no trailing slash. + */ +export async function getFeeRates(baseUrl: string): Promise { + const response = await fetch(`${baseUrl}/fee-estimates`); if (!response.ok) throw new Error('Failed to fetch fee estimates'); const data = await response.json(); @@ -477,9 +507,15 @@ export function validateBitcoinAddress(address: string): boolean { } } -/** Broadcast a signed transaction hex to the Bitcoin network via mempool.space. Returns the txid. */ -export async function broadcastTransaction(txHex: string): Promise { - const response = await fetch(`${MEMPOOL_API}/tx`, { +/** + * Broadcast a signed transaction hex to the Bitcoin network via an + * Esplora-compatible API. Returns the txid. + * + * @param txHex The signed transaction hex. + * @param baseUrl Esplora REST root, no trailing slash. + */ +export async function broadcastTransaction(txHex: string, baseUrl: string): Promise { + const response = await fetch(`${baseUrl}/tx`, { method: 'POST', body: txHex, }); diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 44efaf57..37d0be47 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -261,6 +261,7 @@ 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(), sidebarWidgets: z.array(z.object({ id: z.string(), height: z.number().optional(), diff --git a/src/test/TestApp.tsx b/src/test/TestApp.tsx index 9063256a..ff3aa5f3 100644 --- a/src/test/TestApp.tsx +++ b/src/test/TestApp.tsx @@ -122,6 +122,7 @@ export function TestApp({ children }: TestAppProps) { autoplayVideos: false, imageQuality: 'compressed', sandboxDomain: 'iframe.diy', + esploraBaseUrl: 'https://mempool.space/api', sidebarWidgets: [], };