Skip to content

fix(nip46): accept secret-echo connect response and send remote-signer-pubkey as first param per NIP-46#390

Open
DocNR wants to merge 2 commits into
nostr-dev-kit:masterfrom
DocNR:fix/nip46-bunker-connect-spec-compliance
Open

fix(nip46): accept secret-echo connect response and send remote-signer-pubkey as first param per NIP-46#390
DocNR wants to merge 2 commits into
nostr-dev-kit:masterfrom
DocNR:fix/nip46-bunker-connect-spec-compliance

Conversation

@DocNR

@DocNR DocNR commented May 9, 2026

Copy link
Copy Markdown

Summary

NDKNip46Signer.blockUntilReady has two NIP-46 spec violations on the bunker:// path that prevent connecting to spec-compliant signers (e.g. Clave, nsec.app's bunker, anything matching nostr-tools BunkerSigner.fromURI semantics). Both bugs are present in every released NDK from 2.12.x through 3.0.3.

Per the NIP-46 spec:

connect: [<remote-signer-pubkey>, <optional_secret>, <optional_requested_perms>]
result: "ack" OR <required-secret-value>

Bug 1 — first connect param is the user-pubkey instead of the remote-signer-pubkey

Line 347 sends [this.userPubkey ?? ""]. For bunker:// URIs without ?pubkey= (the typical single-user signer case), userPubkey is null → an empty string lands as the first param. Per spec the first param is the remote-signer-pubkey, which for a bunker:// URI is the URI hostname (= bunkerPubkey).

Fixed by falling through to bunkerPubkey when userPubkey is null.

Bug 2 — only "ack" is accepted as a valid connect response

Line 354 only accepts response.result === "ack". Per spec, "ack" OR the URI secret echoed back are both valid. Spec-compliant signers like Clave and any signer matching nostr-tools BunkerSigner.fromURI semantics return the secret-echo form. NDK rejects with response.error which is undefined (no error in the response) — leaving downstream catch handlers to throw TypeError: Cannot read properties of undefined (reading 'message').

Fixed by accepting response.result === "ack" || response.result === this.secret.

Drive-by: rejection is now wrapped in a real Error so downstream handlers get a useful message instead of undefined.

Note: blockUntilReadyNostrConnect already does this right

Line 287 of the nostrconnect:// flow already matches response.result === this.nostrConnectSecret. This PR aligns the bunker:// path with that and with the spec.

Real-world failure

stacker.news's NIP-46 bunker login hangs indefinitely when paired with a Clave bunker URI for exactly these reasons. Companion PR (stackernews/stacker.news#2947) extends their existing NDKNip46SignerURLPatch workaround with the same fix; once this PR ships, that workaround can be deleted on next NDK bump.

Tests

3 new tests in core/src/signers/nip46/index.test.ts (following the existing mock pattern):

  • blockUntilReady resolves user on URI secret echoed back (NIP-46 spec) — covers Bug 2
  • blockUntilReady sends bunkerPubkey as first connect param when no userPubkey — covers Bug 1 (URI without ?pubkey=)
  • blockUntilReady sends bunkerPubkey (not userPubkey) as first connect param when the URI carries ?pubkey= — covers Bug 1 for multi-user signer URIs (added after review feedback — the earlier fix still sent userPubkey as params[0] whenever the URI carried ?pubkey=)
  • blockUntilReady rejects with a real Error (not undefined) for unexpected connect responses — covers the drive-by

Manually verified the fix against:

  • A Clave-emulating mock bunker (returns secret echo) on a local nak serve relay → resolves
  • A real Clave bunker URI from an iOS TestFlight build on relay.powr.build → resolves in ~2s

Test plan

  • New unit tests not run locally. NDK is a bun workspace; I don't have bun available, and npm install inside core/ doesn't reproduce the workspace layout (vitest isn't hoisted into core/node_modules). The 4 new tests are written to the exact pattern of the existing blockUntilReady resolves user on ack test — same createMockRpc() harness, same mockImplementation callback shape. A maintainer running bun test is the validation gate here; flagging explicitly rather than claiming a green run I didn't do.
  • Fix verified end-to-end against a Clave-emulating mock bunker (returns the secret echo) on a local nak serve relay and against a real Clave bunker URI from an iOS TestFlight build on relay.powr.build (resolves in ~2s). The mock-bunker harness drives the real NDKNip46Signer code path, not a reimplementation.

🤖 Generated with Claude Code

…r-pubkey as first param per NIP-46

NDKNip46Signer.blockUntilReady has two NIP-46 spec violations on the
bunker:// path that prevent connecting to spec-compliant signers
(present in every released NDK from 2.12.2 through 3.0.3):

1. The first parameter sent to `connect` is the user-pubkey instead
   of the remote-signer-pubkey. For bunker:// URIs without `?pubkey=`
   (the typical single-user signer case, e.g. Clave at
   https://github.com/DocNR/clave), this means an empty string is
   sent. Per NIP-46:

       connect: [<remote-signer-pubkey>, <optional_secret>, <optional_requested_perms>]

   Fall through to `bunkerPubkey` (the URI hostname, which is by
   definition the remote-signer-pubkey) when `userPubkey` is null.

2. The response handler only accepts `response.result === "ack"`.
   Per NIP-46 spec, valid `connect` responses are:

       "ack" OR <required-secret-value>

   Spec-compliant signers like Clave (LightSigner.swift) and any
   signer matching nostr-tools' BunkerSigner.fromURI semantics return
   the URI secret echoed back instead of "ack". Accept both forms.

Also: when rejecting on an unexpected result, wrap in a real `Error`
instead of passing `response.error` (which is undefined when the
bunker returns a successful-shaped response that we don't recognize).
This used to throw `TypeError: Cannot read properties of undefined`
in downstream catch handlers — now they get a useful message.

NDK 3.0.3 already does the right thing in `blockUntilReadyNostrConnect`
(checks `response.result === this.nostrConnectSecret` at line 287);
this aligns the bunker:// path with the spec and with that nostrconnect
implementation.

Adds three regression tests covering:
- The secret-echoed-back response path (Clave-style)
- The first-param-is-bunkerPubkey behavior when no userPubkey
- The Error-not-undefined rejection on unexpected responses

Real-world failure: stacker.news's NIP-46 bunker login hangs forever
when paired with a Clave bunker URI because NDK rejects with
`undefined`, and stackernews's catch handler does `e.message ||
e.toString()` which throws on undefined. A companion stacker.news PR
extends their existing NDKNip46SignerURLPatch with the same fix; this
upstream PR lets that override be deleted on next NDK bump.
Review feedback: the previous `[this.userPubkey || this.bunkerPubkey || ""]`
still sent the user pubkey as connect.params[0] whenever the bunker URI
carried `?pubkey=<user-pubkey>` (NDK parses that into userPubkey).

Per NIP-46, connect.params[0] is the remote-signer-pubkey — always
bunkerPubkey (the URI host). For multi-user signers, `?pubkey=` is the
user identity the client wants to act as, not the connect target; it is
resolved separately via the get_public_key call after the handshake
(getPublicKey() returns the cached userPubkey early when it is already
set, so this is a no-op cost). Sending userPubkey breaks signers that
validate connect.params[0] against their own signer pubkey. nostr-tools'
BunkerSigner sends `this.bp.pubkey` (the bunker pubkey) likewise.

Adds a regression test with distinct bunkerPubkey and userPubkey
asserting params[0] === bunkerPubkey, alongside the existing
no-userPubkey test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant