Files
eranos/src/components/HDSendBitcoinDialog.tsx
T
Alex Gleason 58fd4c41c2 Explain silent-payment-only balance in HD Send dialog
When the user's only spendable balance is in silent-payment outputs,
the Send button stays disabled with no feedback because:

  - The dialog's `ownedUtxos` is sourced from `scan.utxos` (BIP-86
    only, populated by Blockbook).
  - SP UTXOs live in a separate persisted store and aren't included.
  - The PSBT signer can't spend them anyway: SP outputs use a
    BIP-352 tweaked private key that isn't derivable from the
    BIP-86 (chain, index) pair `signHdPsbt` reconstructs, so even
    plumbing them into `ownedUtxos` would just move the failure
    from "button disabled" to "signing throws".

`src/lib/hdwallet/sp/crypto.ts` is explicit: the wallet
"scans-and-displays SP receives but cannot spend or send them".

Surface that explicitly: when `totalBalance === 0` (no BIP-86
funds) but `silentPaymentBalance > 0`, render a one-line alert
above the Send button telling the user spending SP outputs isn't
supported yet and they need to receive on-chain to spend. Doesn't
remove the disabled state — there's nothing to spend regardless —
but at least the user now knows why.
2026-05-22 00:08:42 -05:00

678 lines
26 KiB
TypeScript

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<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: 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<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, 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<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: ['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<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 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 (
<Dialog open={isOpen} onOpenChange={(v) => { if (!v) handleClose(); }}>
<DialogContent className="sm:max-w-md p-0 gap-0 overflow-hidden [&>button]: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)}</>
) : currentFeeRate ? (
<>{currentFeeRate} sat/vB</>
) : (
<></>
)}
<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>
)}
{/* 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 && (
<Alert className="py-2">
<AlertTriangle className="size-3.5" />
<AlertDescription className="text-xs">
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.
</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>
);
}