diff --git a/package-lock.json b/package-lock.json index 444537a8..2a6e6311 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index dea817b6..6d1275c3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 04536666..02aa16df 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -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() { /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/HDSendBitcoinDialog.tsx b/src/components/HDSendBitcoinDialog.tsx new file mode 100644 index 00000000..1baf1b1b --- /dev/null +++ b/src/components/HDSendBitcoinDialog.tsx @@ -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 = { + 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(); + 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(5); + const [feeSpeed, setFeeSpeed] = useState('halfHour'); + const [error, setError] = useState(''); + const [editingAmount, setEditingAmount] = useState(false); + const [feePopoverOpen, setFeePopoverOpen] = useState(false); + const [success, setSuccess] = useState(null); + + const amountInputRef = useRef(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({ + 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 ( + { if (!v) handleClose(); }}> + + Send Bitcoin + + {success ? ( + + ) : ( +
+ {/* Header */} +
+

Send Bitcoin

+ +
+ + {/* Amount */} +
+ {editingAmount ? ( +
+ $ + 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" + /> +
+ ) : ( + + )} + {amountSats > 0 && btcPrice && ( + + ≈ {amountSats.toLocaleString()} sats + + )} +
+ + {/* USD presets */} +
+ {USD_PRESETS.map((preset) => ( + + ))} +
+ + {/* Recipient */} +
+ + setRecipientInput(e.target.value)} + placeholder="bc1… or npub…" + autoComplete="off" + spellCheck={false} + className="font-mono text-sm" + /> + {recipient && ( +

+ {recipient.pubkey ? ( + <>Sending to a Nostr user's on-chain address. + ) : ( + <>Sending to a raw Bitcoin address. + )} +

+ )} +
+ + {/* Privacy disclaimer for raw addresses */} + {isRawAddress && ( + + )} + + {/* Fee speed */} +
+ Network fee + + + + + +
+ {getUniqueFeeSpeeds(feeRates).map((speed) => ( + + ))} +
+
+
+
+ + {showBalance && totalBalance > 0 && btcPrice && ( +

+ Available: {satsToUSD(totalBalance, btcPrice)} ({totalBalance.toLocaleString()} sats) +

+ )} + + {/* Error */} + {error && ( + + + {error} + + )} + + {/* Send button */} + +
+ )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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 ( +
+
+ +
+ + + +
+ +
+

Bitcoin sent

+
+ {usdDisplay || `${amountSats.toLocaleString()} sats`} +
+
+ +
+ + +
+
+ ); +} diff --git a/src/hooks/useHdWallet.ts b/src/hooks/useHdWallet.ts new file mode 100644 index 00000000..3c280541 --- /dev/null +++ b/src/hooks/useHdWallet.ts @@ -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 10–40 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; + /** 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( + 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({ + 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({ + 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(() => { + 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, + }; +} diff --git a/src/hooks/useHdWalletAccess.ts b/src/hooks/useHdWalletAccess.ts new file mode 100644 index 00000000..0586571a --- /dev/null +++ b/src/hooks/useHdWalletAccess.ts @@ -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(() => { + 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]); +} diff --git a/src/lib/hdwallet/derivation.ts b/src/lib/hdwallet/derivation.ts new file mode 100644 index 00000000..69625160 --- /dev/null +++ b/src/lib/hdwallet/derivation.ts @@ -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 { + 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 }; diff --git a/src/lib/hdwallet/scan.ts b/src/lib/hdwallet/scan.ts new file mode 100644 index 00000000..65b62950 --- /dev/null +++ b/src/lib/hdwallet/scan.ts @@ -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; + /** 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; +} + +/** + * 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 { + 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 { + // 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(); + 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 { + 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(); + + 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; +} diff --git a/src/lib/hdwallet/transaction.ts b/src/lib/hdwallet/transaction.ts new file mode 100644 index 00000000..4c65b5e8 --- /dev/null +++ b/src/lib/hdwallet/transaction.ts @@ -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); +} diff --git a/src/pages/HDWalletPage.tsx b/src/pages/HDWalletPage.tsx new file mode 100644 index 00000000..da1a4592 --- /dev/null +++ b/src/pages/HDWalletPage.tsx @@ -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 ( +
+
+
+ +
+
+

HD Bitcoin Wallet

+

+ A hierarchical wallet derived from your Nostr identity. Fresh address per receive, + full transaction history, no address reuse. +

+

+ Requires login with an nsec (your Nostr private key). +

+
+ +
+
+ ); + } + + // ── Logged in, but signer doesn't expose the secret key ───── + if (availability.status === 'unsupported') { + return ( +
+
+
+ +
+
+

HD wallet unavailable

+

+ {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."} +

+

+ The single-address wallet at /wallet{' '} + works for every login type. +

+
+ +
+
+ ); + } + + // ── Available — full HD wallet UI ──────────────────────────── + return ( +
+
+ {/* Balance */} + {isLoading ? ( +
+ + +
+ ) : error ? ( +
+

Failed to scan wallet

+ +
+ ) : ( +
+ + {btcPrice ? satsToUSD(totalBalance, btcPrice) : '---'} + + + {formatBTC(totalBalance)} BTC + + + {pendingBalance !== 0 && ( + + + {btcPrice + ? `${satsToUSD(Math.abs(pendingBalance), btcPrice)} pending` + : 'pending'} + + )} +
+ )} + + {/* Send button */} + {!isLoading && !error && ( + + )} + + setSendOpen(false)} + btcPrice={btcPrice} + /> + + {/* QR Code + address */} + {address && ( + <> +
+ +
+ +
+ +
+ Address #{currentReceiveAddress?.index ?? 0} + · + +
+
+ + )} + + {/* Transactions */} + {transactions && transactions.length > 0 && ( + <> + + + +
+ {transactions.map((tx) => ( + + ))} +
+
+ + )} + + {/* Standard wallet link */} + + + Standard single-address wallet + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Helpers (mirrors WalletPage.tsx) +// --------------------------------------------------------------------------- + +function TxAccordion({ open, children }: { open: boolean; children: React.ReactNode }) { + const contentRef = useRef(null); + return ( +
+
+ {children} +
+
+ ); +} + +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 ( + +
+
+ {isReceive + ? + : } +
+
+

{isReceive ? 'Received' : 'Sent'}

+

{formatTxDate(tx.timestamp)}

+
+
+
+

+ {isReceive ? '+' : '-'} + {btcPrice ? satsToUSD(tx.amount, btcPrice) : `${formatBTC(tx.amount)} BTC`} +

+

{formatBTC(tx.amount)} BTC

+
+ + ); +}