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>
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>
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>
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>
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>
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>
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>