Skip to content

Commit cd6e121

Browse files
DocNRclaude
andcommitted
fix(signer): exclude background AUTH signs from the approval wait
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) <noreply@anthropic.com>
1 parent da129dd commit cd6e121

4 files changed

Lines changed: 71 additions & 3 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { kinds } from 'nostr-tools'
2+
import { afterEach, describe, expect, it, vi } from 'vitest'
3+
import { toast } from 'sonner'
4+
import { withSignerApproval } from '../signer-approval'
5+
6+
// signer-approval imports sonner (for the waiting toast) and @/i18n (for copy).
7+
// Mock both so the module is testable in the node env and we can assert whether
8+
// the toast was scheduled.
9+
vi.mock('sonner', () => ({ toast: { loading: vi.fn(), dismiss: vi.fn() } }))
10+
vi.mock('@/i18n', () => ({ default: { t: (key: string) => key } }))
11+
12+
describe('withSignerApproval', () => {
13+
afterEach(() => {
14+
vi.mocked(toast.loading).mockClear()
15+
vi.mocked(toast.dismiss).mockClear()
16+
})
17+
18+
it('passes NIP-42 relay AUTH (kind 22242) straight through with no toast', async () => {
19+
const result = await withSignerApproval(Promise.resolve('signed'), kinds.ClientAuth)
20+
expect(result).toBe('signed')
21+
expect(toast.loading).not.toHaveBeenCalled()
22+
})
23+
24+
it('passes NIP-98 HTTP AUTH (kind 27235) straight through with no toast', async () => {
25+
const result = await withSignerApproval(Promise.resolve('signed'), kinds.HTTPAuth)
26+
expect(result).toBe('signed')
27+
expect(toast.loading).not.toHaveBeenCalled()
28+
})
29+
30+
it('does not apply the approval timeout to background AUTH signs', async () => {
31+
// A never-resolving AUTH sign must not be rejected by the timeout: it is
32+
// returned verbatim. Race it against a short sentinel — the sentinel wins,
33+
// proving withSignerApproval did not reject it at the (tiny) timeout.
34+
const never = new Promise<string>(() => {})
35+
const sentinel = new Promise<string>((resolve) => setTimeout(() => resolve('sentinel'), 30))
36+
const winner = await Promise.race([withSignerApproval(never, kinds.ClientAuth, 5), sentinel])
37+
expect(winner).toBe('sentinel')
38+
})
39+
40+
it('still enforces the timeout for a normal user-initiated sign (kind 1)', async () => {
41+
const never = new Promise<string>(() => {})
42+
await expect(withSignerApproval(never, 1, 20)).rejects.toThrow('Signer did not respond in time')
43+
})
44+
45+
it('resolves a normal sign and shows then dismisses nothing for an instant resolve', async () => {
46+
const result = await withSignerApproval(Promise.resolve('ok'), 1)
47+
expect(result).toBe('ok')
48+
// Instant resolve beats the 1s show delay, so the loading toast never fires.
49+
expect(toast.loading).not.toHaveBeenCalled()
50+
})
51+
})

src/lib/signer-approval.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import i18n from '@/i18n'
2+
import { kinds } from 'nostr-tools'
23
import { toast } from 'sonner'
34

45
// Signers that require manual approval — NIP-46 remote signers (bunker /
@@ -19,6 +20,14 @@ const SHOW_DELAY_MS = 1000
1920
const TIMEOUT_MS = 30_000
2021
const TOAST_ID = 'signer-approval-waiting'
2122

23+
// NIP-42 relay AUTH (kind 22242) and NIP-98 HTTP AUTH (kind 27235) are signed
24+
// automatically in the background — relay-connection AUTH, media-upload and
25+
// translation HTTP auth — never as a user-initiated action. They must not surface
26+
// an approval-wait toast or be bounded by the user-approval timeout: an AUTH-gated
27+
// relay the user never manually approves would otherwise spam the toast and reject
28+
// every background AUTH at the 30s mark.
29+
const BACKGROUND_SIGN_KINDS = new Set<number>([kinds.ClientAuth, kinds.HTTPAuth])
30+
2231
let pending = 0
2332
let timer: ReturnType<typeof setTimeout> | null = null
2433
let shown = false
@@ -47,7 +56,15 @@ function hide() {
4756
}
4857
}
4958

50-
export async function withSignerApproval<T>(promise: Promise<T>, timeout = TIMEOUT_MS): Promise<T> {
59+
export async function withSignerApproval<T>(
60+
promise: Promise<T>,
61+
kind?: number,
62+
timeout = TIMEOUT_MS
63+
): Promise<T> {
64+
// Background auth signs pass straight through — no toast, no timeout.
65+
if (kind !== undefined && BACKGROUND_SIGN_KINDS.has(kind)) {
66+
return promise
67+
}
5168
if (pending === 0) {
5269
scheduleShow()
5370
}

src/providers/NostrProvider/bunker.signer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export class BunkerSigner implements ISigner {
119119
if (!this.signer) {
120120
throw new Error('Not logged in')
121121
}
122-
return withSignerApproval(this.signer.signEvent(draftEvent))
122+
return withSignerApproval(this.signer.signEvent(draftEvent), draftEvent.kind)
123123
}
124124

125125
async nip04Encrypt(pubkey: string, plainText: string) {

src/providers/NostrProvider/nip-07.signer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export class Nip07Signer implements ISigner {
3636
if (!this.signer) {
3737
throw new Error('Should call init() first')
3838
}
39-
return await withSignerApproval(this.signer.signEvent(draftEvent))
39+
return await withSignerApproval(this.signer.signEvent(draftEvent), draftEvent.kind)
4040
}
4141

4242
async nip04Encrypt(pubkey: string, plainText: string) {

0 commit comments

Comments
 (0)