fix(transport): swap the relay layer NDK → nostr-tools so subscriptions survive reconnects (#42, fixes #41) #43

Merged
padreug merged 3 commits from fix/42-nostr-tools-transport into dev 2026-06-27 00:14:46 +00:00
Owner

Closes the long-running deaf-after-flap saga by replacing the daemon's relay transport. Fixes #41, implements #42.

Why

#41: after a relay flap the bunker went silently deaf — connected but no longer subscribed — and only a manual systemctl restart nsecbunkerd recovered it (twice on the demo, 2026-06-23 / 06-26). Root cause is structural in NDK: a NDKRelaySubscription registers relay.once("ready", …) and never re-arms, and onConnect() never replays openSubs, so a running subscription is dead after any reconnect. We chased it through #4#7#20#21 without closing it; #20's reconnect even blinded the connectedRelays()-only watchdog to the deaf state.

nostr-tools (like every other nostr signing daemon — lightning.pub, signet, FROSTR) binds resubscribe to reconnect. We don't rely on its version-fragile auto-resubscribe (regressed in 2.23.0, fix unreleased) — we own the reconnect loop so the behavior is ours, not a function of the installed version.

What

The daemon's whole relay-facing path now runs on a nostr-tools RelayPool instead of NDK.

  • lib/relay-pool.ts — owns the per-relay connect loop and re-subscribes the entire registry on every (re)connect (the lightning.pub/signet pattern). Exposes healthy() = connected AND subscribed on the wire — the session-liveness signal the old watchdog couldn't make. publish() retries across a reconnect window.
  • nip46/transport.ts — the NIP-46 RPC/crypto wire layer (adaptive nip04/nip44 envelope, kind:24133/24134, {id,result,error}), byte-compatible with NDKNostrRpc so lnbits and the spire are unaffected. Full bidirectional RPC (serve requests + route responses + sendRequest).
  • backend/index.ts — the signer backend rebuilt on the transport (was extends NDKNip46Backend); response strings match NDK's handlers exactly. Token redemption split into backend/token-store.ts and injected, so the protocol layer is DB-free and testable.
  • admin/index.ts — the admin RPC rebuilt on the transport; AdminRpcRequest/AdminRpc replace NDKRpcRequest/NDKNostrRpc (the ~16 command/validation handlers swap the import only); the connection-only watchdog becomes a pool.healthy() session-liveness one. relay-reconnect.ts deleted.
  • run.ts — daemon drives a RelayPool (heartbeat on) for the backend; attachIndefiniteReconnect retired.

Tests

The regression that was missing every prior round: a flap mid-session, asserting signing still works.

  • tests/relay-pool.test.ts — flap a mock relay, inbound event still delivered; healthy() semantics.
  • tests/nip46-backend.test.ts — a real nostr-tools client drives connect/ping/get_public_key/sign_event/nip44_encrypt, survives a flap, deny path returns "Not authorized".
  • tests/admin-transport.test.ts — admin request→reply on the right kinds survives a flap; the approval-flow sendRequest/response routing round-trips.

lifecycle 7 / relay 2 / nip46 1 / admin 2 green; daemon bundles clean; zero new type errors (the 3 pre-existing authorize.ts ones are untouched). Integration test (test:integration) needs a generated prisma engine, unavailable on the nixos dev box — the ACL/token logic is unchanged (moved verbatim into token-store.ts).

Scope / follow-up

This PR swaps the transport only. Residual NDK remains in non-transport helpers — key generation (create_new_key/create_account/profile.ts), the one-shot boot DM, the getKeys npub helper, type-only imports, and the standalone CLI. A follow-up PR removes @nostr-dev-kit/ndk from the daemon entirely (tracked separately).

Refs: #41, #42, #4, #7, #9, #20, #21, #24, #25

🤖 Generated with Claude Code

Closes the long-running deaf-after-flap saga by replacing the daemon's relay transport. **Fixes #41**, implements #42. ## Why #41: after a relay flap the bunker went silently deaf — connected but no longer subscribed — and only a manual `systemctl restart nsecbunkerd` recovered it (twice on the demo, 2026-06-23 / 06-26). Root cause is structural in NDK: a `NDKRelaySubscription` registers `relay.once("ready", …)` and never re-arms, and `onConnect()` never replays `openSubs`, so a running subscription is dead after any reconnect. We chased it through #4 → #7 → #20 → #21 without closing it; #20's reconnect even *blinded* the `connectedRelays()`-only watchdog to the deaf state. nostr-tools (like every other nostr signing daemon — lightning.pub, signet, FROSTR) binds resubscribe to reconnect. We don't rely on its version-fragile auto-resubscribe (regressed in 2.23.0, fix unreleased) — **we own the reconnect loop** so the behavior is ours, not a function of the installed version. ## What The daemon's whole relay-facing path now runs on a nostr-tools `RelayPool` instead of NDK. - **`lib/relay-pool.ts`** — owns the per-relay connect loop and **re-subscribes the entire registry on every (re)connect** (the lightning.pub/signet pattern). Exposes `healthy()` = *connected AND subscribed on the wire* — the session-liveness signal the old watchdog couldn't make. `publish()` retries across a reconnect window. - **`nip46/transport.ts`** — the NIP-46 RPC/crypto wire layer (adaptive nip04/nip44 envelope, kind:24133/24134, `{id,result,error}`), byte-compatible with `NDKNostrRpc` so lnbits and the spire are unaffected. Full bidirectional RPC (serve requests + route responses + `sendRequest`). - **`backend/index.ts`** — the signer backend rebuilt on the transport (was `extends NDKNip46Backend`); response strings match NDK's handlers exactly. Token redemption split into `backend/token-store.ts` and injected, so the protocol layer is DB-free and testable. - **`admin/index.ts`** — the admin RPC rebuilt on the transport; `AdminRpcRequest`/`AdminRpc` replace `NDKRpcRequest`/`NDKNostrRpc` (the ~16 command/validation handlers swap the import only); the connection-only watchdog becomes a `pool.healthy()` session-liveness one. `relay-reconnect.ts` deleted. - **`run.ts`** — daemon drives a `RelayPool` (heartbeat on) for the backend; `attachIndefiniteReconnect` retired. ## Tests The regression that was missing every prior round: a flap mid-session, asserting signing still works. - `tests/relay-pool.test.ts` — flap a mock relay, inbound event still delivered; `healthy()` semantics. - `tests/nip46-backend.test.ts` — a real nostr-tools client drives connect/ping/get_public_key/sign_event/nip44_encrypt, **survives a flap**, deny path returns "Not authorized". - `tests/admin-transport.test.ts` — admin request→reply on the right kinds survives a flap; the approval-flow `sendRequest`/response routing round-trips. `lifecycle 7 / relay 2 / nip46 1 / admin 2` green; daemon bundles clean; zero new type errors (the 3 pre-existing `authorize.ts` ones are untouched). Integration test (`test:integration`) needs a generated prisma engine, unavailable on the nixos dev box — the ACL/token logic is unchanged (moved verbatim into `token-store.ts`). ## Scope / follow-up This PR swaps the **transport** only. Residual NDK remains in **non-transport** helpers — key generation (`create_new_key`/`create_account`/`profile.ts`), the one-shot boot DM, the `getKeys` npub helper, type-only imports, and the standalone CLI. A follow-up PR removes `@nostr-dev-kit/ndk` from the daemon entirely (tracked separately). Refs: #41, #42, #4, #7, #9, #20, #21, #24, #25 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(transport): nostr-tools relay pool that re-subscribes on reconnect (#42)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
1409941d11
First increment of the NDK -> nostr-tools transport swap (#42), the root fix
for #41 (bunker goes silently deaf after a relay flap).

NDK does not replay subscriptions on reconnect: a NDKRelaySubscription registers
`relay.once("ready", execute)` and never re-arms, so after a flap the socket
reconnects but the kind:24133 REQ is never re-sent. We chased that through
#4/#7/#20/#21 without closing it because it is structural in NDK.

`RelayPool` (src/daemon/lib/relay-pool.ts) owns the connect loop, modelled on
lightning.pub's RelayConnection and signet's relay-pool (both nostr-tools, both
bind resubscribe to reconnect). Every (re)connect re-subscribes the whole
registry, so subscription liveness can't drift from socket liveness. It also
exposes `healthy()` (connected AND registry subscribed on the wire) — the
session-liveness signal the old connectedRelays()-only watchdog couldn't make,
which is what let #20's reconnect mask the deaf state.

We disable nostr-tools' own `enableReconnect`: its auto-resubscribe is
version-fragile right now (regressed in 2.23.0 fb7de7f; the 455124e fix is
unreleased as of 2026-06-26), so the resubscribe is OUR code, not a function of
which nostr-tools version is installed.

Regression test (tests/relay-pool.test.ts + tests/helpers/mock-relay.ts): an
in-process mock relay flaps mid-session (down + back up on the same port) and we
assert a subsequent inbound kind:24133 is still delivered — the exact #41
scenario, and the test that was missing every prior round. Green; existing
lifecycle suite unchanged.

Next increments on this branch: port Backend (NIP-46) + AdminInterface (RPC)
onto the pool, wire run.ts, retire relay-reconnect.ts + the connection-only
watchdog.

Refs: #42, #41, #21, #20, #9
feat(transport): port the NIP-46 backend off NDK onto the relay pool (#42)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
ea923b472d
Second increment of the NDK -> nostr-tools transport swap. The daemon's backend
signing path no longer uses NDK at all.

- nip46/transport.ts: the NIP-46 RPC wire layer over the RelayPool, replacing
  NDKNostrRpc. Same crypto + framing so existing clients (lnbits, the spire) are
  unaffected byte-for-byte: adaptive nip04/nip44 envelope (nip04 iff content has
  `?iv=`, fallback to the other), verify the kind:24133 signature, JSON
  `{id,method,params}` in / `{id,result,error}` out, signed as the held key and
  `#p`-tagged to the client.
- backend/index.ts: the Backend is rebuilt on that transport instead of
  `extends NDKNip46Backend`. The dispatch + response strings match NDK's
  strategies exactly (connect->ack, ping->pong, get_public_key, sign_event->
  signed event JSON, nip04/44 encrypt/decrypt, reject->error/"Not authorized").
  The ACL hook (pubkeyAllowed -> permitCallback) is unchanged; the ACL only
  reads `.kind` off the sign_event payload, so a plain parsed event suffices.
- backend/token-store.ts: the prisma-backed connection-token redemption
  (validateToken/applyToken) split out of the Backend and injected, so the
  protocol layer has no database dependency and is unit-testable. Logic
  unchanged (#24/#25 live-lifecycle semantics preserved).
- run.ts: the daemon now drives a RelayPool (heartbeat on) for the backend
  transport instead of an NDK instance + attachIndefiniteReconnect; startKey
  wires the prisma applyToken.
- relay-pool.ts: publish() now retries across a reconnect window — a publish
  that lands mid-flap rejects ("relay connection errored"), so we wait for the
  pool to recover and retry (relays dedupe by id; clients match by request id).

Test (tests/nip46-backend.test.ts): a real nostr-tools NIP-46 client drives
connect/ping/get_public_key/sign_event/nip44_encrypt through a mock relay,
asserts each response, FLAPS the relay, and asserts the backend still answers —
then checks the deny path returns "Not authorized". Green. lifecycle + relay
suites unchanged.

The admin interface (NDKRpc) + the getKeys listing still use NDK; that's the
next increment. NDK remains a dependency until then.

Refs: #42, #41, #25, #24, #21, #9
feat(transport): port the admin RPC off NDK onto the relay pool (#42)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
a676d4fa98
Third increment of the NDK -> nostr-tools transport swap. The admin interface's
runtime RPC now runs on the RelayPool transport, so the admin channel — like the
signer channel — re-subscribes on every relay reconnect and can't go silently
deaf after a flap (#41).

- nip46/transport.ts is now a full RPC: besides serving inbound requests it
  routes inbound RESPONSES to one-shot handlers (the pending map) and can
  sendRequest() — needed for the interactive approval flow (bunker -> operator
  "acl" request). start() takes the kinds to listen on; sendResponse() takes the
  response kind. The signer backend is unaffected (still one kind, response-only).
- admin/index.ts: rebuilt on RelayPool + Nip46Transport instead of NDK + NDKNostrRpc
  + attachIndefiniteReconnect. `rpc` is a small adapter the command handlers keep
  calling; it resolves each request's envelope scheme (nip04/nip44) by id and
  publishes on the admin channel (24134). requestPermission/Response use
  transport.sendRequest + nip19 instead of NDKNostrRpc + NDKUser. The
  connectedRelays()-only watchdog is replaced by a session-liveness one on
  pool.healthy() (connected AND subscribed) — the check the old one couldn't make
  (#20/#41).
- admin/types.ts: AdminRpcRequest / AdminRpc replace NDKRpcRequest / NDKNostrRpc.
  The ~16 command + validation handlers swap the import only (they use
  req.{id,pubkey,method,params,event.kind}); no logic change.
- admin/kinds.ts: plain numeric kinds (24133/24134), no NDKKind type dep.
- relay-reconnect.ts deleted — its job (reconnect) now lives in the pool, and its
  blind spot (no resubscribe) is exactly what #41 was.

Still on NDK (not transport, addressed separately): the one-shot boot DM
(notifyAdminsOnBoot, throwaway NDK over public relays), key generation in
create_new_key/create_account, the getKeys npub helper, and the standalone CLI
client (src/client.ts).

Tests (tests/admin-transport.test.ts): a request on 24133 is answered on 24134
and survives a relay flap; the sendRequest + response-routing approval flow round-
trips. lifecycle 7 / relay 2 / nip46 1 / admin 2 all green; daemon bundles clean;
zero new type errors.

Refs: #42, #41, #20, #7
padreug deleted branch fix/42-nostr-tools-transport 2026-06-27 00:14:47 +00:00
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!43
No description provided.