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

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>
This commit is contained in:
Padreug 2026-05-30 12:25:45 +02:00
commit 65a6966b9f
2 changed files with 40 additions and 3 deletions

View file

@ -22,6 +22,43 @@ export class Backend extends NDKNip46Backend {
// this.setStrategy('publish_event', new PublishEventHandlingStrategy()); // this.setStrategy('publish_event', new PublishEventHandlingStrategy());
} }
/**
* Override NDKNip46Backend.start() to await the kind-24133
* subscription's EOSE before resolving. The base implementation
* calls `this.ndk.subscribe(...)` and returns immediately the
* NDKSubscription queues a REQ on the relay connection but the
* relay's acknowledgement (EOSE) 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.
*
* aiolabs/lnbits#33's eager-bind chain publishes a NIP-46
* `connect` event in the same HTTP round-trip as `create_new_key`,
* which loses this race deterministically the bunker never
* sees the connect event because its subscription wasn't yet
* registered with the relay when the event was broadcast.
*
* Awaiting EOSE closes the race: 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.
*
* See aiolabs/nsecbunkerd#9 for the full diagnosis.
*/
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());
});
}
private async validateToken(token: string) { private async validateToken(token: string) {
if (!token) throw new Error("Invalid token"); if (!token) throw new Error("Invalid token");

View file

@ -257,14 +257,14 @@ class Daemon {
const nsec = decryptNsec(iv, data, passphrase); const nsec = decryptNsec(iv, data, passphrase);
this.activeKeys[keyName] = nsec; this.activeKeys[keyName] = nsec;
this.startKey(keyName, nsec); await this.startKey(keyName, nsec);
return true; return true;
} }
loadNsec(keyName: string, nsec: string) { async loadNsec(keyName: string, nsec: string) {
this.activeKeys[keyName] = nsec; this.activeKeys[keyName] = nsec;
this.startKey(keyName, nsec); await this.startKey(keyName, nsec);
} }
} }