feat(pairing): slim the spire seed + carry lnbits_npub (bitspire-#70) #37

Merged
padreug merged 7 commits from bitspire-70-seed-lnbits-npub into main 2026-07-02 17:45:02 +00:00
Owner

What

Mints the new-shape spire-seed the bitSpire consumer now expects (aiolabs/bitspire#70). Two goals:

  1. Shrink the QR. The pubkey was spelled three times in the seed (spire_npub + spire_pubkey hex + inside a full bunker_url). Now it rides once, as spire_npub; the consumer derives the hex and reconstructs bunker_url from bunker_secret + bunker_relay/relays[0]. ~a third smaller encoded — real headroom for a QR that's already hard to scan off the machine's camera.
  2. Make a paired machine self-sufficient. The seed now carries lnbits_npub (this LNbits' nostr-transport server identity), so a paired ATM reaches the backend with nothing else provisioned — no VITE_LNBITS_SERVER_PUBKEY / VITE_RELAY_URL in .env.

Changes

  • build_seed_url emits {v, spire_npub, lnbits_npub, bunker_secret, relays}, plus bunker_relay only when it differs from relays[0] (omitted in the common case). Drops spire_pubkey and the full bunker_url from the payload.
  • pair_spire reads settings.nostr_transport_public_key, hex_to_npubs it into lnbits_npub, and raises PairingError when it's empty (transport not running → can't mint a self-sufficient seed). bunker_url is still returned in PairResult for operator display / audit — only the seed stops embedding it.

Kept as v: 1 (redefined in place; no shipped seed to preserve — one-shot tokens, no prod bitSpire machines, and a paired machine resumes from its stored binding, not by re-parsing the seed).

Wire shape

spire-seed:v1:<base64url(json)>   json = {
  "v": 1,
  "spire_npub":    "npub1…",   // signing identity (hex + bunker_url derived)
  "lnbits_npub":   "npub1…",   // this lnbits' transport server id
  "bunker_secret": "<sec>",    // one-shot NIP-46 connect token
  "relays":        ["wss://…"],// spire's own events (21000/30078)
  "bunker_relay":  "wss://…"   // OPTIONAL — omitted when == relays[0]
}

Tests

  • test_pairing.py updated to the new shape: seed-contract decode, build_seed_url round-trip, default-bunker_relay omission, and missing-transport-pubkey → PairingError. A transport-pubkey fixture sets settings.nostr_transport_public_key.
  • Full suite green (216 passed). Includes a separate first commit fixing two pre-existing endpoint-test failures on main (stale fake_pair missing the bunker_relay param; unmocked get_super_config DB read) — unrelated to #70 but they made the suite red.

Consumer side (lockstep)

Merged on aiolabs/bitspire dev (the parser slim + machine wiring is queued behind this): packages/nostr-client/src/seed.ts derives hex from npub + reconstructs bunker_url, and the machine sources relay(s) + server pubkey from the pairing. This PR must merge before a new-shape seed is minted in prod.

Rollout

Merge here → bump aiolabs/lnbits-extensions catalog (spirekeeper entry) → push bitspire dev + cache → re-pair the Sintra with a freshly-minted seed to verify blank-.env → wizard → paired → backend end-to-end.

🤖 Generated with Claude Code

## What Mints the new-shape `spire-seed` the bitSpire consumer now expects (aiolabs/bitspire#70). Two goals: 1. **Shrink the QR.** The pubkey was spelled three times in the seed (`spire_npub` + `spire_pubkey` hex + inside a full `bunker_url`). Now it rides **once**, as `spire_npub`; the consumer derives the hex and reconstructs `bunker_url` from `bunker_secret` + `bunker_relay`/`relays[0]`. ~a third smaller encoded — real headroom for a QR that's already hard to scan off the machine's camera. 2. **Make a paired machine self-sufficient.** The seed now carries **`lnbits_npub`** (this LNbits' nostr-transport server identity), so a paired ATM reaches the backend with nothing else provisioned — no `VITE_LNBITS_SERVER_PUBKEY` / `VITE_RELAY_URL` in `.env`. ## Changes - **`build_seed_url`** emits `{v, spire_npub, lnbits_npub, bunker_secret, relays}`, plus `bunker_relay` **only when it differs from `relays[0]`** (omitted in the common case). Drops `spire_pubkey` and the full `bunker_url` from the payload. - **`pair_spire`** reads `settings.nostr_transport_public_key`, `hex_to_npub`s it into `lnbits_npub`, and raises `PairingError` when it's empty (transport not running → can't mint a self-sufficient seed). `bunker_url` is still returned in `PairResult` for operator display / audit — only the *seed* stops embedding it. Kept as **`v: 1`** (redefined in place; no shipped seed to preserve — one-shot tokens, no prod bitSpire machines, and a paired machine resumes from its stored binding, not by re-parsing the seed). ## Wire shape ```jsonc spire-seed:v1:<base64url(json)> json = { "v": 1, "spire_npub": "npub1…", // signing identity (hex + bunker_url derived) "lnbits_npub": "npub1…", // this lnbits' transport server id "bunker_secret": "<sec>", // one-shot NIP-46 connect token "relays": ["wss://…"],// spire's own events (21000/30078) "bunker_relay": "wss://…" // OPTIONAL — omitted when == relays[0] } ``` ## Tests - `test_pairing.py` updated to the new shape: seed-contract decode, `build_seed_url` round-trip, default-`bunker_relay` omission, and missing-transport-pubkey → `PairingError`. A transport-pubkey fixture sets `settings.nostr_transport_public_key`. - Full suite green (**216 passed**). Includes a **separate first commit** fixing two *pre-existing* endpoint-test failures on `main` (stale `fake_pair` missing the `bunker_relay` param; unmocked `get_super_config` DB read) — unrelated to #70 but they made the suite red. ## Consumer side (lockstep) Merged on `aiolabs/bitspire` `dev` (the parser slim + machine wiring is queued behind this): `packages/nostr-client/src/seed.ts` derives hex from npub + reconstructs `bunker_url`, and the machine sources relay(s) + server pubkey from the pairing. **This PR must merge before a new-shape seed is minted in prod.** ## Rollout Merge here → bump `aiolabs/lnbits-extensions` catalog (spirekeeper entry) → push bitspire `dev` + cache → re-pair the Sintra with a freshly-minted seed to verify blank-`.env` → wizard → paired → backend end-to-end. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The pairing endpoint tests' fake_pair predated the bunker_relay parameter
(views_api passes bunker_relay=data.bunker_relay), so it raised TypeError; and
api_pair_machine later grew a get_super_config() read for the post-pair fee
publish that the doubles never mocked, hitting a DB with no spirekeeper.super_config
table. Both were pre-existing failures on main (masked one behind the other).

Add bunker_relay to the fake_pair signature and mock get_super_config → None
(the happy path doesn't exercise fee publishing). Suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(pairing): slim the spire seed + carry lnbits_npub (bitspire-#70)
Some checks failed
ci.yml / feat(pairing): slim the spire seed + carry lnbits_npub (bitspire-#70) (pull_request) Failing after 0s
9dc4d09973
Mint the new-shape seed the bitspire consumer now expects: the pubkey rides
once as spire_npub (consumer derives the hex + reconstructs bunker_url from
bunker_secret + bunker_relay|relays[0]), and lnbits_npub is embedded so a paired
machine reaches this lnbits' nostr-transport with nothing else provisioned.

- build_seed_url emits {spire_npub, lnbits_npub, bunker_secret, relays} and
  bunker_relay only when it differs from relays[0] (omitted in the common case).
  Drops spire_pubkey + the full bunker_url from the payload.
- pair_spire reads settings.nostr_transport_public_key, hex_to_npub's it, and
  raises PairingError when it's empty (transport not running → can't mint a
  self-sufficient seed). bunker_url is still returned in PairResult for operator
  display / audit; only the seed stops embedding it.

Consumer side: bitspire packages/nostr-client/src/seed.ts + the #70 machine
wiring. Kept as v: 1 (redefined in place; no shipped seed to preserve).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(pairing): default seed relay to the nostrclient endpoint + validate relays
Some checks failed
ci.yml / feat(pairing): default seed relay to the nostrclient endpoint + validate relays (pull_request) Failing after 0s
c3791ed6c8
Two robustness fixes for on-machine pairing (bitspire-#70), after a QR scan
silently corrupted a seed's relay (ws://→As://) and crash-looped a machine on
an unreachable relay:

- Default relays: when the operator omits `relays`, derive the seed's relay from
  THIS lnbits' own nostrclient proxy endpoint — `<ws(s)>://<host>/nostrclient/api/v1/relay`,
  built from `lnbits_baseurl` (default_relay_endpoint). Operators configure
  upstream relays once in the nostrclient extension (public_ws must be on) and
  every seed points at one stable, operator-independent URL. `relays` is now
  optional on PairMachineData + the /pair endpoint.
- Validate every relay (+ bunker_relay) is a `ws://`/`wss://` URL AND reject
  loopback hosts (localhost/127.0.0.1/::1/0.0.0.0) — a seed is redeemed by a
  REMOTE machine, so a localhost relay is exactly the unreachable case. Catches
  both the ws://→As:// corruption and the localhost /pair gotcha at mint time.

Consumer side (bitspire): parseSpireSeed rejects non-ws relays, and the wizard
gained a "test relay" reachability button before committing.

223 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(pairing-ui): pre-fill the pair dialog relay with the default endpoint
Some checks failed
ci.yml / feat(pairing-ui): pre-fill the pair dialog relay with the default endpoint (pull_request) Failing after 0s
4e36b98534
The backend now defaults an omitted relay to the nostrclient proxy endpoint,
but the pair dialog still showed an empty, client-side-required field — so the
operator saw "no default". Wire it through:

- GET /api/v1/dca/default-relay returns default_relay_endpoint() (the derived
  ws(s)://<host>/nostrclient/api/v1/relay).
- openPairDialog pre-fills the relay textarea with it; the operator can override
  or clear it (blank → same server-side default). Drops the client-side
  "at least one relay is required" guard.
- Hint updated to explain the default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(pairing): retry get_key_tokens past a transient empty result
Some checks failed
ci.yml / fix(pairing): retry get_key_tokens past a transient empty result (pull_request) Failing after 0s
765b07737b
pair_spire failed with "bunker returned no tokens after create_new_token" when
the nsecbunkerd was briefly slow — get_key_tokens listed nothing in the few ms
after create_new_token before the write landed. Observed live: a nip44_decrypt
timeout, then a 502 on pair, then the identical call 25s later succeeding.

Retry get_key_tokens up to 5x with a 0.4s backoff before giving up, so pairing
survives a sluggish bunker instead of flaking. Tests cover the transient-empty
recovery and the exhausted-attempts failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(pairing): default relay to the transport's nostrrelay, not nostrclient proxy
Some checks failed
ci.yml / fix(pairing): default relay to the transport's nostrrelay, not nostrclient proxy (pull_request) Failing after 0s
0bb9939822
The nostrclient endpoint is a subscription MULTIPLEXER, not a full relay: its
router forwards a client's EVENT upstream but never returns an OK ack (see
nostrclient/router.py). A transport client that awaits OK on publish therefore
times out ("publish timed out"), so kind-21000 RPCs never complete — verified
on the Sintra: connect succeeded but list_wallets hung, and switching to the
nostrrelay endpoint made the whole flow work (wallet, balance, availability).

default_relay_endpoint now derives from settings.nostr_transport_relays — the
relay the transport actually listens on: use it as-is when already
machine-reachable, or re-home its path on lnbits_baseurl when it's a co-located
loopback relay (the bundled nostrrelay). Validation/localhost-reject and the
pair-dialog pre-fill/hint carry over; wording updated to "transport relay".

227 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(pairing): retry all bunker admin RPCs past transient timeouts (#38)
Some checks failed
ci.yml / fix(pairing): retry all bunker admin RPCs past transient timeouts (#38) (pull_request) Failing after 0s
2b90590104
The get_key_tokens retry only covered an empty token list; a transient
NsecBunkerTimeoutError on any admin RPC still failed pairing with a 502 (seen
live on aio-demo: create_new_key and get_key_tokens both 15s-timed-out, then a
manual retry succeeded). Generalise to `_bunker_retry`, wrapping every admin
call in the pair_spire chain (create_new_key, ensure_policy, create_new_token,
get_key_tokens): a NsecBunkerTimeoutError (and, for get_key_tokens, an empty
list) is transient → retry with backoff; a NsecBunkerRpcError rejection or
misconfig is terminal → fail fast. create_new_key is replace-by-name and
ensure_policy reconciles idempotently, so retrying on timeout is safe.

Also: _validate_relay now rejects a host-less ws:// (mint was laxer than the
consumer's parseSpireSeed), and stale "nostrclient" comments in the pair UI +
a test are corrected to "transport relay".

229 tests pass (incl. timeout-retry + fail-fast-rejection coverage).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
padreug deleted branch bitspire-70-seed-lnbits-npub 2026-07-02 17:45:02 +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/spirekeeper!37
No description provided.