From bf540fb5c1eab65afd0041f5276956310e355689 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 29 Apr 2026 22:39:27 -0500 Subject: [PATCH] Offer QR-code fallback for unsupported signers in onchain zap dialog When the user's signer can't sign PSBTs (extension without signPsbt, or a bunker that rejected sign_psbt), the onchain zap tab previously showed a dead-end 'Bitcoin zaps aren't available' panel. Replace it with an amount selector + BIP-21 QR code the user can scan from any external Bitcoin wallet, plus copy buttons for the address and payment link. Because Ditto never sees the resulting transaction, no kind 8333 is published in this flow. A warning explains that the zap won't show up as theirs on Nostr even though the recipient still gets the Bitcoin. Also gate the mempool.space UTXO and fee-rate queries on the capability being non-unsupported to avoid pointless network calls in this branch. --- src/components/OnchainZapContent.tsx | 235 ++++++++++++++++++++++++--- 1 file changed, 213 insertions(+), 22 deletions(-) diff --git a/src/components/OnchainZapContent.tsx b/src/components/OnchainZapContent.tsx index 287ed9ab..6596e392 100644 --- a/src/components/OnchainZapContent.tsx +++ b/src/components/OnchainZapContent.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useCallback, useEffect } from 'react'; -import { AlertTriangle, Zap, Gauge, Loader2, Bitcoin } from 'lucide-react'; +import { AlertTriangle, Zap, Gauge, Loader2, Bitcoin, Copy, Check } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; @@ -11,10 +11,13 @@ import { PopoverTrigger, } from '@/components/ui/popover'; import { Textarea } from '@/components/ui/textarea'; +import { QRCodeCanvas } from '@/components/ui/qrcode'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useBitcoinSigner } from '@/hooks/useBitcoinSigner'; import { useOnchainZap, type OnchainFeeSpeed } from '@/hooks/useOnchainZap'; +import { useToast } from '@/hooks/useToast'; import { useNostrLogin } from '@nostrify/react/login'; import { nostrPubkeyToBitcoinAddress, @@ -77,13 +80,14 @@ export function OnchainZapContent({ target, onSuccess }: OnchainZapContentProps) const { data: utxos } = useQuery({ queryKey: ['bitcoin-utxos', senderAddress], queryFn: () => fetchUTXOs(senderAddress), - enabled: !!senderAddress, + enabled: !!senderAddress && capability !== 'unsupported', staleTime: 30_000, }); const { data: feeRates } = useQuery({ queryKey: ['bitcoin-fee-rates'], queryFn: getFeeRates, + enabled: capability !== 'unsupported', staleTime: 30_000, }); @@ -164,29 +168,24 @@ export function OnchainZapContent({ target, onSuccess }: OnchainZapContentProps) }, [user, target.pubkey, btcPrice, amountSats, utxos, insufficient, zapAsync, comment, 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. if (user && capability === 'unsupported') { - // Tailor the hint to the login type so the user knows what to change - // to regain Bitcoin-zap capability. - const message = - loginType === 'extension' - ? "Your browser extension doesn't support sending Bitcoin. Try a different extension, or log in with your secret key." - : loginType === 'bunker' - ? "Your remote signer doesn't support sending Bitcoin. Update your signer, or log in with your secret key." - : "Log in with your secret key to send Bitcoin zaps."; - return ( -
-
- -
-
-

Bitcoin zaps aren't available

-

- {message} -

-
-
+ ); } @@ -348,3 +347,195 @@ function progressLabel(progress: 'idle' | 'building' | 'signing' | 'broadcasting 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 ( +
+

+ {explanation} You can still zap by scanning this QR from any Bitcoin wallet. +

+ + {/* Amount presets (USD) */} + { if (v) setUsdAmount(Number(v)); }} + className="grid grid-cols-5 gap-1 w-full" + > + {USD_PRESETS.map((v) => ( + + ${v} + + ))} + + +
+
+ OR +
+
+ +
+ $ + setUsdAmount(e.target.value)} + className="pl-6" + /> +
+ + {/* QR / placeholder */} +
+ {hasAmount && bip21 ? ( +
+ +
+ ) : ( +
+ {btcPrice + ? 'Choose an amount above to generate a payment QR.' + : 'Loading BTC price…'} +
+ )} +
+ + {/* Amount summary */} + {hasAmount && btcPrice && ( +
+ + {currentUsd > 0 ? `$${currentUsd}` : ''} + + + {' · '}{formatSats(amountSats)} sats + +
+ )} + + {/* Recipient */} + {recipientAddress && ( +
+
+ + To: + {truncatedRecipient} +
+
+ )} + + {/* Copy buttons */} +
+ + +
+ + {/* Warning: no kind 8333 will be published */} + + + + Because we can't see your transaction, this zap won't show up as yours on Nostr. The recipient will still get the Bitcoin. + + + + {onClose && ( + + )} +
+ ); +}