diff --git a/src/components/BitcoinContentHeader.tsx b/src/components/BitcoinContentHeader.tsx
new file mode 100644
index 00000000..b03efef7
--- /dev/null
+++ b/src/components/BitcoinContentHeader.tsx
@@ -0,0 +1,641 @@
+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, formatBTC } 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 (
+
+ );
+}
+
+/** 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 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 ;
+
+ if (error || !tx) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ {tx.confirmed ? : }
+
+
+
+ {tx.confirmed ? 'Confirmed' : 'Unconfirmed'}
+
+ {tx.blockTime && (
+
{formatBlockTime(tx.blockTime)}
+ )}
+
+
+
+ {/* Transaction ID */}
+
+
+ {/* Stats grid */}
+
+ {tx.confirmed && tx.blockHeight !== undefined && (
+ } label="Block" value={formatNumber(tx.blockHeight)} />
+ )}
+ } label="Size" value={`${formatNumber(tx.weight / 4)} vB`} />
+ }
+ label="Fee"
+ value={`${formatSats(tx.fee)} sat`}
+ subtitle={`${(tx.fee / (tx.weight / 4)).toFixed(1)} sat/vB`}
+ />
+ }
+ label="Amount"
+ value={`${formatBTC(tx.totalOutput)} BTC`}
+ subtitle={btcPrice ? satsToUSD(tx.totalOutput, btcPrice) : undefined}
+ />
+
+
+
+ {/* Inputs → Outputs flow */}
+
+
+
+
+ {/* Footer: link to mempool.space */}
+
+
+ );
+}
+
+function StatCard({ icon, label, value, subtitle }: { icon: React.ReactNode; label: string; value: string; subtitle?: string }) {
+ return (
+
+
+ {icon}
+ {label}
+
+
{value}
+ {subtitle &&
{subtitle}
}
+
+ );
+}
+
+/** Inputs → Outputs visualization, mempool.space-style. */
+function TxFlow({ tx, btcPrice }: { tx: TxDetail; btcPrice?: number }) {
+ return (
+
+
+
{tx.inputs.length} Input{tx.inputs.length !== 1 ? 's' : ''}
+
+
{tx.outputs.length} Output{tx.outputs.length !== 1 ? 's' : ''}
+
+
+
+ {/* Inputs */}
+
+ {tx.inputs.slice(0, 10).map((input, i) => (
+
+ ))}
+ {tx.inputs.length > 10 && (
+
+ +{tx.inputs.length - 10} more input{tx.inputs.length - 10 !== 1 ? 's' : ''}
+
+ )}
+
+
+ {/* Outputs */}
+
+ {tx.outputs.slice(0, 10).map((output, i) => (
+
+ ))}
+ {tx.outputs.length > 10 && (
+
+ +{tx.outputs.length - 10} more output{tx.outputs.length - 10 !== 1 ? 's' : ''}
+
+ )}
+
+
+
+ );
+}
+
+function TxInputRow({ input, btcPrice }: { input: TxInput; btcPrice?: number }) {
+ if (input.isCoinbase) {
+ return (
+
+
+ Coinbase
+ {formatBTC(input.value)} BTC
+
+
+ );
+ }
+
+ return (
+
+
+ {input.address ? (
+
+ {truncateMiddle(input.address, 10, 6)}
+
+ ) : (
+ Unknown
+ )}
+ {formatBTC(input.value)} BTC
+
+ {btcPrice !== undefined && (
+
{satsToUSD(input.value, btcPrice)}
+ )}
+
+ );
+}
+
+function TxOutputRow({ output, btcPrice }: { output: TxOutput; btcPrice?: number }) {
+ const isOpReturn = output.scriptpubkeyType === 'op_return';
+
+ if (isOpReturn) {
+ return (
+
+ OP_RETURN
+
+ );
+ }
+
+ return (
+
+
+ {output.address ? (
+
+ {truncateMiddle(output.address, 10, 6)}
+
+ ) : (
+ Unknown
+ )}
+ {formatBTC(output.value)} BTC
+
+ {btcPrice !== undefined && (
+
{satsToUSD(output.value, btcPrice)}
+ )}
+
+ );
+}
+
+function TxSkeleton() {
+ return (
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Bitcoin Address Header
+// ---------------------------------------------------------------------------
+
+export function BitcoinAddressHeader({ address }: { address: string }) {
+ const { addressDetail, btcPrice, isLoading, error, refetch } = useBitcoinAddress(address);
+
+ if (isLoading) return ;
+
+ if (error || !addressDetail) {
+ return (
+
+
+
Failed to load address
+
{address}
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
Bitcoin Address
+
+ {addressDetail.txCount + addressDetail.pendingTxCount} transaction{(addressDetail.txCount + addressDetail.pendingTxCount) !== 1 ? 's' : ''}
+
+
+
+
+ {/* Address */}
+
+
+ {/* Balance hero */}
+
+
Balance
+
+ {btcPrice ? satsToUSD(addressDetail.totalBalance, btcPrice) : `${formatBTC(addressDetail.totalBalance)} BTC`}
+
+
+ {formatBTC(addressDetail.totalBalance)} BTC
+
+ {addressDetail.pendingBalance !== 0 && (
+
+
+ {btcPrice
+ ? `${satsToUSD(addressDetail.pendingBalance, btcPrice)} pending`
+ : `${formatBTC(addressDetail.pendingBalance)} BTC pending`}
+
+ )}
+
+
+ {/* Stats grid */}
+
+ }
+ label="Total Received"
+ value={`${formatBTC(addressDetail.totalReceived)} BTC`}
+ subtitle={btcPrice ? satsToUSD(addressDetail.totalReceived, btcPrice) : undefined}
+ />
+ }
+ label="Total Sent"
+ value={`${formatBTC(addressDetail.totalSent)} BTC`}
+ subtitle={btcPrice ? satsToUSD(addressDetail.totalSent, btcPrice) : undefined}
+ />
+
+
+
+ {/* Recent Transactions */}
+ {addressDetail.recentTxs.length > 0 && (
+
+
+
+ Recent Transactions
+
+
+
+ {addressDetail.recentTxs.slice(0, 10).map((tx) => (
+
+ ))}
+
+ {addressDetail.recentTxs.length > 10 && (
+
+
+ {addressDetail.txCount - 10} more transaction{addressDetail.txCount - 10 !== 1 ? 's' : ''}
+
+
+ )}
+
+ )}
+
+ {/* Footer: link to mempool.space */}
+
+
+ );
+}
+
+function AddressTxRow({ tx, btcPrice }: { tx: { txid: string; amount: number; type: 'receive' | 'send'; confirmed: boolean; timestamp?: number }; btcPrice?: number }) {
+ const isReceive = tx.type === 'receive';
+
+ return (
+
+
+
+
+
{isReceive ? 'Received' : 'Sent'}
+
{truncateMiddle(tx.txid, 8, 8)}
+
+
+
+
+ {isReceive ? '+' : '-'}{formatBTC(tx.amount)} BTC
+
+ {btcPrice && (
+
+ {satsToUSD(tx.amount, btcPrice)}
+
+ )}
+
+
+ );
+}
+
+function AddressSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Compact previews (used in NoteCard embeds, hover cards, etc.)
+// ---------------------------------------------------------------------------
+
+/** Compact preview for a Bitcoin transaction — fetches real data. */
+export function BitcoinTxPreview({ txid, link }: { txid: string; link: string }) {
+ const { tx, btcPrice, isLoading } = useBitcoinTx(txid);
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ const amount = tx ? tx.totalOutput : 0;
+ const fee = tx?.fee ?? 0;
+
+ return (
+
+
+
+
+
+
+
+ Bitcoin Transaction
+ {tx && (
+
+ {tx.confirmed ? 'Confirmed' : 'Unconfirmed'}
+
+ )}
+
+
+ {tx ? `${satsToBTC(amount)} BTC` : truncateMiddle(txid, 12, 8)}
+ {tx && btcPrice ? (
+ ({satsToUSD(amount, btcPrice)})
+ ) : null}
+
+ {tx && (
+
+ Fee {formatSats(fee)} sats
+ {tx.blockHeight ? ` · Block ${tx.blockHeight.toLocaleString()}` : ''}
+
+ )}
+
+
+
+ );
+}
+
+/** Compact preview for a Bitcoin address — fetches real data. */
+export function BitcoinAddressPreview({ address, link }: { address: string; link: string }) {
+ const { addressDetail, btcPrice, isLoading } = useBitcoinAddress(address);
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ const balance = addressDetail?.totalBalance ?? 0;
+ const txCount = addressDetail ? addressDetail.txCount + addressDetail.pendingTxCount : 0;
+
+ return (
+
+
+
+
+
+
+
+ Bitcoin Address
+
+
+ {addressDetail ? `${satsToBTC(balance)} BTC` : truncateMiddle(address, 12, 8)}
+ {addressDetail && btcPrice ? (
+ ({satsToUSD(balance, btcPrice)})
+ ) : null}
+
+ {addressDetail && (
+
+ {txCount.toLocaleString()} transaction{txCount !== 1 ? 's' : ''}
+ {' · '}
+ {truncateMiddle(address, 8, 6)}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/hooks/useBitcoinAddress.ts b/src/hooks/useBitcoinAddress.ts
new file mode 100644
index 00000000..298ea157
--- /dev/null
+++ b/src/hooks/useBitcoinAddress.ts
@@ -0,0 +1,65 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { fetchAddressData, fetchAddressTxs, type AddressData } from '@/lib/bitcoin';
+import { useAppContext } from '@/hooks/useAppContext';
+import { useBtcPrice } from '@/hooks/useBtcPrice';
+
+/**
+ * A simplified address-relative transaction row used in the
+ * `BitcoinAddressHeader` recent transactions list.
+ */
+export interface AddressRecentTx {
+ txid: string;
+ /** Absolute satoshi amount of the address-relative net flow. */
+ amount: number;
+ /** Whether this tx was a net receive or send for the address. */
+ type: 'receive' | 'send';
+ confirmed: boolean;
+ /** Block time (unix seconds), undefined if unconfirmed. */
+ timestamp?: number;
+}
+
+export interface AddressDetail extends AddressData {
+ address: string;
+ /** Most recent transactions (up to 25). */
+ recentTxs: AddressRecentTx[];
+}
+
+/**
+ * Fetch full Bitcoin address details (balance + recent transactions) via the
+ * configured Esplora API roots, alongside the current BTC/USD price.
+ */
+export function useBitcoinAddress(address: string) {
+ const { config } = useAppContext();
+ const { esploraApis } = config;
+
+ const { data: addressDetail, isLoading, error, refetch } = useQuery({
+ queryKey: ['bitcoin-address-detail', esploraApis, address],
+ queryFn: async ({ signal }): Promise => {
+ const [addrData, txs] = await Promise.all([
+ fetchAddressData(address, esploraApis, signal),
+ fetchAddressTxs(address, esploraApis, undefined, signal),
+ ]);
+
+ const recentTxs: AddressRecentTx[] = txs.slice(0, 25).map((tx) => ({
+ txid: tx.txid,
+ amount: Math.abs(tx.netSats),
+ type: tx.netSats >= 0 ? 'receive' : 'send',
+ confirmed: tx.confirmed,
+ timestamp: tx.blockTime,
+ }));
+
+ return {
+ address,
+ ...addrData,
+ recentTxs,
+ };
+ },
+ enabled: !!address,
+ refetchInterval: 30_000,
+ });
+
+ const { data: btcPrice } = useBtcPrice();
+
+ return { addressDetail, btcPrice, isLoading, error, refetch };
+}
diff --git a/src/hooks/useBitcoinTx.ts b/src/hooks/useBitcoinTx.ts
new file mode 100644
index 00000000..142ec2c0
--- /dev/null
+++ b/src/hooks/useBitcoinTx.ts
@@ -0,0 +1,25 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { fetchTxDetail } from '@/lib/bitcoin';
+import { useAppContext } from '@/hooks/useAppContext';
+import { useBtcPrice } from '@/hooks/useBtcPrice';
+
+/**
+ * Fetch full transaction details for a Bitcoin txid via the configured
+ * Esplora API roots, alongside the current BTC/USD price for display.
+ */
+export function useBitcoinTx(txid: string) {
+ const { config } = useAppContext();
+ const { esploraApis } = config;
+
+ const { data: tx, isLoading, error } = useQuery({
+ queryKey: ['bitcoin-tx-detail', esploraApis, txid],
+ queryFn: ({ signal }) => fetchTxDetail(txid, esploraApis, signal),
+ enabled: !!txid,
+ staleTime: 60_000,
+ });
+
+ const { data: btcPrice } = useBtcPrice();
+
+ return { tx, btcPrice, isLoading, error };
+}
diff --git a/src/lib/bitcoin.ts b/src/lib/bitcoin.ts
index e527d574..b7933bfd 100644
--- a/src/lib/bitcoin.ts
+++ b/src/lib/bitcoin.ts
@@ -153,7 +153,7 @@ export async function fetchAddressData(
// ---------------------------------------------------------------------------
/** Convert satoshis to a BTC string with up to 8 decimal places. */
-function satsToBTC(sats: number): string {
+export function satsToBTC(sats: number): string {
return (sats / 100_000_000).toFixed(8);
}
@@ -269,7 +269,7 @@ export interface Transaction {
// ---------------------------------------------------------------------------
/** A single input in a full transaction. */
-interface TxInput {
+export interface TxInput {
txid: string;
vout: number;
address?: string;
@@ -278,7 +278,7 @@ interface TxInput {
}
/** A single output in a full transaction. */
-interface TxOutput {
+export interface TxOutput {
address?: string;
value: number;
scriptpubkeyType: string;
@@ -287,7 +287,7 @@ interface TxOutput {
}
/** Full transaction detail returned by the Esplora API. */
-interface TxDetail {
+export interface TxDetail {
txid: string;
version: number;
locktime: number;
diff --git a/src/pages/ExternalContentPage.tsx b/src/pages/ExternalContentPage.tsx
index 9cce721a..e3b641de 100644
--- a/src/pages/ExternalContentPage.tsx
+++ b/src/pages/ExternalContentPage.tsx
@@ -23,6 +23,10 @@ import {
BookContentHeader,
CountryContentHeader,
} from '@/components/ExternalContentHeader';
+import {
+ BitcoinTxHeader,
+ BitcoinAddressHeader,
+} from '@/components/BitcoinContentHeader';
import { parseExternalUri, headerLabel, seoTitle, type ExternalContent } from '@/lib/externalContent';
import { ratingToStars } from '@/lib/bookstr';
import { formatNumber } from '@/lib/formatNumber';
@@ -393,6 +397,8 @@ export function ExternalContentPage() {
{content.type === 'url' &&
}
{content.type === 'isbn' &&
}
+ {content.type === 'bitcoin-tx' &&
}
+ {content.type === 'bitcoin-address' &&
}
{content.type === 'unknown' && (