Commit graph

187 commits

Author SHA1 Message Date
cb236703d6 fix(machine): point the favicon at logo.png
index.html still linked Vite's scaffold favicon at /vite.svg, which does
not exist in public/ — so every browser tab (the public demo included)
showed a broken icon next to the title. Use the bitSpire logo that is
already shipped for the idle screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4
2026-09-19 09:58:50 +02:00
46e52f6598 chore: scrub "Lamassu" from shipped labels
The kiosk's <title> still read "Lamassu ATM" — visible as the browser tab
on the public demo, and inherited by the Electron window. The product has
been bitSpire since the rename; Lamassu belongs in the provenance credits
(README, the c0b69d1 boundary note), not on the artifact.

Rename the user-facing labels that ship: the page title, the flake
description (surfaces in `nix flake metadata`), the ISO build banner, the
header comments on the live-USB config / udev rules / app derivation that
land on the machine image, and the workspace packages' descriptions.

Deliberately NOT touched, because they are identifiers rather than labels
and renaming them has deployed-machine consequences:
- VITE_LAMASSU_MACHINE_MODEL / VITE_LAMASSU_FIAT_CODE (provisioned .env)
- LamassuEventKind (exported enum)
- localStorage keys lamassu-theme / lamassu-color-mode (would reset
  every machine's stored theme)
- docker container names + devenv scripts (dev-only)
- the packages/hal Cargo crate name
Hardware names in HAL driver comments ("Lamassu Sintra", "Douro", "Tejo")
stay: those are the physical machines' real names — that IS the credit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4
2026-09-19 09:58:42 +02:00
8264dd7472 feat(machine): VITE_DEMO_TAG for the public web demo
The browser path (no electronAPI) is already a first-class code path:
initializeWithLightning() resolves an EPHEMERAL LocalSigner, allows mock
fallback and leaves debugMode on, so the bill simulator stands in for the
validator. That is what makes a hosted kiosk demo possible at all. Two
things still needed fixing for it.

1. Cursor. `cursor: none` was applied globally for the touchscreen, which in
   an ordinary browser reads as a broken page. Scope it to `.kiosk`, set on
   <html> by main.ts unless VITE_DEMO_TAG is present — so every real machine
   keeps today's behavior and only the demo build shows a pointer.

2. Cleanup. An ephemeral identity per page load is the right call (it isolates
   concurrent visitors, and each fresh account gets its own auto-credit under
   LNBITS_DEMO_MODE, whereas a single baked-in key would be credited once and
   then drain). The cost is a throwaway LNbits account per visit, and nothing
   in an auto-created row distinguishes one: pubkey-set/prvkey-NULL equally
   describes a real ATM.

   A nostr pubkey can't carry a marker — grinding a vanity prefix is far too
   slow to do on page load — and the account/wallet the server auto-creates
   isn't nameable by the client. So when VITE_DEMO_TAG is set the ATM mints
   one extra, never-used wallet whose NAME is the tag, turning the sweep into
   an exact string match instead of a heuristic about what looks disposable.

Both are inert on a real machine: the var is unset outside the demo build.
The marker call is fire-and-forget — losing it degrades cleanup, not the demo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4
2026-09-06 19:24:15 +02:00
1b671bf407 build(machine): add a web-only build:web target
The machine app's `build` script runs vue-tsc, the Vite build, two electron
tsc passes and an esbuild bundle. Serving the kiosk as a plain SPA needs only
the middle one, and the electron passes drag in native-addon typings that a
web build has no use for.

Add `build:web` (just `vite build`) with a turbo task that still builds the
workspace packages first via `dependsOn: ["^build"]`, so a consumer can run
`pnpm build:web` at the repo root and get `apps/machine/dist`.

`env: ["VITE_*"]` is declared on the task because the Vite vars are baked into
the bundle at build time — without it turbo would happily serve a cached
build produced under different env.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4
2026-09-06 19:24:15 +02:00
Patrick Mulligan
ffbacafe39 fix(nfc): auto-recover a wedged CCID reader via USB power-cycle
The Feitian R502-CL (and cheap CCID readers generally) can wedge: it keeps
detecting a card but every APDU returns "card absent or mute", and ONLY a
USB power-cycle clears it — restarting pcscd or the app does not (confirmed
on-device). Until now that left cash-out/cash-in taps dead until a manual
replug.

- nfc-service.ts: count consecutive read failures; after 3 (gated by a 30s
  cooldown so a still-wedged reader can't reset-loop) trigger
  nfc-reader-reset.service. nfc-pcsc then re-detects the reader on USB
  hotplug with no app restart (verified live).
- batm3.nix: nfc-reader-reset.service (oneshot, root) re-binds the reader's
  USB device (a software replug); reader-agnostic via the CCID interface
  class (0x0B) so it also covers a future ACR1252U. A polkit rule lets the
  unprivileged `bitspire` app start just that one unit.

Hardware track (separate): the durable fix is a better reader (ACR1252U —
large antenna for behind-panel, firmware-upgradable). This change makes any
reader's wedge a ~2s self-heal in the meantime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 19:51:39 +02:00
Patrick Mulligan
64582e7fe6 feat(machine): Bolt Card (NFC) tap-to-receive on cash-in
Tap-to-receive for the buy flow, the receive counterpart to #83's
cash-out tap-to-pay. A Bolt Card only emits an lnurlw withdraw voucher
(wrong direction to deposit into it), so the tap is used as an
authenticated identity (external_id + SUN p/c) to resolve the card
wallet's lnurlp/Lightning Address; the ATM then pays an invoice for the
payout over its existing nostr transport.

- electron/lnurl-pay.ts: resolveCardInvoice() — resolve card -> pay
  target -> LUD-16/LUD-06 -> BOLT11. scanUrlToResolver() is the single
  HTTPS-today / nostr-tomorrow transport seam. 13 tests.
- IPC lnurl:pay-card (main-process HTTPS to dodge renderer CORS) +
  preload/electron.d.ts surface.
- stores/atm.ts: handleBoltCardReceive() settles via the existing
  payInvoice -> PAYMENT_RECEIVED path; the one NFC listener now routes
  the same tap by flow (cash-out pulls, cash-in receives).
- CashInView.vue: NFC status + dev tap input.
- docs/boltcard-receive-resolver.md: spec for the custom LNbits
  /boltcards/api/v1/pay/<id> resolver endpoint (omni-private side).

Card issuance is unchanged — same NDEF/keys/external_id; receive is a
server-side reading of the same tap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 00:30:13 +02:00
Patrick Mulligan
73376a6c68 fix(machine): cooldown after failed NFC read to prevent reader wedge
Hammering a flaky CCID reader with rapid re-reads wedges it into a
present↔empty storm (only a USB replug clears it). After a failed read,
ignore card re-detections for 1.5s; successful reads don't cool down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 20:46:18 +02:00
Patrick Mulligan
ed04c63b2f perf(machine): fewer NFC APDUs (E104-first) + single-attempt read
Retrying a read hammered the cheap CCID reader into a stuck present↔empty
loop (only cleared by a reboot), so drop the retry: a read is a single
attempt and the user re-taps if the RF link drops mid-read. Also skip the
Capability-Container round-trip in the common case — NTAG424 Bolt Cards use
NDEF FileID E104, so try E104/0004 directly and only read the CC to discover
the id if both fail. Fewer APDUs → a read completes inside a shorter stable
window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 20:26:29 +02:00
Patrick Mulligan
ea736dfab0 fix(machine): read NTAG424 NDEF via Capability Container (Bolt Card FileID)
First real-card tap read the NDEF file with id 0004 and got "not a Bolt
Card" — NTAG424 (Bolt Cards) use FileID E104. Read the Capability Container
(EF E103) after selecting the NDEF app to learn the advertised NDEF FileID,
then read that file; fall back to E104/0004. Tolerates a transient transmit
error (surfaced as a retryable status; the next tap re-reads).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 20:02:26 +02:00
Patrick Mulligan
0a3156855c feat(deploy): authorize bitspire for pcscd (polkit) + NFC diagnostics
pcscd gates clients via polkit; the sandboxed bitspire user was "Rejected
unauthorized PC/SC client", so add a polkit rule granting it
access_pcsc/access_card. Also log NFC reader status + taps from the main
process to journald (value redacted — it carries the card's SUN p/c) so
reader detection and taps are observable during testing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 05:01:08 +02:00
Patrick Mulligan
07978a8de1 feat(machine): wire Bolt Card tap into cash-out displayingInvoice + UI
Renderer side of tap-to-pay. The store subscribes to the main-process
reader (onNfcCardTapped/onNfcStatus); a tap during displayingInvoice pulls
payment for the shown invoice via lnurlWithdraw (amount in msats), guarded
against double-taps. Settlement still flows through the existing invoice
watcher → PAYMENT_RECEIVED → dispensingCash, so the state machine is
unchanged. Bolt Card state clears when leaving the invoice screen.

CashOutView: "Tap Card or Scan to Pay" + live reader/processing/declined
status on the invoice screen, plus a dev input to simulate a tap with a
pasted lnurlw. Exposes nfcStatus / boltCardProcessing / simulateBoltCardTap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 04:45:51 +02:00
Patrick Mulligan
84d746a0da feat(machine): NFC Bolt Card reader driver + IPC (main process)
Main-process driver over nfc-pcsc (PC/SC). On tap it reads the NTAG424
Type-4 NDEF file via ISO7816 APDUs (select NDEF app D2760000850101 →
select file → ReadBinary NLEN + message) and extracts the lnurlw voucher
(fresh SUN p/c per tap), forwarding it to the renderer on `nfc:card-tapped`
(+ `nfc:status`). Lazy, guarded import — a missing reader/pcscd just
reports 'unavailable', never breaking the cash-out QR path. Preload
removeAllListeners guards against a double payment-trigger on renderer reload.

- electron/nfc-service.ts: startNfcReader() + readNdefLnurlw()/extractLnurlw().
- electron/nfc-service.test.ts: 7 tests (NDEF URI extraction, Type-4 read
  sequence incl. AID select, empty-file + select-fail handling).
- main.ts start + IPC forward; preload + electron.d.ts listeners.
- add nfc-pcsc dep (native @pokusew/pcsclite; nix build handling next).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 04:42:07 +02:00
Patrick Mulligan
457761719f feat(machine): LNURL-withdraw executor for Bolt Card cash-out (LUD-03)
First, hardware-independent piece of Bolt Card tap-to-pay on the cash-out
flow. When a customer taps a Bolt Card, the ATM (which already has its
cash-out BOLT11) becomes the LNURL-*withdrawing* party: GET the card's
lnurlw voucher → GET callback?k1=…&pr=<invoice> so the card's wallet pays
the invoice. Settlement is still observed via the existing invoice watcher
(a returned ok=true means "card accepted the pull", not "cash dispensed").

- electron/lnurl-withdraw.ts: executeLnurlWithdraw() + lnurlwToHttps().
  Runs in the main process (Node fetch) to avoid renderer CORS, since LNURL
  endpoints send no CORS headers. Fully injectable fetch for testing.
- electron/lnurl-withdraw.test.ts: 12 tests (scheme mapping, two-step happy
  path passing k1+pr, ERROR surfacing, non-withdraw tag, amount-over-limit
  short-circuit, callback decline, network failure).
- IPC `lnurl:withdraw` (main) + preload + electron.d.ts.

Next: pcscd + an nfc-pcsc reader driver (reads the NTAG424 NDEF lnurlw),
then wire the tap into the cashOut displayingInvoice state + "tap or scan" UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 04:34:38 +02:00
c36c2fb1c4 Merge pull request 'feat(machine): connectivity auto-recovery + on-screen Retry' (#82) from feat/connection-recovery into dev
Reviewed-on: #82
2026-08-05 02:33:01 +00:00
Patrick Mulligan
6a833d357e feat(machine): connectivity auto-recovery + on-screen Retry
A connectivity-type init failure (e.g. "No connected relays" when the box
boots before the network) landed on "ATM Unavailable" permanently: init is
one-shot and the nostr reconnect only helps after a first successful
connect, so a machine never self-healed when internet returned.

Recover by reloading the renderer, which re-runs init from a clean JS
context (no leaked actors/subscriptions) while the main process keeps HAL:
- main.ts: new `app:recover` IPC → reloadRenderer() (resets secretsConsumed).
- hal:init is now idempotent (reuse the existing instance) so the reload —
  and the pre-existing watchdog crash-reload — can't double-open serial ports.
- App.vue: when initError is a connectivity type (not the operator/
  self-clearing states unpaired/awaiting-fees/maintenance), watch for the
  `online` event (recover immediately) plus a 45s backoff safety net, and
  render a kiosk-sized Retry button for a person at the machine.

Preserves pairing + /var/lib state (renderer reload, not a process restart).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 18:11:40 +02:00
Patrick Mulligan
8fbe6df5c3 fix(lightning): cash-in double-charged commission (send gross, not net)
Buy Bitcoin short-changed the customer: a $5 buy at 1564 sats/USD with a
12% commission paid out 6056 sats instead of 6882 — an effective ~22.6%.
The commission was applied twice.

`calculateSats` already subtracts the fee (7820 gross → 6882 net) into
`context.satsAmount`. But `generateLnurlWithdraw` then passed that
already-net value as `principal_sats` to the server's create_withdraw,
which derives fee + net from the principal and subtracted 12% AGAIN:

  [ATM Service] create_withdraw: principal=6882 fee=826 net=6056

This contradicted the function's own contract ("the ATM sends only the
hardware-attested gross principal; the operator side derives fee + NET").
The quote, the recorded transaction (sats=6882, fee_fraction=0.12), and
the on-screen commission (12%) all read a single fee — only the delivered
LNURL-withdraw amount was double-charged.

Fix: send the GROSS principal (fiat × rate, before commission), so the
server applies the fee exactly once. Now 7820 → server 12% → net 6882,
matching the quote/receipt. Exchange rate itself was always correct.

Verified: vue-tsc typechecks; math checks (gross=7820 fee=938 net=6882).
Hardware retest (one $5 buy → 6882) recommended before relying in prod.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 02:36:46 +02:00
Patrick Mulligan
2c71d9d823 fix(config): use stable /dev/ttyF56 symlink for batm3 dispenser
The batm3 dispenser used the raw /dev/ttyUSB0, which is
enumeration-order dependent — a re-plug or reboot could reassign
ttyUSB0 to a different adapter. Switch to the stable udev symlink
/dev/ttyF56 (batm3.nix, serial DDDLb103Y23), matching the validator's
/dev/ttyMEI, so both peripherals bind by identity and survive
re-enumeration (incl. on an internal-SATA flash). Per-box override:
VITE_LAMASSU_DISPENSER_DEVICE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 01:14:11 +02:00
Patrick Mulligan
eafdce36b6 fix(config): point batm3 validator at /dev/ttyMEI (was nonexistent ttyACM0)
The batm3 preset defaulted the EBDS validator to /dev/ttyACM0, assuming a
CDC-ACM BNR Advance. The actual MEI acceptor enumerates as a USB-serial
device (ttyUSB*), exposed via the stable udev symlink /dev/ttyMEI
(batm3.nix). Because /dev/ttyACM0 never existed, hal-service skipped the
validator entirely and logged "[HAL] No validator — cash-in disabled", so
Buy Bitcoin silently ignored inserted bills.

Verified on hardware: with the correct device the validator starts, and
(with the EBDS latch fix) a bill escrows → stacks → credits. Removes the
need for the VITE_LAMASSU_VALIDATOR_DEVICE=/dev/ttyMEI per-box override.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 00:57:48 +02:00
Patrick Mulligan
4d0e42f289 fix(hal): EBDS escrow stack/return latch + return-on-disable
Cash-in stalled on the batm3: a note reached escrow and was read, but the
acceptor never stacked or returned it, and the customer was never
credited. Root cause: EBDS carries the stack/return decision as bits in
the omnibus *poll* command, but the driver sent stack()/reject() as a
single one-shot frame while a free-running 100ms poller kept sending
plain polls. The lone stack frame races/collides with the poller (or its
ack desyncs), gets dropped, and the device holds the note in escrow
indefinitely.

- ebds-rs232: latch the escrow decision (`pendingAction`) into the poll
  command byte and re-assert it on every poll until the device leaves
  escrow (cleared in _process when `!escrowed`). A dropped frame is now
  simply retried on the next poll.
- hal-service: return an escrowed note on disableValidator() — disable
  alone does not release it on EBDS, so an inactivity timeout / cancel
  previously stranded the bill in the transport (observed on the batm3).
- atm store: stringify the `[ATM] Sending event` / `[ATM] State` logs —
  they were printing `[object Object]`, which blinded the cash-in trace.

Verified: hal builds, machine app typechecks. Hardware behaviour to be
confirmed on the batm3 (no unit tests exist for this serial driver).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 00:40:06 +02:00
7a67c2182f fix(machine): credit bills on stacked-confirmation, not stack command (#58)
Backports the legacy brain.js escrow interlock (from the public-domain
lamassu-machine tree at c0b69d1, see CLAUDE.md provenance):

- The id003/ebds drivers' `billsValid` event (bill physically reached
  the stacker) is now the credit trigger. hal-service tracks
  escrow → in-flight and fires onBillInserted only on confirmation;
  hal:stack-bill no longer synthesizes the credit at command time.
- New BILL_PENDING machine event marks the in-flight bill;
  FINISH_INSERTING is guard-blocked while one is pending, so "done"
  pressed mid-stack can no longer mint an LNURL that includes a bill
  still sitting in escrow (the aiolabs/bitspire#58 loss).
- BILL_INSERTED now requires a matching pending bill (stray or
  out-of-state confirmations are never credited) and BILL_REJECTED
  clears the in-flight marker — a failed/returned stack was never
  credited, so nothing to unwind.
- Escrow decision is fail-closed (legacy _billsRead parity): bill read
  outside insertingBills, or with unknown rate/balance, is returned to
  the customer instead of stacked-and-swallowed (closes the #35 gap at
  the decision point that physically takes the money).
- CashInView disables "Done" and shows a processing hint while a bill
  is in flight; the dev simulator drives the same guarded two-event
  path.

Both loss directions verified against the legacy semantics:
operator-pays-for-unstacked-cash and customer-bill-swallowed-uncredited.

6 new state-machine interlock tests; 27 state-machine + 43 machine-app
tests pass; full build (vue-tsc + vite + electron tsc) clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 01:07:58 +02:00
79e1823cc5 fix(machine): decrement cassettes by position, not denomination, on cash-out
recordTransaction() updated cassette counts with WHERE denomination = ?,
but the v9 migration made position the PK precisely so duplicate
denominations across bays are legal (and the HAL dispense path already
returns authoritative per-position results). On any machine with two
bays of the same denomination, a single dispense drained every matching
bay row — silently corrupting inventory, the operator cassette-state
publish, and out-of-money gating.

- cassettes branch: decrement by c.position
- mock-only fallback (no per-bay results): drain matching bays greedily
  in position order, mirroring the dispenser's own fill order
- regression tests with a duplicate-denomination layout (3 of 5 fail
  against the old code)

Found during the dev-branch architecture review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 00:28:24 +02:00
fdb9a507c2 feat(machine): consume get_machine_config over the transport (#71)
Source the operator pubkey + fee config from LNbits via the get_machine_config
kind-21000 RPC (spirekeeper#41) right after list_wallets, instead of the
operator pubkey coming only from VITE_OPERATOR_PUBKEYS (env). A seed-only
machine (blank .env) had an empty operator allowlist → the fees/operator-config
services disabled themselves → permanent "awaiting configuration". Now it pulls
its config over the already-authenticated channel and configures itself with
zero per-machine provisioning — closing bitspire#70 P1.

- LnbitsClient.getMachineConfig() → sendRpc('get_machine_config') + the
  MachineConfigResponse / FeeConfigWire types.
- lightning.ts, only when VITE_OPERATOR_PUBKEYS is empty (env override still
  wins): set CONFIG.operatorPubkeys from operator_pubkey (re-enables the
  services), and persist fee_config via the existing applyFeeConfig IPC (mapping
  snake_case → camelCase) so atm.ts's awaiting-fees gate clears immediately —
  robust to the replaceable kind-30078 not being fetchable from the relay. The
  live kind-30078 subscription still handles mid-run fee updates.
- Soft-fail: older spirekeeper (no RPC) or a transport error falls back to the
  env/kind-30078 path.

lnbits + machine typecheck clean; lnbits suite 29 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:54:18 +00:00
5cf39a05ee chore(machine): log operator-pubkey provenance so the config gap is loud (#70)
Relay + server pubkey already log (env)/(pairing)/(default) provenance; operator
pubkeys did not. An empty operator set silently disables the fees/operator-config
services → the machine sits at "awaiting configuration" with no signal why. Log
the resolved operator pubkey(s) and their source, and flag the empty case
explicitly (pending the #70 P1 server-delivered operator pubkey).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:53:51 +00:00
78592d89f7 fix(machine): re-pair wipes the prior operator's config + watermarks (#70)
A new-seed re-pair UPSERTed the bunker binding but left fee_config, cassettes,
and the created_at replay watermarks intact. The watermarks are the trap: a new
backend whose first config event has a lower created_at than the old operator's
last event is silently dropped as a replay, so re-pairing a long-lived install
to a fresh backend appears to pair but never picks up new config.

Add resetForRepair() (main-process state-store): in one transaction it clears
fee_config and resets both replay watermarks to 0. Wired function → IPC
(state:reset-for-repair) → preload → renderer, and called from the re-pair branch
in signer-resolver, gated on an existing binding (re-pair only; a first pair has
nothing to reset). Deliberately preserves cassettes/cashbox/transactions — those
track PHYSICAL cash that survives an operator handover; a full wipe is the
factory-reset path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:53:51 +00:00
7bc718f9e3 fix(machine): rotate pairing camera preview 90° CCW for the Sintra mount
The Sintra's camera is physically mounted rotated, so the wizard's viewfinder
showed a sideways image — hard to aim at the spire-seed QR. Rotate the preview
90° CCW (-rotate-90). Preview-only: qr-source decodes the raw frame (CSS
transforms don't touch canvas drawImage) and QR decoding is rotation-invariant,
so scanning is unaffected. The viewfinder is a square, overflow-hidden container,
so the rotated square stays in the box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:53:51 +00:00
06f73b2d76 fix(machine): point DEV_DEFAULT_RELAY at the real dev relay
The last-ditch dev fallback (used only when neither env nor the pairing seed
supplies a relay) was ws://localhost:7777 — a standalone strfry we no longer
run. Align it to the dev stack's LNbits bundled nostrrelay
(ws://localhost:5001/nostrrelay/test) so the fallback points at a relay that
actually exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:53:51 +00:00
7abc2e3305 refactor(machine): remove dead Lightning.Pub nprofile UI (post-3d cutover)
The LP backend was deleted on dev, so VITE_LIGHTNING_PUB_PUBKEY /
config.lightningPubPubkey are never set — the "add this ATM's node to your
wallet" nprofile QR (IdleView dev button + overlay, SupportView ShockWallet
card + deep-link) rendered empty, and the LP fields in RuntimeConfig
(lightningPubPubkey/lightningPubApiUrl/extensionApiUrl) were never populated.
Remove them. The concept has no clean LNbits analog (the ATM is a cash↔LN
gateway, not a node customers peer with) — tracked as a fresh feature request
on lnbits. ShockWallet stays listed as a downloadable wallet (plain URL).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:53:51 +00:00
e99628ef84 docs: relay + LNbits pubkey are seed-provided, not required (#70)
Env table (CLAUDE.md), .env.example, and the deploy README still framed
VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY as required/provisioned; they now come
from the pairing seed and are env overrides only. Also refresh the slimmed seed
shape, the relayUrl/pubkey module examples ("" not wss://relay.aiolabs.dev), and
the stale lamassu-next autoUpgrade flake URL (→ aiolabs/bitspire).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:53:51 +00:00
ce87f85a73 fix(machine): maintenance beacon uses the pairing seed's relay (#70)
The maintenance-mode beacon resolved the relay from env only (config.relayUrl ||
VITE_RELAY_URL), so on a blank-.env seed-driven machine it was undefined and the
beacon was skipped — a paired ATM in maintenance never broadcast. It already
resolves the signer (which carries the transport); fall back to
resolved.transport.relays[0], mirroring lightning.ts's env → pairing precedence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:53:51 +00:00
eaa7cbe33c fix(machine): don't inject a localhost relay default in get-config
The Electron main's get-config returned relayUrl = VITE_RELAY_URL ||
'ws://localhost:7777'. On an unprovisioned (blank-.env) machine that non-empty
localhost default reached the renderer and, via the env-first precedence, won
over the pairing seed's relay — then failed strict validation as localhost.
That defeated #70's "the seed provides the relay": the Sintra paired fine but
booted with ws://localhost:7777 instead of the seed's nostrclient endpoint.

Return '' when unset so the renderer falls through to the seed's transport
relay (its own ws://localhost:7777 dev fallback only applies when neither env
nor pairing supplies one). Mirror of the renderer default fixed in e578680.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:53:51 +00:00
0bc57dc754 feat(machine): pairing review step with a relay-reachability test
A well-formed but unreachable relay (localhost baked into a seed for a remote
machine, a wrong LAN IP, a relay that's down) parses fine and only fails later
as a NIP-46 connect crash-loop. Give the operator a way to catch it on-machine
before committing (bitspire-#70).

The wizard no longer commits immediately on a good scan: it now parses (without
persisting) and shows a review step with the decoded spire + relay(s), a "Test
relay" button (opens a WebSocket + NIP-01 REQ, reports reachable/latency or
unreachable), and Pair / Rescan. Only on "Pair" does it persist + relaunch into
the real pairing path.

- parseScannedSeed: validate-only split of ingestScannedSeed (no persist).
- testRelay: WebSocket reachability probe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:53:51 +00:00
883c599835 feat(machine): source LNbits transport from the pairing seed, not just env
Completes the consumer half of bitspire-#70: a paired machine gets its LNbits
transport relay(s) + server pubkey from the pairing, so a blank-.env unit reaches
the backend after scanning a seed — no VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY
provisioning.

- resolveSigner now returns { signer, transport }. transport (relays +
  lnbitsServerPubkey) comes from the seed on a fresh pair / seeded resume, and
  from the binding on a seedless resume. It's threaded out of resolveSigner
  rather than re-parsed in loadLightningConfig because the seed arrives over the
  one-shot get-atm-secrets IPC — a second consumer would break that contract.
- bunker_binding persists relays + lnbits_server_pubkey (state.db v11→v12,
  nullable so pre-#70 bindings resume and fall back to env). Mirrored into
  BunkerBindingRecord (preload + electron.d.ts).
- initializeLightningServices resolves effective transport with env-wins
  precedence (explicit env override for dev, else pairing, else a dev-only
  localhost relay), mutating CONFIG to a single source of truth and building the
  Nostr/LNbits/CLINK clients from the full relay list. Strict + required-config
  validation now run on the resolved values.

state.db round-trip test covers the new columns + their absence on a pre-#70
binding. Renderer + electron typechecks and all 38 machine tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:53:51 +00:00
786789f517 fix(machine): resume from binding when a stored spire seed won't parse
resolveSigner parses the stored VITE_SPIRE_SEED on every boot before it checks
the binding, so a machine whose .env still holds a legacy-shape seed would
throw on the new parser (bitspire-#70) and surface "ATM Unavailable" on the
next auto-pull — even though it has a perfectly good, server-persistent binding
to resume from.

Guard the parse: an unparseable stored seed with a binding present falls back
to resuming the binding (authoritative); with no binding it still fails closed,
since the seed is then the only pairing input. Also dedupes the three
resume-from-binding call sites behind a small local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:53:51 +00:00
98bdd92044 refactor(nostr-client): slim the spire-seed to carry the pubkey once, add lnbits_npub
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>
2026-07-02 21:53:51 +00:00
334cb86771 fix(machine): resolve signer before LNbits config so an unpaired machine reaches the pairing wizard
initializeLightningServices() validated VITE_LNBITS_SERVER_PUBKEY (and, in
strict mode, rejected a localhost relay) *before* calling resolveSigner. An
unpaired machine — no seed, no binding, blank .env — therefore threw a generic
config Error that classifyInitError surfaces as the static "ATM Unavailable"
screen, never the NoPairingError that routes to the QR-pairing wizard.

Pairing is what's meant to provide the transport config, so the pairing check
must come first. Move resolveSigner ahead of the strict + server-pubkey
validation: an unpaired machine now throws NoPairingError → 'unpaired' →
wizard regardless of relay/pubkey provisioning, while a paired machine still
hits the config validation it legitimately needs.

Surfaced testing the freshly-built Sintra images (live ISO + USB disk image),
both of which ship a blank .env by design and booted straight to "ATM
Unavailable". bitspire-#70 (part 1 of 2; part 2 = seed carries the LNbits
server pubkey).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:06:34 +02:00
fd4f69826d fix(machine): decode QR at intrinsic frame + tuned capture resolution
The camera pairing source decoded frames at the <video> element's CSS box
size (qr's readFrame default) rather than the intrinsic frame, and let the
stream stay at the panel-bound ~720p that frontalCamera negotiates from the
screen size. On the 1280x800 kiosk with a fixed-focus 5MP scan camera that
left far too few pixels-per-module for a dense spire-seed QR, so a centered,
in-square code never decoded.

Decode the intrinsic frame (readFrame fullSize=true) and pin a deliberate
1280x960 capture via applyConstraints. lamassu-machine caps QR scanning at
640x480 for decode speed (megapixels only slow the per-frame decode); our
seed QR is denser than a lightning invoice, so 1280x960 balances
pixels-per-module against latency and keeps auto-exposure from blowing out a
frame-filling phone screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 23:53:16 +02:00
aca6aebcb6 feat(machine): render QR-pairing wizard for unpaired machines
Wires the capture + ingest pieces into a screen (aiolabs/bitspire#52). When
the machine boots `unpaired` (fresh, or binding revoked/expired) and runs
under Electron, App.vue renders `PairingWizard` in place of the static
"Pairing Required" card.

The wizard probes available sources, shows the camera viewfinder, and on a
valid scan persists + relaunches. A stray/non-seed QR is rejected with a hint
and scanning resumes. NFC (when present) appears as an alternate source
button. Browser dev (no Electron bridge) still falls back to the static card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:07:43 +02:00
d22157b40c feat(machine): pairing-source abstraction + QR/NFC capture + seed ingest
The capture half of the QR-pairing wizard (aiolabs/bitspire#52), behind a
`PairingSource` seam so the wizard UI stays agnostic to how the seed arrives:

- `QrPairingSource` — camera capture + decode via `qr` (paulmillr). Chosen
  over the dormant, unmaintained `jsqr`: `qr` is zero-dependency, auditable,
  dual MIT/Apache, actively maintained, and authored by the same person as the
  `@noble`/`@scure` crypto our nostr stack already trusts. Its `qr/dom.js`
  helper wraps getUserMedia + the per-frame decode loop.
- `NfcPairingSource` — Web NFC scaffold; `isAvailable()` is false on the
  Sintra's Linux Electron, so it's inert until real NFC hardware lands (the
  user flagged NFC as a plausible future pairing method).
- `ingestScannedSeed` — validates the scan parses as a spire-seed (rejecting a
  stray QR), persists it, and relaunches. Covered by unit tests
  (invalid-seed / no-bridge / persist-failed / happy path).
- `availablePairingSources()` probes each source and returns the runnable ones
  in preference order (camera first).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:07:31 +02:00
9935807f8c feat(machine): persist scanned spire-seed + signal unpaired state for wizard
Foundation for the on-machine QR-pairing wizard (aiolabs/bitspire#52). An
unpaired ATM can now have a seed planted at runtime rather than only via
provisioning:

- electron IPC `state:save-spire-seed` writes VITE_SPIRE_SEED into the runtime
  .env (0600), and `app:relaunch` restarts the kiosk so the normal boot path
  (signer-resolver → connectNewSeed) does the actual bunker pairing. We
  deliberately do NOT pair in-renderer — persist + relaunch reuses the single,
  hardware-tested pairing path.
- signer-resolver throws a typed `NoPairingError` (distinct `.name`, survives
  the bundle boundary) when there's no seed and no binding, instead of a
  generic Error.
- init-error maps NoPairingError → `unpaired`, so the renderer can route a
  fresh machine to the interactive wizard (next commit) rather than a
  dead-end fault screen. Revoked/TTL bindings already map there too — re-pair
  is the same scan-a-fresh-seed flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:07:18 +02:00
a762a7ea40 fix(machine): guard the availability beacon sign against bunker blips
The beacon's createSignedEvent (a bunker round-trip) sat OUTSIDE its try/catch,
and publish() is fire-and-forget — so a transient BunkerTimeoutError /
BunkerRejectedError during the periodic sign surfaced as an uncaught promise
rejection (seen on the Sintra after a bunker watchdog blip during the cash-in
smoke). Move the sign inside the try; the beacon re-publishes every interval, so
swallow + log is correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:56:08 +00:00
9c74a28a06 feat(machine): secure cash-in via server-stamped create_withdraw RPC
Replaces the cash-in LNURL-withdraw creation with the secure create_withdraw
RPC (aiolabs/spirekeeper#31/#32). The ATM now sends only the hardware-attested
gross principal_sats; the operator side verifies the signer, derives fee + NET,
and stamps the link's attribution (source/nostr_sender_pubkey) from the VERIFIED
sender. Closes the dev-stack weakness where the ATM set the withdraw amount +
extra itself (could understate the fee / forge attribution).

- LnbitsClient.createWithdraw(walletId, {principal_sats, fiat_amount?, fiat_code?,
  title?, wait_time?, client_ref?}) -> {link_id, lnurl, net_sats, principal_sats,
  fee_sats}. Non-idempotent (mints a link) -> not retry-wrapped.
- lightning.ts generateLnurlWithdraw: createWithdrawLink -> createWithdraw; the
  ATM no longer computes amount/fee/extra. LNURL-session map re-keyed on link_id
  (the secure response carries no unique_hash); settlement-watch half unchanged
  (subscribe_payments tag:'withdraw', link_id).

Server RPC is live on the dev stack (spirekeeper#32 registered create_withdraw),
so this is ready for the joint cash-in test. typecheck 12/12, full suite + prod
build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:31:24 +02:00
762b0def5c fix(machine): republish cassettes-state after dispense + on reload
The cassette-state beacon was published only once at bootstrap, so after a
cash-out dispense the operator's view stayed frozen at the bootstrap snapshot
(still 20x4/50x7 after dispensing) — the ATM decremented its local HAL counts
but never told the operator. Coord 2026-06-21 (post cash-out leg).

- operator-config.ts: extract publishCassettesState() (the live, ungated
  publish) out of the one-shot bootstrap; expose it on OperatorConfigService;
  also fire it after an operator-config apply (the "on reload" case).
- atm.ts: republish after each cash-out dispense (complete + partial), once the
  decremented counts are persisted. kind-30078 is replaceable (latest wins) and
  the operator already consumes every update — no operator-side change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 09:56:14 +00:00
78d54cdc94 feat(machine): re-pair UX on bunker deauth at boot
A revoked / TTL-expired / off-policy bunker binding now surfaces a dedicated
"Pairing Required" screen instead of a raw error, and a signer/relay timeout
shows "Signer Unreachable" (transient). Shared classifyInitError() maps the
typed BunkerRejectedError / BunkerTimeoutError (by name, so it survives bundle
boundaries) to maintenance-screen sentinels, used at every store init catch +
the App.vue fallback. App.vue's nested-ternary screen copy refactored to a
keyed map (cleaner, and the new screens drop in).

Scope: boot-time detection (covers the dominant restart-after-revoke case).
Mid-session re-pair detection (flipping the screen when a sign fails during a
live flow) is a deliberate follow-up.

Part of Phase D, aiolabs/bitspire#52.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:38:11 +00:00
0391dbaeb0 chore(machine): fund-atm resumes from binding; VITE_SPIRE_SEED docs/env
fund-atm resolves its signer by resuming the bunker binding from state.db
(the connect token is already spent by the main app, so it can't re-pair);
falls back to a dev nsec via VITE_ATM_PRIVATE_KEY. better-sqlite3 marked
external in the esbuild bundle. .env.example + CLAUDE.md document
VITE_SPIRE_SEED as the prod identity, VITE_ATM_PRIVATE_KEY as dev-only.

(fund-atm is slated for deprecation in favour of the operator funding the
wallet directly via the LNbits UI — kept working for now.)

Part of Phase C, aiolabs/bitspire#52.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:15:59 +02:00
82a9e79d0e feat(machine): resolve signer from spire seed / bunker binding at bootstrap
New signer-resolver.ts turns the ATM's pairing state into a Signer:
 - seed present, fingerprint differs from stored binding → pair: generate a
   transport key, redeem the one-shot connect secret, persist the binding,
   reset the bootstrap gate (re-publish hello to the new operator, #56);
 - seed matches binding, or binding-only → resume (no re-redeem);
 - neither → ephemeral LocalSigner (dev) or throw (strict/prod).

lightning.ts drops the atmPrivateKey plumbing and calls resolveSigner; the
Phase-A Signer seam means nothing downstream changes. App.vue's maintenance
beacon resolves the same way (best-effort, skips if unpaired).

Part of Phase C, aiolabs/bitspire#52.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:15:45 +02:00
209e4c3e20 feat(machine): seed + bunker-binding IPC bridge
get-atm-secrets now returns { spireSeed, bunkerBinding } instead of the raw
nsec (one-shot semantics kept). Adds IPC handlers + preload bindings for
saveBunkerBinding / clearBunkerBinding / resetBootstrapGate so the renderer
can persist a pairing and re-arm the cassette-state hello on re-pair (#56).
resetBootstrapGate added to state-store. Types mirrored in electron.d.ts.

Part of Phase C, aiolabs/bitspire#52.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:15:31 +02:00
2b8e951de5 feat(machine): persist NIP-46 bunker binding (state.db schema v11)
Adds a bunker_binding singleton table + get/save/clearBunkerBinding
accessors holding the ATM's own NIP-46 transport key (client_nsec), the
spire signing pubkey, the bunker URL, and the seed fingerprint. Persisted
so a restart resumes the bunker session without re-redeeming the one-shot
connect secret; a changed fingerprint signals a re-pair.

The v10→v11 migration is idempotent (CREATE TABLE IF NOT EXISTS), and the
v9→v10 block now advances existing.value so a v9 install chains straight
through to v11 in one boot (matching the v6→v8 blocks).

Phase B of aiolabs/bitspire#52. The IPC bridge + bootstrap resolution that
consume these accessors land in Phase C.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:24:32 +02:00
d6b22e1156 refactor(nostr): route signing + encryption through a Signer abstraction
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>
2026-06-18 19:56:35 +02:00
52eb37ceaf refactor(machine): drop electron theme allowlist, defer to renderer
The VALID_THEMES set in electron/main.ts duplicated the renderer's
ThemeId list and silently coerced any unlisted branding.json theme to
null — which is how darkmatter regressed to the localStorage theme after
db074e2 added themes to the renderer but not this allowlist (fixed in
a0c2f38). Remove the second list entirely: pass raw.theme through and
let useTheme's applyBrandingTheme (themes[] + the 'custom' branch) be
the single validation point. Unknown values are ignored downstream, so
nothing reaches the DOM unvetted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:09:02 +02:00
a0c2f38ef0 fix(machine): add 6 new themes to electron VALID_THEMES allowlist
db074e2 added countrysidecastle/darkmatter/emeraldforest/lightgreen/
neobrut/starrynight to the renderer's ThemeId union and style.css but
not to the electron main-process branding allowlist. branding.json
'theme' values outside the allowlist were silently dropped (theme=null),
so the renderer fell through to the localStorage theme (cyberpunk).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 17:36:19 +02:00