Commit graph

419 commits

Author SHA1 Message Date
082f738ffa fix(deploy): console=tty0 was being dropped on the Pi 5
raspberry-pi-5.nix set `boot.kernelParams = lib.mkDefault [ "console=tty0" ]`
to keep the serial console off the GPIO UART so a validator can own it. It
never took effect: kernelParams is list-merged, and only definitions at the
highest priority survive — nixpkgs defines loglevel/lsm at normal priority,
so the mkDefault list was discarded wholesale. Effective params on
rpi5-installed were `[ "loglevel=4" "lsm=landlock,yama,bpf" ]`, no console=
at all, which makes the kernel fall back to the device tree's stdout-path:
that same UART.

Drop the mkDefault so the entry merges. Verified by evaluating
config.boot.kernelParams before/after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4
2026-09-24 21:43:53 +02:00
d8680f67ae feat(deploy): split rpi5 target into installed + image variants
`rpi5-installed` previously bundled the sd-image-aarch64 module, so it
was only good for building a flashable image — a `nixos-rebuild switch`
against it (local or the git+ssh remote form) would drag in the image
builder and its image-specific fs wiring. Split it, mirroring the x86
fleet's mkLiveConfig/mkInstalledConfig separation:

- rpi5-installed → in-place rebuild target. Declares the flashed media's
  own root fs (NIXOS_SD / FIRMWARE labels), nothing image-specific. This
  is what
    sudo nixos-rebuild switch --flake \
      "git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=<branch>#rpi5-installed"
  targets, the aarch64 equivalent of the sintra/douro deploy ritual.
- rpi5-image → same shared runtime + the sd-image builder. Its
  system.build.sdImage is the flashable artifact; packages.aarch64-linux
  .sd-image-rpi5 now points here.

Shared runtime extracted into piBaseModules/mkPiRuntime; folded the
aiolabs cachix substituter + trusted key into the Pi's nix.settings so a
remote rebuild substitutes the heavy aarch64 closure instead of building
it on the Pi (no max-jobs/timeout watchdog — the Pi 5 can build locally
if it must).

Verified: rpi5-installed evaluates to a valid system toplevel (root fs
present), rpi5-image/sd-image-rpi5 to the .img.zst builder, and x86
sintra-installed is byte-identically unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SGUJJjBDuYwRaWSkFdK3bm
2026-08-17 08:41:52 +02:00
a77bae50ee feat(deploy): add aarch64 Raspberry Pi 5 target (sd-image)
Proper aarch64 NixOS target for the DIY Pi 5 build, wired additively so
the x86 fleet path is untouched (both the new Pi sd-image and the
existing sintra-installed still evaluate cleanly):

- nixos-hardware input (raspberry-pi-5 module) for Pi kernel/firmware/GPU.
- aarch64 pkgs + pkgs-unstable + mkAtmApp instances (parallel to x86).
- mkPiConfig: aarch64 nixosSystem reusing the shared configuration.nix +
  bitspire-atm service, replicating the installed-config runtime (bitspire
  service, first-boot env seed, electron service override, swap). Drops
  the x86 fleet machinery for a first bring-up: no determinate/autoUpgrade
  (not yet fleet-managed) and no atm-tui (needs an aarch64 package).
- raspberry-pi-5.nix hardware module: extlinux boot, vc4/v3d KMS for X,
  primary UART free for a GPIO-wired validator, no-suspend, and stable
  /dev/ttyValidator* udev symlinks for USB-serial validator adapters
  (Apex 7600 RS-232 via adapter, NV10 USB+).
- nixosConfigurations.rpi5-installed + packages.aarch64-linux.sd-image-rpi5.

BUILD NOTE: the app closure (aarch64 electron/native addons) needs an
aarch64 builder — a native Pi/arm box or `boot.binfmt` qemu emulation on
an x86 host. Config evaluates on x86; it just can't build there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
2026-08-16 21:30:53 +02:00
16d235079f feat(hal): add Pyramid Apex RS-232 bill-validator driver
New `apex` validator for Pyramid Technologies Apex-series acceptors
(Apex 5000/7000/7600) on their RS-232 interface, for the Raspberry Pi 5
build. Implemented from Pyramid's PUBLIC protocol facts (RS-232 Serial
Interface Specification + their published integrator samples) — not
ported from lamassu-machine or any licensed source, so it stays inside
this repo's provenance boundary and AGPL.

