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.
This commit is contained in:
@@ -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 (
|
||||
<div className="px-4 py-6 flex flex-col items-center text-center gap-3">
|
||||
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
|
||||
<AlertTriangle className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold">Bitcoin zaps aren't available</p>
|
||||
<p className="text-xs text-muted-foreground max-w-xs">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<UnsupportedSignerQR
|
||||
recipientAddress={recipientAddress}
|
||||
truncatedRecipient={truncatedRecipient}
|
||||
amountSats={amountSats}
|
||||
btcPrice={btcPrice}
|
||||
usdAmount={usdAmount}
|
||||
setUsdAmount={setUsdAmount}
|
||||
loginType={loginType}
|
||||
onClose={onSuccess}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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="flex flex-col h-auto min-w-0 text-xs px-1 py-2"
|
||||
>
|
||||
<span className="font-semibold">${v}</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user