The npubs in the seed are bech32-checksummed, so a mis-scanned character is
caught — but the relay strings are raw inside the base64. A QR misread silently
turned `ws://192.168.0.32:5001/...` into `As://192.168.0.32:5001/...`, which
parsed fine and then crash-looped the machine on an unreachable NIP-46 relay.
Validate every `relays[]` entry (and `bunker_relay`) is a `ws://`/`wss://` URL
at parse time, so a garbled scan is rejected as an invalid seed instead of
persisted. Part of bitspire-#70 pairing robustness.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The v1 seed spelled the spire pubkey three times — spire_npub, spire_pubkey
(hex), and again inside a full bunker_url — which bloats a QR that's already
hard to scan off the machine's camera. Carry it once, as an npub, and derive
the rest:
- spire_pubkey (hex) ← decode(spire_npub). npub is ~the same length as hex but
carries a bech32 checksum, so a mis-scanned character is caught instead of
yielding a wrong-but-valid-looking key.
- bunker_url ← reconstructed from spire_pubkey + bunker_secret + bunker_relay.
- bunker_relay is OPTIONAL, defaulting to relays[0] (option 3): minimal in the
common case where the bunker shares the event relay, explicit when it differs.
- lnbits_npub is NEW — gives a paired machine its LNbits transport server pubkey
from the seed itself, so nothing else needs provisioning (bitspire-#70 part 2).
Kept as v: 1 (redefined in place, no compat shim): the seed is a one-shot
pairing token, no bitspire machine has shipped, and a paired machine resumes
from its stored binding, not by re-parsing the seed. Roughly a third smaller
encoded — ~180-200 fewer chars in the QR.
Lockstep: aiolabs/spirekeeper pairing.py must emit the new shape (spire_npub +
lnbits_npub + bunker_secret, drop spire_pubkey/bunker_url) before a new seed can
be minted. Consumer wiring (relays + lnbitsServerPubkey into LightningConfig)
and a resolver-resilience guard for machines holding an old-shape seed land
separately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nsecbunkerd#27 enforces token lifecycle at sign time (Option D): an expired
token (`expiresAt`) now stops signing post-bind, not just at connect —
reversing the earlier #24 "TTL is connect-window-only" note. A lapsed TTL
now surfaces as the same BunkerRejectedError as a revoke, so the Phase D
re-pair handling covers both. Docstring corrected to say so.
refs nsecbunkerd#27/#24/#25, aiolabs/bitspire#52
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase B of aiolabs/bitspire#52 — the consumer surface for routing signing
to the operator's nsecbunkerd (model A1: the ATM holds only its own NIP-46
transport key; the signing identity lives in the bunker).
- seed.ts: parseSpireSeed for the `spire-seed:v1:<base64url>` contract from
spirekeeper pairing.py — re-pads stripped base64url, validates
{v, spire_pubkey, bunker_url, relays}, leaves percent-decoding of the
bunker URL to parseBunkerInput. seedFingerprint() detects a re-pair.
- bunker-signer.ts: BunkerSigner implements Signer by delegating
sign_event / nip44_* to nostr-tools' nip46 over the bunker relay. pubkey
is the spire identity, known synchronously from the seed. connectNewSeed
redeems the one-shot connect secret; resumeFromBinding reuses the
persisted transport key WITHOUT re-redeeming (the binding is
server-persistent). Per-RPC timeout + typed BunkerRejectedError /
BunkerTimeoutError so callers can distinguish revoked-binding (re-pair)
from a transient outage.
Unit-tested against a fake inner client (delegation, sync pubkey, timeout,
error mapping) + seed round-trip/validation fixtures. Live-relay wiring is
Phase C; live bunker integration is Phase F.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop encryptContent / decryptContent / decryptJSON and the hand-rolled
XChaCha20 + v1 conversation-key machinery they depended on (~230 lines).
The only callers were createMachineStatusEvent / createTransactionEvent,
which had no callers in apps/ and were removed in the Signer migration.
This closes the open question carried in aiolabs/bitspire#52: every live
encryption path is NIP-44 v2, and the nsecbunkerd signer is v2-only, so
there is nothing to keep v1 for. encryptContentV2 / decryptContentV2 stay
as the v2 helpers used by the dormant CLINK client + tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce a Signer interface (signEvent / nip44Encrypt / nip44Decrypt +
sync pubkey) with an in-process LocalSigner backed by an nsec, and route
every signing/encryption call site through it. Behaviour is unchanged —
LocalSigner wraps the same MachineIdentity the code used directly before.
This is Phase A of the bunker migration (aiolabs/bitspire#52): it puts the
seam in place so Phase B can drop in a NIP-46 BunkerSigner at the bootstrap
without touching any call site. The whole chain becomes async (the bunker
path is a relay round-trip; LocalSigner resolves immediately).
Sites moved onto the signer:
- packages/nostr-client: createSignedEvent / createAuthEvent (now async),
NostrClient config (signer not identity), AUTH challenge handler.
- packages/lnbits: LnbitsClient.initialize(nostr, signer); kind-21000 RPC
encrypt + sign + reply-decrypt; handleReply is now async (event-id dedup
still runs synchronously before the awaited decrypt, so replay safety and
per-subscription hash dedup are preserved).
- apps/machine: lightning.ts builds a LocalSigner and exposes it on
LightningServices; operator-config / operator-fees / availability beacon /
maintenance beacon / fund-atm all sign + encrypt via the signer.
NIP-42 auth (kind 22242) is included — under the bunker it must be in the
spire policy (aiolabs/spirekeeper#26, already merged).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a relay reconnects after a disconnect, all active subscriptions
(including Lightning.Pub RPC listener) are now re-established on the
new relay instance. Previously subscriptions were lost permanently.
Also publishes availability broadcast immediately on reconnect instead
of waiting up to 5 minutes for the next heartbeat.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously the relay reconnect logic gave up after 5 attempts (~31s).
If the relay was down longer, the ATM permanently lost connectivity
and couldn't fetch available balance — causing all bills to be
rejected after the recent safety guard change.
Now reconnects indefinitely with exponential backoff capped at 60s.
Attempts reset on successful connection.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move 9 development/testing .mjs scripts out of the package root into
dev/ to keep the published package clean. Update relative imports
and dev.sh reference.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add './dev.sh fund [amount]' for funding ATM via Nostr RPC
- Create invoices via NIP-44 encrypted RPC instead of HTTP API
- Support existing users without fail_if_exists errors
- Add --fund flag to './dev.sh up' for one-command setup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add dev.sh script for managing regtest development environment
- Implement cmd_fund to fund ATM app owner via Lightning.Pub API
- Add --fund flag to cmd_up for automatic funding on startup
- Update setup_atm_app to write VITE_APP_ID to machine .env
- Fix Electron IPC to pass appId and extensionApiUrl to renderer
- Restructure repo from nested lamassu-next/ to root
The dev.sh script now supports:
- ./dev.sh up --fund # Start regtest and auto-fund ATM
- ./dev.sh fund # Fund existing ATM app
- ./dev.sh status # Show environment status
- ./dev.sh reset # Clean restart
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>