fix(#9): close race between create_new_key and NIP-46 connect #10

Merged
padreug merged 1 commit from issue-9-fix-create-new-key-race into dev 2026-05-30 11:23:43 +00:00
Owner

Closes #9.

Summary

Two-layer fix for the race where a fresh client chaining create_new_key + NIP-46 connect on the same target key would time out — the bunker had no subscription registered for the new key by the time the connect event arrived at the relay.

Repro before this PR

Surfaced by aiolabs/lnbits PR #33's eager-bind chain, which publishes a NIP-46 connect event in the same HTTP round-trip as create_new_key. Pre-PR-#33 lnbits deferred the connect to first sign_event (minutes-to-hours after provisioning), so the race window was hidden.

On bohm regtest with aiolabs/lnbits PR #33 + this branch reverted:

[EVENTS] Failed to publish to Nostr: 'connect' returned error: Token already redeemed
NsecBunkerTimeoutError: no NIP-46 response for 'connect' within 15.0s

Bunker logs: zero REQUEST_IN method=connect on kind-24133 in the same window. The connect event reached the relay (lnbits/nostrrelay extension logged _handle_event on the right kind), but the bunker's NIP-46 listener wasn't subscribed yet.

What changed

Layer 1 — src/daemon/run.ts

loadNsec and unlockKey were synchronous and fire-and-forgot the async startKey promise. create_new_key.ts:35 already awaited loadNsec, but the await was a no-op against a sync return. Promoted both to async and properly awaited startKey. This was necessary but not sufficient — backend.start() itself was still returning before the relay registered the subscription.

// Before
loadNsec(keyName: string, nsec: string) {
    this.activeKeys[keyName] = nsec;
    this.startKey(keyName, nsec);                   // fire-and-forget
}

// After
async loadNsec(keyName: string, nsec: string) {
    this.activeKeys[keyName] = nsec;
    await this.startKey(keyName, nsec);
}

Same shape change for unlockKey.

Layer 2 — src/daemon/backend/index.ts

NDKNip46Backend.start() (parent class, from @nostr-dev-kit/ndk@2.8.1):

async start() {
    this.localUser = await this.signer.user();
    const sub = this.ndk.subscribe(
        { kinds: [24133], "#p": [this.localUser.pubkey] },
        { closeOnEose: false }
    );
    sub.on("event", (e) => this.handleIncomingEvent(e));
}

this.ndk.subscribe(...) returns immediately — the NDKSubscription queues a REQ on the relay connection but the relay's EOSE acknowledgement hasn't arrived yet. Any caller that publishes a NIP-46 event in the immediate window after start() returns races against the relay registering this subscription.

This is the actual race-closer. Layer 1's await alone wasn't enough because start() was still returning before the relay registered the subscription.

Overrode start() in our Backend subclass to await EOSE before resolving:

async start(): Promise<void> {
    this.localUser = await this.signer.user();
    await new Promise<void>((resolve) => {
        const sub = this.ndk.subscribe(
            { kinds: [24133], "#p": [this.localUser!.pubkey] },
            { closeOnEose: false }
        );
        sub.on("event", (e: any) => this.handleIncomingEvent(e));
        sub.on("eose", () => resolve());
    });
}

By the time start() resolves, the relay has confirmed it has the bunker's subscription on file and will route matching kind-24133 events to it.

Verification

End-to-end on bohm regtest stack with aiolabs/lnbits PR #33 (b1694186) + this PR (65a6966):

  • Demo account creation through the webapp (http://192.168.0.32:5181/login) completes cleanly in ~3s end-to-end.
  • Bunker logs show the expected NIP-46 flow for the freshly-provisioned key:
🔑 76219886... is being requested to connect by npub1qa4v9qjr...
🔎 npub1qa4v9qjr... is allowed to connect with key 76219886...
🔑 76219886... is being requested to sign_event by npub1qa4v9qjr...
🔎 npub1qa4v9qjr... is allowed to sign_event with key 76219886...
  • Lnbits register handler returns 200 OK with the new account, kind-0 metadata event publishes via the same eager-bind session.
  • No NsecBunkerTimeoutError, no Token already redeemed.
  • #9 — the original bug report
  • aiolabs/lnbits#32 / PR #33 — the chain that surfaced this
  • Cross-session coordination log: ~/dev/coordination/log.md entries 2026-05-30T07:00Z through 2026-05-30T10:30Z documenting the debugging arc.

Test plan

  • End-to-end demo account creation through webapp on bohm regtest succeeds.
  • Bunker logs show kind-24133 connect + sign_event handled for the freshly-provisioned key.
  • No timeouts on consecutive provisions.
  • Integration test: a tight create_new_keyconnect loop run repeatedly (e.g. 100 iterations) without artificial sleep. Worth adding to the test suite as a regression guard. Out of scope for this PR.

🤖 Generated with Claude Code

Closes #9. ## Summary Two-layer fix for the race where a fresh client chaining `create_new_key` + NIP-46 `connect` on the same target key would time out — the bunker had no subscription registered for the new key by the time the connect event arrived at the relay. ## Repro before this PR Surfaced by `aiolabs/lnbits` PR #33's eager-bind chain, which publishes a NIP-46 `connect` event in the same HTTP round-trip as `create_new_key`. Pre-PR-#33 lnbits deferred the connect to first `sign_event` (minutes-to-hours after provisioning), so the race window was hidden. On bohm regtest with `aiolabs/lnbits` PR #33 + this branch reverted: ``` [EVENTS] Failed to publish to Nostr: 'connect' returned error: Token already redeemed NsecBunkerTimeoutError: no NIP-46 response for 'connect' within 15.0s ``` Bunker logs: zero `REQUEST_IN method=connect` on kind-24133 in the same window. The connect event reached the relay (lnbits/nostrrelay extension logged `_handle_event` on the right kind), but the bunker's NIP-46 listener wasn't subscribed yet. ## What changed ### Layer 1 — `src/daemon/run.ts` `loadNsec` and `unlockKey` were synchronous and fire-and-forgot the async `startKey` promise. `create_new_key.ts:35` already awaited `loadNsec`, but the await was a no-op against a sync return. Promoted both to `async` and properly awaited `startKey`. This was necessary but not sufficient — `backend.start()` itself was still returning before the relay registered the subscription. ```ts // Before loadNsec(keyName: string, nsec: string) { this.activeKeys[keyName] = nsec; this.startKey(keyName, nsec); // fire-and-forget } // After async loadNsec(keyName: string, nsec: string) { this.activeKeys[keyName] = nsec; await this.startKey(keyName, nsec); } ``` Same shape change for `unlockKey`. ### Layer 2 — `src/daemon/backend/index.ts` `NDKNip46Backend.start()` (parent class, from `@nostr-dev-kit/ndk@2.8.1`): ```js async start() { this.localUser = await this.signer.user(); const sub = this.ndk.subscribe( { kinds: [24133], "#p": [this.localUser.pubkey] }, { closeOnEose: false } ); sub.on("event", (e) => this.handleIncomingEvent(e)); } ``` `this.ndk.subscribe(...)` returns immediately — the `NDKSubscription` queues a REQ on the relay connection but the relay's EOSE acknowledgement hasn't arrived yet. Any caller that publishes a NIP-46 event in the immediate window after `start()` returns races against the relay registering this subscription. This is the actual race-closer. Layer 1's await alone wasn't enough because `start()` was still returning before the relay registered the subscription. Overrode `start()` in our `Backend` subclass to await EOSE before resolving: ```ts async start(): Promise<void> { this.localUser = await this.signer.user(); await new Promise<void>((resolve) => { const sub = this.ndk.subscribe( { kinds: [24133], "#p": [this.localUser!.pubkey] }, { closeOnEose: false } ); sub.on("event", (e: any) => this.handleIncomingEvent(e)); sub.on("eose", () => resolve()); }); } ``` By the time `start()` resolves, the relay has confirmed it has the bunker's subscription on file and will route matching kind-24133 events to it. ## Verification End-to-end on bohm regtest stack with `aiolabs/lnbits` PR #33 (`b1694186`) + this PR (`65a6966`): - Demo account creation through the webapp (`http://192.168.0.32:5181/login`) completes cleanly in ~3s end-to-end. - Bunker logs show the expected NIP-46 flow for the freshly-provisioned key: ``` 🔑 76219886... is being requested to connect by npub1qa4v9qjr... 🔎 npub1qa4v9qjr... is allowed to connect with key 76219886... 🔑 76219886... is being requested to sign_event by npub1qa4v9qjr... 🔎 npub1qa4v9qjr... is allowed to sign_event with key 76219886... ``` - Lnbits register handler returns 200 OK with the new account, kind-0 metadata event publishes via the same eager-bind session. - No `NsecBunkerTimeoutError`, no `Token already redeemed`. ## Related - #9 — the original bug report - `aiolabs/lnbits#32` / PR #33 — the chain that surfaced this - Cross-session coordination log: `~/dev/coordination/log.md` entries `2026-05-30T07:00Z` through `2026-05-30T10:30Z` documenting the debugging arc. ## Test plan - [x] End-to-end demo account creation through webapp on bohm regtest succeeds. - [x] Bunker logs show kind-24133 connect + sign_event handled for the freshly-provisioned key. - [x] No timeouts on consecutive provisions. - [ ] Integration test: a tight `create_new_key` → `connect` loop run repeatedly (e.g. 100 iterations) without artificial sleep. Worth adding to the test suite as a regression guard. Out of scope for this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(#9): close race between create_new_key and NIP-46 connect
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
65a6966b9f
Two-layer fix for the issue where a fresh client chaining
create_new_key + NIP-46 connect on the same target key would
time out — bunker had no subscription registered for the new
key by the time the connect event arrived at the relay.

Layer 1 — run.ts: loadNsec and unlockKey were synchronous and
fire-and-forgot the async startKey promise. create_new_key.ts:35
already awaited loadNsec, but the await was a no-op against a sync
return. Promoted both to async and properly awaited startKey, so
backend.start() at least gets a chance to run before the caller's
response goes out.

Layer 2 — backend/index.ts: NDKNip46Backend.start() registers the
kind-24133 subscription via this.ndk.subscribe(...) but returns
immediately, before the relay's EOSE confirms it has the
subscription on file. Override start() in our Backend subclass to
await EOSE before resolving. This is the actual race-closer —
layer 1's await alone wasn't enough because start() was still
returning before the relay registered the subscription.

Surfaced by aiolabs/lnbits#33's eager-bind chain, which publishes
a NIP-46 connect event in the same HTTP round-trip as
create_new_key. Pre-fix lnbits deferred the connect to first
sign_event (minutes-to-hours after provisioning), so the race
window was hidden.

Verified end-to-end on bohm regtest: demo account creation through
the webapp now completes cleanly, with bunker logs showing
connect + sign_event for the freshly-provisioned key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/nsecbunkerd!10
No description provided.