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.
+
+
+ {/* 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 && (
+
+ )}
+