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:
Alex Gleason
2026-04-22 15:57:00 -05:00
parent 01980918bc
commit 008f3979e1
4 changed files with 209 additions and 35 deletions
+33 -18
View File
@@ -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>
);
}
+25 -3
View File
@@ -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
View File
@@ -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')
);
}
+10 -1
View File
@@ -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,