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 { isLargeAmount, nostrPubkeyToBitcoinAddress, satsToUSD, validateBitcoinAddress, } from '@/lib/bitcoin'; import { broadcastBlockbookTx, type BlockbookFeeRates, fetchFeeRates, } from '@/lib/hdwallet/blockbook'; import { buildHdUnsignedPsbt, finalizeHdPsbt, type HdSpendableUtxo, previewHdFee, 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: BlockbookFeeRates, 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: BlockbookFeeRates | 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, silentPaymentBalance, refetch: refetchWallet } = useHdWallet(); const { config } = useAppContext(); const { blockbookBaseUrl } = 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: ['blockbook-fee-rates', blockbookBaseUrl], queryFn: ({ signal }) => fetchFeeRates(blockbookBaseUrl, signal), 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]); // Silent-payment UTXOs are scanned and displayed on /hdwallet but cannot // yet be spent by this dialog: the BIP-352 receive output uses a tweaked // private key not derivable from the BIP-86 (chain, index) pair the PSBT // signer expects. Detect the case where the wallet's _only_ funds are SP // funds so we can surface a clear explanation instead of leaving the user // with a silently-disabled Send button. const onlyHasSpFunds = totalBalance === 0 && silentPaymentBalance > 0; // ── 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 (matches the actual coin selection) ──────── // // Crucially we do NOT use `ownedUtxos.length` as the input count: an HD // wallet typically has many UTXOs across many addresses, but a real send // only consumes the minimal set the coin selector picks. Using the full // count would over-estimate fees by 10x or more on an active wallet, and // would also make the UI think we're insufficient when we're not. const estimatedFeeSats = useMemo(() => { if (!ownedUtxos.length || !currentFeeRate || !amountSats) return 0; return previewHdFee(ownedUtxos, amountSats, currentFeeRate); }, [ownedUtxos, currentFeeRate, amountSats]); const totalSats = amountSats + estimatedFeeSats; // `previewHdFee` returns 0 when the coin selector can't cover `amount + fee`. // Treat that as insufficient so the UI doesn't claim a 0-sat fee is fine. const selectionFailed = amountSats > 0 && !!currentFeeRate && ownedUtxos.length > 0 && estimatedFeeSats === 0; const insufficient = selectionFailed || (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 fee = previewHdFee(ownedUtxos, amountSats, rate); if (fee > 0 && fee <= threshold) { target = speed; break; } } setFeeSpeed((prev) => (prev === target ? prev : target)); }, [amountSats, feeRates, ownedUtxos, 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); }, [amountSats, currentFeeRate, btcPrice, recipient?.address]); // Reset the privacy acknowledgement only when the recipient changes — // not when the user adjusts the amount or fee tier. Toggling between // fee speeds should not silently uncheck the warning. useEffect(() => { setAcknowledgedPublic(false); }, [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 broadcastBlockbookTx(blockbookBaseUrl, txHex); return { txid, amountSats, fee: built.fee }; }, onSuccess: (result) => { notificationSuccess(); setSuccess(result); queryClient.invalidateQueries({ queryKey: ['hdwallet-scan'] }); 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} )} {/* Silent-payment-only balance: the PSBT signer can't yet spend BIP-352 outputs, so explain why Send is disabled instead of leaving the button greyed out with no feedback. */} {onlyHasSpFunds && ( Your balance is held in silent-payment outputs. Spending them isn't supported yet — receive on-chain bitcoin to your regular receive address to send. )} {/* 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`}
); }