From da129dd6f8bf29aa65357139c56ac7687d51285f Mon Sep 17 00:00:00 2001 From: DocNR Date: Sun, 14 Jun 2026 07:08:30 -0400 Subject: [PATCH 1/2] feat(signer): show waiting indicator and time out remote-signer approvals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap signEvent for the bunker (NIP-46) and NIP-07 signers with a shared withSignerApproval helper: after a 1s delay (so instant auto-approvals stay silent) a low-key loading toast appears and auto-dismisses when the signature returns; concurrent signs reference-count into one toast. The wait is bounded by a 30s timeout so an unresponsive signer (offline bunker, closed extension popup) rejects instead of hanging the composer forever. Ported from upstream Jumble e0c4dca3. nsec/npub signers are intentionally not wrapped (instant local sign / view-only). Upstream's mobile toast-offset tweak is skipped — jank already tunes mobileOffset for its own chrome. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/i18n/locales/en.ts | 4 +- src/lib/signer-approval.ts | 73 ++++++++++++++++++++ src/providers/NostrProvider/bunker.signer.ts | 3 +- src/providers/NostrProvider/nip-07.signer.ts | 3 +- 4 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 src/lib/signer-approval.ts diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 828a1c9..709b3f4 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1089,6 +1089,8 @@ export default { 'No muted users': 'No muted users', 'No muted threads': 'No muted threads', 'Muted words apply to all your accounts.': 'Muted words apply to all your accounts.', - 'Reveal muted thread': 'Reveal muted thread' + 'Reveal muted thread': 'Reveal muted thread', + 'Waiting for signer approval...': 'Waiting for signer approval...', + 'Signer did not respond in time': 'Signer did not respond in time' } } diff --git a/src/lib/signer-approval.ts b/src/lib/signer-approval.ts new file mode 100644 index 0000000..e836904 --- /dev/null +++ b/src/lib/signer-approval.ts @@ -0,0 +1,73 @@ +import i18n from '@/i18n' +import { toast } from 'sonner' + +// Signers that require manual approval — NIP-46 remote signers (bunker / +// nostr-connect) and NIP-07 browser extensions configured to prompt — forward +// a sign request to the user's signer and wait. Without any feedback the user +// has no idea a signature is pending and may forget to approve it. +// +// We show a deliberately low-key hint: after a short delay (so instant +// auto-approvals stay silent), a small loading toast appears in the corner and +// auto-dismisses once the signature comes back. Concurrent sign requests are +// reference-counted into a single toast so frequent signing never stacks up. +// +// We also bound the wait with a timeout: if the signer never responds (offline +// bunker, closed extension popup, etc.) the request rejects instead of hanging +// forever. The window is generous so a user manually approving still makes it. + +const SHOW_DELAY_MS = 1000 +const TIMEOUT_MS = 30_000 +const TOAST_ID = 'signer-approval-waiting' + +let pending = 0 +let timer: ReturnType | null = null +let shown = false + +function scheduleShow() { + timer = setTimeout(() => { + timer = null + if (pending > 0) { + shown = true + toast.loading(i18n.t('Waiting for signer approval...'), { + id: TOAST_ID, + duration: Infinity + }) + } + }, SHOW_DELAY_MS) +} + +function hide() { + if (timer) { + clearTimeout(timer) + timer = null + } + if (shown) { + shown = false + toast.dismiss(TOAST_ID) + } +} + +export async function withSignerApproval(promise: Promise, timeout = TIMEOUT_MS): Promise { + if (pending === 0) { + scheduleShow() + } + pending++ + + let timeoutTimer: ReturnType | undefined + const timeoutPromise = new Promise((_, reject) => { + timeoutTimer = setTimeout( + () => reject(new Error(i18n.t('Signer did not respond in time'))), + timeout + ) + }) + + try { + return await Promise.race([promise, timeoutPromise]) + } finally { + clearTimeout(timeoutTimer) + pending-- + if (pending === 0) { + hide() + } + } +} diff --git a/src/providers/NostrProvider/bunker.signer.ts b/src/providers/NostrProvider/bunker.signer.ts index e8c52cb..17edce9 100644 --- a/src/providers/NostrProvider/bunker.signer.ts +++ b/src/providers/NostrProvider/bunker.signer.ts @@ -1,3 +1,4 @@ +import { withSignerApproval } from '@/lib/signer-approval' import { ISigner, TDraftEvent } from '@/types' import { bytesToHex, hexToBytes } from '@noble/hashes/utils' import { base64 } from '@scure/base' @@ -118,7 +119,7 @@ export class BunkerSigner implements ISigner { if (!this.signer) { throw new Error('Not logged in') } - return this.signer.signEvent(draftEvent) + return withSignerApproval(this.signer.signEvent(draftEvent)) } async nip04Encrypt(pubkey: string, plainText: string) { diff --git a/src/providers/NostrProvider/nip-07.signer.ts b/src/providers/NostrProvider/nip-07.signer.ts index f6bbc48..ebe027a 100644 --- a/src/providers/NostrProvider/nip-07.signer.ts +++ b/src/providers/NostrProvider/nip-07.signer.ts @@ -1,3 +1,4 @@ +import { withSignerApproval } from '@/lib/signer-approval' import { ISigner, TDraftEvent, TNip07 } from '@/types' export class Nip07Signer implements ISigner { @@ -35,7 +36,7 @@ export class Nip07Signer implements ISigner { if (!this.signer) { throw new Error('Should call init() first') } - return await this.signer.signEvent(draftEvent) + return await withSignerApproval(this.signer.signEvent(draftEvent)) } async nip04Encrypt(pubkey: string, plainText: string) { From cd6e1213a8d618ec2be86c5505d592fc7b235cde Mon Sep 17 00:00:00 2001 From: DocNR Date: Sun, 14 Jun 2026 08:14:39 -0400 Subject: [PATCH 2/2] fix(signer): exclude background AUTH signs from the approval wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NIP-42 relay AUTH (kind 22242) and NIP-98 HTTP auth (kind 27235) flow through the same signer.signEvent chokepoint as user posts, so the new withSignerApproval wrapper was surfacing a "Waiting for signer approval" toast and a 30s timeout for every background relay-AUTH sign — noisy on a multi-relay deck, and it rejected AUTH signs the user never meant to approve (observed live: "subscribe auth function failed: Signer did not respond in time"). Pass the draft kind through to withSignerApproval and pass auth kinds straight through with no toast and no timeout, reverting AUTH to its prior behavior. Real user-initiated signs still get the waiting indicator + bound. Adds a unit test. Found in browser smoke against a real Clave bunker. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/__tests__/signer-approval.spec.ts | 51 ++++++++++++++++++++ src/lib/signer-approval.ts | 19 +++++++- src/providers/NostrProvider/bunker.signer.ts | 2 +- src/providers/NostrProvider/nip-07.signer.ts | 2 +- 4 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 src/lib/__tests__/signer-approval.spec.ts diff --git a/src/lib/__tests__/signer-approval.spec.ts b/src/lib/__tests__/signer-approval.spec.ts new file mode 100644 index 0000000..8d2ec87 --- /dev/null +++ b/src/lib/__tests__/signer-approval.spec.ts @@ -0,0 +1,51 @@ +import { kinds } from 'nostr-tools' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import { withSignerApproval } from '../signer-approval' + +// signer-approval imports sonner (for the waiting toast) and @/i18n (for copy). +// Mock both so the module is testable in the node env and we can assert whether +// the toast was scheduled. +vi.mock('sonner', () => ({ toast: { loading: vi.fn(), dismiss: vi.fn() } })) +vi.mock('@/i18n', () => ({ default: { t: (key: string) => key } })) + +describe('withSignerApproval', () => { + afterEach(() => { + vi.mocked(toast.loading).mockClear() + vi.mocked(toast.dismiss).mockClear() + }) + + it('passes NIP-42 relay AUTH (kind 22242) straight through with no toast', async () => { + const result = await withSignerApproval(Promise.resolve('signed'), kinds.ClientAuth) + expect(result).toBe('signed') + expect(toast.loading).not.toHaveBeenCalled() + }) + + it('passes NIP-98 HTTP AUTH (kind 27235) straight through with no toast', async () => { + const result = await withSignerApproval(Promise.resolve('signed'), kinds.HTTPAuth) + expect(result).toBe('signed') + expect(toast.loading).not.toHaveBeenCalled() + }) + + it('does not apply the approval timeout to background AUTH signs', async () => { + // A never-resolving AUTH sign must not be rejected by the timeout: it is + // returned verbatim. Race it against a short sentinel — the sentinel wins, + // proving withSignerApproval did not reject it at the (tiny) timeout. + const never = new Promise(() => {}) + const sentinel = new Promise((resolve) => setTimeout(() => resolve('sentinel'), 30)) + const winner = await Promise.race([withSignerApproval(never, kinds.ClientAuth, 5), sentinel]) + expect(winner).toBe('sentinel') + }) + + it('still enforces the timeout for a normal user-initiated sign (kind 1)', async () => { + const never = new Promise(() => {}) + await expect(withSignerApproval(never, 1, 20)).rejects.toThrow('Signer did not respond in time') + }) + + it('resolves a normal sign and shows then dismisses nothing for an instant resolve', async () => { + const result = await withSignerApproval(Promise.resolve('ok'), 1) + expect(result).toBe('ok') + // Instant resolve beats the 1s show delay, so the loading toast never fires. + expect(toast.loading).not.toHaveBeenCalled() + }) +}) diff --git a/src/lib/signer-approval.ts b/src/lib/signer-approval.ts index e836904..82df7f0 100644 --- a/src/lib/signer-approval.ts +++ b/src/lib/signer-approval.ts @@ -1,4 +1,5 @@ import i18n from '@/i18n' +import { kinds } from 'nostr-tools' import { toast } from 'sonner' // Signers that require manual approval — NIP-46 remote signers (bunker / @@ -19,6 +20,14 @@ const SHOW_DELAY_MS = 1000 const TIMEOUT_MS = 30_000 const TOAST_ID = 'signer-approval-waiting' +// NIP-42 relay AUTH (kind 22242) and NIP-98 HTTP AUTH (kind 27235) are signed +// automatically in the background — relay-connection AUTH, media-upload and +// translation HTTP auth — never as a user-initiated action. They must not surface +// an approval-wait toast or be bounded by the user-approval timeout: an AUTH-gated +// relay the user never manually approves would otherwise spam the toast and reject +// every background AUTH at the 30s mark. +const BACKGROUND_SIGN_KINDS = new Set([kinds.ClientAuth, kinds.HTTPAuth]) + let pending = 0 let timer: ReturnType | null = null let shown = false @@ -47,7 +56,15 @@ function hide() { } } -export async function withSignerApproval(promise: Promise, timeout = TIMEOUT_MS): Promise { +export async function withSignerApproval( + promise: Promise, + kind?: number, + timeout = TIMEOUT_MS +): Promise { + // Background auth signs pass straight through — no toast, no timeout. + if (kind !== undefined && BACKGROUND_SIGN_KINDS.has(kind)) { + return promise + } if (pending === 0) { scheduleShow() } diff --git a/src/providers/NostrProvider/bunker.signer.ts b/src/providers/NostrProvider/bunker.signer.ts index 17edce9..6c72ec6 100644 --- a/src/providers/NostrProvider/bunker.signer.ts +++ b/src/providers/NostrProvider/bunker.signer.ts @@ -119,7 +119,7 @@ export class BunkerSigner implements ISigner { if (!this.signer) { throw new Error('Not logged in') } - return withSignerApproval(this.signer.signEvent(draftEvent)) + return withSignerApproval(this.signer.signEvent(draftEvent), draftEvent.kind) } async nip04Encrypt(pubkey: string, plainText: string) { diff --git a/src/providers/NostrProvider/nip-07.signer.ts b/src/providers/NostrProvider/nip-07.signer.ts index ebe027a..8a7c5e2 100644 --- a/src/providers/NostrProvider/nip-07.signer.ts +++ b/src/providers/NostrProvider/nip-07.signer.ts @@ -36,7 +36,7 @@ export class Nip07Signer implements ISigner { if (!this.signer) { throw new Error('Should call init() first') } - return await withSignerApproval(this.signer.signEvent(draftEvent)) + return await withSignerApproval(this.signer.signEvent(draftEvent), draftEvent.kind) } async nip04Encrypt(pubkey: string, plainText: string) {