From 008f3979e1c482da52550a0e643e8ee5bf681e89 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Apr 2026 15:57:00 -0500 Subject: [PATCH] Detect Bitcoin signer capability before submitting a zap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/OnchainZapContent.tsx | 51 +++++---- src/components/ZapDialog.tsx | 28 ++++- src/hooks/useBitcoinSigner.ts | 154 ++++++++++++++++++++++++--- src/hooks/useOnchainZap.ts | 11 +- 4 files changed, 209 insertions(+), 35 deletions(-) diff --git a/src/components/OnchainZapContent.tsx b/src/components/OnchainZapContent.tsx index 9875fa2b..aa8b009b 100644 --- a/src/components/OnchainZapContent.tsx +++ b/src/components/OnchainZapContent.tsx @@ -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(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 ( -
- - - - Your signer doesn't support Bitcoin transaction signing. Log in with your nsec, a - NIP-07 extension that supports signPsbt, or a NIP-46 remote signer - that supports sign_psbt to send Bitcoin zaps. - - +
+
+ +
+
+

Bitcoin zaps aren't available

+

+ Your signer can't sign Bitcoin transactions. {hint} +

+
); } diff --git a/src/components/ZapDialog.tsx b/src/components/ZapDialog.tsx index 10514d86..ed0f1041 100644 --- a/src/components/ZapDialog.tsx +++ b/src/components/ZapDialog.tsx @@ -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; diff --git a/src/hooks/useBitcoinSigner.ts b/src/hooks/useBitcoinSigner.ts index a89d9188..ddd76b37 100644 --- a/src/hooks/useBitcoinSigner.ts +++ b/src/hooks/useBitcoinSigner.ts @@ -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(); + +/** + * 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(() => { + if (loginType !== 'extension') return 'unknown'; + const n = (globalThis as { nostr?: Record }).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 }).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).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') + ); +} diff --git a/src/hooks/useOnchainZap.ts b/src/hooks/useOnchainZap.ts index b4b73f83..87200d96 100644 --- a/src/hooks/useOnchainZap.ts +++ b/src/hooks/useOnchainZap.ts @@ -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,