- apex-rs232.ts: 8-byte poll frame (STX/len/ctrl+ACK-toggle/enable/cmd/
  rsvd/ETX/XOR-checksum), reply parsing (state+event+credit bytes),
  escrow stack/return latching re-asserted until the note leaves escrow.
- apex-fsm.ts: status tracker → BillValidator events (mirrors the EBDS
  tracker; dedupes continuous polling; escrow-latency watchdog).
- denominations.ts: per-fiat channel→value table (channel order must
  match the acceptor's programmed dataset — verify on the unit).
- index.ts: ApexValidator implementing BillValidator; wired into the
  createValidator factory as ValidatorType 'apex'.
- 13 unit tests for checksum, frame build, status priority, denom map.

Bench-verify on real hardware before trusting: the reply checksum range
(parsed leniently for now) and return-by-disable escrow behaviour are
flagged in-code as needing confirmation on the 7600.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
2026-08-16 21:22:47 +02:00
83300784ec Merge pull request 'fix(nfc): auto-recover a wedged CCID reader via USB power-cycle' (#85) from fix/nfc-reader-auto-recovery into dev
Reviewed-on: #85
2026-08-09 16:36:46 +00: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
d00f3c0bbd fix(batm3): make touch calibration immune to USB-boot timing race
egalax-calibrate polls 30s for the eGalax X device then gives up; on a
slow USB boot usbtouchscreen binds the panel later than that, so the
calibration matrix is never applied and touch registers in the wrong
place ("dead" panel). Seen on cold boots (2/2 today), fine on others —
a nondeterministic race, not a regression.

- Add a udev rule that (re)starts egalax-calibrate the instant the eGalax
  input node appears (SYSTEMD_WANTS) — device-driven, can't lose the race.
- Widen the calibrate poll window 30s -> 120s as a fallback.

Recovery when it does strand: `systemctl restart egalax-calibrate`, or
apply the matrix live via xinput set-prop.
2026-08-06 18:52:33 +02:00
0aeb3d01ef Merge pull request 'feat(machine): Bolt Card (NFC) tap-to-receive on cash-in' (#84) from feat/boltcard-nfc-cashin into dev
Reviewed-on: #84
2026-08-06 16:33:09 +00: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
1e074682e3 Merge pull request 'feat(machine): Bolt Card (NFC) tap-to-pay on cash-out' (#83) from feat/boltcard-nfc-cashout into dev
Reviewed-on: #83
2026-08-05 21:38:34 +00: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
51dcf0d6f6 feat(deploy): build nfc-pcsc's native pcsclite addon for Electron (mkAtmApp)
Rebuild @pokusew/pcsclite (V8 C++ addon) against Electron headers like
better-sqlite3, and package nfc-pcsc + @pokusew/pcsclite into the runtime
node_modules. Its binding.gyp hardcodes Debian /usr/include/PCSC + /usr/lib,
so point the compiler/linker at nixpkgs pcsclite via CPATH/LIBRARY_PATH
(winscard.h lives under include/PCSC); pcsclite.lib in buildInputs lets
autoPatchelf wire libpcsclite.so.1 into the .node RPATH. Bumps the pnpmDeps
hash for the added nfc-pcsc dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 04:55:30 +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
74c420fbd3 feat(deploy): enable pcscd on batm3 for the Bolt Card reader
The Feitian KP382 (096e:0608) is a CCID contactless reader; PC/SC must be
running for the CCID driver to bind it. The app will talk to pcscd's socket
via nfc-pcsc. Idle/harmless when no reader is attached.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 04:34:38 +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
f8f2037100 refactor(deploy): expose batm3-usb as a named nixosConfiguration
Lift the USB-variant config (distinct fs labels, nofail /boot, no
growPartition, autoUpgrade off) out of the inline disk-image-batm3-usb
`let` into `nixosConfigurations.batm3-usb`, and build the disk-image from
that same config. Enables in-place app deploys to a running stick via
`nix copy` + `switch-to-configuration` (build the toplevel, copy the
closure, activate) — no reflash, preserving pairing + /var/lib state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 18:49:59 +02: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
44f5c0dbcf docs(adr): amend ADR-002 — app recovery is first-line, SSH/NetBird last-resort
The access/recovery plane (SSH/NetBird) stands and may carry recovery
procedures, but it is explicitly NOT the only or first-line recovery. Add
a layered, cheapest-first recovery model: (1) app auto-recovery of its own
relay/Lightning connectivity, (2) an on-screen Retry for an operator at the
kiosk, (3) SSH/NetBird as the last-resort remote plane for genuine app/OS
failure. A public kiosk must not need remote shell access to recover from a
transient/boot-before-network outage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 18:11:40 +02:00
543f21a060 Merge pull request 'fix(lightning): cash-in commission charged twice (send gross principal, not net)' (#81) from fix/cashin-double-fee into dev
Reviewed-on: #81
2026-07-30 01:04:19 +00: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
75e1187e67 Merge pull request 'fix(batm3): eGalax touchscreen (kernel 6.6) + USB-bootable image' (#80) from fix/batm3-usb-boot-and-touchscreen into dev
Reviewed-on: #80
2026-07-29 23:57:27 +00:00
Patrick Mulligan
f52d942e57 fix(deploy): eGalax touchscreen on batm3 (kernel 6.6 + calibration)
The Dell 9030 AIO's built-in eGalax SAW panel (0eef:0001) was unusable:
touches either didn't register or landed in the wrong place. Full fix:

- Pin linuxPackages_6_6. On 25.11's default 6.12 kernel hid-multitouch
  grabs the controller and mis-parses its HID report (axes read stuck)
  and usbtouchscreen refuses to bind. On 6.6 usbtouchscreen binds and
  produces a clean single-touch ABS device (the known-good internal-SATA
  install runs 6.6.68). Mirrors douro.nix's per-hardware kernel pin.

- udev rule now modprobes usbtouchscreen ITSELF before unbinding usbhid
  and handing over via new_id. On a USB boot systemd-udev-trigger fires
  this rule (~2s) before systemd-modules-load loads usbtouchscreen
  (~12s), so new_id previously hit a not-yet-loaded driver and the panel
  bound to nothing. Loading it inline removes the boot-ordering race.

- Add an X evdev InputClass (99-egalax.conf) so X uses evdev + the
  transformation matrix rather than libinput. Mirrors the working
  internal-SATA install.

- egalax-calibrate: add XAUTHORITY (=/home/bitspire/.Xauthority) — the
  actual boot-time bug. Without the auth cookie xinput died with
  "Invalid MIT-MAGIC-COOKIE-1 key / Unable to connect to X server", so
  the coordinate-transformation matrix was never applied and touches
  landed in the wrong place. Also replace the fixed ExecStartPre sleep
  with a 30s retry loop on the eGalax X device appearing — more robust
  to boot timing than a race against display-manager.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 23:56:41 +00:00
Patrick Mulligan
6edcb8b96d feat(deploy): USB-bootable batm3 test image (disk-image-batm3-usb)
Add a USB-bootable BATM3 disk-image target plus the batm3 hardware
changes that make a dd'd USB stick boot reliably on the Dell 9030 AIO.

flake.nix — new `disk-image-batm3-usb` target:
- Distinct partition labels (nixos-usb / ESP-USB) so stage-1 by-label
  resolution can't latch onto an internal SATA drive that already holds
  a generic nixos/ESP-labelled install. Post-build mlabel relabels the
  ESP FAT volume to ESP-USB (bootloader files untouched; UEFI still
  loads /EFI/BOOT/BOOTX64.EFI).
- /boot mounted nofail + short device-timeout: the firmware already
  loaded the bootloader before Linux; without nofail a slow/late ESP-USB
  enumeration drops to emergency mode with root locked — a dead end.
- NO growPartition/autoResize on the USB image: sfdisk rewriting the
  partition table on first boot is the single most bus-stressing write,
  and flaky USB bridges drop off the bus mid-rewrite (sfdisk wedges in
  uninterruptible D-state and ESP-USB vanishes with the device, so /boot
  times out too). Persistent state is a few MB and the image already
  ships ~2GB free in root. The internal-SATA disk-image-batm3 keeps
  growPartition — a real AHCI SSD won't drop the bus.
- autoUpgrade off (test image, not a managed fleet member).

batm3.nix — USB-boot reliability:
- Add usb_storage to initrd.availableKernelModules so stage-1 binds the
  stick and /dev/disk/by-label/* appears.
- Blacklist uas + usbcore.autosuspend=-1: force the slower-but-reliable
  Bulk-Only Transport path and stop the boot medium being power-suspended
  mid-I/O — both were causing "device offline error" bus drops.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 23:56:41 +00:00
da3f3265ab Merge pull request 'fix(batm3): get cash-in working — EBDS escrow latch + correct serial device paths' (#79) from fix/ebds-escrow-stack-latch into dev
Reviewed-on: #79
2026-07-29 23:56:24 +00: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
f70105e8ff feat(deploy): add self-growing disk-image-batm3 target
batm3-installed existed as a nixosConfiguration but had no dd-able disk
image (only the live ISO, which is tmpfs — no persistent state.db/.env).
Mirrors the douro/sintra make-disk-image blocks, with one improvement:
boot.growPartition + fileSystems."/".autoResize so the root partition
and ext4 expand to fill the target drive on first boot. Flashing is
dd-and-done — no manual parted/resize2fs — and the full drive is
available to the nix store from day one (the #55 headroom lesson).

Image-only override via extendModules: the running system's
batm3-installed config (what auto-upgrade rebuilds against) is
unchanged.

Build: nix build .#disk-image-batm3  → result/nixos.img

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:58:22 +02:00
5b523a488a Merge pull request 'fix(machine): credit bills on stacked-confirmation, not stack command (#58)' (#77) from fix/escrow-credit-interlock into dev
Reviewed-on: #77
2026-07-25 20:44:11 +00: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
47151ebe8c docs: correct the lamassu-machine licensing boundary to commit c0b69d1
The provenance section claimed v8.1.5 was the last fully-open release.
GitHub history says otherwise: a9234d124d ("chore: add LICENSE",
2023-09-19) removed UNLICENSE and added the proprietary Appendix A SLA,
and the v8.1.5 tag (2023-09-21) already ships it. The true public-domain
boundary is that commit's parent, c0b69d1 ("chore: v8.6.0-beta.9") —
which is further along than 8.1.5 feature-wise.

lamassu-server's own boundary is unverified; flagged in the doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 01:00:12 +02:00
3cee0c5301 Merge pull request 'fix(machine): decrement cassettes by position, not denomination, on cash-out' (#75) from fix/cassette-decrement-by-position into dev
Reviewed-on: #75
2026-07-03 22:31:47 +00: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
86b35b5415 Merge pull request 'feat(machine): consume get_machine_config over the transport (#70 P1 client)' (#74) from feat/machine-config-consumer into dev
Reviewed-on: #74
2026-07-02 21:54:33 +00: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
a01e64cc70 Merge pull request 'feat: seed-driven pairing over the LNbits nostr-transport (#70)' (#73) from feat/seed-driven-pairing into dev
Reviewed-on: #73
2026-07-02 21:54:10 +00:00
936fc9fb46 feat(deploy): add factory-reset-atm.sh for a truly-fresh machine (#70)
Deterministically reproduce a brand-new machine so tests aren't masked by
leftover env/db values: stops bitspire, deletes state.db (+ WAL/SHM), truncates
.env to the minimal image-baked template (preserving model + fiat), restarts.
The ATM then boots unpaired into the wizard exactly like a fresh disk image.
Confirmation-gated (FORCE=1 to skip; ATM_USER= to override the SSH user).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:53:51 +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
42c0d3e9ca chore(deploy): seed a minimal .env — stop pre-seeding maskable vars (#70)
The bitspire-env activation seeds .env only when ABSENT (never refreshes on
redeploy), and env WINS over the pairing seed — so any value written at first
boot is frozen for the disk's life and silently masks the seed's source. That's
how a dead relay.aiolabs.dev and a provisioned VITE_OPERATOR_PUBKEYS made stale
installs "work" while a fresh machine broke.

Seed ONLY image-baked, non-maskable values (model, fiat, ELECTRON_FORCE_PROD,
DISPLAY, empty VITE_SPIRE_SEED placeholder). Relay + server pubkey come from the
seed; operator pubkey + fee config come from LNbits over the transport — so those
keys are no longer pre-seeded at all. VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY
are emitted only when the operator deliberately pins them via the Nix options (an
explicit override). Also drops the inert RELAY_URL/LNBITS_SERVER_PUBKEY lines from
/etc/bitspire/config.env (never loaded — EnvironmentFile is forced to .env).

Verified: built sintra-installed .env template is 5 lines, 0 maskable vars.

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