528 lines
20 KiB
TypeScript
528 lines
20 KiB
TypeScript
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
|
import { AlertTriangle, Loader2, Bitcoin, Copy, Check } from 'lucide-react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from '@/components/ui/popover';
|
|
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
import { BitcoinAmountPicker } from '@/components/BitcoinAmountPicker';
|
|
import { getBitcoinFeeRate, getUniqueBitcoinFeeSpeeds } from '@/lib/bitcoinFeeSpeed';
|
|
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
|
|
import { useOnchainZap, type OnchainFeeSpeed } from '@/hooks/useOnchainZap';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { useAppContext } from '@/hooks/useAppContext';
|
|
import { useNostrLogin } from '@nostrify/react/login';
|
|
import {
|
|
nostrPubkeyToBitcoinAddress,
|
|
fetchUTXOs,
|
|
fetchBtcPrice,
|
|
getFeeRates,
|
|
estimateFee,
|
|
isLargeAmount,
|
|
satsToUSD,
|
|
formatSats,
|
|
} from '@/lib/bitcoin';
|
|
import type { NostrEvent } from '@nostrify/nostrify';
|
|
|
|
const USD_PRESETS = [1, 5, 10, 25, 100];
|
|
|
|
const FEE_SPEED_LABELS: Record<OnchainFeeSpeed, string> = {
|
|
fastest: '~10 min',
|
|
halfHour: '~30 min',
|
|
hour: '~1 hour',
|
|
economy: '~1 day',
|
|
};
|
|
|
|
interface OnchainZapContentProps {
|
|
target: NostrEvent;
|
|
/** Called with the tx result when a zap successfully broadcasts. */
|
|
onSuccess?: (result: { txid: string; amountSats: number }) => void;
|
|
/** Called when the user dismisses without a send (e.g. "Done" in the
|
|
* unsupported-signer QR fallback). */
|
|
onClose?: () => void;
|
|
}
|
|
|
|
/**
|
|
* Bitcoin zap flow. Publishes a BTC transaction paying the target author's
|
|
* derived Taproot address, then publishes a kind 8333 event linking the tx
|
|
* to the target event.
|
|
*
|
|
* UX mirrors the Lightning zap flow: one screen, one button, no review step.
|
|
* Balance, fee breakdown, and confirmation are all hidden unless needed.
|
|
*/
|
|
export function OnchainZapContent({ target, onSuccess, onClose }: OnchainZapContentProps) {
|
|
const { user } = useCurrentUser();
|
|
const { capability } = useBitcoinSigner();
|
|
const { logins } = useNostrLogin();
|
|
const { config } = useAppContext();
|
|
const { esploraApis } = config;
|
|
const loginType = logins[0]?.type;
|
|
|
|
const [usdAmount, setUsdAmount] = useState<number | string>(5);
|
|
const [feeSpeed, setFeeSpeed] = useState<OnchainFeeSpeed>('halfHour');
|
|
const [error, setError] = useState('');
|
|
const [feePopoverOpen, setFeePopoverOpen] = useState(false);
|
|
|
|
// Tracks whether the user has manually picked a fee speed. Once true, we
|
|
// stop auto-adjusting the fee in response to amount changes.
|
|
const feeSpeedUserChanged = useRef(false);
|
|
|
|
const senderAddress = user ? nostrPubkeyToBitcoinAddress(user.pubkey) : '';
|
|
const recipientAddress = useMemo(() => nostrPubkeyToBitcoinAddress(target.pubkey), [target.pubkey]);
|
|
const truncatedRecipient = recipientAddress
|
|
? `${recipientAddress.slice(0, 10)}…${recipientAddress.slice(-8)}`
|
|
: '';
|
|
|
|
const { data: btcPrice } = useQuery({
|
|
queryKey: ['btc-price', esploraApis],
|
|
queryFn: ({ signal }) => fetchBtcPrice(esploraApis, signal),
|
|
staleTime: 30_000,
|
|
});
|
|
|
|
const { data: utxos } = useQuery({
|
|
queryKey: ['bitcoin-utxos', esploraApis, senderAddress],
|
|
queryFn: ({ signal }) => fetchUTXOs(senderAddress, esploraApis, signal),
|
|
enabled: !!senderAddress && capability !== 'unsupported',
|
|
staleTime: 30_000,
|
|
});
|
|
|
|
const { data: feeRates } = useQuery({
|
|
queryKey: ['bitcoin-fee-rates', esploraApis],
|
|
queryFn: ({ signal }) => getFeeRates(esploraApis, signal),
|
|
enabled: capability !== 'unsupported',
|
|
staleTime: 30_000,
|
|
});
|
|
|
|
const totalBalance = useMemo(() => utxos?.reduce((s, u) => s + u.value, 0) ?? 0, [utxos]);
|
|
|
|
const currentFeeRate = useMemo(() => {
|
|
if (!feeRates) return 0;
|
|
return getBitcoinFeeRate(feeRates, feeSpeed);
|
|
}, [feeRates, feeSpeed]);
|
|
|
|
// Convert the USD amount to sats
|
|
const amountSats = useMemo(() => {
|
|
if (!btcPrice) return 0;
|
|
const usd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
|
|
if (!Number.isFinite(usd) || usd <= 0) return 0;
|
|
const btc = usd / btcPrice;
|
|
return Math.round(btc * 100_000_000);
|
|
}, [usdAmount, btcPrice]);
|
|
|
|
const estimatedFeeSats = useMemo(() => {
|
|
if (!utxos?.length || !currentFeeRate || !amountSats) return 0;
|
|
const fee2 = estimateFee(utxos.length, 2, currentFeeRate);
|
|
const change = totalBalance - amountSats - fee2;
|
|
const numOutputs = change > 546 ? 2 : 1;
|
|
return estimateFee(utxos.length, numOutputs, currentFeeRate);
|
|
}, [utxos, currentFeeRate, amountSats, totalBalance]);
|
|
|
|
const totalSats = amountSats + estimatedFeeSats;
|
|
const insufficient = totalBalance > 0 && totalSats > totalBalance;
|
|
const showBalance = insufficient || (amountSats > 0 && totalBalance === 0);
|
|
|
|
// Auto-adjust fee speed when the amount changes, unless the user has
|
|
// already picked a speed manually. Aim for a fee below 40% of the amount
|
|
// by stepping down through the unique speed tiers. If every tier still
|
|
// blows past 40% (tiny amount), fall back to the cheapest tier so we at
|
|
// least minimize the hit.
|
|
useEffect(() => {
|
|
if (feeSpeedUserChanged.current) return;
|
|
if (!utxos?.length || !feeRates || amountSats <= 0) return;
|
|
|
|
const uniqueSpeeds = getUniqueBitcoinFeeSpeeds(feeRates);
|
|
const threshold = amountSats * 0.4;
|
|
|
|
let target: OnchainFeeSpeed = uniqueSpeeds[uniqueSpeeds.length - 1];
|
|
for (const speed of uniqueSpeeds) {
|
|
const rate = getBitcoinFeeRate(feeRates, speed);
|
|
const fee2 = estimateFee(utxos.length, 2, rate);
|
|
const change = totalBalance - amountSats - fee2;
|
|
const outputs = change > 546 ? 2 : 1;
|
|
const fee = estimateFee(utxos.length, outputs, rate);
|
|
if (fee <= threshold) {
|
|
target = speed;
|
|
break;
|
|
}
|
|
}
|
|
|
|
setFeeSpeed((prev) => (prev === target ? prev : target));
|
|
}, [amountSats, feeRates, utxos, totalBalance]);
|
|
|
|
const handleFeeSpeedChange = useCallback((speed: OnchainFeeSpeed) => {
|
|
feeSpeedUserChanged.current = true;
|
|
setFeeSpeed(speed);
|
|
setFeePopoverOpen(false);
|
|
}, []);
|
|
|
|
// For large amounts, require a two-tap confirmation on the primary button.
|
|
// This catches fat-finger sends without nagging on normal amounts.
|
|
const isLarge = isLargeAmount(totalSats, btcPrice);
|
|
const [confirmArmed, setConfirmArmed] = useState(false);
|
|
|
|
// Re-arm (i.e. clear confirmation) whenever the amount, fee rate, or price
|
|
// moves — so editing after arming forces another deliberate click.
|
|
useEffect(() => {
|
|
setConfirmArmed(false);
|
|
}, [amountSats, currentFeeRate, btcPrice]);
|
|
|
|
const { zapAsync, isZapping, progress } = useOnchainZap(target, (result) => {
|
|
// Forward the txid + amount so the dialog can render its success screen.
|
|
onSuccess?.({ txid: result.txid, amountSats: result.amountSats });
|
|
});
|
|
|
|
const handleZap = useCallback(async () => {
|
|
setError('');
|
|
if (!user) { setError('You must be logged in.'); return; }
|
|
if (user.pubkey === target.pubkey) { setError("You can't zap yourself."); return; }
|
|
// `capability === 'unsupported'` is already handled by the UI replacement
|
|
// above; 'supported' and 'unknown' both proceed (the latter may fail at
|
|
// sign time, which will then flip the UI to the unsupported state).
|
|
if (!btcPrice) { setError('Waiting for BTC price…'); return; }
|
|
if (amountSats <= 0) { setError('Enter an amount.'); return; }
|
|
if (!utxos?.length) { setError("You don't have any Bitcoin yet. Receive some first."); return; }
|
|
if (insufficient) { setError('Not enough Bitcoin for this amount + network fee.'); return; }
|
|
|
|
// Two-tap safety for large amounts: first click arms, second click sends.
|
|
if (isLarge && !confirmArmed) {
|
|
setConfirmArmed(true);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await zapAsync({ amountSats, comment: '', feeSpeed });
|
|
// onSuccess (passed to useOnchainZap) closes the dialog; toast is shown by the hook.
|
|
} catch (err) {
|
|
// Capability errors flip the UI via `reportSignerUnsupported` in the
|
|
// hook's `onError`; no need to surface a form-level error for those.
|
|
const msg = err instanceof Error ? err.message : 'Zap failed';
|
|
const isCapability = /does not support|doesn't support|signpsbt|sign_psbt/i.test(msg);
|
|
if (!isCapability) setError(msg);
|
|
}
|
|
}, [user, target.pubkey, btcPrice, amountSats, utxos, insufficient, zapAsync, feeSpeed, isLarge, confirmArmed]);
|
|
|
|
// ── Signer not supported ──────────────────────────────────────
|
|
// The user's signer can't sign PSBTs locally (extension without signPsbt,
|
|
// or a bunker that rejected sign_psbt). Instead of a dead-end, show a QR
|
|
// they can scan with any external Bitcoin wallet. We can't observe the
|
|
// resulting txid, so we don't publish a kind 8333 — the user is warned
|
|
// that the zap won't be attributed to them on Nostr.
|
|
|
|
const currentUsd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
|
|
const hasValidAmount = Number.isFinite(currentUsd) && currentUsd > 0;
|
|
const totalUsdString = btcPrice ? satsToUSD(totalSats, btcPrice) : '';
|
|
const uniqueFeeSpeeds = useMemo(() => getUniqueBitcoinFeeSpeeds(feeRates), [feeRates]);
|
|
|
|
if (user && capability === 'unsupported') {
|
|
return (
|
|
<UnsupportedSignerQR
|
|
recipientAddress={recipientAddress}
|
|
truncatedRecipient={truncatedRecipient}
|
|
amountSats={amountSats}
|
|
btcPrice={btcPrice}
|
|
usdAmount={usdAmount}
|
|
setUsdAmount={setUsdAmount}
|
|
loginType={loginType}
|
|
onClose={onClose}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="grid gap-4 px-4 py-4 w-full overflow-hidden">
|
|
<BitcoinAmountPicker
|
|
usdAmount={usdAmount}
|
|
onUsdAmountChange={setUsdAmount}
|
|
presets={USD_PRESETS}
|
|
insufficient={insufficient}
|
|
onAmountChangeStart={() => setError('')}
|
|
/>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<p className="text-xs text-destructive">{error}</p>
|
|
)}
|
|
|
|
<Button
|
|
onClick={handleZap}
|
|
disabled={!btcPrice || amountSats <= 0 || isZapping || insufficient}
|
|
variant={(insufficient || isLarge) && !isZapping ? 'destructive' : 'default'}
|
|
className="w-full"
|
|
>
|
|
{isZapping ? (
|
|
<>
|
|
<Loader2 className="size-4 mr-1.5 animate-spin" />
|
|
{progressLabel(progress)}
|
|
</>
|
|
) : insufficient ? (
|
|
<>Not enough Bitcoin</>
|
|
) : isLarge && confirmArmed ? (
|
|
<>Tap again to send {totalUsdString}</>
|
|
) : (
|
|
<>Send {totalUsdString || (hasValidAmount ? `$${currentUsd}` : '')}</>
|
|
)}
|
|
</Button>
|
|
|
|
{/* Fee line — click to open speed picker */}
|
|
{amountSats > 0 && (
|
|
<div className="flex items-center justify-center gap-3 -mt-1 text-xs">
|
|
<Popover open={feePopoverOpen} onOpenChange={setFeePopoverOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<span>
|
|
Fee{' '}
|
|
{estimatedFeeSats > 0 && btcPrice
|
|
? `≈ ${satsToUSD(estimatedFeeSats, btcPrice)}`
|
|
: currentFeeRate
|
|
? `${currentFeeRate} sat/vB`
|
|
: 'loading'}
|
|
<span className="opacity-60"> · {FEE_SPEED_LABELS[feeSpeed]}</span>
|
|
</span>
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="center" sideOffset={6} className="w-56 p-1">
|
|
<div className="flex flex-col">
|
|
{uniqueFeeSpeeds.map((speed) => {
|
|
const rate = feeRates ? getBitcoinFeeRate(feeRates, speed) : 0;
|
|
const selected = speed === feeSpeed;
|
|
return (
|
|
<button
|
|
key={speed}
|
|
type="button"
|
|
onClick={() => handleFeeSpeedChange(speed)}
|
|
className={`flex items-center justify-between px-2 py-1.5 rounded-sm text-xs text-left hover:bg-muted transition-colors ${selected ? 'bg-muted font-medium' : ''}`}
|
|
>
|
|
<span>{FEE_SPEED_LABELS[speed]}</span>
|
|
<span className="text-muted-foreground">{rate} sat/vB</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{showBalance && !insufficient && btcPrice && (
|
|
<span className="text-muted-foreground">
|
|
Balance: {satsToUSD(totalBalance, btcPrice)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function progressLabel(progress: 'idle' | 'building' | 'signing' | 'broadcasting' | 'publishing'): string {
|
|
switch (progress) {
|
|
case 'building': return 'Building…';
|
|
case 'signing': return 'Signing…';
|
|
case 'broadcasting': return 'Broadcasting…';
|
|
case 'publishing': return 'Publishing…';
|
|
default: return 'Processing…';
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// Unsupported-signer QR fallback
|
|
// ──────────────────────────────────────────────────────────────
|
|
|
|
interface UnsupportedSignerQRProps {
|
|
recipientAddress: string;
|
|
truncatedRecipient: string;
|
|
amountSats: number;
|
|
btcPrice: number | undefined;
|
|
usdAmount: number | string;
|
|
setUsdAmount: (v: number | string) => void;
|
|
loginType: string | undefined;
|
|
onClose?: () => void;
|
|
}
|
|
|
|
/**
|
|
* Fallback shown when the user's signer can't sign PSBTs locally. Renders a
|
|
* BIP-21 QR the user can scan with any external Bitcoin wallet. Because we
|
|
* never see the resulting tx, we skip publishing the kind 8333 zap event and
|
|
* explicitly warn the user about that.
|
|
*/
|
|
function UnsupportedSignerQR({
|
|
recipientAddress,
|
|
truncatedRecipient,
|
|
amountSats,
|
|
btcPrice,
|
|
usdAmount,
|
|
setUsdAmount,
|
|
loginType,
|
|
onClose,
|
|
}: UnsupportedSignerQRProps) {
|
|
const { toast } = useToast();
|
|
const [copied, setCopied] = useState<'address' | 'uri' | null>(null);
|
|
|
|
// BIP-21 URI. Include `amount` (in BTC, 8 decimals) only when > 0 so an
|
|
// empty-amount placeholder QR doesn't include `?amount=0`.
|
|
const bip21 = useMemo(() => {
|
|
if (!recipientAddress) return '';
|
|
if (amountSats <= 0) return `bitcoin:${recipientAddress}`;
|
|
const btc = (amountSats / 100_000_000).toFixed(8);
|
|
return `bitcoin:${recipientAddress}?amount=${btc}`;
|
|
}, [recipientAddress, amountSats]);
|
|
|
|
const explanation =
|
|
loginType === 'extension'
|
|
? "Your browser extension can't sign Bitcoin transactions."
|
|
: loginType === 'bunker'
|
|
? "Your remote signer can't sign Bitcoin transactions."
|
|
: "Your signer can't sign Bitcoin transactions.";
|
|
|
|
const copy = useCallback(
|
|
async (value: string, which: 'address' | 'uri', label: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(value);
|
|
setCopied(which);
|
|
toast({ title: 'Copied', description: `${label} copied to clipboard` });
|
|
setTimeout(() => setCopied(null), 2000);
|
|
} catch {
|
|
toast({ title: 'Copy failed', description: 'Please copy manually.', variant: 'destructive' });
|
|
}
|
|
},
|
|
[toast],
|
|
);
|
|
|
|
const currentUsd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
|
|
const hasAmount = amountSats > 0;
|
|
|
|
return (
|
|
<div className="grid gap-3 px-4 py-4 w-full overflow-hidden">
|
|
<p className="text-xs text-muted-foreground">
|
|
{explanation} You can still zap by scanning this QR from any Bitcoin wallet.
|
|
</p>
|
|
|
|
{/* Amount presets (USD) */}
|
|
<ToggleGroup
|
|
type="single"
|
|
value={USD_PRESETS.includes(Number(usdAmount)) ? String(usdAmount) : ''}
|
|
onValueChange={(v) => { if (v) setUsdAmount(Number(v)); }}
|
|
className="grid grid-cols-5 gap-1 w-full"
|
|
>
|
|
{USD_PRESETS.map((v) => (
|
|
<ToggleGroupItem
|
|
key={v}
|
|
value={String(v)}
|
|
className="h-8 min-w-0 text-xs font-semibold px-1"
|
|
>
|
|
${v}
|
|
</ToggleGroupItem>
|
|
))}
|
|
</ToggleGroup>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-px flex-1 bg-muted" />
|
|
<span className="text-xs text-muted-foreground">OR</span>
|
|
<div className="h-px flex-1 bg-muted" />
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">$</span>
|
|
<Input
|
|
type="number"
|
|
inputMode="decimal"
|
|
min={0}
|
|
step="0.01"
|
|
placeholder="Custom amount (USD)"
|
|
value={usdAmount}
|
|
onChange={(e) => setUsdAmount(e.target.value)}
|
|
className="pl-6"
|
|
/>
|
|
</div>
|
|
|
|
{/* QR / placeholder */}
|
|
<div className="flex justify-center">
|
|
{hasAmount && bip21 ? (
|
|
<div className="bg-white p-3 rounded-xl" aria-label="Bitcoin payment QR code">
|
|
<QRCodeCanvas value={bip21} size={220} level="M" className="block" />
|
|
</div>
|
|
) : (
|
|
<div className="size-[220px] rounded-xl border border-dashed flex items-center justify-center text-xs text-muted-foreground text-center px-4">
|
|
{btcPrice
|
|
? 'Choose an amount above to generate a payment QR.'
|
|
: 'Loading BTC price…'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Amount summary */}
|
|
{hasAmount && btcPrice && (
|
|
<div className="text-center text-sm">
|
|
<span className="font-medium">
|
|
{currentUsd > 0 ? `$${currentUsd}` : ''}
|
|
</span>
|
|
<span className="text-muted-foreground">
|
|
{' · '}{formatSats(amountSats)} sats
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Recipient */}
|
|
{recipientAddress && (
|
|
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<Bitcoin className="size-3.5 text-orange-500 shrink-0" />
|
|
<span className="shrink-0">To:</span>
|
|
<span className="font-mono truncate" title={recipientAddress}>{truncatedRecipient}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Copy buttons */}
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => copy(recipientAddress, 'address', 'Address')}
|
|
disabled={!recipientAddress}
|
|
className="text-xs"
|
|
>
|
|
{copied === 'address' ? <Check className="size-3.5 mr-1.5" /> : <Copy className="size-3.5 mr-1.5" />}
|
|
Copy address
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => copy(bip21, 'uri', 'Payment link')}
|
|
disabled={!hasAmount || !bip21}
|
|
className="text-xs"
|
|
>
|
|
{copied === 'uri' ? <Check className="size-3.5 mr-1.5" /> : <Copy className="size-3.5 mr-1.5" />}
|
|
Copy link
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Warning: no kind 8333 will be published */}
|
|
<Alert>
|
|
<AlertTriangle className="size-4" />
|
|
<AlertDescription className="text-xs">
|
|
Because we can't see your transaction, this zap won't show up as yours on Nostr. The recipient will still get the Bitcoin.
|
|
</AlertDescription>
|
|
</Alert>
|
|
|
|
{onClose && (
|
|
<Button type="button" variant="secondary" onClick={onClose} className="w-full">
|
|
Done
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|