Add NIP-73 Bitcoin transaction and address detail pages

Integrate Bitcoin content into the /i/* external content system using NIP-73
identifiers (bitcoin:tx:{txid} and bitcoin:address:{address}).

- Add bitcoin-tx and bitcoin-address types to ExternalContent parser
- Create BitcoinTxHeader with mempool.space-style inputs/outputs flow view
- Create BitcoinAddressHeader with balance, stats, and recent transactions
- Add useBitcoinTx and useBitcoinAddress hooks (mempool.space Esplora API)
- Switch all Bitcoin API calls from blockstream.info to mempool.space
- Update WalletPage to link transactions to /i/bitcoin:tx:{txid} pages
- Remove unused blockExplorerAddress/blockExplorerTx config fields
- Add compact Bitcoin previews for embedded note contexts
This commit is contained in:
Alex Gleason
2026-04-06 20:58:17 -05:00
parent 773592f9dd
commit e74cd1efbb
13 changed files with 826 additions and 30 deletions
+17 -6
View File
@@ -89,15 +89,26 @@ import * as ecc from '@bitcoinerlab/secp256k1';
bitcoin.initEccLib(ecc);
```
## Balance API
## Balance & Transaction APIs
Balance data is fetched from the public Blockstream Esplora API:
All Bitcoin data is fetched from the public [mempool.space](https://mempool.space) Esplora-compatible API:
```
GET https://blockstream.info/api/address/{address}
```
| Endpoint | 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) |
Returns confirmed and mempool stats (funded/spent sums, transaction counts). The wallet page polls this endpoint every 30 seconds.
The wallet page polls balance and transaction data every 30 seconds. BTC/USD price is fetched from CoinGecko every 60 seconds.
## NIP-73 Integration
Transaction and address detail pages use [NIP-73](https://github.com/nostr-protocol/nips/blob/master/73.md) external content identifiers, enabling Nostr comments and reactions on Bitcoin transactions and addresses:
- **Transaction pages**: `/i/bitcoin:tx:{txid}` -- renders a mempool.space-style transaction view with inputs, outputs, fee, block info, and USD values
- **Address pages**: `/i/bitcoin:address:{address}` -- renders balance, recent transactions, and total received/sent
These pages are part of the existing `/i/*` external content system, which also supports URLs, ISBNs, country codes, and other NIP-73 identifier types.
## Security Considerations
-2
View File
@@ -149,8 +149,6 @@ const hardcodedConfig: AppConfig = {
plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || "",
savedFeeds: [],
imageQuality: 'compressed',
blockExplorerAddress: 'https://mempool.space/address/{address}',
blockExplorerTx: 'https://mempool.space/tx/{txid}',
};
/**
+567
View File
@@ -0,0 +1,567 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
ArrowDownLeft,
ArrowRight,
ArrowUpRight,
Bitcoin,
Check,
Clock,
Copy,
ExternalLink,
Hash,
Layers,
RefreshCw,
Weight,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useBitcoinTx } from '@/hooks/useBitcoinTx';
import { useBitcoinAddress } from '@/hooks/useBitcoinAddress';
import { satsToBTC, satsToUSD, formatSats } from '@/lib/bitcoin';
import type { TxDetail, TxInput, TxOutput } from '@/lib/bitcoin';
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
function truncateMiddle(str: string, startLen = 8, endLen = 8): string {
if (str.length <= startLen + endLen + 3) return str;
return `${str.slice(0, startLen)}...${str.slice(-endLen)}`;
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// clipboard not available
}
};
return (
<button
onClick={handleCopy}
className="p-1 rounded hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground cursor-pointer"
title="Copy"
>
{copied ? <Check className="size-3.5 text-green-500" /> : <Copy className="size-3.5" />}
</button>
);
}
/** Format a unix timestamp as a readable date string. */
function formatBlockTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true,
});
}
/** Format BTC amount, stripping trailing zeros. */
function formatBTC(sats: number): string {
return satsToBTC(sats).replace(/\.?0+$/, '');
}
/** Format a large number with locale separators. */
function formatNumber(n: number): string {
return n.toLocaleString();
}
// ---------------------------------------------------------------------------
// Bitcoin Transaction Header
// ---------------------------------------------------------------------------
export function BitcoinTxHeader({ txid }: { txid: string }) {
const { tx, btcPrice, isLoading, error } = useBitcoinTx(txid);
if (isLoading) return <TxSkeleton />;
if (error || !tx) {
return (
<div className="rounded-2xl border border-border p-6 text-center space-y-3">
<Bitcoin className="size-10 mx-auto text-muted-foreground/40" />
<p className="text-sm text-destructive">Failed to load transaction</p>
<p className="text-xs text-muted-foreground font-mono break-all">{txid}</p>
</div>
);
}
return (
<div className="rounded-2xl border border-border overflow-hidden">
{/* Header */}
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center size-10 rounded-full ${
tx.confirmed
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'bg-orange-500/10 text-orange-600 dark:text-orange-400'
}`}>
{tx.confirmed ? <Check className="size-5" /> : <Clock className="size-5" />}
</div>
<div>
<h2 className="text-lg font-bold">
{tx.confirmed ? 'Confirmed' : 'Unconfirmed'}
</h2>
{tx.blockTime && (
<p className="text-sm text-muted-foreground">{formatBlockTime(tx.blockTime)}</p>
)}
</div>
</div>
{/* Transaction ID */}
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Transaction ID</p>
<div className="flex items-center gap-2">
<p className="text-sm font-mono text-foreground break-all">{tx.txid}</p>
<CopyButton text={tx.txid} />
</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3">
{tx.confirmed && tx.blockHeight !== undefined && (
<StatCard icon={<Layers className="size-3.5" />} label="Block" value={formatNumber(tx.blockHeight)} />
)}
<StatCard icon={<Weight className="size-3.5" />} label="Size" value={`${formatNumber(tx.weight / 4)} vB`} />
<StatCard
icon={<Bitcoin className="size-3.5" />}
label="Fee"
value={`${formatSats(tx.fee)} sat`}
subtitle={`${(tx.fee / (tx.weight / 4)).toFixed(1)} sat/vB`}
/>
<StatCard
icon={<Hash className="size-3.5" />}
label="Amount"
value={`${formatBTC(tx.totalOutput)} BTC`}
subtitle={btcPrice ? satsToUSD(tx.totalOutput, btcPrice) : undefined}
/>
</div>
</div>
{/* Inputs → Outputs flow */}
<div className="border-t border-border">
<TxFlow tx={tx} btcPrice={btcPrice} />
</div>
{/* Footer: link to mempool.space */}
<div className="border-t border-border px-5 py-2.5">
<a
href={`https://mempool.space/tx/${txid}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Bitcoin className="size-3.5" />
<span>View on mempool.space</span>
<ExternalLink className="size-3" />
</a>
</div>
</div>
);
}
function StatCard({ icon, label, value, subtitle }: { icon: React.ReactNode; label: string; value: string; subtitle?: string }) {
return (
<div className="rounded-xl bg-secondary/40 px-3.5 py-2.5 space-y-0.5">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{icon}
<span>{label}</span>
</div>
<p className="text-sm font-semibold">{value}</p>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
</div>
);
}
/** Inputs → Outputs visualization, mempool.space-style. */
function TxFlow({ tx, btcPrice }: { tx: TxDetail; btcPrice?: number }) {
return (
<div className="p-4 space-y-3">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
<span>{tx.inputs.length} Input{tx.inputs.length !== 1 ? 's' : ''}</span>
<ArrowRight className="size-3" />
<span>{tx.outputs.length} Output{tx.outputs.length !== 1 ? 's' : ''}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Inputs */}
<div className="space-y-1.5">
{tx.inputs.slice(0, 10).map((input, i) => (
<TxInputRow key={`${input.txid}-${input.vout}-${i}`} input={input} btcPrice={btcPrice} />
))}
{tx.inputs.length > 10 && (
<p className="text-xs text-muted-foreground text-center py-1">
+{tx.inputs.length - 10} more input{tx.inputs.length - 10 !== 1 ? 's' : ''}
</p>
)}
</div>
{/* Outputs */}
<div className="space-y-1.5">
{tx.outputs.slice(0, 10).map((output, i) => (
<TxOutputRow key={`${output.address ?? 'op_return'}-${i}`} output={output} btcPrice={btcPrice} />
))}
{tx.outputs.length > 10 && (
<p className="text-xs text-muted-foreground text-center py-1">
+{tx.outputs.length - 10} more output{tx.outputs.length - 10 !== 1 ? 's' : ''}
</p>
)}
</div>
</div>
</div>
);
}
function TxInputRow({ input, btcPrice }: { input: TxInput; btcPrice?: number }) {
if (input.isCoinbase) {
return (
<div className="rounded-lg bg-amber-500/5 border border-amber-500/20 px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">Coinbase</span>
<span className="text-xs font-mono">{formatBTC(input.value)} BTC</span>
</div>
</div>
);
}
return (
<div className="rounded-lg bg-red-500/5 border border-red-500/10 px-3 py-2 space-y-0.5">
<div className="flex items-center justify-between gap-2">
{input.address ? (
<Link
to={`/i/bitcoin:address:${input.address}`}
className="text-xs font-mono text-red-600 dark:text-red-400 hover:underline truncate"
>
{truncateMiddle(input.address, 10, 6)}
</Link>
) : (
<span className="text-xs text-muted-foreground">Unknown</span>
)}
<span className="text-xs font-mono shrink-0">{formatBTC(input.value)} BTC</span>
</div>
{btcPrice !== undefined && (
<p className="text-[10px] text-muted-foreground text-right">{satsToUSD(input.value, btcPrice)}</p>
)}
</div>
);
}
function TxOutputRow({ output, btcPrice }: { output: TxOutput; btcPrice?: number }) {
const isOpReturn = output.scriptpubkeyType === 'op_return';
if (isOpReturn) {
return (
<div className="rounded-lg bg-secondary/60 border border-border/50 px-3 py-2">
<span className="text-xs text-muted-foreground">OP_RETURN</span>
</div>
);
}
return (
<div className="rounded-lg bg-green-500/5 border border-green-500/10 px-3 py-2 space-y-0.5">
<div className="flex items-center justify-between gap-2">
{output.address ? (
<Link
to={`/i/bitcoin:address:${output.address}`}
className="text-xs font-mono text-green-600 dark:text-green-400 hover:underline truncate"
>
{truncateMiddle(output.address, 10, 6)}
</Link>
) : (
<span className="text-xs text-muted-foreground">Unknown</span>
)}
<span className="text-xs font-mono shrink-0">{formatBTC(output.value)} BTC</span>
</div>
{btcPrice !== undefined && (
<p className="text-[10px] text-muted-foreground text-right">{satsToUSD(output.value, btcPrice)}</p>
)}
</div>
);
}
function TxSkeleton() {
return (
<div className="rounded-2xl border border-border overflow-hidden">
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-3.5 w-40" />
</div>
</div>
<div className="space-y-1.5">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-4 w-full" />
</div>
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
</div>
</div>
<div className="border-t border-border p-4 space-y-3">
<Skeleton className="h-3 w-32" />
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Skeleton className="h-12 rounded-lg" />
<Skeleton className="h-12 rounded-lg" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-12 rounded-lg" />
<Skeleton className="h-12 rounded-lg" />
</div>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Bitcoin Address Header
// ---------------------------------------------------------------------------
export function BitcoinAddressHeader({ address }: { address: string }) {
const { addressDetail, btcPrice, isLoading, error, refetch } = useBitcoinAddress(address);
if (isLoading) return <AddressSkeleton />;
if (error || !addressDetail) {
return (
<div className="rounded-2xl border border-border p-6 text-center space-y-3">
<Bitcoin className="size-10 mx-auto text-muted-foreground/40" />
<p className="text-sm text-destructive">Failed to load address</p>
<p className="text-xs text-muted-foreground font-mono break-all">{address}</p>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="size-3.5 mr-1.5" />
Retry
</Button>
</div>
);
}
return (
<div className="rounded-2xl border border-border overflow-hidden">
{/* Header */}
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-full bg-primary/10 text-primary">
<Bitcoin className="size-5" />
</div>
<div>
<h2 className="text-lg font-bold">Bitcoin Address</h2>
<p className="text-xs text-muted-foreground">
{addressDetail.txCount + addressDetail.pendingTxCount} transaction{(addressDetail.txCount + addressDetail.pendingTxCount) !== 1 ? 's' : ''}
</p>
</div>
</div>
{/* Address */}
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Address</p>
<div className="flex items-center gap-2">
<p className="text-sm font-mono text-foreground break-all">{address}</p>
<CopyButton text={address} />
</div>
</div>
{/* Balance hero */}
<div className="rounded-xl bg-secondary/40 p-4 text-center space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wider">Balance</p>
<p className="text-3xl font-bold tracking-tight">
{btcPrice ? satsToUSD(addressDetail.totalBalance, btcPrice) : `${formatBTC(addressDetail.totalBalance)} BTC`}
</p>
<p className="text-sm text-muted-foreground">
{formatBTC(addressDetail.totalBalance)} BTC
</p>
{addressDetail.pendingBalance !== 0 && (
<p className="flex items-center justify-center gap-1 text-xs text-orange-500 dark:text-orange-400 pt-1">
<RefreshCw className="size-3 animate-spin" />
{btcPrice
? `${satsToUSD(addressDetail.pendingBalance, btcPrice)} pending`
: `${formatBTC(addressDetail.pendingBalance)} BTC pending`}
</p>
)}
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3">
<StatCard
icon={<ArrowDownLeft className="size-3.5" />}
label="Total Received"
value={`${formatBTC(addressDetail.totalReceived)} BTC`}
subtitle={btcPrice ? satsToUSD(addressDetail.totalReceived, btcPrice) : undefined}
/>
<StatCard
icon={<ArrowUpRight className="size-3.5" />}
label="Total Sent"
value={`${formatBTC(addressDetail.totalSent)} BTC`}
subtitle={btcPrice ? satsToUSD(addressDetail.totalSent, btcPrice) : undefined}
/>
</div>
</div>
{/* Recent Transactions */}
{addressDetail.recentTxs.length > 0 && (
<div className="border-t border-border">
<div className="px-5 py-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Recent Transactions
</p>
</div>
<div className="divide-y divide-border">
{addressDetail.recentTxs.slice(0, 10).map((tx) => (
<AddressTxRow key={tx.txid} tx={tx} btcPrice={btcPrice} />
))}
</div>
{addressDetail.recentTxs.length > 10 && (
<div className="px-5 py-3 text-center">
<p className="text-xs text-muted-foreground">
{addressDetail.txCount - 10} more transaction{addressDetail.txCount - 10 !== 1 ? 's' : ''}
</p>
</div>
)}
</div>
)}
{/* Footer: link to mempool.space */}
<div className="border-t border-border px-5 py-2.5">
<a
href={`https://mempool.space/address/${address}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Bitcoin className="size-3.5" />
<span>View on mempool.space</span>
<ExternalLink className="size-3" />
</a>
</div>
</div>
);
}
function AddressTxRow({ tx, btcPrice }: { tx: { txid: string; amount: number; type: 'receive' | 'send'; confirmed: boolean; timestamp?: number }; btcPrice?: number }) {
const isReceive = tx.type === 'receive';
return (
<Link
to={`/i/bitcoin:tx:${tx.txid}`}
className="flex items-center justify-between py-3 px-5 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center size-8 rounded-full ${
isReceive
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'bg-red-500/10 text-red-600 dark:text-red-400'
}`}>
{isReceive ? <ArrowDownLeft className="size-4" /> : <ArrowUpRight className="size-4" />}
</div>
<div>
<p className="text-sm font-medium">{isReceive ? 'Received' : 'Sent'}</p>
<p className="text-xs text-muted-foreground font-mono">{truncateMiddle(tx.txid, 8, 8)}</p>
</div>
</div>
<div className="text-right">
<p className={`text-sm font-medium ${
isReceive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}`}>
{isReceive ? '+' : '-'}{formatBTC(tx.amount)} BTC
</p>
{btcPrice && (
<p className="text-xs text-muted-foreground">
{satsToUSD(tx.amount, btcPrice)}
</p>
)}
</div>
</Link>
);
}
function AddressSkeleton() {
return (
<div className="rounded-2xl border border-border overflow-hidden">
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-3.5 w-24" />
</div>
</div>
<div className="space-y-1.5">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-full" />
</div>
<div className="rounded-xl bg-secondary/40 p-4 space-y-2 flex flex-col items-center">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-9 w-40" />
<Skeleton className="h-4 w-28" />
</div>
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Compact previews (used in NoteCard embeds, etc.)
// ---------------------------------------------------------------------------
/** Compact preview for a Bitcoin transaction (used in ExternalContentPreview). */
export function BitcoinTxPreview({ txid, link }: { txid: string; link: string }) {
return (
<Link
to={link}
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
>
<div className="size-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
<Bitcoin className="size-5 text-orange-600 dark:text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Bitcoin className="size-3 shrink-0" />
<span>Bitcoin Transaction</span>
</div>
<p className="text-sm font-mono font-medium truncate mt-0.5">{truncateMiddle(txid, 12, 8)}</p>
</div>
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
</Link>
);
}
/** Compact preview for a Bitcoin address (used in ExternalContentPreview). */
export function BitcoinAddressPreview({ address, link }: { address: string; link: string }) {
return (
<Link
to={link}
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
>
<div className="size-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
<Bitcoin className="size-5 text-orange-600 dark:text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Bitcoin className="size-3 shrink-0" />
<span>Bitcoin Address</span>
</div>
<p className="text-sm font-mono font-medium truncate mt-0.5">{truncateMiddle(address, 12, 8)}</p>
</div>
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
</Link>
);
}
+5
View File
@@ -11,6 +11,7 @@ import { LinkEmbed } from '@/components/LinkEmbed';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { WikipediaIcon } from '@/components/icons/WikipediaIcon';
import { BlueskyIcon } from '@/components/icons/BlueskyIcon';
import { BitcoinTxPreview, BitcoinAddressPreview } from '@/components/BitcoinContentHeader';
import { extractYouTubeId, extractWikipediaTitle, extractBlueskyPost } from '@/lib/linkEmbed';
import { parseExternalUri, formatIsbn } from '@/lib/externalContent';
import { shareOrCopy } from '@/lib/share';
@@ -746,6 +747,10 @@ export function ExternalContentPreview({ identifier }: { identifier: string }) {
return <BookPreview isbn={content.value} link={link} />;
case 'iso3166':
return <CountryPreview code={content.code} link={link} />;
case 'bitcoin-tx':
return <BitcoinTxPreview txid={content.txid} link={link} />;
case 'bitcoin-address':
return <BitcoinAddressPreview address={content.address} link={link} />;
default:
return (
<Link to={link} className="block px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors">
-4
View File
@@ -241,10 +241,6 @@ export interface AppConfig {
savedFeeds: SavedFeed[];
/** Image upload quality: "compressed" resizes/optimizes, "original" uploads as-is. Default: "compressed". */
imageQuality: 'compressed' | 'original';
/** Block explorer URI template for address pages. Supports RFC 6570 variable: {address}. */
blockExplorerAddress: string;
/** Block explorer URI template for transaction pages. Supports RFC 6570 variable: {txid}. */
blockExplorerTx: string;
}
export interface AppContextType {
+25
View File
@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { fetchAddressDetail, fetchBtcPrice } from '@/lib/bitcoin';
/**
* Fetch full address details (balance + recent txs) via the mempool.space API.
* Also fetches the current BTC/USD price for display.
*/
export function useBitcoinAddress(address: string) {
const { data: addressDetail, isLoading, error, refetch } = useQuery({
queryKey: ['bitcoin-address-detail', address],
queryFn: () => fetchAddressDetail(address),
enabled: !!address,
refetchInterval: 30_000,
});
const { data: btcPrice } = useQuery({
queryKey: ['btc-price'],
queryFn: fetchBtcPrice,
refetchInterval: 60_000,
staleTime: 30_000,
});
return { addressDetail, btcPrice, isLoading, error, refetch };
}
+25
View File
@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { fetchTxDetail, fetchBtcPrice } from '@/lib/bitcoin';
/**
* Fetch full transaction details for a Bitcoin txid via the mempool.space API.
* Also fetches the current BTC/USD price for display.
*/
export function useBitcoinTx(txid: string) {
const { data: tx, isLoading, error } = useQuery({
queryKey: ['bitcoin-tx-detail', txid],
queryFn: () => fetchTxDetail(txid),
enabled: !!txid,
staleTime: 60_000,
});
const { data: btcPrice } = useQuery({
queryKey: ['btc-price'],
queryFn: fetchBtcPrice,
refetchInterval: 60_000,
staleTime: 30_000,
});
return { tx, btcPrice, isLoading, error };
}
+154 -5
View File
@@ -1,5 +1,12 @@
import * as bitcoin from 'bitcoinjs-lib';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Base URL for the mempool.space Esplora-compatible REST API. */
const MEMPOOL_API = 'https://mempool.space/api';
/**
* Convert a Nostr public key (32-byte hex) to a Bitcoin Taproot (P2TR) address.
*
@@ -23,7 +30,11 @@ export function nostrPubkeyToBitcoinAddress(pubkeyHex: string): string {
}
}
/** Balance data returned by the Blockstream API. */
// ---------------------------------------------------------------------------
// Balance / Address data (wallet page)
// ---------------------------------------------------------------------------
/** Balance data returned by the Esplora API. */
export interface AddressData {
/** Confirmed on-chain balance in satoshis. */
balance: number;
@@ -43,10 +54,10 @@ export interface AddressData {
/**
* Fetch balance and transaction stats for a Bitcoin address from the
* Blockstream Esplora API.
* mempool.space Esplora API.
*/
export async function fetchAddressData(address: string): Promise<AddressData> {
const response = await fetch(`https://blockstream.info/api/address/${address}`);
const response = await fetch(`${MEMPOOL_API}/address/${address}`);
if (!response.ok) {
throw new Error('Failed to fetch balance');
@@ -68,6 +79,10 @@ export async function fetchAddressData(address: string): Promise<AddressData> {
};
}
// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------
/** Convert satoshis to a BTC string with up to 8 decimal places. */
export function satsToBTC(sats: number): string {
return (sats / 100_000_000).toFixed(8);
@@ -103,6 +118,10 @@ export function satsToUSD(sats: number, btcPrice: number): string {
});
}
// ---------------------------------------------------------------------------
// Wallet-page transaction list (simplified per-address view)
// ---------------------------------------------------------------------------
/** A simplified transaction relevant to a specific address. */
export interface Transaction {
/** Transaction ID (hex). */
@@ -118,11 +137,11 @@ export interface Transaction {
}
/**
* Fetch transactions for a Bitcoin address from the Blockstream Esplora API.
* Fetch transactions for a Bitcoin address from the mempool.space Esplora API.
* Returns simplified transactions with net amount relative to the address.
*/
export async function fetchTransactions(address: string): Promise<Transaction[]> {
const response = await fetch(`https://blockstream.info/api/address/${address}/txs`);
const response = await fetch(`${MEMPOOL_API}/address/${address}/txs`);
if (!response.ok) {
throw new Error('Failed to fetch transactions');
@@ -162,3 +181,133 @@ export async function fetchTransactions(address: string): Promise<Transaction[]>
} satisfies Transaction;
});
}
// ---------------------------------------------------------------------------
// Full transaction detail (NIP-73 /i/bitcoin:tx:... page)
// ---------------------------------------------------------------------------
/** A single input in a full transaction. */
export interface TxInput {
txid: string;
vout: number;
address?: string;
value: number;
isCoinbase: boolean;
}
/** A single output in a full transaction. */
export interface TxOutput {
address?: string;
value: number;
scriptpubkeyType: string;
/** True if the output has been spent. */
spent: boolean;
}
/** Full transaction detail returned by the Esplora API. */
export interface TxDetail {
txid: string;
version: number;
locktime: number;
size: number;
weight: number;
fee: number;
confirmed: boolean;
blockHeight?: number;
blockHash?: string;
blockTime?: number;
inputs: TxInput[];
outputs: TxOutput[];
/** Total value of all inputs (sats). */
totalInput: number;
/** Total value of all outputs (sats). */
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}`);
if (!response.ok) throw new Error('Failed to fetch transaction');
const tx = await response.json();
const vin = tx.vin as Array<{
txid: string;
vout: number;
prevout: { scriptpubkey_address?: string; value: number } | null;
is_coinbase: boolean;
}>;
const vout = tx.vout as Array<{
scriptpubkey_address?: string;
value: number;
scriptpubkey_type: string;
}>;
const status = tx.status as { confirmed: boolean; block_height?: number; block_hash?: string; block_time?: number };
const inputs: TxInput[] = vin.map((input) => ({
txid: input.txid,
vout: input.vout,
address: input.prevout?.scriptpubkey_address,
value: input.prevout?.value ?? 0,
isCoinbase: input.is_coinbase,
}));
const outputs: TxOutput[] = vout.map((output) => ({
address: output.scriptpubkey_address,
value: output.value,
scriptpubkeyType: output.scriptpubkey_type,
spent: false, // Esplora /tx endpoint doesn't include spending info
}));
const totalInput = inputs.reduce((sum, i) => sum + i.value, 0);
const totalOutput = outputs.reduce((sum, o) => sum + o.value, 0);
return {
txid: tx.txid as string,
version: tx.version as number,
locktime: tx.locktime as number,
size: tx.size as number,
weight: tx.weight as number,
fee: tx.fee as number,
confirmed: status.confirmed,
blockHeight: status.block_height,
blockHash: status.block_hash,
blockTime: status.block_time,
inputs,
outputs,
totalInput,
totalOutput,
};
}
// ---------------------------------------------------------------------------
// Full address detail (NIP-73 /i/bitcoin:address:... page)
// ---------------------------------------------------------------------------
/** Full address detail combining balance stats + recent transactions. */
export interface AddressDetail {
address: string;
balance: number;
pendingBalance: number;
totalBalance: number;
totalReceived: number;
totalSent: number;
txCount: number;
pendingTxCount: number;
/** Most recent transactions (up to 25). */
recentTxs: Transaction[];
}
/** Fetch full address details (balance + recent txs) from mempool.space. */
export async function fetchAddressDetail(address: string): Promise<AddressDetail> {
const [addrData, txs] = await Promise.all([
fetchAddressData(address),
fetchTransactions(address),
]);
return {
address,
...addrData,
recentTxs: txs.slice(0, 25),
};
}
+24
View File
@@ -10,6 +10,8 @@ export type ExternalContent =
| { type: 'url'; value: string }
| { type: 'isbn'; value: string }
| { type: 'iso3166'; value: string; code: string }
| { type: 'bitcoin-tx'; value: string; txid: string }
| { type: 'bitcoin-address'; value: string; address: string }
| { type: 'unknown'; value: string };
/** Parse a URI string into a typed external content object. */
@@ -21,6 +23,16 @@ export function parseExternalUri(uri: string): ExternalContent {
const code = uri.slice('iso3166:'.length);
return { type: 'iso3166', value: uri, code };
}
// NIP-73 Bitcoin transaction: bitcoin:tx:<txid>
const btcTxMatch = uri.match(/^bitcoin:tx:([0-9a-f]{64})$/i);
if (btcTxMatch) {
return { type: 'bitcoin-tx', value: uri, txid: btcTxMatch[1].toLowerCase() };
}
// NIP-73 Bitcoin address: bitcoin:address:<address>
const btcAddrMatch = uri.match(/^bitcoin:address:(.+)$/);
if (btcAddrMatch) {
return { type: 'bitcoin-address', value: uri, address: btcAddrMatch[1] };
}
if (uri.startsWith('http://') || uri.startsWith('https://')) {
return { type: 'url', value: uri };
}
@@ -89,6 +101,10 @@ export function headerLabel(content: ExternalContent): string {
const info = getCountryInfo(content.code);
return info?.subdivisionName ?? info?.name ?? 'Country';
}
case 'bitcoin-tx':
return 'Bitcoin Transaction';
case 'bitcoin-address':
return 'Bitcoin Address';
default:
return 'External Content';
}
@@ -112,6 +128,14 @@ export function seoTitle(content: ExternalContent, appName: string): string {
const seoName = seoInfo?.subdivisionName ?? seoInfo?.name;
return seoName ? `${seoName} | ${appName}` : `Country | ${appName}`;
}
case 'bitcoin-tx': {
const shortTxid = `${content.txid.slice(0, 8)}...${content.txid.slice(-8)}`;
return `Bitcoin TX ${shortTxid} | ${appName}`;
}
case 'bitcoin-address': {
const shortAddr = `${content.address.slice(0, 8)}...${content.address.slice(-6)}`;
return `Bitcoin Address ${shortAddr} | ${appName}`;
}
default:
return `External Content | ${appName}`;
}
-2
View File
@@ -245,8 +245,6 @@ export const AppConfigSchema = z.object({
})
).optional().default([]),
imageQuality: z.enum(['compressed', 'original']),
blockExplorerAddress: z.string(),
blockExplorerTx: z.string(),
});
// ─── DittoConfigSchema (build-time ditto.json) ───────────────────────
+3
View File
@@ -22,6 +22,7 @@ import {
BookContentHeader,
CountryContentHeader,
} from '@/components/ExternalContentHeader';
import { BitcoinTxHeader, BitcoinAddressHeader } from '@/components/BitcoinContentHeader';
import { PrecipitationEffect } from '@/components/PrecipitationEffect';
import { parseExternalUri, headerLabel, seoTitle, type ExternalContent } from '@/lib/externalContent';
import { ratingToStars } from '@/lib/bookstr';
@@ -259,6 +260,8 @@ export function ExternalContentPage() {
{content.type === 'url' && <UrlContentHeader url={content.value} />}
{content.type === 'isbn' && <BookContentHeader isbn={content.value} />}
{content.type === 'iso3166' && <CountryContentHeader code={content.code} />}
{content.type === 'bitcoin-tx' && <BitcoinTxHeader txid={content.txid} />}
{content.type === 'bitcoin-address' && <BitcoinAddressHeader address={content.address} />}
{content.type === 'unknown' && (
<div className="rounded-2xl border border-border p-5 text-center">
<Globe className="size-8 mx-auto mb-2 text-muted-foreground/40" />
+6 -9
View File
@@ -1,6 +1,6 @@
import { useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import UriTemplate from 'uri-templates';
import { Bitcoin, Copy, Check, RefreshCw, Wallet, ChevronDown, ArrowDownLeft, ArrowUpRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -129,7 +129,7 @@ export function WalletPage() {
<TxAccordion open={txOpen}>
<div className="w-full divide-y">
{transactions.map((tx) => (
<TxRow key={tx.txid} tx={tx} btcPrice={btcPrice} txUrlTemplate={config.blockExplorerTx} />
<TxRow key={tx.txid} tx={tx} btcPrice={btcPrice} />
))}
</div>
</TxAccordion>
@@ -174,15 +174,12 @@ function formatTxDate(timestamp?: number): string {
}
/** Single transaction row. */
function TxRow({ tx, btcPrice, txUrlTemplate }: { tx: Transaction; btcPrice?: number; txUrlTemplate: string }) {
function TxRow({ tx, btcPrice }: { tx: Transaction; btcPrice?: number }) {
const isReceive = tx.type === 'receive';
const txUrl = UriTemplate(txUrlTemplate).fill({ txid: tx.txid });
return (
<a
href={txUrl}
target="_blank"
rel="noopener noreferrer"
<Link
to={`/i/bitcoin:tx:${tx.txid}`}
className="flex items-center justify-between py-3 px-1 hover:bg-muted/50 transition-colors rounded-lg -mx-1 px-2"
>
<div className="flex items-center gap-3">
@@ -213,6 +210,6 @@ function TxRow({ tx, btcPrice, txUrlTemplate }: { tx: Transaction; btcPrice?: nu
{satsToBTC(tx.amount).replace(/\.?0+$/, '')} BTC
</p>
</div>
</a>
</Link>
);
}
-2
View File
@@ -111,8 +111,6 @@ export function TestApp({ children }: TestAppProps) {
plausibleEndpoint: "",
savedFeeds: [],
imageQuality: 'compressed',
blockExplorerAddress: 'https://mempool.space/address/{address}',
blockExplorerTx: 'https://mempool.space/tx/{txid}',
};
return (