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.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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<number | string>(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,
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 <ZapSuccessScreen />.
|
||||
@@ -320,8 +323,8 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||
const amountInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: btcPrice } = useQuery({
|
||||
queryKey: ['btc-price'],
|
||||
queryFn: fetchBtcPrice,
|
||||
queryKey: ['btc-price', esploraBaseUrl],
|
||||
queryFn: () => fetchBtcPrice(esploraBaseUrl),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<OnchainZapEntry | null> {
|
||||
export async function verifyOnchainZap(
|
||||
event: NostrEvent,
|
||||
esploraBaseUrl: string,
|
||||
): Promise<OnchainZapEntry | null> {
|
||||
const txid = extractOnchainZapTxid(event);
|
||||
const recipientPubkey = extractOnchainZapRecipient(event);
|
||||
if (!txid || !recipientPubkey) return null;
|
||||
@@ -73,7 +80,7 @@ export async function verifyOnchainZap(event: NostrEvent): Promise<OnchainZapEnt
|
||||
|
||||
let detail;
|
||||
try {
|
||||
detail = await fetchTxDetail(txid);
|
||||
detail = await fetchTxDetail(txid, esploraBaseUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -107,6 +114,8 @@ export async function verifyOnchainZap(event: NostrEvent): Promise<OnchainZapEnt
|
||||
*/
|
||||
export function useOnchainZaps(target: NostrEvent | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
const { config } = useAppContext();
|
||||
const { esploraBaseUrl } = config;
|
||||
const isAddressable = target && target.kind >= 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,
|
||||
});
|
||||
|
||||
+65
-29
@@ -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<AddressData> {
|
||||
const response = await fetch(`${MEMPOOL_API}/address/${address}`);
|
||||
export async function fetchAddressData(address: string, baseUrl: string): Promise<AddressData> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<Transaction[]> {
|
||||
const response = await fetch(`${MEMPOOL_API}/address/${address}/txs`);
|
||||
export async function fetchTransactions(address: string, baseUrl: string): Promise<Transaction[]> {
|
||||
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<TxDetail> {
|
||||
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<TxDetail> {
|
||||
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<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.
|
||||
*/
|
||||
export async function fetchAddressDetail(address: string, baseUrl: string): Promise<AddressDetail> {
|
||||
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<UTXO[]> {
|
||||
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<UTXO[]> {
|
||||
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<FeeRates> {
|
||||
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<FeeRates> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
const response = await fetch(`${baseUrl}/tx`, {
|
||||
method: 'POST',
|
||||
body: txHex,
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -122,6 +122,7 @@ export function TestApp({ children }: TestAppProps) {
|
||||
autoplayVideos: false,
|
||||
imageQuality: 'compressed',
|
||||
sandboxDomain: 'iframe.diy',
|
||||
esploraBaseUrl: 'https://mempool.space/api',
|
||||
sidebarWidgets: [],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user