Detect Bitcoin signer capability before submitting a zap
Previously, when a user's signer couldn't sign PSBTs, the Bitcoin zap
flow only discovered this after the user pressed Zap — surfacing a
toast after an otherwise-normal submission. The zap button was offered
as if it would work, and failure felt like a bug rather than a
capability limit.
Now useBitcoinSigner returns a three-state `capability`:
* supported — nsec login, or extension with window.nostr.signPsbt
present.
* unsupported — extension without signPsbt, OR a bunker that has
already rejected sign_psbt once in this session.
* unknown — bunker login with no capability info yet (NIP-46
has no capability-discovery RPC). Attempt is allowed
and if it fails with a 'does not support' error, the
hook calls reportSignerUnsupported(pubkey) to flip
the capability to 'unsupported' for the rest of the
session. A DOM event broadcasts the change so
consumer hooks re-render without a shared store.
OnchainZapContent renders an explicit 'Bitcoin zaps aren't available'
panel whenever capability === 'unsupported', with copy tailored to the
login type (different hints for nsec/extension/bunker). Inside
useOnchainZap, capability errors no longer show the generic failure
toast — the UI replacement is the only feedback the user sees.
ZapDialog defaults to the Lightning tab when Bitcoin is unsupported
and Lightning is available, and auto-switches mid-session if a bunker
rejects sign_psbt while the dialog is open — so the user is never
stranded on an unusable tab.
This commit is contained in:
@@ -10,12 +10,12 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
|
||||
import { useOnchainZap, type OnchainFeeSpeed } from '@/hooks/useOnchainZap';
|
||||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import {
|
||||
nostrPubkeyToBitcoinAddress,
|
||||
fetchUTXOs,
|
||||
@@ -51,7 +51,9 @@ interface OnchainZapContentProps {
|
||||
*/
|
||||
export function OnchainZapContent({ target, onSuccess }: OnchainZapContentProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { canSignPsbt } = useBitcoinSigner();
|
||||
const { capability } = useBitcoinSigner();
|
||||
const { logins } = useNostrLogin();
|
||||
const loginType = logins[0]?.type;
|
||||
|
||||
const [usdAmount, setUsdAmount] = useState<number | string>(5);
|
||||
const [comment, setComment] = useState('');
|
||||
@@ -122,10 +124,9 @@ export function OnchainZapContent({ target, onSuccess }: OnchainZapContentProps)
|
||||
setError('');
|
||||
if (!user) { setError('You must be logged in.'); return; }
|
||||
if (user.pubkey === target.pubkey) { setError("You can't zap yourself."); return; }
|
||||
if (!canSignPsbt) {
|
||||
setError("Your signer doesn't support Bitcoin signing. Log in with your nsec, or an extension/bunker that supports signPsbt.");
|
||||
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; }
|
||||
@@ -135,23 +136,37 @@ export function OnchainZapContent({ target, onSuccess }: OnchainZapContentProps)
|
||||
await zapAsync({ amountSats, comment, feeSpeed });
|
||||
// onSuccess (passed to useOnchainZap) closes the dialog; toast is shown by the hook.
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Zap failed');
|
||||
// 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, canSignPsbt, btcPrice, amountSats, utxos, insufficient, zapAsync, comment, feeSpeed]);
|
||||
}, [user, target.pubkey, btcPrice, amountSats, utxos, insufficient, zapAsync, comment, feeSpeed]);
|
||||
|
||||
// ── Signer not supported ──────────────────────────────────────
|
||||
|
||||
if (user && !canSignPsbt) {
|
||||
if (user && capability === 'unsupported') {
|
||||
// Tailor the hint to the login type so the user knows exactly what to
|
||||
// change to regain Bitcoin-zap capability.
|
||||
const hint =
|
||||
loginType === 'extension'
|
||||
? "Your browser extension doesn't expose signPsbt. Try a different extension, or log in with your nsec."
|
||||
: loginType === 'bunker'
|
||||
? "Your remote signer doesn't support sign_psbt. Update your signer, or log in with your nsec."
|
||||
: "Log in with your nsec, a NIP-07 extension that exposes signPsbt, or a NIP-46 remote signer that supports sign_psbt.";
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4">
|
||||
<Alert>
|
||||
<AlertTriangle className="size-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
Your signer doesn't support Bitcoin transaction signing. Log in with your nsec, a
|
||||
NIP-07 extension that supports <code>signPsbt</code>, or a NIP-46 remote signer
|
||||
that supports <code>sign_psbt</code> to send Bitcoin zaps.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<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">
|
||||
Your signer can't sign Bitcoin transactions. {hint}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocompl
|
||||
import { OnchainZapContent } from '@/components/OnchainZapContent';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useZaps } from '@/hooks/useZaps';
|
||||
import { useWallet } from '@/hooks/useWallet';
|
||||
@@ -295,9 +296,16 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||
const customEmojis = feedSettings.showCustomEmojis !== false ? allCustomEmojis : [];
|
||||
const { insertAtCursor, insertEmoji } = useInsertText(commentTextareaRef, comment, setComment);
|
||||
|
||||
// Default tab: onchain. Users can switch to Lightning if available.
|
||||
const [activeTab, setActiveTab] = useState<'onchain' | 'lightning'>('onchain');
|
||||
// Default tab: Bitcoin. Users can switch to Lightning if available.
|
||||
// If the user's signer can't sign PSBTs AND Lightning is available, we
|
||||
// transparently default to Lightning instead of showing an unusable
|
||||
// Bitcoin tab as the primary option.
|
||||
const { capability: btcCapability } = useBitcoinSigner();
|
||||
const hasLightning = canZap(author?.metadata);
|
||||
const bitcoinUnsupported = btcCapability === 'unsupported';
|
||||
const [activeTab, setActiveTab] = useState<'onchain' | 'lightning'>(
|
||||
bitcoinUnsupported && hasLightning ? 'lightning' : 'onchain',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (target) {
|
||||
@@ -367,15 +375,29 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||
setInvoice(null);
|
||||
setCopied(false);
|
||||
setQrCodeUrl('');
|
||||
setActiveTab('onchain');
|
||||
setActiveTab(bitcoinUnsupported && hasLightning ? 'lightning' : 'onchain');
|
||||
} else {
|
||||
setAmount(100);
|
||||
setInvoice(null);
|
||||
setCopied(false);
|
||||
setQrCodeUrl('');
|
||||
}
|
||||
// `bitcoinUnsupported`/`hasLightning` deliberately excluded — we only
|
||||
// want to reset the active tab on open/close, not on every capability
|
||||
// re-render. The mid-session flip is handled by the effect below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, setInvoice]);
|
||||
|
||||
// If Bitcoin capability flips to `unsupported` while the dialog is open
|
||||
// (e.g. a bunker just rejected `sign_psbt`) and Lightning is available,
|
||||
// transparently switch to the Lightning tab. Otherwise the user would be
|
||||
// stuck staring at the "Bitcoin zaps aren't available" panel.
|
||||
useEffect(() => {
|
||||
if (open && bitcoinUnsupported && hasLightning && activeTab === 'onchain') {
|
||||
setActiveTab('lightning');
|
||||
}
|
||||
}, [open, bitcoinUnsupported, hasLightning, activeTab]);
|
||||
|
||||
const handleZap = () => {
|
||||
impactMedium();
|
||||
const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount;
|
||||
|
||||
+141
-13
@@ -1,32 +1,141 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { type BtcSigner, hasBtcSigning } from '@/lib/bitcoin-signers';
|
||||
|
||||
/**
|
||||
* Hook that exposes Bitcoin PSBT signing capability from the current user's signer.
|
||||
* Three possible states for Bitcoin PSBT signing capability:
|
||||
*
|
||||
* Works with all login types:
|
||||
* - **nsec**: Signs locally using the Taproot-tweaked private key.
|
||||
* - **extension (NIP-07)**: Delegates to `window.nostr.signPsbt()`.
|
||||
* - **bunker (NIP-46)**: Sends `sign_psbt` RPC to the remote signer.
|
||||
* - `supported` — Known to work (nsec login, or extension that exposes `signPsbt`).
|
||||
* - `unsupported` — Known not to work (extension without `signPsbt`, or a remote
|
||||
* signer that has already rejected a `sign_psbt` request).
|
||||
* - `unknown` — Cannot be determined in advance. Applies to NIP-46 bunker
|
||||
* logins, since NIP-46 has no standard capability-discovery
|
||||
* RPC. Treat as "attempt, then propagate the error if it
|
||||
* fails" — the UI should allow the attempt but fall back to
|
||||
* the unsupported state when a `sign_psbt` call rejects with
|
||||
* a capability error (see `reportSignerUnsupported`).
|
||||
*/
|
||||
export type BitcoinSignerCapability = 'supported' | 'unsupported' | 'unknown';
|
||||
|
||||
/**
|
||||
* Module-level registry of bunker pubkeys that have been observed to reject
|
||||
* `sign_psbt`. Persists for the lifetime of the page so that the user doesn't
|
||||
* see the "attempt then fail" path twice for the same bunker.
|
||||
*/
|
||||
const knownUnsupportedBunkers = new Set<string>();
|
||||
|
||||
/**
|
||||
* Mark a bunker (keyed by user pubkey) as known-unsupported for PSBT signing.
|
||||
* Subsequent renders of `useBitcoinSigner` will return `'unsupported'` for
|
||||
* that user.
|
||||
*/
|
||||
export function reportSignerUnsupported(pubkey: string): void {
|
||||
knownUnsupportedBunkers.add(pubkey);
|
||||
// Dispatch a DOM event so hook consumers can re-render without plumbing a
|
||||
// shared store through the app. Listened to by `useBitcoinSigner`.
|
||||
window.dispatchEvent(new CustomEvent('bitcoin-signer-unsupported', { detail: pubkey }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that exposes Bitcoin PSBT signing capability for the current login.
|
||||
*
|
||||
* Returns `canSignPsbt: false` if the user is not logged in or their signer
|
||||
* doesn't support `signPsbt` (shouldn't happen with the Btc-extended signers,
|
||||
* but is a safety guard for unexpected signer types).
|
||||
* Capability is probed eagerly for known login types so that the UI can
|
||||
* replace itself with an "unsupported" state before the user attempts to
|
||||
* sign anything (rather than surfacing a toast after the fact).
|
||||
*
|
||||
* - **nsec** → always `'supported'` (local signing).
|
||||
* - **extension** → probes `window.nostr.signPsbt`. Returns `'supported'` if
|
||||
* present, `'unsupported'` if absent, or `'unknown'` while
|
||||
* still waiting for the extension to inject `window.nostr`.
|
||||
* - **bunker** → `'unknown'` by default (NIP-46 has no capability RPC).
|
||||
* Flips to `'unsupported'` for the session once a
|
||||
* `sign_psbt` attempt has rejected with a capability error
|
||||
* (see `reportSignerUnsupported`).
|
||||
*/
|
||||
export function useBitcoinSigner() {
|
||||
const { user } = useCurrentUser();
|
||||
const { logins } = useNostrLogin();
|
||||
const loginType = logins[0]?.type;
|
||||
|
||||
// ── Extension: probe window.nostr.signPsbt ───────────────────
|
||||
|
||||
const [extensionProbe, setExtensionProbe] = useState<BitcoinSignerCapability>(() => {
|
||||
if (loginType !== 'extension') return 'unknown';
|
||||
const n = (globalThis as { nostr?: Record<string, unknown> }).nostr;
|
||||
if (n && typeof n.signPsbt === 'function') return 'supported';
|
||||
if (n) return 'unsupported';
|
||||
return 'unknown';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (loginType !== 'extension') return;
|
||||
// Re-probe periodically in case the extension injects `window.nostr` late.
|
||||
let cancelled = false;
|
||||
const probe = () => {
|
||||
const n = (globalThis as { nostr?: Record<string, unknown> }).nostr;
|
||||
if (!n) return false;
|
||||
setExtensionProbe(typeof n.signPsbt === 'function' ? 'supported' : 'unsupported');
|
||||
return true;
|
||||
};
|
||||
if (probe()) return;
|
||||
const interval = setInterval(() => {
|
||||
if (cancelled) return;
|
||||
if (probe()) clearInterval(interval);
|
||||
}, 250);
|
||||
// Stop polling after 3 s — if the extension hasn't shown up by then it
|
||||
// likely isn't going to.
|
||||
const stop = setTimeout(() => clearInterval(interval), 3000);
|
||||
return () => { cancelled = true; clearInterval(interval); clearTimeout(stop); };
|
||||
}, [loginType]);
|
||||
|
||||
// ── Bunker: listen for capability-failure events ─────────────
|
||||
|
||||
const [bunkerUnsupported, setBunkerUnsupported] = useState(() =>
|
||||
user ? knownUnsupportedBunkers.has(user.pubkey) : false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (loginType !== 'bunker' || !user) return;
|
||||
if (knownUnsupportedBunkers.has(user.pubkey)) setBunkerUnsupported(true);
|
||||
const onUnsupported = (e: Event) => {
|
||||
const detail = (e as CustomEvent<string>).detail;
|
||||
if (detail === user.pubkey) setBunkerUnsupported(true);
|
||||
};
|
||||
window.addEventListener('bitcoin-signer-unsupported', onUnsupported);
|
||||
return () => window.removeEventListener('bitcoin-signer-unsupported', onUnsupported);
|
||||
}, [loginType, user]);
|
||||
|
||||
// ── Aggregate capability ─────────────────────────────────────
|
||||
|
||||
const capability: BitcoinSignerCapability = useMemo(() => {
|
||||
if (!user) return 'unsupported';
|
||||
switch (loginType) {
|
||||
case 'nsec':
|
||||
// Local signing is always available for nsec logins.
|
||||
return 'supported';
|
||||
case 'extension':
|
||||
return extensionProbe;
|
||||
case 'bunker':
|
||||
return bunkerUnsupported ? 'unsupported' : 'unknown';
|
||||
default:
|
||||
// Unknown login type: fall back to the structural check.
|
||||
return hasBtcSigning(user.signer) ? 'unknown' : 'unsupported';
|
||||
}
|
||||
}, [user, loginType, extensionProbe, bunkerUnsupported]);
|
||||
|
||||
const btcSigner = useMemo((): BtcSigner | null => {
|
||||
if (!user) return null;
|
||||
if (!user || capability === 'unsupported') return null;
|
||||
if (hasBtcSigning(user.signer)) return user.signer;
|
||||
return null;
|
||||
}, [user]);
|
||||
}, [user, capability]);
|
||||
|
||||
return {
|
||||
/** Whether the current user's signer supports Bitcoin PSBT signing. */
|
||||
canSignPsbt: btcSigner !== null,
|
||||
/** Detailed capability state. See {@link BitcoinSignerCapability}. */
|
||||
capability,
|
||||
/** True when capability is `'supported'` or `'unknown'` (attempt allowed). */
|
||||
canSignPsbt: capability !== 'unsupported' && btcSigner !== null,
|
||||
/**
|
||||
* Sign a hex-encoded PSBT. Throws if the signer doesn't support it.
|
||||
* The returned hex is a signed (but not finalized) PSBT.
|
||||
@@ -36,3 +145,22 @@ export function useBitcoinSigner() {
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a signer error as a "capability error" (the signer fundamentally
|
||||
* cannot sign PSBTs) versus a transient/operational error (network blip,
|
||||
* user cancellation, malformed PSBT, etc.).
|
||||
*
|
||||
* Used by `useOnchainZap` to decide whether a failed send should flip the
|
||||
* UI into the `'unsupported'` state or just surface a normal error toast.
|
||||
*/
|
||||
export function isSignerCapabilityError(err: unknown): boolean {
|
||||
if (!err) return false;
|
||||
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
||||
return (
|
||||
msg.includes('does not support') ||
|
||||
msg.includes("doesn't support") ||
|
||||
msg.includes('signpsbt') ||
|
||||
msg.includes('sign_psbt')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
|
||||
import { useBitcoinSigner, isSignerCapabilityError, reportSignerUnsupported } from '@/hooks/useBitcoinSigner';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { notificationSuccess } from '@/lib/haptics';
|
||||
@@ -177,6 +177,15 @@ export function useOnchainZap(
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (err) => {
|
||||
// If the signer turned out to not support PSBT signing (common for
|
||||
// NIP-46 bunkers where capability can't be probed up front), mark the
|
||||
// signer as unsupported for the rest of the session. The dialog UI
|
||||
// watches this state and replaces itself with an "unsupported" panel
|
||||
// instead of relying on this toast.
|
||||
if (isSignerCapabilityError(err) && user) {
|
||||
reportSignerUnsupported(user.pubkey);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: 'Bitcoin zap failed',
|
||||
description: err.message,
|
||||
|
||||
Reference in New Issue
Block a user