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:
@@ -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
|
||||
|
||||
|
||||
@@ -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}',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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) ───────────────────────
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user