Add HD Bitcoin wallet at /hdwallet

A production-grade BIP86 Taproot HD wallet, separate from the single-address
wallet at /wallet. The seed is derived deterministically from the user's nsec
via HKDF-SHA-256 with an app-specific info string, so there is no new secret
for the user to back up \u2014 if they have their nsec they have the wallet.

Architecture:

  - src/lib/hdwallet/derivation.ts  \u2014 HKDF seed, BIP86 (m/86'/0'/0'),
    receive (0) and change (1) chains, per-leaf P2TR addresses, TapTweaked
    signing keys.

  - src/lib/hdwallet/scan.ts  \u2014 Standard gap-limit (20) scan across both
    chains via Esplora. Aggregated UTXO set, balance, and tx history
    (merged by txid so send-with-change shows as one row).

  - src/lib/hdwallet/transaction.ts  \u2014 Largest-first coin selection
    (confirmed first), multi-input P2TR PSBT build with per-input
    tapInternalKey from re-derived child keys, fresh change addresses on
    the internal chain (no address reuse).

  - useHdWalletAccess  \u2014 Gates on login type === 'nsec'. Extension and
    bunker logins keep the key isolated, so the page shows an explanatory
    card with a link back to /wallet.

  - useHdWallet  \u2014 Scan + tx history queries (60 s refresh), persisted
    receive-cursor in secure storage (Keychain on native, localStorage on
    web), auto-advance when chain catches up so old addresses are never
    re-shown.

  - HDWalletPage  \u2014 Mirrors /wallet's clean UX: big USD balance, send
    button, QR + truncated address, 'New address' rotator, collapsible tx
    history.

  - HDSendBitcoinDialog  \u2014 Mirrors SendBitcoinDialog (USD presets, fee
    speed picker, two-tap arm for large amounts, raw-address privacy
    disclaimer, success screen) but uses the HD UTXO set across many
    addresses and signs with per-input HD-derived keys.
This commit is contained in:
Alex Gleason
2026-05-21 12:48:40 -05:00
parent 25ef304e42
commit 522c265041
10 changed files with 2078 additions and 34 deletions
+46 -34
View File
@@ -96,6 +96,7 @@
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.2.8",
"@scure/bip32": "^2.2.0",
"@scure/bip39": "^1.6.0",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
@@ -6208,55 +6209,40 @@
"license": "MIT"
},
"node_modules/@scure/bip32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.2.0.tgz",
"integrity": "sha512-zFr7t2F+a9+5tB7QbarF2HQNYrgjCNaoLAupZdKkrFMYMozJf5zqH2WJCQibMzm1qQ0QogrxVGO3qXfQDYMaQg==",
"license": "MIT",
"dependencies": {
"@noble/curves": "~1.1.0",
"@noble/hashes": "~1.3.1",
"@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/curves": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.1"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"license": "MIT",
"engines": {
"node": ">= 16"
"@noble/curves": "2.2.0",
"@noble/hashes": "2.2.0",
"@scure/base": "2.2.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz",
"integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 16"
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@scure/base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz",
@@ -11828,6 +11814,32 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@scure/bip32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
"license": "MIT",
"dependencies": {
"@noble/curves": "~1.1.0",
"@noble/hashes": "~1.3.1",
"@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@scure/bip32/node_modules/@noble/curves": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.1"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@scure/bip39": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
+1
View File
@@ -103,6 +103,7 @@
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.2.8",
"@scure/bip32": "^2.2.0",
"@scure/bip39": "^1.6.0",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
+2
View File
@@ -87,6 +87,7 @@ const VinesFeedPage = lazy(() => import("./pages/VinesFeedPage").then(m => ({ de
const WalletPage = lazy(() => import("./pages/WalletPage").then(m => ({ default: m.WalletPage })));
const WalletRecoveryPage = lazy(() => import("./pages/WalletRecoveryPage").then(m => ({ default: m.WalletRecoveryPage })));
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
const HDWalletPage = lazy(() => import("./pages/HDWalletPage").then(m => ({ default: m.HDWalletPage })));
const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage })));
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
@@ -287,6 +288,7 @@ export function AppRouter() {
/>
<Route path="/wallet" element={<WalletPage />} />
<Route path="/wallet/recovery" element={<WalletRecoveryPage />} />
<Route path="/hdwallet" element={<HDWalletPage />} />
<Route path="/bitcoin" element={<Navigate to="/wallet" replace />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
<Route path="/ai-chat" element={<AIChatPage />} />
+647
View File
@@ -0,0 +1,647 @@
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import {
AlertTriangle,
Check,
ExternalLink,
Loader2,
X,
} from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
import { cn } from '@/lib/utils';
import { useToast } from '@/hooks/useToast';
import { useAppContext } from '@/hooks/useAppContext';
import { useHdWalletAccess } from '@/hooks/useHdWalletAccess';
import { useHdWallet } from '@/hooks/useHdWallet';
import { notificationSuccess } from '@/lib/haptics';
import {
broadcastTransaction,
estimateFee,
getFeeRates,
isLargeAmount,
nostrPubkeyToBitcoinAddress,
satsToUSD,
validateBitcoinAddress,
type FeeRates,
} from '@/lib/bitcoin';
import {
buildHdUnsignedPsbt,
finalizeHdPsbt,
type HdSpendableUtxo,
signHdPsbt,
} from '@/lib/hdwallet/transaction';
import { useQuery } from '@tanstack/react-query';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const USD_PRESETS = [1, 5, 10, 25, 100];
type FeeSpeed = 'fastest' | 'halfHour' | 'hour' | 'economy';
const FEE_SPEED_LABELS: Record<FeeSpeed, string> = {
fastest: '~10 min',
halfHour: '~30 min',
hour: '~1 hour',
economy: '~1 day',
};
const FEE_SPEED_ORDER: FeeSpeed[] = ['fastest', 'halfHour', 'hour', 'economy'];
function getRateForSpeed(rates: FeeRates, speed: FeeSpeed): number {
switch (speed) {
case 'fastest': return rates.fastestFee;
case 'halfHour': return rates.halfHourFee;
case 'hour': return rates.hourFee;
case 'economy': return rates.economyFee;
}
}
function getUniqueFeeSpeeds(rates: FeeRates | undefined): FeeSpeed[] {
if (!rates) return FEE_SPEED_ORDER;
const seen = new Set<number>();
const result: FeeSpeed[] = [];
for (const speed of FEE_SPEED_ORDER) {
const rate = getRateForSpeed(rates, speed);
if (!seen.has(rate)) { seen.add(rate); result.push(speed); }
}
return result;
}
// ---------------------------------------------------------------------------
// Recipient resolution
// ---------------------------------------------------------------------------
interface ResolvedRecipient {
/** Final P2TR/P2WPKH/etc. address used as the PSBT output. */
address: string;
/** Optional Nostr pubkey when the recipient was an npub/nprofile. */
pubkey?: string;
/** Raw text the user typed (for re-display). */
raw: string;
}
/**
* Parse the recipient input as one of:
* - bare Bitcoin address (mainnet, any standard type)
* - npub1… → P2TR derived from the Nostr pubkey (matches /wallet's mapping)
* - nprofile1… → P2TR derived from the encoded pubkey
*
* Returns `null` for unparseable input. The caller should treat `null` as
* "input still in progress" rather than "error" until the user submits.
*/
function resolveRecipient(input: string): ResolvedRecipient | null {
const trimmed = input.trim();
if (!trimmed) return null;
// Try bare Bitcoin address first — common case.
if (validateBitcoinAddress(trimmed)) {
return { address: trimmed, raw: trimmed };
}
// Try NIP-19 npub / nprofile.
if (trimmed.startsWith('npub1') || trimmed.startsWith('nprofile1')) {
try {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'npub') {
const address = nostrPubkeyToBitcoinAddress(decoded.data);
if (address) return { address, pubkey: decoded.data, raw: trimmed };
} else if (decoded.type === 'nprofile') {
const address = nostrPubkeyToBitcoinAddress(decoded.data.pubkey);
if (address) return { address, pubkey: decoded.data.pubkey, raw: trimmed };
}
} catch {
// fall through
}
}
return null;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface HDSendBitcoinDialogProps {
isOpen: boolean;
onClose: () => void;
/** BTC/USD price — passed in to avoid duplicate fetches. */
btcPrice?: number;
}
interface SendResult {
txid: string;
amountSats: number;
fee: number;
}
/**
* "Send Bitcoin" dialog for the HD wallet at `/hdwallet`.
*
* Mirrors the UX of `SendBitcoinDialog` for visual consistency — large
* editable USD amount, preset chips, fee speed picker, two-tap arming for
* large amounts, privacy disclaimer for raw addresses — but uses the HD
* wallet's UTXO set across many addresses, signs with per-input HD-derived
* keys, and emits change to a fresh internal address.
*/
export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoinDialogProps) {
const availability = useHdWalletAccess();
const { scan, refetch: refetchWallet } = useHdWallet();
const { config } = useAppContext();
const { esploraBaseUrl } = config;
const { toast } = useToast();
const queryClient = useQueryClient();
const isReady = availability.status === 'available';
// ── Form state ───────────────────────────────────────────────
const [recipientInput, setRecipientInput] = useState('');
const [usdAmount, setUsdAmount] = useState<number | string>(5);
const [feeSpeed, setFeeSpeed] = useState<FeeSpeed>('halfHour');
const [error, setError] = useState('');
const [editingAmount, setEditingAmount] = useState(false);
const [feePopoverOpen, setFeePopoverOpen] = useState(false);
const [success, setSuccess] = useState<SendResult | null>(null);
const amountInputRef = useRef<HTMLInputElement>(null);
const feeSpeedUserChanged = useRef(false);
const recipient = useMemo(() => resolveRecipient(recipientInput), [recipientInput]);
// ── Fee rates ────────────────────────────────────────────────
const { data: feeRates } = useQuery({
queryKey: ['bitcoin-fee-rates', esploraBaseUrl],
queryFn: () => getFeeRates(esploraBaseUrl),
enabled: isOpen && isReady,
staleTime: 30_000,
});
const currentFeeRate = useMemo(() => {
if (!feeRates) return undefined;
return getRateForSpeed(feeRates, feeSpeed);
}, [feeRates, feeSpeed]);
// ── Owned UTXO set ───────────────────────────────────────────
const ownedUtxos: HdSpendableUtxo[] = useMemo(() => scan?.utxos ?? [], [scan]);
const totalBalance = useMemo(() => ownedUtxos.reduce((s, u) => s + u.value, 0), [ownedUtxos]);
// ── USD → sats ───────────────────────────────────────────────
const amountSats = useMemo(() => {
if (!btcPrice) return 0;
const usd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
if (!Number.isFinite(usd) || usd <= 0) return 0;
return Math.round((usd / btcPrice) * 100_000_000);
}, [usdAmount, btcPrice]);
// ── Fee estimate (uses a conservative input count) ───────────
//
// We don't yet know coin selection will use _all_ UTXOs, but using the
// full count is the safe over-estimate (real fee will be ≤ this).
const estimatedFeeSats = useMemo(() => {
if (!ownedUtxos.length || !currentFeeRate || !amountSats) return 0;
const fee2 = estimateFee(ownedUtxos.length, 2, currentFeeRate);
const change = totalBalance - amountSats - fee2;
const numOutputs = change > 546 ? 2 : 1;
return estimateFee(ownedUtxos.length, numOutputs, currentFeeRate);
}, [ownedUtxos.length, currentFeeRate, amountSats, totalBalance]);
const totalSats = amountSats + estimatedFeeSats;
const insufficient = totalBalance > 0 && totalSats > totalBalance;
const showBalance = insufficient || (amountSats > 0 && totalBalance === 0);
// Auto-tune fee speed to keep fees < 40% of the send amount, unless the
// user has manually overridden.
useEffect(() => {
if (feeSpeedUserChanged.current) return;
if (!ownedUtxos.length || !feeRates || amountSats <= 0) return;
const uniqueSpeeds = getUniqueFeeSpeeds(feeRates);
const threshold = amountSats * 0.4;
let target: FeeSpeed = uniqueSpeeds[uniqueSpeeds.length - 1];
for (const speed of uniqueSpeeds) {
const rate = getRateForSpeed(feeRates, speed);
const fee2 = estimateFee(ownedUtxos.length, 2, rate);
const change = totalBalance - amountSats - fee2;
const outputs = change > 546 ? 2 : 1;
const fee = estimateFee(ownedUtxos.length, outputs, rate);
if (fee <= threshold) { target = speed; break; }
}
setFeeSpeed((prev) => (prev === target ? prev : target));
}, [amountSats, feeRates, ownedUtxos.length, totalBalance]);
const handleFeeSpeedChange = useCallback((speed: FeeSpeed) => {
feeSpeedUserChanged.current = true;
setFeeSpeed(speed);
setFeePopoverOpen(false);
}, []);
// ── Two-tap arm + raw-address disclaimer ─────────────────────
const isLarge = isLargeAmount(totalSats, btcPrice);
const isRawAddress = !!recipient && !recipient.pubkey;
const [confirmArmed, setConfirmArmed] = useState(false);
const [acknowledgedPublic, setAcknowledgedPublic] = useState(false);
useEffect(() => {
setConfirmArmed(false);
setAcknowledgedPublic(false);
}, [amountSats, currentFeeRate, btcPrice, recipient?.address]);
const requiresArm = isLarge || isRawAddress;
// ── Amount focus management ──────────────────────────────────
useEffect(() => {
if (editingAmount) {
amountInputRef.current?.focus();
amountInputRef.current?.select();
}
}, [editingAmount]);
const commitAmountEdit = useCallback(() => {
setEditingAmount(false);
if (typeof usdAmount === 'string' && usdAmount.trim() === '') setUsdAmount(0);
}, [usdAmount]);
// ── Send mutation ────────────────────────────────────────────
const [progress, setProgress] = useState<'idle' | 'building' | 'signing' | 'broadcasting'>('idle');
const sendMutation = useMutation<SendResult, Error, void>({
mutationFn: async () => {
if (availability.status !== 'available') {
throw new Error('HD wallet is not available for this login type.');
}
if (!recipient) throw new Error('Enter a Bitcoin address or npub.');
if (!ownedUtxos.length) throw new Error('No spendable Bitcoin in this wallet.');
if (!feeRates) throw new Error('Fee rates not loaded.');
if (recipient.pubkey === availability.pubkey) throw new Error("You can't send to yourself.");
if (amountSats <= 0) throw new Error('Enter an amount.');
if (insufficient) throw new Error('Not enough Bitcoin for this amount + network fee.');
const rate = getRateForSpeed(feeRates, feeSpeed);
const nextChangeIndex = scan?.change.firstUnusedIndex ?? 0;
setProgress('building');
const built = buildHdUnsignedPsbt(
availability.account,
ownedUtxos,
recipient.address,
amountSats,
rate,
nextChangeIndex,
);
setProgress('signing');
const signedHex = signHdPsbt(built.psbtHex, built.inputDerivations, availability.account);
const txHex = finalizeHdPsbt(signedHex);
setProgress('broadcasting');
const txid = await broadcastTransaction(txHex, esploraBaseUrl);
return { txid, amountSats, fee: built.fee };
},
onSuccess: (result) => {
notificationSuccess();
setSuccess(result);
// Invalidate HD wallet caches and the legacy single-address ones too
// (some screens still read them).
queryClient.invalidateQueries({ queryKey: ['hdwallet-scan'] });
queryClient.invalidateQueries({ queryKey: ['hdwallet-txs'] });
void refetchWallet();
},
onError: (err) => {
toast({ title: 'Transaction failed', description: err.message, variant: 'destructive' });
},
onSettled: () => setProgress('idle'),
});
const handleSend = useCallback(() => {
setError('');
if (availability.status !== 'available') {
setError('HD wallet is not available for this login type.'); return;
}
if (!recipient) { setError('Enter a Bitcoin address or npub.'); return; }
if (recipient.pubkey === availability.pubkey) { setError("You can't send to yourself."); return; }
if (!btcPrice) { setError('Waiting for BTC price…'); return; }
if (amountSats <= 0) { setError('Enter an amount.'); return; }
if (!ownedUtxos.length) { setError("You don't have any Bitcoin yet."); return; }
if (insufficient) { setError('Not enough Bitcoin for this amount + network fee.'); return; }
if (isRawAddress && !acknowledgedPublic) {
setError('Acknowledge the privacy warning before sending.'); return;
}
if (requiresArm && !confirmArmed) { setConfirmArmed(true); return; }
sendMutation.mutate();
}, [
availability,
recipient,
btcPrice,
amountSats,
ownedUtxos.length,
insufficient,
isRawAddress,
acknowledgedPublic,
requiresArm,
confirmArmed,
sendMutation,
]);
// ── Reset on close ───────────────────────────────────────────
const handleClose = useCallback(() => {
if (sendMutation.isPending) return;
onClose();
// defer to allow exit animation
setTimeout(() => {
setRecipientInput('');
setUsdAmount(5);
setError('');
setConfirmArmed(false);
setAcknowledgedPublic(false);
setSuccess(null);
feeSpeedUserChanged.current = false;
}, 200);
}, [onClose, sendMutation.isPending]);
// ── Render helpers ───────────────────────────────────────────
const sendButtonLabel = (() => {
if (sendMutation.isPending) {
switch (progress) {
case 'building': return 'Building transaction…';
case 'signing': return 'Signing…';
case 'broadcasting': return 'Broadcasting…';
default: return 'Sending…';
}
}
if (confirmArmed) return 'Tap again to confirm';
return 'Send Bitcoin';
})();
const sendDisabled =
sendMutation.isPending ||
!recipient ||
!btcPrice ||
amountSats <= 0 ||
insufficient ||
!ownedUtxos.length ||
(isRawAddress && !acknowledgedPublic);
// ── Render ───────────────────────────────────────────────────
return (
<Dialog open={isOpen} onOpenChange={(v) => { if (!v) handleClose(); }}>
<DialogContent className="sm:max-w-md p-0 gap-0 overflow-hidden">
<DialogTitle className="sr-only">Send Bitcoin</DialogTitle>
{success ? (
<SuccessScreen
txid={success.txid}
amountSats={success.amountSats}
btcPrice={btcPrice}
onClose={handleClose}
/>
) : (
<div className="grid gap-5 px-6 py-6">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold">Send Bitcoin</h2>
<button
onClick={handleClose}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Close"
>
<X className="size-4" />
</button>
</div>
{/* Amount */}
<div className="flex flex-col items-center py-2">
{editingAmount ? (
<div className="flex items-center text-4xl font-bold tracking-tight">
<span className="text-muted-foreground">$</span>
<Input
ref={amountInputRef}
type="number"
inputMode="decimal"
value={usdAmount}
onChange={(e) => setUsdAmount(e.target.value)}
onBlur={commitAmountEdit}
onKeyDown={(e) => { if (e.key === 'Enter') commitAmountEdit(); }}
className="bg-transparent border-none focus-visible:ring-0 text-4xl font-bold tracking-tight w-32 text-center px-0 h-auto"
/>
</div>
) : (
<button
type="button"
onClick={() => setEditingAmount(true)}
className="text-4xl font-bold tracking-tight hover:text-primary transition-colors cursor-text"
>
${typeof usdAmount === 'number' ? usdAmount : (parseFloat(usdAmount) || 0)}
</button>
)}
{amountSats > 0 && btcPrice && (
<span className="text-xs text-muted-foreground mt-1 tabular-nums">
{amountSats.toLocaleString()} sats
</span>
)}
</div>
{/* USD presets */}
<div className="flex flex-wrap justify-center gap-1.5">
{USD_PRESETS.map((preset) => (
<button
key={preset}
type="button"
onClick={() => { setUsdAmount(preset); setEditingAmount(false); }}
className={cn(
'px-3 py-1 rounded-full text-xs border transition-colors',
Number(usdAmount) === preset
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:bg-muted/50',
)}
>
${preset}
</button>
))}
</div>
{/* Recipient */}
<div className="grid gap-1">
<label className="text-xs text-muted-foreground" htmlFor="hd-recipient-input">
Recipient
</label>
<Input
id="hd-recipient-input"
value={recipientInput}
onChange={(e) => setRecipientInput(e.target.value)}
placeholder="bc1… or npub…"
autoComplete="off"
spellCheck={false}
className="font-mono text-sm"
/>
{recipient && (
<p className="text-xs text-muted-foreground">
{recipient.pubkey ? (
<>Sending to a Nostr user&apos;s on-chain address.</>
) : (
<>Sending to a raw Bitcoin address.</>
)}
</p>
)}
</div>
{/* Privacy disclaimer for raw addresses */}
{isRawAddress && (
<BitcoinPublicDisclaimer
acknowledged={acknowledgedPublic}
onAcknowledgedChange={setAcknowledgedPublic}
/>
)}
{/* Fee speed */}
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Network fee</span>
<Popover open={feePopoverOpen} onOpenChange={setFeePopoverOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 hover:text-foreground transition-colors text-muted-foreground tabular-nums"
>
{estimatedFeeSats > 0 && btcPrice ? (
<> {satsToUSD(estimatedFeeSats, btcPrice)}</>
) : (
<></>
)}
<span className="opacity-60">·</span>
{FEE_SPEED_LABELS[feeSpeed]}
</button>
</PopoverTrigger>
<PopoverContent className="w-44 p-1" align="end">
<div className="grid gap-0.5">
{getUniqueFeeSpeeds(feeRates).map((speed) => (
<button
key={speed}
type="button"
onClick={() => handleFeeSpeedChange(speed)}
className={cn(
'flex justify-between items-center px-3 py-1.5 rounded-md text-xs hover:bg-muted/50 transition-colors',
feeSpeed === speed && 'bg-muted',
)}
>
<span>{FEE_SPEED_LABELS[speed]}</span>
{feeRates && (
<span className="text-muted-foreground tabular-nums">
{getRateForSpeed(feeRates, speed)} sat/vB
</span>
)}
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
{showBalance && totalBalance > 0 && btcPrice && (
<p className="text-xs text-muted-foreground text-center">
Available: {satsToUSD(totalBalance, btcPrice)} ({totalBalance.toLocaleString()} sats)
</p>
)}
{/* Error */}
{error && (
<Alert variant="destructive" className="py-2">
<AlertTriangle className="size-3.5" />
<AlertDescription className="text-xs">{error}</AlertDescription>
</Alert>
)}
{/* Send button */}
<Button
type="button"
onClick={handleSend}
disabled={sendDisabled}
className={cn(
'w-full',
confirmArmed && !sendMutation.isPending && 'bg-amber-500 hover:bg-amber-600 text-white',
)}
>
{sendMutation.isPending && <Loader2 className="size-4 mr-2 animate-spin" />}
{sendButtonLabel}
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Success screen
// ---------------------------------------------------------------------------
interface SuccessScreenProps {
txid: string;
amountSats: number;
btcPrice: number | undefined;
onClose: () => void;
}
function SuccessScreen({ txid, amountSats, btcPrice, onClose }: SuccessScreenProps) {
const usdDisplay = btcPrice ? satsToUSD(amountSats, btcPrice) : '';
return (
<div
role="status"
aria-live="polite"
className="relative grid gap-5 px-6 py-8 w-full overflow-hidden text-center motion-safe:animate-success-fade-up"
>
<div
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_50%_35%,hsl(var(--primary)/0.18),transparent_65%)]"
/>
<div className="relative mx-auto flex size-28 items-center justify-center">
<span
aria-hidden
className="absolute inset-0 rounded-full bg-gradient-to-br from-amber-400/40 to-orange-500/30 motion-safe:animate-success-halo"
/>
<span
aria-hidden
className="absolute inset-0 rounded-full bg-gradient-to-br from-amber-400 to-orange-500 shadow-lg shadow-orange-500/30 motion-safe:animate-success-pop"
/>
<Check className="relative size-14 text-white drop-shadow-sm motion-safe:animate-success-pop" strokeWidth={3} aria-hidden />
</div>
<div className="grid gap-1">
<h2 className="text-lg font-semibold tracking-tight">Bitcoin sent</h2>
<div className="text-4xl font-bold tabular-nums bg-gradient-to-br from-amber-500 to-orange-600 bg-clip-text text-transparent">
{usdDisplay || `${amountSats.toLocaleString()} sats`}
</div>
</div>
<div className="grid gap-2">
<Button type="button" variant="outline" asChild className="w-full">
<Link to={`/i/bitcoin:tx:${txid}`} onClick={onClose}>
<ExternalLink className="size-4 mr-2" />
View transaction
</Link>
</Button>
<Button type="button" onClick={onClose} className="w-full">Done</Button>
</div>
</div>
);
}
+184
View File
@@ -0,0 +1,184 @@
import { useCallback, useMemo } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useAppContext } from '@/hooks/useAppContext';
import { useSecureLocalStorage } from '@/hooks/useSecureLocalStorage';
import { useHdWalletAccess, type HdWalletAvailability } from '@/hooks/useHdWalletAccess';
import { deriveReceiveAddress, type DerivedAddress } from '@/lib/hdwallet/derivation';
import {
type AccountScanResult,
fetchHdTransactions,
type HdTransaction,
scanAccount,
} from '@/lib/hdwallet/scan';
// ---------------------------------------------------------------------------
// Persisted UI cursor (per user)
// ---------------------------------------------------------------------------
//
// We persist a single integer per user: the "preferred receive index" — the
// index of the address we are *currently advertising* on the wallet page.
// The chain-scan source of truth is the relay-derived `firstUnusedIndex`,
// but if the user explicitly bumps to a fresh address (or rotates back),
// we honour that until the chain catches up.
//
// On native, this lives in the Keychain / KeyStore via secureStorage. On web
// it's localStorage. Either way it's not secret — losing it means we fall
// back to firstUnusedIndex on next login.
const STORAGE_KEY = (pubkey: string) => `hdwallet:cursor:${pubkey}`;
interface PersistedCursor {
/** Currently-displayed receive index. */
receiveIndex: number;
}
const DEFAULT_CURSOR: PersistedCursor = { receiveIndex: 0 };
// ---------------------------------------------------------------------------
// Query refresh cadence
// ---------------------------------------------------------------------------
/** Re-scan + refresh balances every 60 s. Slower than the single-address
* wallet (30 s) because each scan can be 1040 Esplora calls. */
const REFRESH_INTERVAL_MS = 60_000;
// ---------------------------------------------------------------------------
// Return shape
// ---------------------------------------------------------------------------
export interface UseHdWalletResult {
/** Availability status — mirrors `useHdWalletAccess`. */
availability: HdWalletAvailability;
/** Currently-advertised receive address (the one the UI shows). */
currentReceiveAddress?: DerivedAddress;
/** Full scan result — UTXOs, used addresses, etc. */
scan?: AccountScanResult;
/** Aggregated wallet-level transaction history (newest first). */
transactions?: HdTransaction[];
/** Confirmed + pending balance in sats. */
totalBalance: number;
/** Pending (mempool) balance in sats. May be negative for outgoing. */
pendingBalance: number;
/** Initial scan in progress. */
isLoading: boolean;
/** Either scan or tx-history loading. */
isFetching: boolean;
/** Scan error, if any. */
error: unknown;
/** Trigger a manual scan + tx refresh. */
refetch: () => Promise<unknown>;
/** Advance the receive cursor to the next unused address. Persisted. */
nextReceiveAddress: () => DerivedAddress | undefined;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
/**
* Top-level HD wallet hook. Returns the cached scan, balance, transactions,
* and the current receive address.
*
* The hook is safe to call regardless of login state — it returns
* `availability.status !== 'available'` for non-nsec users without doing any
* derivation or network work.
*/
export function useHdWallet(): UseHdWalletResult {
const { config } = useAppContext();
const { esploraBaseUrl } = config;
const availability = useHdWalletAccess();
const queryClient = useQueryClient();
const pubkey = availability.status === 'available' ? availability.pubkey : '';
const account = availability.status === 'available' ? availability.account : undefined;
// ── Persisted receive cursor ─────────────────────────────────
// Key by pubkey so account-switching doesn't leak indices across users.
const [cursor, setCursor] = useSecureLocalStorage<PersistedCursor>(
pubkey ? STORAGE_KEY(pubkey) : 'hdwallet:cursor:none',
DEFAULT_CURSOR,
);
// ── Scan query ───────────────────────────────────────────────
const scanKey = ['hdwallet-scan', esploraBaseUrl, pubkey];
const {
data: scan,
isLoading: scanLoading,
isFetching: scanFetching,
error: scanError,
refetch: refetchScan,
} = useQuery<AccountScanResult>({
queryKey: scanKey,
queryFn: async ({ signal }) => {
if (!account) throw new Error('HD wallet account unavailable');
return scanAccount(account, esploraBaseUrl, signal);
},
enabled: !!account,
refetchInterval: REFRESH_INTERVAL_MS,
staleTime: REFRESH_INTERVAL_MS / 2,
});
// ── Transaction history query ────────────────────────────────
const {
data: transactions,
isFetching: txFetching,
refetch: refetchTxs,
} = useQuery<HdTransaction[]>({
queryKey: ['hdwallet-txs', esploraBaseUrl, pubkey, scan?.receive.used.length, scan?.change.used.length],
queryFn: async ({ signal }) => {
if (!scan) return [];
return fetchHdTransactions(scan, esploraBaseUrl, signal);
},
enabled: !!scan,
refetchInterval: REFRESH_INTERVAL_MS,
staleTime: REFRESH_INTERVAL_MS / 2,
});
// ── Current receive address ──────────────────────────────────
//
// Resolution rules, in order:
// 1. If the persisted cursor is *behind* the chain-derived
// firstUnusedIndex, the persisted index has been used by a sender →
// auto-advance to firstUnusedIndex. (No address reuse.)
// 2. If the persisted cursor is *ahead* of firstUnusedIndex (user clicked
// "next" multiple times without any deposits), honour it.
// 3. Otherwise use the chain-derived firstUnusedIndex.
const currentReceiveAddress = useMemo<DerivedAddress | undefined>(() => {
if (!account) return undefined;
const chainNextUnused = scan?.receive.firstUnusedIndex ?? 0;
const resolved = Math.max(chainNextUnused, cursor.receiveIndex);
return deriveReceiveAddress(account, resolved);
}, [account, scan, cursor.receiveIndex]);
// ── Advance to next receive address ──────────────────────────
const nextReceiveAddress = useCallback((): DerivedAddress | undefined => {
if (!account) return undefined;
const chainNextUnused = scan?.receive.firstUnusedIndex ?? 0;
const current = Math.max(chainNextUnused, cursor.receiveIndex);
const next = current + 1;
setCursor({ receiveIndex: next });
return deriveReceiveAddress(account, next);
}, [account, scan, cursor.receiveIndex, setCursor]);
// ── Unified refetch ──────────────────────────────────────────
const refetch = useCallback(async () => {
// Invalidate UTXO/balance queries used by the send dialog too.
await queryClient.invalidateQueries({ queryKey: ['hdwallet-scan'] });
return Promise.all([refetchScan(), refetchTxs()]);
}, [queryClient, refetchScan, refetchTxs]);
return {
availability,
currentReceiveAddress,
scan,
transactions,
totalBalance: scan?.totalBalance ?? 0,
pendingBalance: scan?.pendingBalance ?? 0,
isLoading: scanLoading,
isFetching: scanFetching || txFetching,
error: scanError,
refetch,
nextReceiveAddress,
};
}
+67
View File
@@ -0,0 +1,67 @@
import { useMemo } from 'react';
import { useNostrLogin } from '@nostrify/react/login';
import { nip19 } from 'nostr-tools';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { deriveAccountFromNsec, type HdAccount } from '@/lib/hdwallet/derivation';
/**
* Aggregate availability of the HD wallet for the active login.
*
* The HD wallet derives all of its keys from the user's raw Nostr secret key.
* Only the `nsec` login type stores that key in a form we can read; both the
* NIP-07 browser extension and NIP-46 remote bunker keep the key elsewhere
* (the extension never exposes it; the bunker never sends it). Without the
* raw secret we cannot derive child keys, so the HD wallet is gated to nsec
* logins.
*/
export type HdWalletAvailability =
/** User logged in with nsec — full HD wallet access. */
| { status: 'available'; account: HdAccount; nsecBytes: Uint8Array; pubkey: string }
/** Not logged in at all. */
| { status: 'logged-out' }
/** Logged in, but the login type doesn't expose the secret key. */
| { status: 'unsupported'; loginType: 'extension' | 'bunker' | 'other' };
/**
* Hook that returns whether the HD wallet is usable for the active login,
* and (when usable) the derived BIP86 account.
*
* **Security note**: the returned `account` holds private extended keys in
* memory for as long as the consumer holds the reference. This is unavoidable
* for a wallet that signs locally — the nsec is already in plaintext
* localStorage in the same threat model.
*
* The hook intentionally re-derives on every login change rather than caching
* across logouts, so a fresh login starts from a clean derivation.
*/
export function useHdWalletAccess(): HdWalletAvailability {
const { user } = useCurrentUser();
const { logins } = useNostrLogin();
const activeLogin = logins[0];
return useMemo<HdWalletAvailability>(() => {
if (!user || !activeLogin) return { status: 'logged-out' };
if (activeLogin.type !== 'nsec') {
const loginType =
activeLogin.type === 'extension'
? 'extension'
: activeLogin.type === 'bunker'
? 'bunker'
: 'other';
return { status: 'unsupported', loginType };
}
// Decode the nsec → 32-byte secret key, then derive the BIP86 account.
const decoded = nip19.decode(activeLogin.data.nsec);
if (decoded.type !== 'nsec') {
// Defensive — should be impossible given the discriminated union.
return { status: 'unsupported', loginType: 'other' };
}
const nsecBytes = decoded.data;
const account = deriveAccountFromNsec(nsecBytes);
return { status: 'available', account, nsecBytes, pubkey: user.pubkey };
}, [user, activeLogin]);
}
+234
View File
@@ -0,0 +1,234 @@
import * as bitcoin from 'bitcoinjs-lib';
import { toXOnly } from 'bitcoinjs-lib';
import { HDKey } from '@scure/bip32';
import { hkdf } from '@noble/hashes/hkdf';
import { sha256 } from '@noble/hashes/sha2';
import * as ecc from '@bitcoinerlab/secp256k1';
import { ECPairFactory, type ECPairAPI } from 'ecpair';
// ---------------------------------------------------------------------------
// HD wallet derivation (BIP86 — Taproot single-key, key-path-only)
// ---------------------------------------------------------------------------
//
// This wallet derives a full BIP32 hierarchy from the user's Nostr secret key
// (nsec). There is no separate BIP39 mnemonic — the 32-byte secret key is
// stretched through HKDF-SHA-256 with an app-specific info string to a 64-byte
// BIP32 seed, then run through standard BIP86 derivation:
//
// m / 86' / 0' / 0' / change / index
//
// `change ∈ {0, 1}` distinguishes the receive chain (external, advertised to
// senders) from the change chain (internal, only used as our own change
// outputs). Industry standard: never reuse addresses. The wallet always
// advances to the next unused index on the receive chain when an address is
// shown, and emits change to a fresh index on the change chain.
//
// Output script: P2TR with the derived xonly pubkey as `internalPubkey` (no
// tapscript tree — key-path spends only), per BIP86.
//
// ---------------------------------------------------------------------------
/** HKDF info string. Change ⇒ all derived wallets change. Do not edit. */
const HKDF_INFO = 'agora-hdwallet:bip86:v1';
/** Standard BIP86 account base path. */
const BIP86_ACCOUNT_PATH = "m/86'/0'/0'";
/** External (receive) chain index. */
export const RECEIVE_CHAIN = 0;
/** Internal (change) chain index. */
export const CHANGE_CHAIN = 1;
/** Network — mainnet only. Testnet support is intentionally omitted. */
const NETWORK = bitcoin.networks.bitcoin;
// ---------------------------------------------------------------------------
// ECC initialisation (lazy)
// ---------------------------------------------------------------------------
let _ECPair: ECPairAPI | null = null;
let _eccInitialized = false;
/** Initialize bitcoinjs-lib's ECC backend exactly once. */
function ensureEcc(): void {
if (!_eccInitialized) {
bitcoin.initEccLib(ecc);
_eccInitialized = true;
}
}
function getECPair(): ECPairAPI {
ensureEcc();
if (!_ECPair) _ECPair = ECPairFactory(ecc);
return _ECPair;
}
// ---------------------------------------------------------------------------
// Seed derivation
// ---------------------------------------------------------------------------
/**
* Stretch a Nostr secret key (32 bytes) into a 64-byte BIP32 seed via
* HKDF-SHA-256.
*
* - The Nostr secret key serves as input keying material (IKM).
* - A fixed app-specific `info` string domain-separates the output from any
* other use of the same key (e.g. NIP-44 ECDH). This means the Bitcoin
* wallet cannot be recovered by anyone holding only a NIP-44 conversation
* key, only by someone holding the raw secret key itself.
* - No salt: deterministic output for the same nsec across all devices.
*
* @param nsecBytes 32-byte raw Nostr secret key (the `data` field from
* `nip19.decode(nsec)`).
* @returns 64-byte seed suitable for `HDKey.fromMasterSeed`.
*/
export function nsecToBip32Seed(nsecBytes: Uint8Array): Uint8Array {
if (nsecBytes.length !== 32) {
throw new Error('nsec must be 32 bytes');
}
return hkdf(sha256, nsecBytes, undefined, HKDF_INFO, 64);
}
// ---------------------------------------------------------------------------
// HD key handles
// ---------------------------------------------------------------------------
/** Result of deriving the account-level xpub. */
export interface HdAccount {
/** Account-level extended public key. Used to derive receive/change chains. */
accountNode: HDKey;
/** External-chain extended public key (m/86'/0'/0'/0). */
receiveNode: HDKey;
/** Internal/change-chain extended public key (m/86'/0'/0'/1). */
changeNode: HDKey;
}
/**
* Derive the BIP86 account hierarchy from a raw Nostr secret key.
*
* Returns extended **private** keys (so signing is possible). For balance
* scanning, prefer `deriveWatchOnlyAccount` which only needs the xpub.
*/
export function deriveAccountFromNsec(nsecBytes: Uint8Array): HdAccount {
const seed = nsecToBip32Seed(nsecBytes);
const root = HDKey.fromMasterSeed(seed);
const accountNode = root.derive(BIP86_ACCOUNT_PATH);
const receiveNode = accountNode.deriveChild(RECEIVE_CHAIN);
const changeNode = accountNode.deriveChild(CHANGE_CHAIN);
return { accountNode, receiveNode, changeNode };
}
// ---------------------------------------------------------------------------
// Address derivation
// ---------------------------------------------------------------------------
/** A single derived address with everything needed to spend from it. */
export interface DerivedAddress {
/** Bech32m P2TR address (bc1p…). */
address: string;
/** 32-byte x-only internal pubkey (hex). */
internalPubkeyHex: string;
/** Chain (0 = receive, 1 = change). */
chain: 0 | 1;
/** Address index within the chain. */
index: number;
/** Full BIP32 path, e.g. `m/86'/0'/0'/0/3`. */
path: string;
}
/**
* Derive a single P2TR address from a chain extended key (either the receive
* or change node). The chain index is supplied so the returned `path` is
* accurate.
*/
export function deriveAddress(chainNode: HDKey, chain: 0 | 1, index: number): DerivedAddress {
ensureEcc();
if (!Number.isInteger(index) || index < 0) {
throw new Error(`Invalid address index: ${index}`);
}
const child = chainNode.deriveChild(index);
const pubkey = child.publicKey;
if (!pubkey) throw new Error('HDKey is missing a public key');
// BIP86: drop the parity byte to get the 32-byte x-only key.
const internalPubkey = Buffer.from(toXOnly(Buffer.from(pubkey)));
const { address } = bitcoin.payments.p2tr({
internalPubkey,
network: NETWORK,
});
if (!address) throw new Error('Failed to derive P2TR address');
return {
address,
internalPubkeyHex: internalPubkey.toString('hex'),
chain,
index,
path: `${BIP86_ACCOUNT_PATH}/${chain}/${index}`,
};
}
/** Convenience: derive a single receive address at the given index. */
export function deriveReceiveAddress(account: HdAccount, index: number): DerivedAddress {
return deriveAddress(account.receiveNode, RECEIVE_CHAIN, index);
}
/** Convenience: derive a single change address at the given index. */
export function deriveChangeAddress(account: HdAccount, index: number): DerivedAddress {
return deriveAddress(account.changeNode, CHANGE_CHAIN, index);
}
// ---------------------------------------------------------------------------
// Signing keys
// ---------------------------------------------------------------------------
/**
* Derive the 32-byte raw private key for a specific (chain, index) leaf.
*
* The caller is responsible for zeroing the returned buffer when done (best
* effort — JS does not guarantee this). This function is the only place where
* leaf private keys are materialised.
*/
export function deriveLeafPrivateKey(
account: HdAccount,
chain: 0 | 1,
index: number,
): Uint8Array {
const chainNode = chain === RECEIVE_CHAIN ? account.receiveNode : account.changeNode;
const child = chainNode.deriveChild(index);
if (!child.privateKey) {
throw new Error('Derived HDKey has no private key (xpub-only?)');
}
// Defensive copy — @scure/bip32 holds an internal reference.
return new Uint8Array(child.privateKey);
}
/**
* Compute the BIP-341 TapTweaked signing keypair for a given leaf. Returns an
* ECPair instance whose private scalar is `priv + H_tapTweak(P)` mod n.
*
* Used by the PSBT signer.
*/
export function deriveLeafTaprootSigner(
account: HdAccount,
chain: 0 | 1,
index: number,
): ReturnType<ECPairAPI['fromPrivateKey']> {
const ECPair = getECPair();
const privKey = deriveLeafPrivateKey(account, chain, index);
try {
const keyPair = ECPair.fromPrivateKey(Buffer.from(privKey));
const internalPubkey = toXOnly(keyPair.publicKey);
return keyPair.tweak(bitcoin.crypto.taggedHash('TapTweak', internalPubkey));
} finally {
// Best-effort wipe of the local copy.
privKey.fill(0);
}
}
// ---------------------------------------------------------------------------
// Network constant export
// ---------------------------------------------------------------------------
export { NETWORK as HD_WALLET_NETWORK };
+292
View File
@@ -0,0 +1,292 @@
import {
type AddressData,
fetchAddressData,
fetchTransactions,
fetchUTXOs,
type UTXO,
} from '@/lib/bitcoin';
import {
CHANGE_CHAIN,
type DerivedAddress,
deriveAddress,
type HdAccount,
RECEIVE_CHAIN,
} from './derivation';
// ---------------------------------------------------------------------------
// Gap-limit chain scanning
// ---------------------------------------------------------------------------
//
// BIP44 gap limit: a wallet considers a chain "fully scanned" after observing
// `GAP_LIMIT` consecutive addresses that have never been used (zero history).
// Industry standard is 20.
//
// We scan in batches of `SCAN_BATCH_SIZE` to amortise round-trip latency
// while still bounding fan-out on the Esplora server.
// ---------------------------------------------------------------------------
/** Standard BIP44 gap limit. */
export const GAP_LIMIT = 20;
/** Number of addresses fetched per request batch. */
const SCAN_BATCH_SIZE = 5;
/** Hard ceiling on addresses scanned per chain. Protects against bugs/loops. */
const MAX_INDEX = 10_000;
/** Information about a single derived address that has been observed. */
export interface ScannedAddress {
derived: DerivedAddress;
data: AddressData;
utxos: UTXO[];
}
/** Full scan result for a single chain (receive or change). */
export interface ChainScanResult {
/** All addresses with any history (tx_count > 0 on either confirmed or mempool). */
used: ScannedAddress[];
/** All addresses currently holding spendable UTXOs (incl. unconfirmed). */
withBalance: ScannedAddress[];
/** Index of the first address with no history (the "next" address to advertise). */
firstUnusedIndex: number;
/** Whether the scan hit MAX_INDEX without finding a clean gap. */
hitMaxIndex: boolean;
}
/** Combined receive+change scan result for an entire account. */
export interface AccountScanResult {
receive: ChainScanResult;
change: ChainScanResult;
/** All UTXOs across both chains. */
utxos: Array<UTXO & { address: string; chain: 0 | 1; index: number }>;
/** Confirmed + pending balance in satoshis, summed across both chains. */
totalBalance: number;
/** Sum of `pendingBalance` across all addresses (positive = incoming, negative = outgoing). */
pendingBalance: number;
/** Map from address → derived metadata. Used by the tx aggregator and signer. */
addressMap: Map<string, DerivedAddress>;
}
/**
* Has this address ever been used? "Used" means it has any history at all,
* confirmed or in the mempool. We treat the address as advertised-and-burned
* the moment a sender touches it.
*/
function isUsed(data: AddressData): boolean {
return data.txCount > 0 || data.pendingTxCount > 0;
}
/**
* Scan a single chain (receive or change) until `GAP_LIMIT` consecutive
* unused addresses are observed.
*/
async function scanChain(
account: HdAccount,
chain: 0 | 1,
esploraBaseUrl: string,
signal?: AbortSignal,
): Promise<ChainScanResult> {
const chainNode = chain === RECEIVE_CHAIN ? account.receiveNode : account.changeNode;
const used: ScannedAddress[] = [];
const withBalance: ScannedAddress[] = [];
let firstUnusedIndex = 0;
let firstUnusedSet = false;
let consecutiveUnused = 0;
let index = 0;
let hitMaxIndex = false;
while (consecutiveUnused < GAP_LIMIT) {
if (index >= MAX_INDEX) {
hitMaxIndex = true;
break;
}
// Build the next batch of addresses to scan.
const batch: DerivedAddress[] = [];
for (let i = 0; i < SCAN_BATCH_SIZE && consecutiveUnused + i < GAP_LIMIT && index + i < MAX_INDEX; i++) {
batch.push(deriveAddress(chainNode, chain, index + i));
}
if (batch.length === 0) break;
// Fetch address data in parallel. UTXOs are only fetched for addresses
// that turn out to be used — we avoid speculative UTXO calls for the
// ~20 "tail" addresses at the end of every scan.
const dataResults = await Promise.all(
batch.map(async (d) => {
signal?.throwIfAborted();
const data = await fetchAddressData(d.address, esploraBaseUrl);
return { d, data };
}),
);
for (const { d, data } of dataResults) {
if (isUsed(data)) {
// Used — reset gap counter, fetch UTXOs for spending.
signal?.throwIfAborted();
const utxos = await fetchUTXOs(d.address, esploraBaseUrl);
const sa: ScannedAddress = { derived: d, data, utxos };
used.push(sa);
if (utxos.length > 0 || data.totalBalance > 0) withBalance.push(sa);
consecutiveUnused = 0;
// Do NOT update firstUnusedIndex here — we want the first index that
// has never been used, so it stays pointed at the earliest gap.
} else {
if (!firstUnusedSet) {
firstUnusedIndex = d.index;
firstUnusedSet = true;
}
consecutiveUnused++;
}
}
index += batch.length;
}
// Edge case: the chain has zero used addresses. firstUnusedIndex stays 0.
if (!firstUnusedSet) firstUnusedIndex = 0;
return { used, withBalance, firstUnusedIndex, hitMaxIndex };
}
/**
* Scan both chains (receive and change) for an HD account and aggregate the
* results.
*
* @param account The derived HD account.
* @param esploraBaseUrl Esplora REST root, no trailing slash.
* @param signal Optional abort signal.
*/
export async function scanAccount(
account: HdAccount,
esploraBaseUrl: string,
signal?: AbortSignal,
): Promise<AccountScanResult> {
// Both chains in parallel — they're independent of each other.
const [receive, change] = await Promise.all([
scanChain(account, RECEIVE_CHAIN, esploraBaseUrl, signal),
scanChain(account, CHANGE_CHAIN, esploraBaseUrl, signal),
]);
const addressMap = new Map<string, DerivedAddress>();
for (const sa of receive.used) addressMap.set(sa.derived.address, sa.derived);
for (const sa of change.used) addressMap.set(sa.derived.address, sa.derived);
const utxos: AccountScanResult['utxos'] = [];
let totalBalance = 0;
let pendingBalance = 0;
for (const chainResult of [receive, change]) {
for (const sa of chainResult.used) {
totalBalance += sa.data.totalBalance;
pendingBalance += sa.data.pendingBalance;
for (const u of sa.utxos) {
utxos.push({
...u,
address: sa.derived.address,
chain: sa.derived.chain,
index: sa.derived.index,
});
}
}
}
return { receive, change, utxos, totalBalance, pendingBalance, addressMap };
}
// ---------------------------------------------------------------------------
// Aggregated transaction history
// ---------------------------------------------------------------------------
/**
* Aggregated transaction record for an HD wallet. Unlike the per-address
* `Transaction` from `bitcoin.ts`, this one merges all on-chain activity
* across every owned address so a single send-with-change tx shows up as one
* row rather than two.
*/
export interface HdTransaction {
txid: string;
/** Net satoshi change across the entire wallet (positive = received, negative = sent). */
amount: number;
/** Send or receive (based on net amount sign). */
type: 'receive' | 'send';
confirmed: boolean;
timestamp?: number;
}
/**
* Fetch per-address transaction lists for every used address and combine
* them by txid. A single transaction that hits multiple owned addresses
* (e.g. send-with-change) is merged into one record whose `amount` is the
* net wallet-level change.
*/
export async function fetchHdTransactions(
result: AccountScanResult,
esploraBaseUrl: string,
signal?: AbortSignal,
): Promise<HdTransaction[]> {
const allUsed = [...result.receive.used, ...result.change.used];
if (allUsed.length === 0) return [];
// Fetch each address's tx list in parallel. Each call returns a simplified
// per-address view from `fetchTransactions` (net positive/negative).
const perAddress = await Promise.all(
allUsed.map(async (sa) => {
signal?.throwIfAborted();
const txs = await fetchTransactions(sa.derived.address, esploraBaseUrl);
return txs.map((tx) => ({
...tx,
// `fetchTransactions` returns Math.abs(net); recover the signed value.
signedAmount: tx.type === 'receive' ? tx.amount : -tx.amount,
}));
}),
);
// Merge by txid — sum signed amounts so that send-with-change collapses.
const merged = new Map<string, {
txid: string;
netSats: number;
confirmed: boolean;
timestamp?: number;
}>();
for (const list of perAddress) {
for (const tx of list) {
const existing = merged.get(tx.txid);
if (existing) {
existing.netSats += tx.signedAmount;
// Once confirmed, stay confirmed.
existing.confirmed = existing.confirmed || tx.confirmed;
// Prefer the earliest known timestamp.
if (tx.timestamp && (!existing.timestamp || tx.timestamp < existing.timestamp)) {
existing.timestamp = tx.timestamp;
}
} else {
merged.set(tx.txid, {
txid: tx.txid,
netSats: tx.signedAmount,
confirmed: tx.confirmed,
timestamp: tx.timestamp,
});
}
}
}
const out: HdTransaction[] = Array.from(merged.values()).map((m) => ({
txid: m.txid,
amount: Math.abs(m.netSats),
type: m.netSats >= 0 ? 'receive' : 'send',
confirmed: m.confirmed,
timestamp: m.timestamp,
}));
// Sort newest first. Unconfirmed (no timestamp) go to the top.
out.sort((a, b) => {
if (!a.timestamp && !b.timestamp) return 0;
if (!a.timestamp) return -1;
if (!b.timestamp) return 1;
return b.timestamp - a.timestamp;
});
return out;
}
+292
View File
@@ -0,0 +1,292 @@
import * as bitcoin from 'bitcoinjs-lib';
import * as ecc from '@bitcoinerlab/secp256k1';
import {
BITCOIN_DUST_LIMIT,
estimateFee,
type UTXO,
validateBitcoinAddress,
} from '@/lib/bitcoin';
import {
CHANGE_CHAIN,
deriveAddress,
deriveLeafTaprootSigner,
type HdAccount,
HD_WALLET_NETWORK,
} from './derivation';
// ---------------------------------------------------------------------------
// HD wallet transaction construction & signing
// ---------------------------------------------------------------------------
//
// A "spendable" UTXO is one of our own UTXOs together with the (chain, index)
// pair that derived its address. We need both to:
//
// 1. Set `tapInternalKey` on the PSBT input correctly per BIP-371.
// 2. Reconstruct the BIP-341 tweaked private key when signing.
//
// Change always goes to a fresh address on the internal (change) chain —
// never to a receive-chain address — to keep the on-chain heuristic
// "change-from-same-owner is the smaller output on the change chain" intact.
// ---------------------------------------------------------------------------
/** A UTXO owned by the HD wallet, annotated with derivation info. */
export interface HdSpendableUtxo extends UTXO {
/** The Bitcoin address the UTXO belongs to. */
address: string;
/** Chain index (0 = receive, 1 = change). */
chain: 0 | 1;
/** Address index within the chain. */
index: number;
}
/** Result of building an unsigned PSBT for the HD wallet. */
export interface HdUnsignedPsbt {
/** Hex-encoded unsigned PSBT. */
psbtHex: string;
/** Network fee in satoshis. */
fee: number;
/** Whether a change output was added. */
hasChange: boolean;
/** Address used for change (if any). */
changeAddress?: string;
/** Per-input (chain, index) so signing knows which key to derive. */
inputDerivations: Array<{ chain: 0 | 1; index: number }>;
}
// ---------------------------------------------------------------------------
// Coin selection
// ---------------------------------------------------------------------------
/**
* Branch-and-bound is overkill here. A "largest-first" coin selector is fine
* for a wallet whose UTXO set is bounded by gap-limit scanning:
*
* - Picks the smallest set of inputs that covers `target + fee`.
* - Prefers confirmed UTXOs over unconfirmed.
* - Returns the candidate set OR `null` if balance is insufficient.
*
* This deliberately avoids the privacy pitfall of the "smallest first"
* heuristic (which signals "I am consolidating dust" to chain analysis).
*/
function selectUtxos(
utxos: readonly HdSpendableUtxo[],
target: number,
feeRate: number,
): { selected: HdSpendableUtxo[]; total: number } | null {
// Confirmed first, then largest first within each group.
const sorted = [...utxos].sort((a, b) => {
if (a.status.confirmed !== b.status.confirmed) return a.status.confirmed ? -1 : 1;
return b.value - a.value;
});
const selected: HdSpendableUtxo[] = [];
let total = 0;
for (const utxo of sorted) {
selected.push(utxo);
total += utxo.value;
// Fee for current set, assuming 2 outputs (recipient + change). If we
// can cover target+fee even without change, we'll get a chance to drop
// the change output below.
const feeWithChange = estimateFee(selected.length, 2, feeRate);
if (total >= target + feeWithChange) return { selected, total };
const feeNoChange = estimateFee(selected.length, 1, feeRate);
if (total >= target + feeNoChange) return { selected, total };
}
return null;
}
// ---------------------------------------------------------------------------
// PSBT build
// ---------------------------------------------------------------------------
/**
* Build an unsigned P2TR PSBT for the HD wallet.
*
* Inputs are chosen by `selectUtxos`. Change (if any) goes to a fresh address
* on the internal chain, derived at `account.changeNode / nextChangeIndex`.
*
* @param account HD account (used to derive change address & re-derive input keys).
* @param ownedUtxos Candidate UTXOs to spend (must include `chain`/`index`).
* @param toAddress Recipient Bitcoin address.
* @param amountSats Amount to send in satoshis (must be >= dust limit).
* @param feeRate Fee rate in sat/vB.
* @param nextChangeIndex The next unused index on the change chain.
*/
export function buildHdUnsignedPsbt(
account: HdAccount,
ownedUtxos: readonly HdSpendableUtxo[],
toAddress: string,
amountSats: number,
feeRate: number,
nextChangeIndex: number,
): HdUnsignedPsbt {
if (!validateBitcoinAddress(toAddress)) {
throw new Error(`Invalid Bitcoin address: ${toAddress}`);
}
if (!Number.isInteger(amountSats) || amountSats < BITCOIN_DUST_LIMIT) {
throw new Error(`Amount must be at least ${BITCOIN_DUST_LIMIT} sats.`);
}
if (!Number.isFinite(feeRate) || feeRate <= 0) {
throw new Error('Fee rate must be positive.');
}
const selection = selectUtxos(ownedUtxos, amountSats, feeRate);
if (!selection) {
const total = ownedUtxos.reduce((s, u) => s + u.value, 0);
throw new Error(
`Insufficient funds. Need at least ${amountSats.toLocaleString()} sats + fees, ` +
`have ${total.toLocaleString()} sats spendable.`,
);
}
const { selected, total: totalInput } = selection;
// Recompute fee for the final selected set.
const feeWithChange = estimateFee(selected.length, 2, feeRate);
const changeIfKept = totalInput - amountSats - feeWithChange;
const hasChange = changeIfKept >= BITCOIN_DUST_LIMIT;
const numOutputs = hasChange ? 2 : 1;
const fee = estimateFee(selected.length, numOutputs, feeRate);
const change = totalInput - amountSats - fee;
if (change < 0) {
throw new Error(
`Insufficient funds. Need ${(amountSats + fee).toLocaleString()} sats, ` +
`have ${totalInput.toLocaleString()} sats.`,
);
}
bitcoin.initEccLib(ecc);
const psbt = new bitcoin.Psbt({ network: HD_WALLET_NETWORK });
const inputDerivations: HdUnsignedPsbt['inputDerivations'] = [];
for (const utxo of selected) {
// Re-derive the input's internal key from the account hierarchy. We do
// not trust the address string for this — the chain/index pair is the
// single source of truth.
const derived = deriveAddress(
utxo.chain === CHANGE_CHAIN ? account.changeNode : account.receiveNode,
utxo.chain,
utxo.index,
);
if (derived.address !== utxo.address) {
throw new Error(
`UTXO address mismatch at ${utxo.chain}/${utxo.index}: ` +
`expected ${derived.address}, got ${utxo.address}`,
);
}
const internalPubkey = Buffer.from(derived.internalPubkeyHex, 'hex');
psbt.addInput({
hash: utxo.txid,
index: utxo.vout,
witnessUtxo: {
script: bitcoin.payments.p2tr({
internalPubkey,
network: HD_WALLET_NETWORK,
}).output!,
value: BigInt(utxo.value),
},
tapInternalKey: internalPubkey,
});
inputDerivations.push({ chain: utxo.chain, index: utxo.index });
}
psbt.addOutput({ address: toAddress, value: BigInt(amountSats) });
let changeAddress: string | undefined;
if (hasChange) {
const changeDerived = deriveAddress(account.changeNode, CHANGE_CHAIN, nextChangeIndex);
changeAddress = changeDerived.address;
psbt.addOutput({ address: changeAddress, value: BigInt(change) });
}
return {
psbtHex: psbt.toHex(),
fee,
hasChange,
changeAddress,
inputDerivations,
};
}
// ---------------------------------------------------------------------------
// PSBT signing
// ---------------------------------------------------------------------------
/**
* Sign every input in a PSBT using its corresponding HD-derived tweaked key.
*
* `inputDerivations` MUST be aligned 1:1 with the PSBT's inputs in order.
* (`buildHdUnsignedPsbt` returns them in the right order.)
*
* @returns Hex-encoded signed (but not finalised) PSBT.
*/
export function signHdPsbt(
psbtHex: string,
inputDerivations: ReadonlyArray<{ chain: 0 | 1; index: number }>,
account: HdAccount,
): string {
bitcoin.initEccLib(ecc);
const psbt = bitcoin.Psbt.fromHex(psbtHex, { network: HD_WALLET_NETWORK });
if (psbt.inputCount !== inputDerivations.length) {
throw new Error(
`PSBT input count (${psbt.inputCount}) does not match derivations ` +
`length (${inputDerivations.length}).`,
);
}
for (let i = 0; i < psbt.inputCount; i++) {
const { chain, index } = inputDerivations[i];
const tweakedSigner = deriveLeafTaprootSigner(account, chain, index);
psbt.signInput(i, tweakedSigner);
}
return psbt.toHex();
}
/**
* Finalise a signed PSBT and extract the raw transaction hex.
*/
export function finalizeHdPsbt(psbtHex: string): string {
bitcoin.initEccLib(ecc);
const psbt = bitcoin.Psbt.fromHex(psbtHex, { network: HD_WALLET_NETWORK });
psbt.finalizeAllInputs();
return psbt.extractTransaction().toHex();
}
/**
* Convenience: build → sign → finalise in one call.
*/
export function createHdTransaction(
account: HdAccount,
ownedUtxos: readonly HdSpendableUtxo[],
toAddress: string,
amountSats: number,
feeRate: number,
nextChangeIndex: number,
): { txHex: string; fee: number; hasChange: boolean; changeAddress?: string } {
const built = buildHdUnsignedPsbt(account, ownedUtxos, toAddress, amountSats, feeRate, nextChangeIndex);
const signed = signHdPsbt(built.psbtHex, built.inputDerivations, account);
const txHex = finalizeHdPsbt(signed);
return { txHex, fee: built.fee, hasChange: built.hasChange, changeAddress: built.changeAddress };
}
// ---------------------------------------------------------------------------
// Max-sendable
// ---------------------------------------------------------------------------
/**
* Compute the maximum amount sendable to a single recipient if every owned
* UTXO is consumed and no change is produced.
*/
export function maxHdSendable(utxos: readonly HdSpendableUtxo[], feeRate: number): number {
const total = utxos.reduce((s, u) => s + u.value, 0);
const fee = estimateFee(utxos.length, 1, feeRate);
return Math.max(0, total - fee);
}
+313
View File
@@ -0,0 +1,313 @@
import { useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import {
Bitcoin,
Copy,
Check,
RefreshCw,
ChevronDown,
ArrowDownLeft,
ArrowUpRight,
Send,
ShieldOff,
ArrowRight,
KeyRound,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { LoginArea } from '@/components/auth/LoginArea';
import { QRCodeCanvas } from '@/components/ui/qrcode';
import { HDSendBitcoinDialog } from '@/components/HDSendBitcoinDialog';
import { useAppContext } from '@/hooks/useAppContext';
import { useHdWallet } from '@/hooks/useHdWallet';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { satsToUSD, formatBTC } from '@/lib/bitcoin';
import type { HdTransaction } from '@/lib/hdwallet/scan';
export function HDWalletPage() {
const { config } = useAppContext();
const {
availability,
currentReceiveAddress,
transactions,
totalBalance,
pendingBalance,
isLoading,
error,
refetch,
nextReceiveAddress,
} = useHdWallet();
const { data: btcPrice } = useBtcPrice();
const [copiedAddress, setCopiedAddress] = useState(false);
const [txOpen, setTxOpen] = useState(false);
const [sendOpen, setSendOpen] = useState(false);
useSeoMeta({
title: `HD Wallet | ${config.appName}`,
description: 'Hierarchical-deterministic Bitcoin wallet derived from your Nostr nsec.',
});
const address = currentReceiveAddress?.address ?? '';
const copyAddress = async () => {
if (!address) return;
try {
await navigator.clipboard.writeText(address);
setCopiedAddress(true);
setTimeout(() => setCopiedAddress(false), 2000);
} catch {
// clipboard unavailable
}
};
const truncatedAddress = address
? `${address.slice(0, 12)}...${address.slice(-8)}`
: '';
// ── Logged out ────────────────────────────────────────────────
if (availability.status === 'logged-out') {
return (
<main>
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<KeyRound className="size-8 text-primary" />
</div>
<div className="space-y-2 max-w-xs">
<h2 className="text-xl font-bold">HD Bitcoin Wallet</h2>
<p className="text-muted-foreground text-sm">
A hierarchical wallet derived from your Nostr identity. Fresh address per receive,
full transaction history, no address reuse.
</p>
<p className="text-muted-foreground text-xs pt-2">
Requires login with an nsec (your Nostr private key).
</p>
</div>
<LoginArea className="max-w-60" />
</div>
</main>
);
}
// ── Logged in, but signer doesn't expose the secret key ─────
if (availability.status === 'unsupported') {
return (
<main>
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center max-w-md mx-auto">
<div className="p-4 rounded-full bg-muted">
<ShieldOff className="size-8 text-muted-foreground" />
</div>
<div className="space-y-2">
<h2 className="text-xl font-bold">HD wallet unavailable</h2>
<p className="text-muted-foreground text-sm">
{availability.loginType === 'extension'
? 'Your browser extension keeps your secret key isolated, so we can\'t derive child keys for an HD wallet.'
: availability.loginType === 'bunker'
? 'Your remote signer (NIP-46 bunker) keeps your secret key on the bunker side, so we can\'t derive child keys for an HD wallet.'
: "Your login type doesn't expose the secret key needed to derive an HD wallet."}
</p>
<p className="text-muted-foreground text-sm pt-2">
The single-address wallet at <Link to="/wallet" className="underline">/wallet</Link>{' '}
works for every login type.
</p>
</div>
<Button asChild variant="outline">
<Link to="/wallet">
Go to standard wallet
<ArrowRight className="size-4 ml-2" />
</Link>
</Button>
</div>
</main>
);
}
// ── Available — full HD wallet UI ────────────────────────────
return (
<main>
<div className="flex flex-col items-center px-4 pt-8 pb-4 space-y-6 max-w-sm mx-auto">
{/* Balance */}
{isLoading ? (
<div className="flex flex-col items-center space-y-2">
<Skeleton className="h-10 w-40 rounded-lg" />
<Skeleton className="h-4 w-24 rounded" />
</div>
) : error ? (
<div className="text-center space-y-3">
<p className="text-sm text-destructive">Failed to scan wallet</p>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="size-3.5 mr-1.5" />
Retry
</Button>
</div>
) : (
<div className="flex flex-col items-center space-y-1">
<span className="text-4xl font-bold tracking-tight">
{btcPrice ? satsToUSD(totalBalance, btcPrice) : '---'}
</span>
<span className="text-sm text-muted-foreground">
{formatBTC(totalBalance)} BTC
</span>
{pendingBalance !== 0 && (
<span className="flex items-center gap-1 text-xs text-orange-500 dark:text-orange-400 pt-1">
<RefreshCw className="size-3 animate-spin" />
{btcPrice
? `${satsToUSD(Math.abs(pendingBalance), btcPrice)} pending`
: 'pending'}
</span>
)}
</div>
)}
{/* Send button */}
{!isLoading && !error && (
<Button
variant="outline"
size="sm"
onClick={() => setSendOpen(true)}
className="rounded-full"
>
<Send className="size-3.5 mr-1.5" />
Send
</Button>
)}
<HDSendBitcoinDialog
isOpen={sendOpen}
onClose={() => setSendOpen(false)}
btcPrice={btcPrice}
/>
{/* QR Code + address */}
{address && (
<>
<div className="rounded-2xl bg-white p-4 shadow-sm">
<QRCodeCanvas value={address} size={200} level="M" />
</div>
<div className="flex flex-col items-center gap-2">
<button
onClick={copyAddress}
className="flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-mono text-muted-foreground hover:bg-muted/50 transition-colors cursor-pointer"
>
{truncatedAddress}
{copiedAddress ? (
<Check className="size-3.5 text-green-500" />
) : (
<Copy className="size-3.5" />
)}
</button>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>Address #{currentReceiveAddress?.index ?? 0}</span>
<span aria-hidden>·</span>
<button
onClick={() => nextReceiveAddress()}
className="hover:text-foreground underline-offset-4 hover:underline transition-colors cursor-pointer"
>
New address
</button>
</div>
</div>
</>
)}
{/* Transactions */}
{transactions && transactions.length > 0 && (
<>
<button
onClick={() => setTxOpen((o) => !o)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
Transactions
<ChevronDown className={`size-3 transition-transform duration-200 ${txOpen ? 'rotate-180' : ''}`} />
</button>
<TxAccordion open={txOpen}>
<div className="w-full divide-y">
{transactions.map((tx) => (
<TxRow key={tx.txid} tx={tx} btcPrice={btcPrice} />
))}
</div>
</TxAccordion>
</>
)}
{/* Standard wallet link */}
<Link
to="/wallet"
className="text-xs text-muted-foreground hover:text-foreground underline-offset-4 hover:underline transition-colors flex items-center gap-1"
>
<Bitcoin className="size-3" />
Standard single-address wallet
</Link>
</div>
</main>
);
}
// ---------------------------------------------------------------------------
// Helpers (mirrors WalletPage.tsx)
// ---------------------------------------------------------------------------
function TxAccordion({ open, children }: { open: boolean; children: React.ReactNode }) {
const contentRef = useRef<HTMLDivElement>(null);
return (
<div
className="w-full grid transition-[grid-template-rows] duration-300 ease-in-out"
style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
>
<div ref={contentRef} className="overflow-hidden">
{children}
</div>
</div>
);
}
function formatTxDate(timestamp?: number): string {
if (!timestamp) return 'Pending';
const date = new Date(timestamp * 1000);
const now = new Date();
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function TxRow({ tx, btcPrice }: { tx: HdTransaction; btcPrice?: number }) {
const isReceive = tx.type === 'receive';
return (
<Link
to={`/i/bitcoin:tx:${tx.txid}`}
className="flex items-center justify-between py-3 hover:bg-muted/50 transition-colors rounded-lg -mx-1 px-2"
>
<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">{formatTxDate(tx.timestamp)}</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 ? '+' : '-'}
{btcPrice ? satsToUSD(tx.amount, btcPrice) : `${formatBTC(tx.amount)} BTC`}
</p>
<p className="text-xs text-muted-foreground">{formatBTC(tx.amount)} BTC</p>
</div>
</Link>
);
}