Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
}
51 changes: 51 additions & 0 deletions src/lib/__tests__/signer-approval.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string>(() => {})
const sentinel = new Promise<string>((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<string>(() => {})
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()
})
})
90 changes: 90 additions & 0 deletions src/lib/signer-approval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import i18n from '@/i18n'
import { kinds } from 'nostr-tools'
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'

// 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<number>([kinds.ClientAuth, kinds.HTTPAuth])

let pending = 0
let timer: ReturnType<typeof setTimeout> | 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<T>(
promise: Promise<T>,
kind?: number,
timeout = TIMEOUT_MS
): Promise<T> {
// Background auth signs pass straight through — no toast, no timeout.
if (kind !== undefined && BACKGROUND_SIGN_KINDS.has(kind)) {
return promise
}
if (pending === 0) {
scheduleShow()
}
pending++

let timeoutTimer: ReturnType<typeof setTimeout> | undefined
const timeoutPromise = new Promise<never>((_, 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()
}
}
}
3 changes: 2 additions & 1 deletion src/providers/NostrProvider/bunker.signer.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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), draftEvent.kind)
}

async nip04Encrypt(pubkey: string, plainText: string) {
Expand Down
3 changes: 2 additions & 1 deletion src/providers/NostrProvider/nip-07.signer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { withSignerApproval } from '@/lib/signer-approval'
import { ISigner, TDraftEvent, TNip07 } from '@/types'

export class Nip07Signer implements ISigner {
Expand Down Expand Up @@ -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), draftEvent.kind)
}

async nip04Encrypt(pubkey: string, plainText: string) {
Expand Down
Loading