diff --git a/WALLET.md b/WALLET.md
index a50641f2..0113ee7b 100644
--- a/WALLET.md
+++ b/WALLET.md
@@ -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
diff --git a/src/App.tsx b/src/App.tsx
index 0299ec05..1ef943aa 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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}',
};
/**
diff --git a/src/components/BitcoinContentHeader.tsx b/src/components/BitcoinContentHeader.tsx
new file mode 100644
index 00000000..790b54b5
--- /dev/null
+++ b/src/components/BitcoinContentHeader.tsx
@@ -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 (
+
+ );
+}
+
+/** 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 ;
+
+ if (error || !tx) {
+ return (
+
+
+
Failed to load transaction
+
{txid}
+
+ );
+ }
+
+ 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, etc.)
+// ---------------------------------------------------------------------------
+
+/** Compact preview for a Bitcoin transaction (used in ExternalContentPreview). */
+export function BitcoinTxPreview({ txid, link }: { txid: string; link: string }) {
+ return (
+
+
+
+
+
+
+
+ Bitcoin Transaction
+
+
{truncateMiddle(txid, 12, 8)}
+
+
+
+ );
+}
+
+/** Compact preview for a Bitcoin address (used in ExternalContentPreview). */
+export function BitcoinAddressPreview({ address, link }: { address: string; link: string }) {
+ return (
+
+
+
+
+
+
+
+ Bitcoin Address
+
+
{truncateMiddle(address, 12, 8)}
+
+
+
+ );
+}
diff --git a/src/components/ExternalContentHeader.tsx b/src/components/ExternalContentHeader.tsx
index a22a70c6..386bd814 100644
--- a/src/components/ExternalContentHeader.tsx
+++ b/src/components/ExternalContentHeader.tsx
@@ -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 ;
case 'iso3166':
return ;
+ case 'bitcoin-tx':
+ return ;
+ case 'bitcoin-address':
+ return ;
default:
return (
diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts
index a7eca7dd..492244ae 100644
--- a/src/contexts/AppContext.ts
+++ b/src/contexts/AppContext.ts
@@ -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 {
diff --git a/src/hooks/useBitcoinAddress.ts b/src/hooks/useBitcoinAddress.ts
new file mode 100644
index 00000000..56830f40
--- /dev/null
+++ b/src/hooks/useBitcoinAddress.ts
@@ -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 };
+}
diff --git a/src/hooks/useBitcoinTx.ts b/src/hooks/useBitcoinTx.ts
new file mode 100644
index 00000000..6063e423
--- /dev/null
+++ b/src/hooks/useBitcoinTx.ts
@@ -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 };
+}
diff --git a/src/lib/bitcoin.ts b/src/lib/bitcoin.ts
index 1f2d70cf..c24aa2a6 100644
--- a/src/lib/bitcoin.ts
+++ b/src/lib/bitcoin.ts
@@ -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 {
- 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 {
};
}
+// ---------------------------------------------------------------------------
+// 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 {
- 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
} 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 {
+ 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 {
+ const [addrData, txs] = await Promise.all([
+ fetchAddressData(address),
+ fetchTransactions(address),
+ ]);
+
+ return {
+ address,
+ ...addrData,
+ recentTxs: txs.slice(0, 25),
+ };
+}
diff --git a/src/lib/externalContent.ts b/src/lib/externalContent.ts
index b01db600..17594494 100644
--- a/src/lib/externalContent.ts
+++ b/src/lib/externalContent.ts
@@ -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:
+ 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:
+ 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}`;
}
diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts
index 39a2ad5f..ee034e12 100644
--- a/src/lib/schemas.ts
+++ b/src/lib/schemas.ts
@@ -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) ───────────────────────
diff --git a/src/pages/ExternalContentPage.tsx b/src/pages/ExternalContentPage.tsx
index 1f1c53c9..4bea4f71 100644
--- a/src/pages/ExternalContentPage.tsx
+++ b/src/pages/ExternalContentPage.tsx
@@ -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' && }
{content.type === 'isbn' && }
{content.type === 'iso3166' && }
+ {content.type === 'bitcoin-tx' && }
+ {content.type === 'bitcoin-address' && }
{content.type === 'unknown' && (
diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx
index a60d5930..c880f93d 100644
--- a/src/pages/WalletPage.tsx
+++ b/src/pages/WalletPage.tsx
@@ -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() {
{transactions.map((tx) => (
-
+
))}
@@ -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 (
-
@@ -213,6 +210,6 @@ function TxRow({ tx, btcPrice, txUrlTemplate }: { tx: Transaction; btcPrice?: nu
{satsToBTC(tx.amount).replace(/\.?0+$/, '')} BTC
-
+
);
}
diff --git a/src/test/TestApp.tsx b/src/test/TestApp.tsx
index 46edafcf..2a3f6076 100644
--- a/src/test/TestApp.tsx
+++ b/src/test/TestApp.tsx
@@ -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 (