Compare commits

..

210 commits

Author SHA1 Message Date
94b3cf1fb6 Merge pull request 'feat(machine): apply the settle_transaction operator op (ADR-005 §6)' (#124) from feat/settle-transaction-op into dev
Reviewed-on: #124
2026-10-10 20:30:29 +00:00
5a4f70c90e docs(adr-005): record the NIP-17 alert and the settle-cash-owed path as built 2026-10-10 22:27:39 +02:00
651c43d7b0 feat(machine): apply the settle_transaction operator op
ADR-005 §6: the operator paid the customer by hand and recorded it in
spirekeeper. The op carries the txid and the note; the machine flips its
own dispense_error/partial row to remediated via remediateTransaction,
which only touches rows still in an error state, so re-delivery is a
no-op. Closes the machine side of the ledger for an owed-cash sale
without dispensing anything.
2026-10-10 22:27:39 +02:00
23bd54738d docs(claude): sintra's nightly also fails on the timeout; the push-cache rule, the pnpm hash rule, the activation PATH rule
The fleet table called sintra a working dev unit; its nixos-upgrade had
failed every night 2026-10-06 → 10-09 on the same 60 s atm-app timeout
as batm3, so nothing merged that week reached it. Recorded with the
interim rule — push-cache after every dev push, because mkAtmApp's
src = self makes any commit invalidate the cached toplevel — and the two
traps found while applying it: a lockfile change silently reuses a stale
pnpmDeps store unless the hash is re-derived, and activation scripts
don't have grep/sed on PATH.
2026-10-10 22:09:39 +02:00
9b07880925 fix(deploy): the .env migration called sed and grep by bare name on the activation PATH
NixOS activation scripts run with a minimal PATH that has coreutils but
not gnugrep or gnused. On sintra the snippet printed its success line and
then failed with "sed: command not found" (127) — the keys were never
renamed, the new app booted on fallback config, and switch-to-
configuration exited 2. Both binaries are now referenced by store path.
2026-10-10 22:08:48 +02:00
65d19f4f1e fix(nix): pnpmDeps hash for the current lockfile
The atm-app's pnpm store is a fixed-output derivation keyed by this hash.
pnpm-lock.yaml changed twice today (the dead-script dependency prune in
763817b, then qrcode declared where fund-atm.ts uses it in cbff654) and
the hash was not updated, so nix reused the stale store and the
sandboxed `pnpm install --offline` failed on @types/qrcode
(ERR_PNPM_NO_OFFLINE_TARBALL). That is what sintra's nightly upgrade and
the cachix push were dying on.

Rule to carry forward: any commit that touches pnpm-lock.yaml must
re-derive this hash (blank it, build, paste the `got:` value) — the
local `pnpm build` passing says nothing about the nix build.
2026-10-10 22:01:44 +02:00
0204514ed2 Merge pull request 'ADR-005 rollout step 1: value-confirmed dispense, fault screens, cash-out latch, dispense-report outbox' (#123) from feat/dispense-outcome into dev
Reviewed-on: #123
2026-10-10 19:54:55 +00:00
e8106b665a feat(machine): durable dispense-report outbox to spirekeeper (ADR-005 §2)
Every cash-out now produces one report_dispense — on success as well as
failure — and the machine does not stop sending it until spirekeeper
acknowledges it.

state.db gains a dispense_reports table (migration v13 → v14): the report
is written INSIDE recordTransaction's SQLite transaction, alongside the
transactions row, so a crash between the two cannot lose it. Rows carry
attempts / last_attempt_at / last_error / acked_at. Three IPC calls
(pending / ack / note-attempt) expose it to the renderer.

The store builds the report when a cash-out reaches complete,
dispenseFault or outOfCash: txid, payment hash, dispense_confirmed,
error / error_code / raw_code / error_class, per-denomination requested
vs dispensed vs rejected, the per-bay cassette record verbatim, and
counts_uncertain. The success report is what lets the server capture
(distribute) the settlement; the failure report is what puts a customer
on the owed-cash worklist instead of leaving the only record on the ATM.

Delivery is at-least-once: a flusher drains pending rows after each
persist, on relay (re)connect, and every 60 s, acking only on an OK reply
and backing off 30 s · 2^attempts (capped 1 h) otherwise. While
spirekeeper has not registered the RPC every send fails the same way; the
backoff keeps that quiet and the rows wait — this half ships first.

The lightning service exposes reportDispense; the function pointer is
set at all three lightning-init sites so the flusher works on every path.
2026-10-10 21:37:47 +02:00
7120f306b6 feat(lnbits): report_dispense RPC and the dispense-report wire types (ADR-005 §2)
One cash-out's dispense outcome, sent on success as well as failure —
the success report is what captures the settlement server-side. Field
names follow lamassu-server's cash_out_txs / cash_out_actions
(dispense_confirmed, error, error_code) with raw_code and error_class
alongside, per-denomination bills with `requested`, per-bay cassettes
verbatim, the payment hash as the join key, and counts_uncertain.

Idempotent on txid (the server upserts), so the call is wrapped in
idempotent() and safe for the machine's outbox to retry. Until
spirekeeper registers the RPC it rejects with LnbitsRpcError, which the
outbox treats like any other transient failure.
2026-10-10 21:37:47 +02:00
7f055cd4c6 docs(adr): ADR-005 §4 — outOfCash after payment is owed too; only the cause and the latch differ 2026-10-10 21:37:47 +02:00
888870d01a feat(machine): value-confirmed dispense, cash-out hold, fault screens, counts-uncertain on zero-with-error (ADR-005 §3–§5)
HAL glue (electron/hal-service.ts and the renderer-side services/hal.ts):
dispenseConfirmed is Σ(denomination × dispensed) === Σ(denomination ×
requested), computed on value. The driver's tagged error is carried
through as errorCode / rawCode / errorClass / human; pre-dispense
inventory refusals are errorClass 'inventory' so they route to outOfCash
rather than the fault screen. The manual-dispense command result keeps
its wire key `dispensed` (spirekeeper's poller reads it) and gains the
new fields alongside.

Cash-out hold: state-store persists it in meta as one JSON value beside
countsUncertainSince, idempotent on set (the first fault's `since` is
kept); IPC get/set/clear through preload. The store persists the hold the
moment the machine sets it and restores it into the machine on boot. A
recount clears it in the store (same gesture that clears counts-
uncertain); operator-config also honours a new resume_cash_out op — not a
cassette op, split off before applyOperatorCassetteOps, and honoured only
when stamped after the hold began so a re-delivered old resume cannot
clear a fresh fault. Either release calls back into the store, which
sends CASH_OUT_RELEASED. The cassettes-state document carries
cash_out_held_since / _reason / _code (additive, like
counts_uncertain_since); the availability beacon reports cash_out false
while held; the idle Sell button is disabled with the reason.

Store watcher: dispenseFault and outOfCash both record dispense_error /
partial (the customer has paid either way). A report of zero dispensed
WITH a hardware error now sets countsUncertainSince instead of being
trusted as zero — a note stopped in the transport completes neither
counter (sintra 2026-10-09: bay read 66, held 65, one in the transport).

Fault screen: both terminal states show "your payment went through",
amount paid, per-denomination dispensed, the txid as QR and text, the
payment hash (threaded from the settlement watch through PAYMENT_RECEIVED)
and the time, with "keep this reference" and an acknowledge button. The
raw dispenser code is not shown; it travels in the report.
2026-10-10 21:37:47 +02:00
3ff86de4ed feat(state-machine): dispenseConfirmed on value, dispenseFault vs outOfCash, cash-out latch (ADR-005 §3–§5)
DispenseCashResult.dispensed (a driver boolean) is replaced by
dispenseConfirmed — Σ(denomination × dispensed) equals the requested
value, computed by the HAL — plus errorCode / rawCode / errorClass.
dispensingCash.onDone guards on dispenseConfirmed and nothing else.

The single dispenseError state becomes two. dispenseFault: the dispenser
reported an error, the customer has paid and is owed — 120 s screen with
evidence, ACKNOWLEDGE_FAULT to dismiss. outOfCash: a shortfall with no
hardware error or an inventory refusal — 30 s. A hung dispense is a
terminal fault.

A terminal errorClass latches cash-out off: context.cashOutHeld, set by
latchCashOutIfTerminal, preserved across resetContext (it is machine
health, not transaction state), guarding idle's SELECT_CASH_OUT. Cash-in
is unaffected. Only CASH_OUT_RELEASED clears it — the store sends that
when an operator recount or resume_cash_out op lands; re-initialising
the dispenser never does, because re-init does not move a stuck note.
CASH_OUT_HELD lets the store restore a persisted hold on boot.

PAYMENT_RECEIVED now carries the payment hash into context.paymentHash
so the fault screen can show the reference the server indexes.

Tests: the dispense section is rewritten around outcomes — value
confirmation, fault vs out-of-cash routing, terminal latch + release,
recoverable does not latch, partial-with-error is a fault, inventory
refusal is out-of-cash, boot-restored hold gates, 30 s vs 120 s timers,
acknowledge/cancel, timeout latches. 46/46.
2026-10-10 21:37:47 +02:00
c70d43523c feat(hal): dispense error taxonomy, F56 decode table, and the first HAL tests (ADR-005 §7)
Every dispenser now returns a tagged DispenseError: errorCode (the
family name, e.g. F56DispenseError), rawCode (driver-native, '78 42'),
errorClass (terminal | recoverable | inventory) and a human decode. The
class is what the state machine routes on: terminal latches cash-out off,
recoverable shows the fault screen but stays in service, inventory means
nothing was asked of the hardware.

The F56 table is built empirically and from the Fujitsu F56-BDU Error
Code List, seeded with sintra's 78 42 (note stopped at the cassette exit,
terminal) and the Tejo's 82 00 (long-bill reject, recoverable), plus the
83/84/86 00 checks and the 85 0n / B5 .. families. An unknown code fails
SAFE — terminal — so an unfamiliar fault latches rather than letting the
next customer pay into it. f56-rs232 surfaces the raw code structurally
instead of only inside the message string.

Drops the borrowed statusCode 570 from the F56 driver: lamassu-server
read 570 as "insufficient funds", so a jam told operators to refill full
cassettes (their 34ba9203 fix). Puloon gets the same contract with every
fault terminal until it has a decode table.

packages/hal had no tests at all (ADR-005 finding 10). Adds the first
two: the decode table, and the bill-length table — every window [hi, lo]
sane, and GTQ/USD(/HNL when added) sharing one window for what is
physically the same 156 mm note. That second test would have caught the
GTQ fault months ago.
2026-10-10 21:37:47 +02:00
cbff654856 fix(machine): declare qrcode where electron/fund-atm.ts actually uses it
The fund-atm esbuild bundle imports `qrcode`, but apps/machine never
declared it — it resolved only through packages/nostr-client's
devDependency, which 763817b removed along with the dead scripts that
were the only reason it was there. The full `pnpm build` then failed at
its last step ("Could not resolve qrcode"), which is what the nix image
build runs. Declared (with @types/qrcode) in the package that imports it.
2026-10-10 21:37:45 +02:00
f3c333c0bb docs(adr): ADR-005 accepted 2026-10-10 21:07:43 +02:00
1a24bfdda8 docs(adr): ADR-005 — a partial dispense distributes once, when the outcome is final
Decision 1's partial row said "operator confirms → distribute scaled"
and left the undispensed remainder's fate implicit. Made explicit:
partial_pending holds everything — including the share of the notes
that did dispense — until the operator records how the shortfall was
resolved (remediated → full amount; vouchered or written off → scaled),
then one distribution runs at that amount with the existing scaling
arithmetic. A vouchered remainder waits for redemption or expiry.

The alternative (scaled part now, remainder on resolution) is recorded
as deferred, not rejected: it needs a second additive distribution pass
the repo lacks, and a partial is almost always a terminal fault that
has latched cash-out off, so resolution is hours. Decided 2026-10-10.

Also carries the hold-invoice decision rule that fell out of the same
analysis: dispensed > 0 → settle, dispensed == 0 → cancel. An exit jam
that reports zero cancels cleanly — the customer is charged nothing and
the stuck note is the operator's to recover — so hold invoices remove
owed-cash for full faults and exit jams, not for true partials.
2026-10-10 21:06:08 +02:00
fa4858ed66 chore: drop stragglers from the regtest-tooling removal
.gitignore still carried rules for docker/**/data/ and docker/.state/ —
paths that no longer exist — under a now-empty "# Docker" header. The
docs skill's example sync report named LIGHTNING_PUB_URL as its sample
env var; swapped for a variable that exists.
2026-10-09 22:25:42 +02:00
e907bcc085 chore: stop tracking the generated .devenv.flake.nix
devenv regenerates this file on every `devenv shell`; the committed copy
was pinned to a directory that no longer exists
(~/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next). Untracked
and gitignored beside .devenv/. The file stays on disk.
2026-10-09 22:25:07 +02:00
763817b9f3 chore(dev): remove the Lightning.Pub-era regtest tooling
docker/ (two compose stacks, dev.sh, regtest.sh, start-with-regtest.sh,
regtest-bootstrap.sh, strfry.conf), packages/nostr-client/dev/ (nine
agent and test scripts), and the seventeen devenv commands that drove
them — infra-*, lncli, btccli, mine-blocks, auto-mine, setup-channel,
alice-*, fund-atm, test-setup, test-payment, node-info — along with the
devenv postgres service, DATABASE_URL, LIGHTNING_PUB_URL, pgcli,
docker-compose and the `just` runner (no justfile exists).

None of it could talk to the app on `dev`. Every piece was built around
Lightning.Pub (a `lightning-pub` service in both compose files, 37
references in dev.sh, LIGHTNING_PUB_PUBKEY and the :1776 API in every
dev script, a NIP-44 v1 implementation the project forbids), and the
last substantive change predates the LNbits cutover that deleted
packages/lightning. No container under either name exists on any
machine. Development runs against LNbits: FakeWallet needs nothing,
bohm's native instance answers on :5001, and the shared regtest stack
lives at ~/dev/local/docker/regtest, outside this repo.

devenv.nix keeps the toolchain, the hardware/serial utilities, the git
hooks and `relay-test`, now pointed at LNbits's bundled nostrrelay. The
Rust toolchain stays for the orphaned crate until that is removed on its
own. nostr-client drops the four dependencies and two devDependencies
only the dead scripts imported (@noble/curves, @scure/base,
@shocknet/clink-sdk, @stablelib/xchacha20, qrcode, ws); lockfile
regenerated, −272 lines.

Verified: devenv.nix parses; nostr-client 43/43 + tsc; clink 11/11 + tsc;
machine app vue-tsc clean. The ndebit-cash-in-flow doc, kept as CLINK
design history, now says the commands it quotes no longer exist here.
2026-10-09 22:25:07 +02:00
723553522c docs: purge stale lamassu naming; fix the hal-check skill's provenance boundary
- @lamassu/clink import examples → @bitSpire/clink, the package's real name.
- machine-installation.md: the service user is `bitspire`, not `lamassu`
  (renamed in configuration.nix long ago; the doc never followed).
- README: clone aiolabs/bitspire, not lamassu-next; the fleet sentence
  claiming batm3/douro run `main` against Lightning.Pub was stale.
- nostr-check skill: table headers say bitSpire.
- hal-check skill: the boundary is c0b69d1, not v8.1.5 (CLAUDE.md corrected
  this 2026-07-04; the skill kept asserting the wrong tag), and the
  "forbidden operations" now reflect the recorded permission —
  reference over port, name the source commit — plus a rule born of the
  GTQ window: no value table without a test over it.

Deliberately kept: every `aiolabs/lamassu-next#NN` issue citation, the
provenance sections, "Ported from lamassu-machine" driver headers, and
the hardware names "Lamassu Sintra/Tejo/Douro" — those are the machines.
2026-10-09 21:58:55 +02:00
6524cad7a8 chore(flake): drop the lamassu-live-* alias outputs; retire the lamassu-next comment
The aliases were added for the brand transition with a note to drop them
once nothing referenced them. Nothing does. The autoUpgrade comment still
said the legacy aiolabs/lamassu-next repo fed batm3 and douro; every live
machine pulls from this repo now (CLAUDE.md → Branch model).
2026-10-09 21:58:55 +02:00
34c2a6c42d refactor: rename the remaining Lamassu-branded identifiers
LamassuEventKind → BitSpireEventKind (nostr-client; no consumers outside
the package), the kiosk theme localStorage keys lamassu-theme /
lamassu-color-mode → bitspire-* (a one-time theme reset on existing
kiosks), the ui-shared UMD global LamassuUIShared → BitSpireUIShared, and
the orphaned Rust HAL's Cargo name/description/repository plus its lib.rs
header — now also labelled as the unbuilt leftover it is.

nostr-client: 43/43 tests, tsc clean. Machine app: vue-tsc + electron tsc clean.
2026-10-09 21:58:55 +02:00
561fd35aed refactor(dev): rename the lamassu-* dev-infra containers, databases and credentials to bitspire-*
Container names (relay, bitcoind, lnd, lnd-alice, lightning-pub, miner,
postgres), the regtest bitcoind rpcuser/rpcpassword, the postgres role and
database (bitspire_dev), the dev admin token, BITSPIRE_HOST_IP, the devenv
project name, and the banners/log prefixes in the scripts. Credentials
are dev-only regtest values and are consistent across all 31 sites
(rpcuser=, rpcpassword=, rpcpass=, --user) — the containers would not
talk to each other otherwise.

Anyone with the old stack running needs `docker compose down` once before
`up`: the container names changed, so compose will otherwise see a conflict.

Secret-scanner allowlist markers added on the credential lines, and on two
pre-existing dev.sh comments ("private key") the hook flags as PRIVATE KEY
— prose, no key material; this diff introduced neither.
2026-10-09 21:58:55 +02:00
bf2b9427aa refactor(config): rename VITE_LAMASSU_* machine-config env vars to VITE_BITSPIRE_*
MACHINE_MODEL, FIAT_CODE, VALIDATOR_DEVICE, DISPENSER_DEVICE and CASSETTES
carried the old brand in their names. Renamed everywhere they are read
(device.ts, electron/main.ts), written (flake.nix, mkAtmApp.nix, live.nix,
provision-atm.sh, factory-reset-atm.sh) and documented (.env.example,
docs/device-configuration.md). No compatibility fallback in code: the
machine reads VITE_BITSPIRE_* and nothing else.

The deployed .env files are the one place the old names persist — sintra's
/var/lib/bitspire/.env holds all three keys today — and the machine reads
MACHINE_MODEL / FIAT_CODE / CASSETTES from that file on every boot. Renaming
the keys in code alone would boot a live machine on preset defaults (wrong
bays, wrong fiat) at the next nightly pull. So configuration.nix gains an
activation script, beside the existing lamassu→bitspire user migration,
that rewrites VITE_LAMASSU_* → VITE_BITSPIRE_* in that file. Idempotent;
runs before bitspire.service starts.
2026-10-09 21:57:38 +02:00
d569e4013e docs(adr): ADR-005 — record that packages/hal has no tests (finding 10) 2026-10-09 21:43:00 +02:00
aa488c0df4 fix(hal): widen the GTQ F56 note-length window to match USD/HNL
Every quetzal note is 156 x 67 mm — physically the same note as USD and
HNL, which the F56 table accepts at 146–166 (±10). GTQ was configured
at ±5 (151–161), with Q5 and Q20 further centred on 158 rather than 156.
This table was carried byte for byte from lamassu-machine, narrow window
included.

On a Tejo in GTQ it produced F56 error 82 00 (bill length, long) on
every Q100 pick — 5/5 notes rejected, 0 dispensed, a false "out of
cash" — byte-identical across transactions days apart, so the BDU was
reading >161 mm consistently. The reject tray held single notes, not
pairs, which rules out the offset double-pick the narrow window exists
to catch.

Every denomination now uses 0xa6 0x92 (146–166), identical to USD/HNL.
Mirrors lamassu-machine b1cc3622 (2026-09-29), ported with permission as
prior art.

Verification: tsc clean. packages/hal has no test files (vitest exits 1,
"No test files found") — recorded as ADR-005 review finding 10.
2026-10-09 21:42:36 +02:00
6a974054ce docs(adr): ADR-005 — future directions and the operator-docs gap
Records where the cash-out design is heading so Decisions 1–7 are made
with the destination in view:

- Hold invoices move authorize/capture from spirekeeper into the
  Lightning layer. LNbits core already has create/settle/cancel
  (lndrest + lndgrpc only; not yet on the nostr-transport). Settlement is
  all-or-nothing per HTLC, so a full fault cancels cleanly but a partial
  fault still needs a voucher — hold invoices remove owed-cash for the
  common case, not every case.
- Vouchers: a fiat-denominated claim at the original rate, a liability
  row linked to its origin settlement, whose undispensed sats stay
  undistributed until redemption or expiry.
- CLINK as the eventual favoured customer protocol; the availability
  beacon should align with the CLINK Beacon spec rather than grow a third
  shape.
- Operator notification is a Nostr event to the operator's pubkey, not
  email/SMS. The pubkey is already on the LNbits account; nsecbunkerd
  supports nip44 so no operator ever needs their nsec.
- An operator-facing error glossary, seeded from the F56-BDU error list
  and this incident's 78 42.
- Cross-reference to the Lamassu Port Backlog, and the finding that
  bitSpire carried lamassu's narrow GTQ window byte for byte.
- Bay layout is machine-authoritative (VITE_LAMASSU_CASSETTES on first
  boot, then state.db); spirekeeper adopts and deletes absent positions;
  no operator document describes any of it, and fleet targets are keyed
  by hostname so a second Tejo cannot join without a new flake target.

Refs #122
2026-10-09 21:42:03 +02:00
f498373e96 docs(claude): record the lamassu reference permission; correct the server boundary and the driver table
Three corrections to facts a session reads before touching code.

The maintainer taking over the Lamassu codebase has given permission to
use lamassu-machine and lamassu-server, post-boundary included, as prior
art (relayed by padreug, 2026-10-09). The hard rule that limited us to
c0b69d1 is superseded; the guidance is now reference-over-port, with
verbatim ports naming their source commit.

lamassu-server DID have a public-domain era — last open commit adbc9709,
licence added in d06a8f54, both 2023-09-19 — contrary to what the
squashed ~/lamassu/lamassu-server checkout suggests (its first commit is
already Appendix A). History survives in ~/dev/repos/ and at Software
Heritage. The earlier "not re-verified" note is replaced with the
verified boundary.

The hardware-driver table claimed ccnet, cashflow_sc, bnr_advance,
genmega, hcm2, gsr50 and three printers. The tree has validators id003
and ebds, dispensers f56 and puloon, no printers directory, and orphaned
Rust files from an abandoned HAL. The table now matches the tree.
2026-10-09 21:42:03 +02:00
483e44aaa9 docs(adr): ADR-005 — cash-out dispense outcome and settlement capture
A customer paid a 40 EUR cash-out, a note jammed at the cassette exit,
and the dashboard showed `processed`. The machine had recorded the
failure correctly. Nothing it knew ever left the box.

The root is ordering, not display: spirekeeper spawns process_settlement
the instant the payment lands, which is before the machine has begun to
dispense. The legs are paid sub-second; the dispense fails afterwards;
and the one remediation tool refuses once any leg has completed. It is
unreachable for the exact case it was built for.

ADR-005 makes payment the authorization and dispense confirmation the
capture — distribution waits for the machine's report. The report is a
report_dispense RPC (not the state doc: ADR-004's losing-writer problem),
carried through a durable outbox, sent on success and failure, adopting
lamassu's dispense_confirmed / error / error_code taxonomy and its
per-bay action log — which the machine already records in cassette_bills
and simply never ships.

Deviates from lamassu in three places it got wrong or never did: a
zero-dispensed report that arrives with an error is treated as
unverified, not as zero; a mechanical fault is its own customer screen
with evidence and is not "out of cash"; and terminal dispenser faults
latch cash-out off until a recount or an explicit operator op, because
re-initialising does not move a stuck note.

Closes the review loop with ten findings outside the ADR's decisions.

Refs #122, #27, #78
2026-10-09 16:34:54 +02:00
4d6ea8f163 fix(ops): atm-transactions queried fee_percent, which no longer exists
The column was renamed to fee_fraction (schema_version 13), so the main
query has been failing outright with "no such column: t.fee_percent" —
the tool only ever worked in --summary and --inventory mode. Caught
while reconciling sintra's cassettes, where listing transactions was
the obvious first step and didn't work.

Refs #40
2026-10-09 09:44:02 +02:00
ee28275bf3 feat(ops): atm-reconcile — check cassette ledgers against recorded history
The cassettes table is a running total, so it can be re-derived: an
absolute truth point (a recount, or an empty) plus the refills and
dispenses since. A derived count that disagrees with the stored one is
evidence of something the ledger never saw.

Reconciliation deliberately refuses to start from a refill. A refill is
a delta, and applying deltas on top of a wrong number just carries the
error forward — which is how sintra's 20-EUR bay ran 10 notes high for
weeks while its 50-EUR bay, zeroed by an `empty` before refilling,
reconciled exactly. A bay with no baseline is reported as
unreconcilable rather than silently assumed good.

Also surfaces the two things that make a count untrustworthy: the
counts-uncertain flag, and any transaction still sitting in
dispense_error / partial.

The SQL uses scalar subqueries rather than joins on purpose — joining
transaction_bills to cassettes fans out across bays, and a LEFT JOIN
whose rows are all excluded by the baseline cutoff collapses to NULL
and poisons the arithmetic downstream (the first draft read "expected:
blank" for exactly that reason).

Exits non-zero on any gap or missing baseline so it can be run as a
check after a test session.

Refs #122
2026-10-09 09:40:41 +02:00
695a8bf98d Merge pull request 'feat(deploy): run the tejo from USB (disk-image-tejo-usb)' (#120) from feat/tejo-usb into dev
Reviewed-on: #120
2026-10-06 17:35:42 +00:00
6042d69356 fix(deploy): tejo had no WireGuard address, so it had no way back in
`networking.wireguard.interfaces.wg0.ips` was set in hardware/douro.nix
and hardware/batm3.nix, but hardware/upboard.nix is shared by tejo and
sintra — an address there would be claimed by both machines on the same
/24, so neither got one. tejo therefore evaluated to `wg0.ips = [ ]`:
the interface comes up with no IP and the tunnel is silently dead. On a
machine with no other route in, that is how you lose a box.

Replace the two per-hardware definitions with one `wireguardIpForModel`
table in flake.nix, keyed on model like fiatCodeForModel /
upgradeWindowForModel / nfcReaderForModel, and give tejo 10.0.0.3/24 —
the address it answers on today under its factory Debian.

douro (10.0.0.4/24) and batm3 (10.0.0.5/24) evaluate unchanged; sintra
stays deliberately unlisted, since it is reachable on the LAN and has
never had a tunnel address.

The address is only half of it: the VPS maps peer pubkey to tunnel IP,
so the machine still needs /var/lib/wireguard/wg0.key carried over from
its previous install (or a fresh key added to the VPS peer list). Both
wireguard units are ConditionPathExists-guarded on that key, so a
keyless first boot is clean and the tunnel starts once it is dropped in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 19:26:14 +02:00
c3e01c9cd3 feat(deploy): USB-bootable tejo image (disk-image-tejo-usb)
The tejo still runs its factory Debian (ubilinux4, kernel 4.9) on
internal storage and has never had bitspire on it. Rather than flash
that drive, give it the run-from-USB shape douro and batm3 already use:
the stick is the system and the internal install is never touched.

- nixosConfigurations.tejo-usb — tejo-installed + usbBootModule +
  usbBusHardening + usbGrubHybridModule. Evaluates identically to
  sintra-usb, which shares hardware/upboard.nix.
- packages.disk-image-tejo-usb — hybrid table, GRUB, BIOS + UEFI.

NOT the efi/systemd-boot shape douro uses. The tejo is the same Aaeon
UP Board as sintra, whose firmware was found to USB-boot in Legacy/BIOS
mode; systemd-boot is UEFI-only, so a dd'd systemd-boot stick would not
be recognised as bootable at all. The hybrid image boots either path, so
it is also the safe choice if the firmware turns out to differ.

README documents both bootloader shapes and which models take which.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 19:18:47 +02:00
81a001c3d3 refactor(deploy): one USB-image helper for both bootloader shapes
disk-image-sintra-usb was a 60-line inline copy of everything
mkUsbDiskImage already does, plus the GRUB/hybrid bits the Aaeon
firmware needs — so the two implementations had already drifted: the
sintra image never picked up the `nofail` /boot that keeps a slow
ESP-USB enumeration out of emergency mode, nor the uas/autosuspend
hardening batm3.nix and douro.nix carry.

- mkUsbDiskImage takes named args with `partitionTableType` ("efi" for
  systemd-boot, "hybrid" for GRUB) and `grubBiosDevice`. The ESP relabel
  is layout-independent: the hybrid table creates the ESP first and
  bios_grub second, so it stays partition 1 either way.
- New `usbGrubHybridModule` + `usbBusHardening` modules. The hardening is
  scoped to the -usb configs rather than hardware/upboard.nix, which
  sintra's eMMC install also reads — no cmdline change on a production
  machine.
- `nixosConfigurations.sintra-usb` is now a named config, so a running
  stick can be updated in place (nix copy + switch-to-configuration)
  like batm3-usb and douro-usb.
- grub.devices is "nodev" in the config and mkForce'd to the build VM's
  disk only for the image: an in-place switch on a live stick has no
  /dev/vda, and GRUB's embedded core.img reads grub.cfg off the
  partition, so the MBR stage needs no per-generation rewrite. Plain
  definition rather than mkForce, since two mkForce lists merge into
  [ "/dev/vda" "nodev" ] instead of replacing.

douro-usb and batm3-usb evaluate to byte-identical kernelParams,
blacklistedKernelModules, fileSystems, bootloader and autoUpgrade
config as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 19:17:18 +02:00
92d5fdb98b Merge pull request 'fix(hal): await the serial close, and stop reporting a cassette layout the dispenser refused' (#119) from fix/dispenser-close-race into dev
Reviewed-on: #119
2026-09-29 21:58:31 +00:00
47d3d0b237 fix(hal-service): don't keep a cassette layout the dispenser refused
setCassettes updated bays and dispenserInitData before re-initialising the
device, deliberately, so that "subsequent dispense calls see the new layout
even if the dispenser re-init is slow / fails". With the re-init failing
every time on douro, that meant the app kept a layout the hardware had
never taken, the operator-config consumer logged "Applied ops", and a
cassettes-state event went out to the operator advertising it. The douro
spent the afternoon reporting bay1:100x60 bay2:200x0 while the device was
still running the boot-time 100x50/200x50. A dispense in that state picks
bays by a layout the device does not share.

Roll the in-memory layout back when the re-init throws, and let the error
propagate as before. Reversing that earlier choice deliberately: a stale
but honest layout beats a fresh but fictional one when the difference is
which cassette pays out.

Also await dispenser.close() here and in cleanup(), now that close()
reports completion.

Closes #118
2026-09-29 23:55:48 +02:00
5fc1fbc9e8 fix(hal): close() resolves once the serial port is actually closed
The Dispenser interface declared close(): void, so no caller could know when
the port was free — and both drivers returned well before it was.

puloon deferred serial.close() behind a 100 ms setTimeout and returned
immediately. A close-then-reopen caller (setCassettes -> init) therefore
raced a handle that was still open and got EAGAIN "Cannot lock port" every
single time, the overlap being the full 100 ms. Worse, had the reopen ever
won, the pending timer would then have closed the *new* handle and nulled
the field, leaving a silently dead dispenser rather than a loud error.

f56 has no timer but serialport's close() is asynchronous regardless, so it
had the same race with a much narrower window — intermittent rather than
deterministic, on sintra/tejo/gaia/batm3.

Both now resolve on serialport's close callback, claiming the handle up
front so concurrent calls can't double-close. puloon keeps its 100 ms drain
(it lets an in-flight write land) but awaits it instead of firing and
forgetting.
2026-09-29 23:55:39 +02:00
7000720ae9 Merge pull request 'fix(nfc): make the Bolt Card reader an opt-in machine capability' (#115) from fix/nfc-opt-in into dev
Reviewed-on: #115
2026-09-29 20:51:37 +00:00
a78fbe1273 Merge pull request 'feat(deploy): USB-bootable douro image (disk-image-douro-usb)' (#114) from feat/douro-usb into dev
Reviewed-on: #114
2026-09-29 20:51:28 +00:00
bb2ad39628 feat(deploy): services.bitspire.nfc.enable — declare the reader per machine
pcscd was enabled in hardware/batm3.nix and hardware/upboard.nix, which
cannot express "is a reader fitted": upboard.nix is shared by sintra (HID
Global OMNIKEY 5022) and tejo (nothing fitted), so tejo inherited pcscd it
has no use for, while the douro — with its own hardware file — got none and
wedged on every boot.

Make it a machine capability instead. services.bitspire.nfc.enable owns
pcscd, the two polkit rules and the wedge-recovery unit, and hands the app
a BITSPIRE_NFC_ENABLED flag so it doesn't initialise nfc-pcsc at all on a
machine with no reader. Per-model truth lives in nfcReaderForModel in
flake.nix next to fiatCodeForModel and upgradeWindowForModel, since a
shared hardware file can't answer the question. batm3 and sintra are true;
douro and tejo flip to true when readers are fitted.

The flag goes through the unit's Environment rather than
/var/lib/bitspire/.env, because .env is only written when absent — a
machine provisioned months ago would never pick up a new value.
2026-09-29 22:49:59 +02:00
ce80d75f95 fix(nfc): bail out when pcscd is absent instead of spinning the main thread
nfc-pcsc's pcsclite binding does not fail when pcscd is not running — it
retries SCardEstablishContext in a tight loop on the calling thread, which
here is Electron's main thread. ~12k stat()s a second on
/run/pcscd/pcscd.comm, event loop dead: the window never paints, the
renderer is never reaped, and the watchdog can't fire because it needs the
same event loop. The douro sat like that for 11 hours at 80% CPU (its
CPUQuota ceiling), ignoring SIGTERM, with nothing in the journal after
[StateStore].

Check the socket exists before touching the binding. This is what makes
the "best-effort, every failure swallowed into a status callback" contract
in the module header true, and it also covers pcscd dying at runtime on a
machine that does have a reader.
2026-09-29 22:49:45 +02:00
23fe4a59f1 feat(deploy): USB-bootable douro image (disk-image-douro-usb)
The douro cutover to bitspire is being done remotely with a USB stick
and the machine's internal drive is not NixOS, so the stick has to be
the system rather than an installer medium. Give douro the same
run-from-USB shape batm3 already has.

flake.nix
- Lift the batm3-usb module and image post-processing into shared
  `usbBootModule` / `mkUsbDiskImage` helpers (distinct nixos-usb/ESP-USB
  labels, nofail /boot, no growPartition, autoUpgrade off, ESP relabel).
  batm3-usb evaluates to the same fileSystems/upgrade config as before.
- Add `nixosConfigurations.douro-usb` and
  `packages.disk-image-douro-usb` on top of douro-installed.

douro.nix
- Blacklist uas and set usbcore.autosuspend=-1, the same bus-drop
  hardening batm3.nix carries, so a stick is a reliable boot medium on
  the Bay Trail box.

README
- Document the -usb outputs and the flash-with-Etcher, no-installer flow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 22:42:46 +02:00
92a82712da Merge pull request 'perf: cut the image 38% and turn GPU acceleration back on' (#112) from perf/gpu-acceleration into dev
Reviewed-on: #112
2026-09-25 05:48:14 +00:00
d28d5ecd85 Merge pull request 'Give the upgrade window its own timezone, not the system' (#111) from fix/upgrade-window-timezone into dev
Reviewed-on: #111
2026-09-25 05:44:54 +00:00
0347530511 perf(deploy): enable GPU acceleration by default, except on douro
The kiosk has launched with --disable-gpu AND
--disable-software-rasterizer since the first ISO commit (19d43c2).
Together those turn off GPU compositing and the SwiftShader fallback,
leaving Chromium to rasterise every pixel on the CPU — on Atom-class
hardware, for no reason anyone wrote down. No comment, no issue, no
commit message ever justified the pair, and /etc/bitspire/config.env has
claimed ELECTRON_DISABLE_GPU=false the whole time, contradicting the
actual command line.

Tested on sintra today. With the flags gone the GPU process is stable —
zero crashes, zero service restarts — and genuinely on hardware:
/proc/<gpu-pid>/maps shows libgallium, libGLX_mesa and dri_gbm, with no
swrast and no SwiftShader. It renders through crocus on Braswell.
Confirmed by eye on the panel, which is the part no log could answer.

Worth noting what the first attempt looked like, because it read as a
failure and was not. Restarting the unit logged "GPU process exited
unexpectedly: exit_code=15" and "has crashed 1 time(s)" — but those came
from the OUTGOING process being SIGTERMed by the restart. The incoming
one logged nothing. Checking crash counts and the gpu-process pid across
an interval, rather than grepping the last sixty lines, is what separates
the two.

DOURO KEEPS THE OLD FLAGS. Bay Trail already carries three display
workarounds — a 5.15 kernel pin for an i915 eDP regression,
i915.enable_psr=0, and vt.handoff=7 to preserve the BIOS display init —
which makes it the one machine where the original flags plausibly fixed
something real rather than being bring-up scaffolding. It is also down
pending a reflash, so it cannot be tested. Shipping an untested display
change to the most display-fragile box in the fleet, to be discovered
whenever it comes back, is not a trade worth making for one machine's
frame rate. Drop the exemption once douro is back and accelerates
cleanly.

The env override still works on every machine, douro included, so this
can be flipped either way without a rebuild.
2026-09-24 23:53:53 +02:00
db5f433706 Merge branch 'perf/mesa-no-llvm' into perf/gpu-acceleration 2026-09-24 23:39:39 +02:00
f565e004e5 docs: correct the fleet and branch model against the actual machines
Three claims in this file were stale, and I took all three at face value
today before checking any of them.

`dev` is not a staging branch. Every live machine runs it. batm3's
nixos-upgrade unit pulls ?ref=dev#batm3-installed daily at 04:00, which
makes "push freely to dev" actively dangerous advice — a bad commit
reaches production hardware overnight, unattended. The file said the
production ATMs ran `main` against Lightning.Pub and only sintra was on
dev. batm3 runs bitspire.service out of /var/lib/bitspire with a
VITE_SPIRE_SEED and no Lightning.Pub vars at all.

bitspire.service runs as `bitspire`, not `lamassu`. Leftover from the
rename in 46e52f6.

Added a surveyed fleet table, because two facts in it are load-bearing
for anything touching hardware. Every GPU binds crocus, including
sintra's Braswell which does so despite being Gen8. And batm3's ethernet
is DOWN — its only working network path is an Intel 7260 over WiFi — so
intel/iwlwifi firmware is what keeps that machine reachable at all.

Also recorded that batm3's nightly upgrade is currently failing. It dies
building the ATM app locally against the 60s nix.settings.timeout,
because the app is in neither aiolabs.cachix.org nor cache.nixos.org.
The timeout comment in flake.nix assumes heavy derivations are
upstream-cached; that holds for nixpkgs and not for our own app. The
machine is therefore pinned to its last successful generation and
nothing merged to dev reaches it. Same class as #98, different mechanism.
The fix is publishing atm-app-* to the cachix, not raising the ceiling.

Noted douro as down, pending a reflash and WireGuard reconnection, and
tejo as still Debian (ubilinux4, kernel 4.9) and never installed with
bitspire — a flake target rather than a deployment. Both matter when
reading "all four models build".
2026-09-24 23:29:07 +02:00
1691511aea perf(deploy): drop the gallium drivers this fleet cannot use
nixpkgs builds Mesa with 21 gallium drivers, the full Vulkan stack and the
VDPAU and VA state trackers, so one binary can serve every GPU and
cross-build case. This fleet is four Intel boards and a kiosk that never
asks for Vulkan.

  mesa closure  974.5 -> 88.5 MiB
  sintra 3940 -> 3201 MB    tejo  3940 -> 3201 MB
  batm3  3918 -> 3179 MB    douro 3894 -> 3156 MB

Keep crocus, i915 and softpipe. softpipe earns its place: it is the
software rasterizer that does NOT use LLVM, so a board whose KMS driver
fails still brings up X slowly rather than dying headless somewhere
nobody can reach it.

IRIS IS OUT, and that is what makes the rest of this possible. Mesa's
meson puts with_gallium_iris in with_driver_using_cl and then

  with_llvm.enable_if(with_clc, error_message : 'CLC requires LLVM')

so asking for iris drags in the OpenCL frontend and with it 540MB of
llvm-lib, and -Dllvm=disabled fails at configure. Nothing here needs iris.
The fleet was surveyed rather than assumed: sintra and tejo are Braswell
[8086:22b0], batm3 is Haswell GT2 [8086:0412], douro is Bay Trail. sintra
and batm3 were read off their running X logs and both say crocus.

That survey corrected an assumption an earlier draft of this commit was
built on. It claimed the UP Boards were the iris machines and crocus was
only for douro and batm3. sintra's Braswell is Gen8 and binds crocus
anyway. Had the list been trimmed to iris on that reasoning, which looked
like the tidier option, sintra would have dropped to software rendering or
lost its display outright.

With iris gone, LLVM goes: verified with patchelf, libgallium.so has no
libLLVM in its DT_NEEDED, not merely absent from the closure listing.
Dropping llvmpipe alone never achieved that.

THE COST IS FUTURE HARDWARE. A newer x86 board — a modern NUC, the "build
it from these parts" kiosk — will need iris, and re-adding it re-adds the
540MB. Until then such a board falls back to softpipe and renders in
software: it boots, it displays, it looks fine, and it is very slow. The
driver list carries that warning. Check `DRI driver:` in /var/log/X.0.log
on any new hardware rather than trusting the list still covers it.

Five secondary failures on the way here, each now a comment where it bites.
Two are nixpkgs' meson hook forcing auto_features=enabled, which turns
Mesa's soft driver guards into hard errors, so gallium-vdpau and gallium-va
must be disabled explicitly once the AMD and NVIDIA drivers are gone. One
is that outputs lists spirv2dxil and cross_tools unconditionally while only
d3d12, asahi and panfrost populate them; nix fails a build that leaves a
declared output unproduced, so they are created empty — and mesa sets
__structuredAttrs, so $outputs is a bash array and the obvious
`for o in $outputs` loop silently does nothing. The last two are the
asahi/panfrost cross tools and install-mesa-clc, which reference
prog_mesa_clc and so must go with LLVM.

Tested on sintra at the previous revision (with iris, 3744MB): X restarted
onto the pruned Mesa, glamor reported hardware acceleration on crocus, no
errors. This revision removes iris and LLVM and has NOT been on hardware
yet. douro and batm3 want their own nixos-rebuild test regardless; batm3
runs a different kernel and douro is the only Bay Trail.
2026-09-24 23:17:28 +02:00
c1ada736d2 feat(deploy): give the upgrade window its own timezone, not the system
An upgrade restarts the app and the Fujitsu dispenser runs an audible
init routine when it does. On sintra that was firing at 10:17 in the
morning, in the room, because `dates = "04:00"` is local time and every
machine inherits America/Guatemala from the shared base config.

The obvious fix is to set the system timezone per machine. This does the
narrower thing instead: systemd 252+ accepts a timezone suffix on a
calendar spec, so the timer follows Europe/Paris and its DST while the
system clock stays a fleet default nobody maintains per host. The only
other consumer of machine-local time is a technician reading the journal
at the machine, and for correlating against relay created_at stamps, UTC
is easier anyway.

The operator dashboard never needed this. It renders timestamps in the
reader's own browser locale, which is why the cassettes tab read
correctly while the journal did not.

Verified by resolving the config for all four hosts, and against
systemd-analyze on sintra itself: 04:00 Europe/Paris is 02:00 UTC in
summer, 04:00 America/Guatemala is 10:00 UTC.

A warning is in the comment because it nearly caught me: the same check
under `nix-shell -p systemd` silently computes EVERY named zone as UTC
while echoing the zone back in its normalized form. It looks accepted and
is wrong. Test on a real system.
2026-09-24 23:11:49 +02:00
645fd57e5b perf(deploy): prune linux-firmware to the hardware bitSpire runs on
hardware.enableRedistributableFirmware installed the entire linux-firmware
tree: 752MB compressed, 16% of the image and its single largest component.
The fleet is four fixed Intel boards. The rest is firmware for Qualcomm,
Mellanox, NVIDIA, Marvell, AMD and MediaTek parts that will never be in
one of these machines.

Keep i915 for the GPU, intel/iwlwifi, rtl_nic, rtw88, rtw89 and brcm for
whatever NIC a given box turns out to have. Turning the option off also
drops the extras it bundles (sof-firmware, libreelec-dvb, alsa-firmware,
intel2200BG, zd1211fw), none of which applies to a soundless kiosk on a
wired Intel board. The regulatory database is normally implied by that
same option so it is now requested explicitly; without it WiFi is pinned
to the most restrictive channel set.

Intel WiFi is 89MB and most of what survives. That is the deliberately
conservative half of the trade: losing the network on a fielded ATM is
not recoverable remotely, and 89MB is cheap next to a site visit.

  sintra 4604 -> 3940 MB    tejo  4604 -> 3940 MB
  batm3  4582 -> 3918 MB    douro 4544 -> 3894 MB

VERIFIED ON HARDWARE. sintra was switched to this and rebooted. It came
back with ethernet up (r8169, RTL8168g), the kiosk running, and no
firmware load failures. Before the reboot, for every module these boards
use, the firmware the kernel declares was confirmed present: i915 44 of
44, r8169 23 of 23, r8152 7 of 7. iwlwifi declares 67 and 28 are absent,
but all 28 are absent from the full upstream tree too, so the module
simply names more files than linux-firmware ships.

The reboot is what earned the intel/fw_sst_* entries. The first boot
after pruning logged

  intel_sst_acpi: Direct firmware load for intel/fw_sst_22a8.bin failed
  with error -2

the Intel Smart Sound DSP that Cherry Trail boards probe at startup. The
audio stack is already gone so nothing was functionally broken, but a
recurring error in a payment terminal's boot log is worth 420KB to
remove: an error people learn to ignore is one they will ignore when it
matters. No static check would have found this — the firmware a driver
requests at probe time is not what modinfo reports.

Two traps found while building it, both carrying comments where they bite:

The symlink loop originally ended in `[ -e ... ] && ln ...`, which makes
the loop's exit status depend on whether the LAST candidate matched. A
non-match returns 1 and set -e fails the build, so whether it worked was
a function of readdir order. It passed standalone and failed once spliced
in.

Kept directories contain symlinks pointing outside themselves: brcm's
blobs are links into cypress/. Left dangling they fail nixpkgs'
compression step, and deleting them would silently drop firmware a device
needs, so the targets get pulled in instead and anything still dangling
is a hard error.

The tree is left uncompressed because NixOS compresses each
hardware.firmware entry itself, zstd or xz depending on the kernel.
Confirmed: sintra gets -zstd, douro's 5.15 gets -xz.

system.forbiddenDependenciesRegexes rejects the upstream package by its
versioned name, so a nixpkgs bump or a stray module re-enabling the
option fails the build instead of quietly putting 750MB back.
2026-09-24 22:52:59 +02:00
425f00a71d perf(deploy): make Electron's GPU flags tunable without a rebuild
The kiosk has launched with --disable-gpu AND
--disable-software-rasterizer since the first ISO commit (19d43c2).
Together those turn off GPU compositing and the SwiftShader fallback,
which leaves Chromium rasterizing every pixel on the CPU. On a Bay Trail
Atom that is expensive, and it is very likely the largest single
contributor to a sluggish UI.

Nothing in git ever justified the pair. There is no comment, no issue and
no commit message about it; the flags arrived with the original hardware
bring-up and were carried through every refactor since. The descriptive
config at /etc/bitspire/config.env has even claimed
ELECTRON_DISABLE_GPU=false this whole time, contradicting the actual
command line. So this looks like bring-up scaffolding rather than a
diagnosed workaround, and it is worth re-testing now that the Mesa work
gives known-good crocus and iris drivers for all three GPU generations in
the fleet.

Testing it by rebuilding is the wrong loop. These are remote machines
with no one at the screen, a wrong flag is a black display, and each
attempt is a large closure copy over WireGuard. So the GPU flags move out
of ExecStart into a shell variable read from /var/lib/bitspire/.env: set
BITSPIRE_ELECTRON_GPU_FLAGS, restart the unit, look at the panel. A bad
value is one edit and a restart away from being undone.

Behaviour is unchanged by default. The variable uses ${VAR-default}, not
${VAR:-default}, so an absent line means today's flags while an
explicitly empty value means no GPU flags at all, i.e. full acceleration.
That distinction is the whole point and is why the .env template ships
the line commented out rather than set: a present-but-empty value would
silently enable the GPU on every machine that regenerates its .env.

The live ISO takes the same launcher via specialArgs, so the ISO and the
installed image cannot drift apart on this.

Closure is unchanged at 4604MB.
2026-09-24 19:13:06 +02:00
e516ab449a perf(deploy): trim systemPackages to kiosk essentials
Every entry here ships to each ATM and eats the eMMC headroom the
nightly nixos-rebuild needs, which is tight enough already that GC runs
at 03:30 purely to clear room for the 04:00 upgrade.

Out: git, at 70MB, since nixos-rebuild fetches the flake with its own
git-minimal that unit-nixos-upgrade.service keeps in the closure, so
auto-upgrade is unaffected. nodejs_22, at 94MB, which nothing runs: the
app is Electron and embeds its own node, and fund-atm references
pkgs-unstable.nodejs by store path. wget, which curl covers. And vim,
replaced by nano.

Keeping an editor at all is deliberate. Field edits to
/var/lib/bitspire/.env happen over ssh, and nano costs a few MB where
vim costs 43. minicom and screen stay for the same reason: the validator
and dispenser sit on ttyJ5 and ttyJ7, those two are how a serial fault
gets diagnosed, and they cost about 2MB between them.

With the two preceding commits the sintra-installed closure goes from
5144MB to 4604MB across 131 fewer store paths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 15:27:17 +02:00
cda3f3f17e perf(deploy): force the unused audio stack off
The block enabling pipewire was commented "for transaction sounds", but
no such sounds exist: nothing under apps/machine or packages/ constructs
an Audio element or ships an audio file. It has been dead weight for as
long as it has been there.

Removing our own `enable = true` is not enough. services.xserver pulls
in NixOS's graphical-desktop module, which mkDefault-enables pipewire
exactly as it does speechd, so the stack survived the first attempt at
this. That is why the line sits beside the speechd mkForce rather than
where the old block was.

Most of PipeWire's dependency chain is shared with the GStreamer that
Electron drags in, and that stays in the closure either way, so this
frees 21MB rather than the whole stack. The remainder comes out with
gtk4/gst, which wants a launch test on the sintra first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 15:27:06 +02:00
73a77c82b3 perf(deploy): stop the app closure retaining its build toolchain
node-gyp leaves its scaffolding beside the addons it compiles, and
several of those files carry absolute store paths to the tools that did
the compiling: build/node_gyp_bins/python3 is an ELF copy of python3
with an RPATH into it, build/config.gypi names python3, nodejs and npm,
the .o.d files under build/Release/.deps name pcsclite's dev output, and
pnpm rewrote a few CLI helpers' shebangs to the full nodejs.

Nix scans $out for store hashes, so each of those became a runtime
reference. Every ATM was carrying python311, nodejs, npm and
pcsclite.dev -- 212MB of closure -- for files nothing reads after the
build. Only build/Release/*.node is ever loaded, through bindings and
node-gyp-build.

Drop the scaffolding, and point the stray shebangs at PATH rather than
deleting files a package might still require. All three addons survive
with their RPATHs intact: better_sqlite3.node, pcsclite.node and the
serialport prebuilds. The derivation's references are now down to bash,
pcsclite.lib and the two gcc runtime libs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 15:26:58 +02:00
48c71f7902 Merge pull request 'Finish the console object-argument sweep' (#108) from fix/log-object-args-sweep into dev
Reviewed-on: #108
2026-09-24 13:09:55 +00:00
d66f50dbdf fix(logging): finish the object-argument sweep
#107 caught three sites by grepping for an object literal as the second
console argument. That pattern misses the more common form, a variable
holding an object, so five more were still landing in the journal as
[object Object]. This time the list came from the machine itself: every
distinct such line in three days of sintra's journal.

The five: the bay list at HAL init, the inventory loaded from state.db,
the inventory pushed to the renderer, the amounts sent to a dispense, and
the access-control audit record. Three sibling sites in the mock and
service paths are fixed too; they had not run recently enough to appear
in the journal but carry the same shapes.

The audit line is the one that mattered. It is the entire record of who
was granted or denied terminal access until #90 persists it to state.db,
and every field of it was being discarded.

formatBays and formatInventory render the denomination/count shapes these
sites share. `count` is optional on a device-config cassette, so an
absent one prints as unknown rather than as zero, which would read as a
drained bay.
2026-09-24 12:26:05 +02:00
74e488cd00 Merge pull request 'Interpolate log fields instead of passing an object' (#107) from fix/renderer-log-object-args into dev
Reviewed-on: #107
2026-09-24 07:31:54 +00:00
a68e462462 fix(logging): interpolate log fields instead of passing an object
Electron's console bridge stringifies each console argument on its way to
the journal, so `console.log('msg:', { a, b })` arrives as
`msg: [object Object]` and every field is lost.

That cost a debugging session today: a cassette-state publish that had
in fact applied an operator refill correctly looked from the journal like
nothing had happened, because the d-tag, event id and stamp were all
inside the object.

Three call sites, the only ones in the renderer passing an object. The
publish line now also carries seq and the applied-op count, which are the
two things worth knowing when an operation seems not to have landed.

Recorded in CLAUDE.md's debugging invariants so it does not come back.
2026-09-23 23:55:13 +02:00
8d2509b092 Merge pull request 'Consume operator cassette operations instead of counts' (#106) from feat/cassette-ops-consumer into dev
Reviewed-on: #106
2026-09-23 21:31:07 +00:00
65f5f982ec docs(adr): record that the operations wire shipped
Decisions 1 through 4 are built on both sides, so the status section says
what landed rather than what is planned, and decision 4a is marked
superseded.

4a is kept rather than deleted. It is the calibration for what a warning
dialog is worth: it depended on an operator reading it at the end of a
refill round, and it could not help at all when the stale value was
already in the form. The dialog and the endpoint behind it are both gone
now, which is a stronger guarantee than any wording could be.

Also records the one gap left open on purpose. An operation recorded
while the relay is unreachable waits for the operator's next action,
because only an operator action triggers a publish.
2026-09-23 12:56:19 +02:00
c889f7f0df feat(cassettes): consume operator operations instead of counts
The machine now owns its bay counts outright. The operator publishes what
it did — a refill in notes added, an empty, a recount, a denomination
change — and this process applies it to the total it already holds.

Both sides used to write the same value over a transport that never tells
a writer it lost. Addressable events order by created_at at second
granularity with ties broken on event id, and a relay returns OK for an
event it then discards, so a dashboard form loaded before a dispense
silently discarded that dispense and neither side could detect it. A
value with one writer cannot be clobbered.

Schema v13 adds cassette_ops, the dedup ledger. A delta applied twice is
wrong and addressable events are re-delivered on every reconnect, so the
operator mints an id per operation and this table records the ones
applied. That also retires the created_at watermark on this path: it was
the only replay defence under absolute counts, but it drops an
out-of-order event whole, operations included, where per-op ids let the
unseen ones through and no-op the rest.

A window is applied oldest-first by `at`, ties broken by id, in one
transaction with the count mutation. A recount then a refill is not the
same as the reverse, and a crash mid-apply must roll back to a coherent
count rather than a partial one.

A malformed op or one naming a bay this machine does not have is neither
applied nor recorded, so it stays pending on the operator's dashboard.
That is the honest outcome. Recording it as applied would stop the noise
by telling the operator their refill landed.

The state document gains applied_ops, seq and schema_version. applied_ops
is the acknowledgement leg — echoing the ids back is the only way the
operator can tell an operation that landed from one merely sent. seq is
bumped on every local count change from any cause, so a reader can reject
a regression without trusting either clock.
2026-09-23 12:55:52 +02:00
9077f9c299 Merge pull request 'docs(adr): record the cassette-state synchronization model' (#105) from docs/adr-cassette-sync into dev
Reviewed-on: #105
2026-09-22 22:26:11 +00:00
7e3112987d docs(adr): record the publish warning, and the live verification
Two things learned after the ADR was first written.

The dashboard's publish dialog already warns that the publish overwrites
the ATM's tracked counts and that decrements since the last baseline will
be lost, and says v2 reconciliation will replace it. That changes how the
gap should be read: a known risk with a human-factors mitigation, not an
oversight, and the product had already reached the same conclusion these
decisions formalise. Worth stating that a warning is the weakest control
available — it depends on an operator reading a dialog, and cannot help
when the stale value is the one already in the form.

Also records that decisions 5 to 8 were verified live on sintra rather
than only by unit test, and which of the listed failures those decisions
do not close.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 00:06:29 +02:00
e4742963a3 docs(adr): record the cassette-state synchronization model
This protocol spans two repos and decides how much cash a machine will
pay out, and its only specification was a closed issue and a chat log.
That is how four separate divergence bugs went unnoticed.

Records what the transport actually permits — an addressable event is an
unconditional overwrite ordered by a second-granularity clock, and a
relay acknowledges an event it then discards, so a losing writer is
never told — and why that rules out compare-and-swap and leads to the
ATM owning the count while the operator publishes operations.

Decisions 5 through 8 shipped in #104 and spirekeeper#44; 1 through 4
are the v2 operations wire and are not yet built.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 23:01:25 +02:00
8c0336c4f6 Merge pull request 'fix(cassettes): close the machine-side divergence paths' (#104) from fix/cassette-sync-machine into dev
Reviewed-on: #104
2026-09-22 20:30:23 +00:00
474903c38b fix(cassettes): say when the counts are unverified instead of reporting a guess
When the dispenser throws or the dispense times out there is no per-bay
report, so nothing is debited — not the cassette rows, not HAL's bays.
Bills may well have reached the customer, and both counters then read
high with nothing to indicate it. The machine went on treating a number
it had reason to doubt as measurement.

A dispense that ends with no report now latches a countsUncertainSince
flag, which rides along in the state document as counts_uncertain_since
so the operator can see the numbers need a recount. The field is
additive: a consumer reading positions ignores it, so this needs no
coordinated release. An operator config apply clears the flag inside the
same transaction, since asserting authoritative counts is precisely what
a recount is.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 22:14:06 +02:00
54c59fadcc fix(cassettes): publish state on every change, and make the stamps monotonic
Three linked failures in one mechanism, so one commit.

The state publish was gated on a one-shot 'have we said hello' flag. It
fired once on first boot and then only after a dispense or an applied
operator config, so any change to the layout itself — a reseed, an
atm-tui edit, direct SQL — was never announced. The operator kept
validating against a bay set the machine no longer had, and a publish
from the dashboard could overwrite a fresh seed (#94). State is now
published on every start.

A publish is one fire-and-forget event with no retry. If the relay was
unreachable at the moment of a dispense, that update was gone until the
next customer bought cash. A five-minute heartbeat makes the channel
self-healing and is also the only way an out-of-band edit to the table
ever reaches the operator.

Addressable events are ordered by created_at at second granularity with
ties broken by lowest event id, and a relay acknowledges an event it
then discards. Two publishes inside one second therefore left the winner
decided by a hash, permanently, and a clock stepping backwards would
have made every report from this machine vanish silently. Each publish
now takes a stamp strictly above the last, recorded in the meta row that
used to hold the gate — same key, no migration, honest name.

Closes #94

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 22:12:07 +02:00
db68e6e244 fix(cassettes): republish after a dispense the renderer didn't run
Two dispense paths bypassed the refresh-and-publish step that cash-out
does. The kind-21003 management command persisted the transaction and
stopped there, and the operator-command poller runs entirely in the main
process, where the renderer cannot see the bays move at all. In both
cases the renderer kept serving a stale inventory and the operator's
cassette view stayed frozen until the next customer cash-out.

The management handler takes an after-hook, and the main process emits
'cassettes:changed' when it mutates the table so the renderer can catch
up. Both land on one helper that reloads the inventory and republishes
the state document.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 22:09:23 +02:00
0d43c4e033 fix(cassettes): report a drained machine as drained
getInventory dropped zero-count bays, so a fully dispensed machine
returned an empty map — identical to a machine with no cassettes
configured. Every caller reads an empty map as "nothing known, ask the
hardware": reloadPersistedInventory skipped the update entirely, so the
last non-empty snapshot stuck and the public availability beacon went on
advertising bills that had already gone out the slot.

Zero-count bays are kept, so an empty map now means exactly one thing:
no cassettes are configured. Consumers already filter for > 0 before
offering a denomination. loadInventoryFromDb returns null when the DB
could not be asked at all (browser dev, failed IPC) so callers can still
tell "no answer" from an answer of "the bays are empty", and only the
former defers to HAL.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 22:07:35 +02:00
5b22447dae fix(cassettes): decrement bays on an operator remediation dispense
recordTransaction only debited the cassette rows for type 'cash_out'.
An operator remediation is recorded as 'manual_dispense', so HAL's
in-memory bays went down while the persisted rows did not — and HAL
re-seeds from those rows on the next boot, so the machine came back
believing it still held bills a customer had already been handed.

A remediation against a partly-dispensed original debits again on
purpose: the original only ever debited what physically left, and this
is a second lot of bills leaving the bay.

Closes #76

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 22:05:42 +02:00
ac27bc36e0 Merge pull request 'fix(deploy): guard the WireGuard peer units too, not just the interface' (#103) from fix/wg-peer-units-guard into dev
Reviewed-on: #103
2026-09-22 18:43:53 +00:00
e3b51eaa37 fix(deploy): guard the WireGuard peer units too, not just the interface
#101 skipped wireguard-wg0 when no key is provisioned, but the module
emits one unit per peer alongside it, and a condition-skipped unit is
not a failed dependency — so the peer unit still ran and died on
'Unable to modify interface: No such device'. Same exit 4 from
switch-to-configuration, different unit, so the nightly auto-upgrade is
still marked failed on an unprovisioned machine (seen on sintra today).

Guard the peers on the same key. Unit names come from the module's own
peers.*.name option rather than re-deriving its escaping here, with the
-refresh suffix following nixpkgs' peerUnitServiceName (a peer's null
interval falls back to the interface's). Verified by evaluation that
every wireguard-* unit in the installed config now carries the
condition, that each is a real unit with an ExecStart, and that the live
image — which mkForce's the interfaces away — still gets none.

Refs #98

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 20:38:35 +02:00
387bed0679 Merge pull request 'fix(deploy): nightly auto-upgrade failed on both ATMs, for different reasons' (#101) from fix/autoupgrade-known-hosts-and-wg into dev
Reviewed-on: #101
2026-09-22 18:28:37 +00:00
71b2691f8c fix(deploy): don't fail activation over an unprovisioned WireGuard tunnel
wg0.key is written per machine after flashing. Until it is, the unit's
`wg set … private-key` exits 1 with 'fopen: No such file or directory',
and one failed unit makes switch-to-configuration exit 4 — which marks
the whole nightly system.autoUpgrade run as failed even though the new
generation applied. sintra has reported a broken updater on that basis
alone; its tunnel was never provisioned and wg0 has never existed.

Skip the unit when there is no key rather than failing activation over
an interface that was never set up. A provisioned machine is unaffected.
Guarded on wg0 still being declared so the live image, which mkForce's
the interfaces away, doesn't inherit a unit with no ExecStart.

Refs #98

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 20:14:46 +02:00
012fecef5e fix(deploy): trust the Forgejo host key so auto-upgrade can fetch
system.autoUpgrade fetches the flake over ssh as root. A machine whose
root has never connected by hand has no known_hosts entry, so the run
dies at 'Host key verification failed' before it even reaches
authentication. batm3 did exactly that, silently, from its 2026-08-06
install until 09-22: six weeks on its install generation while a unit
nobody was watching reported failure every night. sintra only ever
worked because a human had ssh'd as root once and accepted the key.

Declaring the key means a freshly flashed ATM updates from first boot
with no manual step. Verified against the key sintra's root already
trusts.

Refs #98

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 20:14:46 +02:00
aab2d074c3 Merge pull request 'fix(access): instrument Complete, and say when a card can't sell' (#100) from fix/card-complete-blocked-feedback into dev
Reviewed-on: #100
2026-09-22 16:30:57 +00:00
61d5bf0231 fix(access): instrument Complete, and say when a card can't sell
Pressing Complete on a session that fails leaves nothing in the journal:
the decline path has no logging, so a failed sell is indistinguishable
from a button that never fired. Add the telemetry that was missing —
completeWithCard logs outcome, duration and reason, and the entry line
now says whether selling is available at all.

Two real defects alongside it. A session whose withdraw step the card
server withheld (daily limit spent, card disabled) still rendered a
Complete Sale button that could only ever fail; it now shows the
server's reason instead. And the decline path advised 'tap your card to
try again' even for refusals a re-tap cannot lift, so 'blocked' is now
its own outcome: nothing was consumed, the session stays loaded, and no
re-tap is suggested.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 18:29:58 +02:00
67763d6e8d Merge pull request 'fix(cash-out): settlement watch missed one-tap payments' (#99) from fix/cashout-settlement-race into dev
Reviewed-on: #99
2026-09-22 16:29:35 +00:00
67573008ee fix(machine): surface a payment taken with no cash dispensed
When a card accepted a cash-out pull and settlement never confirmed, the
machine returned to the amount screen as though nothing had happened —
the customer's wallet had paid and there was nothing on screen or in the
journal to say so. Latch that transition, log it with the txid, and show
a red notice naming the reference an operator can reconcile against.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 18:16:06 +02:00
ebb07ce22d fix(lightning): arm the cash-out settlement watch before the invoice is shown
A one-tap Bolt Card Complete settles in about a second. Subscribing took
two sequential nostr round trips first — decode_payment to recover the
hash, then subscribe_payments — roughly eight seconds against a remote
relay, because the watch was armed when the invoice was DISPLAYED. The
settlement push is an ephemeral event with no replay, so it fired before
anything was listening: the machine sat on a paid invoice until it timed
out and the customer's sats were taken with no cash dispensed. On sintra
2026-09-22 this hit both one-tap sells (26,660 and 26,500 sats). The old
two-tap flow only ever worked because fumbling with the card covered the
window; at 07:18 the push landed two seconds after the watch went live.

Three layered defences, one mechanism:
- Arm at creation. generateInvoice does not resolve until the watch is
  live, so the invoice cannot reach the screen unwatched.
- Take the payment hash from the create_invoice response instead of
  decoding it back off the bolt11 — the value was already in hand and
  the round trip was half the window (repo guidance says as much).
- Latch and poll. A settlement that still beats the consumer is replayed
  on attach, and get_payment runs alongside the subscription so a push
  that is lost or never sent cannot strand a payment either.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 18:16:06 +02:00
da4969d510 Merge pull request 'fix(access): session Complete failed on IPC clone; keep rate lookup off the unlock path' (#97) from fix/boltcard-session-ipc-clone into dev
Reviewed-on: #97
2026-09-22 14:02:47 +00:00
54244a4708 Merge pull request 'fix(machine): keep the mouse pointer visible on the web demo' (#96) from fix/demo-cursor-visible into dev
Reviewed-on: #96
2026-09-22 12:59:55 +00:00
aa22ba1c27 fix(machine): keep the mouse pointer visible on the web demo
index.html's inline <style> hid the cursor on any viewport >= 1024px:

    @media (min-width: 1024px) { html, body { overflow: hidden; cursor: none; } }

which is every desktop browser opening the public demo. Descendants inherit
it, so the pointer vanished everywhere except over buttons — those carry
Tailwind's .cursor-pointer, which overrode the inherited value and made the
bug look stranger than it was.

This is the rule the earlier .kiosk scoping missed: src/style.css got gated,
this one did not, so the two disagreed.

Delete it rather than gate it. src/style.css's `.kiosk, .kiosk *` rule already
covers <html> and every descendant with !important, and main.ts applies that
class unless VITE_DEMO_TAG is set — so real machines are unaffected and cursor
hiding now has exactly one owner, the one that knows whether this is a kiosk.
The block here cannot make that call: it is static HTML, and the page CSP
(script-src 'self') forbids an inline script that could read the env.

overflow: hidden stays as it was — untouched on both.

Verified by building both ways: without the tag the built HTML has no cursor
rule, the JS still adds .kiosk and the CSS still carries the !important
hide; with the tag the .kiosk branch is dead-code-eliminated and no
cursor: none survives anywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4
2026-09-22 14:54:57 +02:00
8e11c41f62 perf(access): keep the rate lookup off the unlock path, log entry timing
Tap → unlock took ~3 s on sintra. The card server's /session now fills
fiat only from its warm rate cache (aiolabs/boltcards
fix/session-fiat-from-cache); when it returns a currency with fiat null,
the store prices the balance in that currency from the ATM's own rate
source after the unlock, so the chip still shows the wallet's currency.
Log how long the session call took and whether the server priced it, so
the next latency question can be answered from the journal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 14:24:13 +02:00
42e3657fe1 fix(access): pass plain objects over IPC for session Complete
At Complete the store handed the session's withdraw/pay step to
window.electronAPI straight out of the loadedBoltCard ref — a Vue
reactive proxy — and Electron's structured clone refused it:
'[ATM] Bolt Card withdraw failed: Error: An object could not be cloned.'
(sintra, 2026-09-21 06:51). The customer had to re-tap, which works
because the direct-tap path passes a plain string. Copy the steps field
by field into plain objects before they cross the bridge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 14:24:13 +02:00
d304e59ef0 Merge pull request 'fix(idle): drop the redundant "Available: N sats" line' (#95) from fix/idle-drop-available-balance into dev
Reviewed-on: #95
2026-09-20 22:33:12 +00:00
a497f0ca08 fix(idle): drop the redundant 'Available: N sats' line from the centre
The machine's balance already sits in App.vue's top-right status chip.
Repeated in the centre — right above the holder's card chip on a
tap-to-enter session — it reads as *their* balance ('Available: 0 sats'
next to a card showing 959,242 sats). Keep only the buy/sell rates there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 23:34:45 +02:00
3efbdf164b Merge pull request 'feat(access): verified Bolt Card session at entry + hidden-by-default balance' (#93) from feat/boltcard-session-balance into dev
Reviewed-on: #93
2026-09-20 15:16:41 +00:00
ec2b15c08f docs: Bolt Card session contract, ADR-003 amendment for verified entry
docs/boltcard-session.md is the /session wire contract (sibling of
boltcard-receive-resolver.md), including the trust boundary: the session
URL is derived from the card's own host, so open enrollment is still not
a security boundary (#91). ADR-003's amendment now records verified entry
via /session and the hidden-by-default balance display.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 17:07:17 +02:00
6779d8ef55 feat(machine): show the card holder's balance, hidden by default
A tap-to-enter session is effectively the holder logging into their card
wallet, so show its balance — but a kiosk in a public place must not
display a stranger's balance unasked. CardChip renders the card label
with the balance masked (••••••) and an eye toggle; revealed, it mirrors
the LNbits wallet page: sats, then the fiat equivalent formatted with
Intl currency style. Fiat comes from the card server (the wallet's own
currency, else the instance default, at its rate) and falls back to the
ATM's fiat at its display rate when the server priced nothing. Shown on
the idle menu and both cash screens; reveal state resets on re-lock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 17:07:17 +02:00
735132b032 feat(machine): open a verified Bolt Card session at tap-to-enter
Entry now spends the tap's single-use SUN once, on the card server's new
/session endpoint (aiolabs/boltcards feat/card-session-endpoint), instead
of parsing the lnurlw locally and deferring every check to Complete. The
server proves a genuine, non-replayed card and returns the wallet balance
plus the hit-keyed LUD-03 withdraw and LUD-06 pay second steps — the same
single-use bearer /scan and /pay hand out — so Complete still needs no
second tap and the ATM holds no p/c for the visit.

- electron/boltcard-session.ts: /scan → /session URL derivation, response
  parsing, 404 → 'card server does not support sessions'.
- lnurl-withdraw / lnurl-pay: the second steps are now callable on their
  own (executeWithdrawCallback, resolveInvoiceFromPayStep); the tap paths
  are unchanged and reuse them.
- IPC: lnurl:open-card-session, lnurl:withdraw-session, lnurl:pay-session.
- store: handleBoltCardEntry opens the session then authorizes the
  server-returned external_id; the payment handlers take a source (raw
  tap or session); a withheld withdraw step declines with the server's
  reason. The boltcard AccessScan no longer carries the lnurlw.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 17:07:16 +02:00
ce4b5a5dc6 Merge pull request 'fix(access): single-shot loaded card + ADR-003 amendment' (#92) from fix/access-gate-followups into dev
Reviewed-on: #92
2026-09-20 14:41:44 +00:00
f11aced450 chore(access): prune unwired readers, amend ADR-003 for what shipped
ADR-003, .env.example, the access module's headers and the provisioning
schema still described the planned npub-QR → UID → serial-reader path.
What shipped (#86) is Bolt Card tap-to-enter over the main-process
pcscd reader with external_id as the identity, soft entry and
verify-at-payment. Nothing ever called availableAccessReaders(): the
camera npub-QR reader, the mock reader and the AccessReader seam were
dead, so they go; services/access now holds authorize, the card parser
and the credential types. The unused 'uid' scan variant goes with them;
'npub' (+PIN) and the 'challenge' seam stay.

The ADR gets an amendment section recording the differences, including
that open enrollment is not a security boundary and that the audit is
still a stub (both tracked as issues).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 15:16:59 +02:00
a652089441 fix(access): drop the loaded Bolt Card after its first Complete attempt
The card loaded at tap-to-enter carries one SUN p/c pair, and the
boltcards server bumps the counter on the first GET. After a declined
Complete (limit below amount, callback failure, payment error) the
stored lnurlw can never succeed again, yet it stayed loaded with a
Complete button that would keep failing. The same held on the success
path when the payment never settled (cash-out invoice timeout back to
selectingAmount).

The tap handlers now report skipped / accepted / declined, and
completeWithCard clears the card after any real attempt, appending
'tap your card to try again' to a decline. A skipped outcome (guard
bounced it, no server call) keeps the card. A fresh tap on the cash
screen goes through the normal tap-to-pay/receive path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 15:16:47 +02:00
f84cad76a9 Merge pull request 'feat(access): Bolt Card tap-to-enter access gate (ADR-003)' (#86) from feat/access-control-skeleton into dev
Reviewed-on: #86
2026-09-20 13:16:22 +00:00
04767080a1 fix(access): dev unlock defaults OFF
ACCESS_DEV_UNLOCK was opt-out (anything but 'false' enabled it) and
access.example.json shipped it on, so a gated production machine would
render a visible gate-bypass button on the lock screen by default. Flip
to opt-in (=== 'true'), update the example file and the provisioning
schema comment to match.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 14:11:38 +02:00
5114619fce fix(access): only accept END_SESSION from idle so the session cap can't strand funds
The root-level END_SESSION let the 10-minute hard cap (and the End Session
button) jump to `locked` from any state, bypassing the money-path guards
the machine already has: confirmAbandon with bills stacked, an in-flight
dispense, an outbound cash-in payment. Cap fires at minute 10 while a
customer's bills sit in the stacker → locked → next unlock resetContext
wipes them unpaid; during dispensingCash the done-event is dropped and
no transaction record is written.

Nothing is lost by scoping it: every transaction terminal state already
targets #atm.locked on this branch, so the machine re-locks on its own
when the transaction ends. END_SESSION now lives on idle.on only, and
useSessionSecurity defers both deadlines until currentState is idle —
an expired session re-locks on the first tick back at the menu.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 14:11:38 +02:00
82fbf12950 fix(access): reset idle timer on activity + add hard session cap
The idle re-lock was an XState `after` on `idle`, which is anchored to
state ENTRY and never reset on screen touches — so it fired a fixed 60s
countdown regardless of interaction (reported: touching the screen
didn't extend the session). The machine can't observe raw pointer
events, so inactivity can't be measured there.

Move session timeouts to the DOM layer (useSessionSecurity, mounted in
the always-on App shell), enforcing two fail-closed limits that both
re-lock via a new root-level END_SESSION transition:

- SOFT idle (60s): re-lock after no *trusted* pointer/touch/key input
  while on the idle menu; resets on every genuine interaction. Scoped to
  idle so it never interrupts an in-flight cash-in/out.
- HARD cap (10min): absolute ceiling from unlock time, never reset — a
  forgotten/relayed card can't hold a session open. Lives at the machine
  root so it can lock mid-transaction, not just from idle.

Security posture: only event.isTrusted resets the soft timer (synthetic
events can't keep a session alive); wall-clock deadline checks re-lock
immediately after a suspend/resume rather than silently extending;
one-shot disarm-on-fire prevents spin; END_SESSION is guarded to the
active gate so it's inert when the gate is off.

Machine no longer owns the idle timer; tests updated (END_SESSION
re-locks from idle and from an in-flight cash-out; no-op when disabled).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
2026-09-19 10:34:45 +02:00
4ce68c1301 fix(access): put End Session ✕ left of Help, solid destructive red
Per on-device review: order the top-left group ✕ then ?, and use the
`destructive` button variant so the exit swatch is a solid, theme-aware
red (--destructive is scoped per colorscheme) rather than a subtle
outline that didn't read as an exit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
2026-09-19 10:34:45 +02:00
fbcaa3121f fix(access): move End Session to a red ✕ beside Help (top-left)
The top-right "End Session" button overlapped the centered balance /
commission chips, which wrap into the top-right corner on narrower
screens (sintra). Relocate it to a minimal red ✕ icon button grouped
next to the "?" help button in the top-left, clear of the chips. Same
endSession() behavior; shown only while the gate is active.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
2026-09-19 10:34:45 +02:00
d35faf1c93 feat(access): add End Session button to re-lock a tap-in session
A Bolt Card tap loads the holder's card for the whole session, so an
unattended idle menu is transactable by the next person until the 60s
IDLE_LOCK_TIMEOUT fires. Give the holder an explicit re-lock:

- END_SESSION event on `idle`, guarded to the active gate, targets
  `locked` (whose entry already clears the access session + loaded card).
  No-op on a gate-disabled machine that rests at idle.
- endSession() store action; IdleView shows a destructive-styled
  "End Session" button top-right only while accessControl.enabled.
- Tests: END_SESSION re-locks when the gate is active; no-op when off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
2026-09-19 10:34:45 +02:00
c83b40fe5e feat(deploy): enable pcscd on upboard (sintra/tejo) for NFC gate
The access gate (ADR-003, #86) only wired services.pcscd + the pcsc
polkit rule into batm3.nix, so the tap-to-enter reader was invisible on
upboard machines. Port the same device-agnostic wiring to upboard.nix
(HID Global OMNIKEY 5022, 076b:5022) so the gate works on the sintra dev
unit — and on tejo — when #86 lands on dev and the nightly upgrade pulls
it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
2026-09-19 10:34:45 +02:00
Patrick Mulligan
6676c26761 feat(access): auto re-lock the idle menu after inactivity
An unlocked session left unattended (card tapped in, no transaction) stayed
at idle indefinitely, so anyone could then transact on the loaded card. Add
an IDLE_LOCK_TIMEOUT (60s) after-transition on idle → locked, guarded by
accessGateActive so a gate-disabled machine (which rests at idle) never
re-locks. Selecting cash-in/out leaves idle and cancels the timer; the store
clears the loaded Bolt Card on re-lock. Transaction flows already re-lock on
their own inactivity timeouts.
2026-09-19 10:34:45 +02:00
Patrick Mulligan
44a5ebbd12 fix(access): reset router to home on re-lock so the reopened gate lands on idle
After a transaction with the gate enabled, the machine re-locks (cashOut/
cashIn → locked, not idle), so the view's isIdle watch never fires and the
router stays on /cash-out|/cash-in. When the next tap reopens the gate to
idle, the stale transaction view showed (e.g. a completed sell's collect
screen, stuck). Reset the route to / while locked (router-view hidden under
LockedView) so idle renders IdleView.
2026-09-19 10:34:45 +02:00
Patrick Mulligan
c7e312a63f feat(access): Bolt Card tap-to-enter — load card into session, one-press Complete
Builds on the access gate: a single Bolt Card tap at the locked screen both
unlocks the terminal AND pre-loads the card, so buy/sell just need "Complete"
— no second tap. Reuses the #83/#84 payment paths verbatim.

Soft entry, verify-at-payment: the tap is parsed LOCALLY (external_id only) so
the single-use SUN p/c stay valid; the cryptographic check happens at Complete
when the stored lnurlw actually moves sats (withdraw for sell, lnurlp-pay for
buy). Open-enrollment, card-only (no npub-QR, no PIN) per product decision.

- services/access: `boltcard` credential (externalId + lnurlw) in the AccessScan
  union; canonicalId + open-enrollment/allow-list authorize; parseBoltcardLnurlw
  (local, no server). Only external_id is hashed — p/c never enter authorize.
- store: loadedBoltCard (session-scoped, cleared on re-lock); handleBoltCardEntry
  (tap while locked → authorize → grant + load); completeWithCard (routes to the
  existing tap handlers); NFC listener routes locked→enter.
- LockedView: card-only "Tap your Bolt Card" screen (dropped camera/npub-QR/PIN).
- CashIn/CashOutView: "Complete Purchase/Sale" button + card chip when loaded.
- tests: boltcard authorize + parseBoltcardLnurlw (17 access tests total).

Enabling the gate is a provisioning step (access.json enabled+openEnrollment);
other machines default off → unchanged.
2026-09-19 10:34:45 +02:00
Patrick Mulligan
a7b409b109 feat(access): access-control gate — npub-QR badge + PIN + dev bypass (ADR-003)
Squashed skeleton (was 11 commits on feat/access-control-skeleton) for a
clean rebase onto dev. Adds a `locked` gate the terminal boots into until a
credential is presented; opt-in and non-breaking (defaults off → boots
straight to idle as before).

- state-machine: `locked` state + ACCESS_GRANTED/ACCESS_DENIED/DEV_UNLOCK
  events + accessBypass/devUnlockAllowed guards (packages/state-machine).
- services/access: reader abstraction, npub+PIN authorize() (nostr-tools
  nip19; accepts nostr:/nprofile), camera npub-QR reader, mock reader.
- LockedView.vue + ColorModeToggle: branded viewfinder, PIN pad, denied
  reason, dev-unlock; camera off-by-default + idle return.
- store/main/electron.d.ts: seed gate config, grant/deny/devUnlock wiring,
  access.json provisioning (no rebuild), get-config surface.
- deploy: access.example.json + provision-access.sh; ADR-003.

Credential union is npub today; UID (NFC tap) is the next step.
2026-09-19 10:34:45 +02:00
2ea3df01d1 Merge pull request 'chore: scrub "Lamassu" from shipped labels' (#89) from chore/scrub-lamassu-labels into dev
Reviewed-on: #89
2026-09-19 08:18:21 +00:00
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
59e8a9de02 Merge pull request 'feat: support a public browser demo of the kiosk' (#88) from feat/web-demo into dev
Reviewed-on: #88
2026-09-06 18:04:50 +00: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
ac40ea9bb6 feat(lnbits): wrap the create_wallet RPC
The transport has exposed `create_wallet` (AUTH_ACCOUNT) since the RPC
registry was written, but LnbitsClient never wrapped it — the ATM only ever
needed the auto-created default wallet from `list_wallets`.

Add `createWallet(name)` plus its `CreatedWallet` reply type. Account-scoped,
so the envelope deliberately carries no `wallet_id`: that absence is what
makes the server resolve auth to the Account rather than a Wallet. Not
wrapped in `idempotent()` — a retry would mint a duplicate wallet, same
reasoning as create_invoice.

The reply carries the new wallet's adminkey/inkey, hence the type-level note
not to log it verbatim.

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
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
20dbc8ca80 fix(deploy): provision-atm.sh writes relay/pubkey only on explicit override (#70)
The script unconditionally wrote VITE_RELAY_URL + VITE_LNBITS_SERVER_PUBKEY (and
hard-exited if it couldn't scrape the pubkey), env-pinning every provisioned
machine and defeating the seed — the same bug as the activation default. Make it
seed-first: with a SPIRE_SEED, relay + pubkey come from the seed and are written
only when the operator explicitly passes RELAY_URL / LNBITS_SERVER_PUBKEY as a
deliberate pin. The no-seed dev-nsec path still scrapes/defaults them. Also drops
the unused VITE_LNBITS_HTTP_URL line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:53:51 +00:00
7896c122da fix(deploy): relay + LNbits pubkey are seed-provided, not env-pinned (#70)
The bitspire-env activation seeded VITE_RELAY_URL from the relayUrl option
(default wss://relay.aiolabs.dev). Because env wins over the pairing seed, every
fresh machine pinned itself to that relay — which is dead — so a scanned seed's
relay was ignored ("No connected relays"; hit live on the aio-demo USB). Default
relayUrl to "" so both relay and server pubkey come from the seed; a non-empty
option now pins a machine (an explicit override) rather than being the default.
Descriptions updated to match.

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
5179a21da6 fix(nostr-client): reject non-ws(s):// relays in the spire seed
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>
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
a73f345606 Merge pull request 'deploy: bootable slim Sintra image + shared UP Board serial hardware' (#72) from deploy/sintra-boot-hardware into dev
Reviewed-on: #72
2026-07-02 21:53:28 +00:00
7e90719508 refactor(deploy): share UP Board serial hardware between installed + live ISO
The sintra live ISO (live.nix) had no serial support — ftdi_sio and the
ttyJ5/ttyJ7 udev symlinks were only in hardware/upboard.nix (installed), so
booting iso-sintra on real hardware failed on the validator + F56 dispenser
while the disk image worked. The two definitions had already drifted (live's
tejo block lacked ttyS4).

Extract the UP Board serial peripherals (usbserial/ftdi_sio/cp210x, the
ttyJ4/ttyJ5/ttyJ7 udev symlinks + permissions, console=tty0) into
hardware/upboard-serial.nix and import it from both upboard.nix (installed
tejo + sintra) and live.nix (sintra only). Single source of truth — the two
artifacts can't drift again. Named upboard-serial (not sintra-serial) since
upboard.nix serves both tejo-installed and sintra-installed.

Camera + LED/SPI rules stay inline in upboard.nix (installed-specific; the
pairing camera works via getUserMedia without the scanner symlink). Verified
by eval: live sintra now carries ftdi_sio + console=tty0 + ttyJ7; installed
sintra/tejo unchanged (serial present, camera present, no console dupe).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:31:36 +02:00
c8745addbc fix(deploy): make disk-image-sintra-usb BIOS+UEFI bootable (GRUB)
The USB disk image was systemd-boot (UEFI-only) with make-disk-image's "efi"
table (pure GPT + ESP, protective MBR). The Sintra's Aaeon UP Board firmware
USB-boots in Legacy/BIOS mode — it boots the live ISO via that ISO's isolinux
(BIOS) El Torito image, not the UEFI ESP — so a dd'd systemd-boot image has no
BIOS boot code to execute and the firmware won't list it (a hand-added hybrid
MBR didn't help: nothing to run).

Switch the USB target to GRUB with BIOS + UEFI on make-disk-image's "hybrid"
table: it adds a bios_grub partition, GRUB writes its BIOS stage to the MBR AND
a removable /EFI/BOOT/BOOTX64.EFI — mirroring the live ISO's dual boot. The
Aaeon now lists it (as two "ia android" entries, BIOS + UEFI) and boots it.
Scoped to disk-image-sintra-usb only; the eMMC install keeps systemd-boot.
ESP stays partition 1 so the ESP-USB relabel step is unchanged.

Verified on hardware: booted from USB into the wizard with the full upboard.nix
hardware config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:31:36 +02:00
5a420119df perf(deploy): slim the kiosk closure (disable TTS, Qt, docs)
The disk image was ~6.2 GiB of closure, largely desktop/multimedia baggage a
single-purpose Electron kiosk never uses. Cut the clearly-unused stacks:

- services.speechd off → drops speech-dispatcher's espeak-ng + mbrola voices
  (~1 GB text-to-speech). An ATM does not talk.
- v4l-utils built withGUI=false → drops the entire Qt6 stack (~0.5 GB) that only
  backed the qv4l2 GUI; the v4l2-ctl CLI we actually use for the camera stays.
- documentation off (man/info/NixOS manual) — nobody reads them on a kiosk.

Closure 6.2 → 5.0 GiB. The remaining bulk is electron's own runtime (gtk4/
gstreamer/pipewire, unavoidable), mesa+llvm (GPU), and linux-firmware — those
need heavier / riskier work to touch. Distribute the image as .img.zst.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:31:36 +02: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
b2099c7d48 fix(deploy): make the live ISO boot cleanly
Fresh live boots hit a cascade of activation/unit failures because live.nix
shared installed-system config that assumes persistent state:

- bitspire-env chowned /var/lib/bitspire/.env to bitspire:bitspire in the
  default activation order, before `users` runs, so on a fresh boot (no .env
  yet) it failed with 'invalid user'. Move to the attrset form with
  deps=["users"]. (Installed systems skip the block since .env exists.)
- swapDevices=/var/swapfile lives in the live tmpfs and fails to init —
  replace with zramSwap for the low-RAM models' OOM cushion.
- wg0 needs a provisioned key the live boot lacks; it failed and dragged
  network-setup down. Drop the interface on live.
- display-reset runs `xrandr --output eDP-1`, but the Sintra drives HDMI-1
  (no eDP-1) — gate the service off for sintra.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 23:47:31 +02:00
ce649947f6 feat(deploy): add disk-image-sintra-usb with distinct partition labels
A USB-bootable Sintra image variant for booting on a machine whose eMMC
already holds a nixos/ESP-labelled install. make-disk-image hardcodes the
root/ESP labels (nixos/ESP); booting the standard image from USB next to
the eMMC races stage-1's by-label/nixos between the two roots and likely
mounts the eMMC. This variant labels root nixos-usb (via make-disk-image
-L) and relabels the ESP to ESP-USB in a post-step (mtools), with
fileSystems pointed at the new labels. Auto-upgrade is disabled — it's a
portable test / hand-off image, and that also removes scheduled bootloader
writes that could land on the eMMC's ESP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 23:47:18 +02:00
482e0549b9 fix(deploy): make live ISOs USB-bootable (isoImage.makeUsbBootable)
The live ISO config set makeEfiBootable + makeBiosBootable but omitted
makeUsbBootable, so the image got BIOS+UEFI El Torito boot catalogs but
no isohybrid MBR/GPT — i.e. no partition table. dd'd to a USB stick it
shows iso9660 on the whole device with no ESP, and picky firmware (the
Sintra's Aaeon UP Board) won't recognise it as bootable, falling back to
its android-ia entry. Enabling makeUsbBootable applies the isohybrid MBR
(isohdpfx.bin) + GPT/ESP. Fixes USB boot for every model's live ISO.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 22:12:28 +02:00
962214ec15 fix(deploy): point Sintra autoUpgrade at aiolabs/bitspire, not lamassu-next
The dev autoUpgrade flake URL still referenced the pre-migration repo
(aiolabs/lamassu-next), so the Sintra test unit would auto-pull
lamassu-next/dev at 04:00 — which lacks all the bitspire work (the
QR-pairing wizard, etc.) and would revert the box to the old no-seed
build. Repoint it at aiolabs/bitspire?ref=dev, the post-migration home
of this code. lamassu-next still feeds the not-yet-converted production
ATMs (batm3, douro) until they migrate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:40:46 +02:00
a957433eca Merge pull request 'feat(machine): on-machine QR-pairing wizard' (#68) from qr-pairing-wizard into dev
Reviewed-on: #68
2026-06-24 22:40:15 +00: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
d32ddc806a build(nix): bump pnpmDeps hash for the qr dependency
Adding `qr` (and dropping `jsqr`) changed pnpm-lock.yaml, invalidating the
fixed-output hash for the vendored pnpm store. Without this the NixOS build
of the ATM app fails at the FOD before activation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:21:10 +02:00
b9340f7754 docs(machine): document the on-machine QR-pairing wizard
Note the wizard flow next to VITE_SPIRE_SEED + a dedicated Pairing section
(aiolabs/bitspire#52): unpaired → scan seed off camera → persist + relaunch →
normal boot pairs. Records the qr-over-jsqr choice rationale by reference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:08:24 +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
14d62e4c34 Merge pull request 'fix(machine): guard the availability beacon sign against bunker blips' (#67) from beacon-sign-guard into dev
Reviewed-on: #67
2026-06-22 13:56:30 +00: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
14ccbfbeda Merge pull request 'feat: secure cash-in via server-stamped create_withdraw RPC (#52)' (#66) from cash-in-create-withdraw into dev
Reviewed-on: #66
2026-06-22 13:55:59 +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
f4e7dcc99e Merge pull request 'fix(machine): republish cassettes-state after dispense + on reload' (#65) from cassette-state-republish into dev
Reviewed-on: #65
2026-06-22 09:56:39 +00: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
3064acf217 Merge pull request 'Phase D follow-up: retry-policy switch for idempotent reads (#52)' (#64) from phase-d-retry-switch into dev
Reviewed-on: #64
2026-06-22 09:41:01 +00:00
2a64b42cde feat(lnbits): retry-policy switch for idempotent reads (Phase D)
The retry half of the 2026-05-26 error-handling agreement (aiolabs/bitspire#52).
`withRetry` retries an operation per the disposition of the error it throws —
LnbitsRpcError.retryPolicy (operator_signer_unavailable/rate_limited →
backoff, internal_error → retry-once) plus transport timeouts — and rethrows
terminal/unknown errors immediately.

Applied ONLY to idempotent reads (getWallet/getBalance/listWallets/getPayment/
decodePayment + the lnurlw read methods). create_invoice / pay_invoice /
lnurlw_create_link are deliberately NOT wrapped — a blind retry would mint a
duplicate or double-pay; their errors surface for flow-level handling. This is
why the switch lives at the per-call read layer, not as a blanket client retry.

Safe to land before lnbits emits error_code: an absent code already maps to
internal_error (retry-once), so reads get one transparent retry on a transient
blip with no behaviour change otherwise. 10 tests (backoff/terminal/timeout/
unknown/onRetry).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 15:35:35 +02:00
212ba9bbc6 Merge pull request 'Phase E: provision VITE_SPIRE_SEED for bunker pairing (#52)' (#63) from phase-e-provisioning into dev
Reviewed-on: #63
2026-06-21 12:56:01 +00:00
8a02d72bd1 feat(deploy): provision VITE_SPIRE_SEED for bunker pairing (Phase E)
provision-atm.sh now writes VITE_SPIRE_SEED (the spire-seed:v1: pairing seed
from spirekeeper) as the production identity, validating the scheme prefix;
the generated nsec path is kept only as a dev fallback when SPIRE_SEED is
unset. Relay default moved to the LNbits bundled nostrrelay
(ws://$HOST_IP:5001/nostrrelay/test). .env templates (live.nix + the flake's
installed-default) swap VITE_ATM_PRIVATE_KEY → VITE_SPIRE_SEED and drop the
dead LP-era vars. README notes state.db now also holds the bunker binding
(keep it or re-pair).

Part of Phase E, aiolabs/bitspire#52. Unblocks the Sintra live-pairing smoke.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:49:30 +02:00
904dae5a17 Merge pull request 'Phase D: typed LNbits error codes + re-pair UX (#52)' (#61) from phase-d-rekey-ux into dev
Reviewed-on: #61
2026-06-21 10:43:12 +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
b0ac34ee01 feat(lnbits): typed nostr-transport error codes + retry policy
Implements the error-handling layer agreed in the 2026-05-26 cross-session
handshake (aiolabs/bitspire#52). LnbitsClient now rejects ERROR responses
with a typed LnbitsRpcError carrying the machine-readable code + its retry
disposition, so callers (and the state machine, Phase D.3) branch on
disposition rather than string-matching the human-readable message.

- error-codes.ts: LnbitsErrorCode (14 codes, signer/transport/app classes)
  mirroring the lnbits canonical enum; retryPolicyFor() classifier;
  LnbitsRpcError.fromResponse().
- error_code is optional-additive on the wire: an absent or unknown code
  maps to internal_error (retry-once), so this is safe to land before lnbits
  emits codes — no string-matching, no special parser paths.
- invoice_already_paid is flagged terminal-idempotent (isIdempotentSuccess)
  for the cash-out resume-after-reboot case.

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
6281c811f6 Merge pull request 'Phase C: resolve signer from spire seed / bunker binding at bootstrap (#52)' (#60) from phase-c-bunker-bootstrap into dev
Reviewed-on: #60
2026-06-21 10:35:56 +00:00
09ed5e95de docs(nostr-client): TTL expiry is now a post-bind deauth cause
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>
2026-06-19 23:19:58 +02: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
40239aa075 refactor(clink): route CLINK signing + encryption through the Signer
Swap CLINKClient's MachineIdentity for the Signer abstraction: sign_event /
nip44 now go through the signer (async), so the spire identity can live in a
NIP-46 bunker. The kind-21003 management path (operator-driven manual
dispense, the one live CLINK path on dev) decrypts as the spire via the
bunker; the dormant offer/debit paths are migrated too so they're
bunker-ready when CLINK is re-implemented for the upcoming ndebit/k1 spec
(shocknet/CLINK#7, #8).

Part of Phase C, aiolabs/bitspire#52.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:15:17 +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
9c9009af31 feat(nostr-client): NIP-46 bunker signer + spire pairing seed
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>
2026-06-18 23:24:22 +02:00
787de5bff1 refactor(nostr-client): retire dead NIP-44 v1 / Lightning.Pub path
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>
2026-06-18 19:57:02 +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
627d5e63e5 docs(adr): ADR-002 remote access & fleet management — three planes, NetBird
Separate payment (Nostr↔LNbits, SaaS-operator-owned), fleet control
(Nostr #42, machine-operator-owned), and access/recovery (SSH) planes
by trust owner. Recovery access is provisioned at install and
app-independent. Adopt NetBird for the access plane (scale + fully FOSS
self-hostable control plane; rejects Tailscale's closed control plane).

Reject a dashboard 'revoke SaaS-operator access' toggle as a false
promise — the SaaS operator controls LNbits and the default control
plane, so exclusion is by ownership (operator self-hosts), not by
toggle.
2026-06-14 11:17:02 +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
158 changed files with 13532 additions and 7662 deletions

View file

@ -175,7 +175,7 @@ From git commits since last release:
### Suggested Updates ### Suggested Updates
1. `api/clink.md:45` - Add `timeout` parameter to createOffer 1. `api/clink.md:45` - Add `timeout` parameter to createOffer
2. `guides/development.md` - Add LIGHTNING_PUB_URL env var 2. `guides/development.md` - Add VITE_BITSPIRE_CASSETTES env var
### Missing Documentation ### Missing Documentation
- `packages/cashu/src/wallet.ts` - No API docs - `packages/cashu/src/wallet.ts` - No API docs

View file

@ -2,9 +2,9 @@
## Purpose ## Purpose
Validate HAL driver implementations in `packages/hal/` against published hardware protocol specs (JCM ID003, Fujitsu F56 DLE/STX, Puloon LCDM, MEI EBDS, etc.) and against the v8.1.5 release line of `lamassu-machine` — which is the **last** lamassu-machine release published under a fully-open license. Validate HAL driver implementations in `packages/hal/` against published hardware protocol specs (JCM ID003, Fujitsu F56 DLE/STX, Puloon LCDM, MEI EBDS, etc.) and against the public-domain tree of `lamassu-machine` (commit `c0b69d1` and earlier) — which is the **last** lamassu-machine release published under a fully-open license.
> **Provenance boundary.** Drivers in `packages/hal/` derive from `lamassu-machine` at v8.1.5 and earlier (plus hardware-vendor protocol specs). Lamassu Industries AG transitioned to a proprietary source-available license on 2024-01-26 with v8.1.6+ gated behind a paid Operator Support Agreement. **Do not** reference, port, or diff against v8.1.6+ — the only safe upstream tree for porting is `v8.1.5` or earlier. See [CLAUDE.md → Provenance + legal status](../../CLAUDE.md#provenance--legal-status) for the operating rules. > **Provenance.** Drivers in `packages/hal/` derive from `lamassu-machine` up to commit `c0b69d1` (2023-09-19, v8.6.0-beta.9), the last public-domain commit; `a9234d124d` added Lamassu's Appendix A licence the same day, so every 8.1.5+ *tag* is proprietary — the old "v8.1.5 is the boundary" was wrong. Since 2026-10-09 we hold permission to use the post-boundary code as prior art too (see CLAUDE.md → Provenance): reference over port, and name the source commit when a block is ported verbatim.
> **Language note.** ADR-001 selected TypeScript-in-Electron over Rust-in-Tauri for the HAL. Earlier versions of this skill referenced Rust patterns; that's obsolete. All checks below are TypeScript-flavored. > **Language note.** ADR-001 selected TypeScript-in-Electron over Rust-in-Tauri for the HAL. Earlier versions of this skill referenced Rust patterns; that's obsolete. All checks below are TypeScript-flavored.
@ -16,7 +16,7 @@ Validate HAL driver implementations in `packages/hal/` against published hardwar
Commands: Commands:
- `port` — Validate that a driver matches its lamassu-machine v8.1.5 reference (where the driver was ported from one) - `port` — Validate that a driver matches its lamassu-machine reference (`c0b69d1` tree unless the port names a later commit) (where the driver was ported from one)
- `protocol` — Check protocol implementation against published vendor specs - `protocol` — Check protocol implementation against published vendor specs
- `safety` — Type safety, error handling, hardware safety review - `safety` — Type safety, error handling, hardware safety review
- `mock` — Validate mock implementation completeness - `mock` — Validate mock implementation completeness
@ -31,9 +31,9 @@ Drivers:
### Source reference ### Source reference
Each TS driver in `packages/hal/` maps to (at most) one JS source in lamassu-machine v8.1.5 (the last fully-open release): Each TS driver in `packages/hal/` maps to (at most) one JS source in lamassu-machine at `c0b69d1` (the last public-domain commit):
| TS Driver | JS Source (v8.1.5 release tree) | | TS Driver | JS Source (`c0b69d1` tree) |
|---|---| |---|---|
| `validators/id003/*.ts` | `lib/id003/*.js` | | `validators/id003/*.ts` | `lib/id003/*.js` |
| `validators/ccnet/*.ts` | `lib/ccnet/*.js` | | `validators/ccnet/*.ts` | `lib/ccnet/*.js` |
@ -156,7 +156,7 @@ function buildPacket(data: Uint8Array): Uint8Array {
Against the v8.1.5 JS reference (line numbers may vary by tag): Against the v8.1.5 JS reference (line numbers may vary by tag):
```javascript ```javascript
// lamassu-machine v8.1.5 — lib/id003/id003rs232.js // lamassu-machine c0b69d1 — lib/id003/id003rs232.js
function buildPacket(data) { function buildPacket(data) {
const buf = Buffer.alloc(data.length + 4) const buf = Buffer.alloc(data.length + 4)
buf[0] = 0x02 // SYNC buf[0] = 0x02 // SYNC
@ -234,6 +234,6 @@ A discrepancy here (different CRC polynomial, different framing, different endia
## Forbidden operations ## Forbidden operations
- Diff or read `lamassu-machine` source at v8.1.6 or later. Only `v8.1.5` (and the historical commit range leading up to it) is permissible to reference. - Port a post-`c0b69d1` block without naming its source commit in the commit message. The permission to reference that code is recorded in CLAUDE.md; the provenance of anything carried over must be recoverable from `git log`.
- "Backport" any fix or feature from v8.1.6+ JS sources into TypeScript. If a bug fix is needed, implement from the protocol spec or hardware traces. - Copy a value table (note lengths, timings) without a test over it — `bills.ts` carried a wrong GTQ window for months precisely because nothing asserted it.
- Include attribution comments pointing at v8.1.6+ files even if the implementation is your own — readers should be able to trust file-header attributions as accurate. - Include attribution comments pointing at v8.1.6+ files even if the implementation is your own — readers should be able to trust file-header attributions as accurate.

View file

@ -17,7 +17,7 @@ Where `target` can be:
## Relevant NIPs for bitSpire ## Relevant NIPs for bitSpire
### Core NIPs (Must Implement) ### Core NIPs (Must Implement)
| NIP | Description | Usage in Lamassu | | NIP | Description | Usage in bitSpire |
|-----|-------------|------------------| |-----|-------------|------------------|
| NIP-01 | Basic protocol | Event structure, relay communication | | NIP-01 | Basic protocol | Event structure, relay communication |
| NIP-19 | bech32 entities | npub, nsec, nprofile encoding | | NIP-19 | bech32 entities | npub, nsec, nprofile encoding |
@ -26,7 +26,7 @@ Where `target` can be:
| NIP-59 | Gift wrapping | Anonymous message delivery | | NIP-59 | Gift wrapping | Anonymous message delivery |
### Application NIPs ### Application NIPs
| NIP | Description | Usage in Lamassu | | NIP | Description | Usage in bitSpire |
|-----|-------------|------------------| |-----|-------------|------------------|
| NIP-17 | Private DMs | Receipt delivery | | NIP-17 | Private DMs | Receipt delivery |
| NIP-47 | Nostr Wallet Connect | Potential wallet integration | | NIP-47 | Nostr Wallet Connect | Potential wallet integration |

View file

@ -1,513 +0,0 @@
{
inputs =
let
vars = {
version = "1.11.2";
system = "x86_64-linux";
devenv_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next";
project_input_ref = "path:/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next";
devenv_dotfile = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next/.devenv";
devenv_dotfile_path = ./.devenv;
devenv_tmpdir = "/run/user/1000";
devenv_runtime = "/run/user/1000/devenv-f4ba770";
devenv_istesting = false;
devenv_direnvrc_latest_version = 1;
container_name = null;
active_profiles = [
];
hostname = "gizmo";
username = "padreug";
git_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages";
secretspec = null;
};
in
{
git-hooks.url = "github:cachix/git-hooks.nix";
git-hooks.inputs.nixpkgs.follows = "nixpkgs";
pre-commit-hooks.follows = "git-hooks";
nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling";
devenv.url = "github:cachix/devenv?dir=src/modules";
}
// (
if builtins.pathExists (vars.devenv_dotfile_path + "/flake.json") then
builtins.fromJSON (builtins.readFile (vars.devenv_dotfile_path + "/flake.json"))
else
{ }
);
outputs =
{ nixpkgs, ... }@inputs:
let
vars = {
version = "1.11.2";
system = "x86_64-linux";
devenv_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next";
project_input_ref = "path:/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next";
devenv_dotfile = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next/.devenv";
devenv_dotfile_path = ./.devenv;
devenv_tmpdir = "/run/user/1000";
devenv_runtime = "/run/user/1000/devenv-f4ba770";
devenv_istesting = false;
devenv_direnvrc_latest_version = 1;
container_name = null;
active_profiles = [
];
hostname = "gizmo";
username = "padreug";
git_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages";
secretspec = null;
};
devenv =
if builtins.pathExists (vars.devenv_dotfile_path + "/devenv.json") then
builtins.fromJSON (builtins.readFile (vars.devenv_dotfile_path + "/devenv.json"))
else
{ };
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
# Function to create devenv configuration for a specific system with profiles support
mkDevenvForSystem =
targetSystem:
let
getOverlays =
inputName: inputAttrs:
map (
overlay:
let
input =
inputs.${inputName} or (throw "No such input `${inputName}` while trying to configure overlays.");
in
input.overlays.${overlay}
or (throw "Input `${inputName}` has no overlay called `${overlay}`. Supported overlays: ${nixpkgs.lib.concatStringsSep ", " (builtins.attrNames input.overlays)}")
) inputAttrs.overlays or [ ];
overlays = nixpkgs.lib.flatten (nixpkgs.lib.mapAttrsToList getOverlays (devenv.inputs or { }));
permittedUnfreePackages =
devenv.nixpkgs.per-platform."${targetSystem}".permittedUnfreePackages
or devenv.nixpkgs.permittedUnfreePackages or [ ];
pkgs = import nixpkgs {
system = targetSystem;
config = {
allowUnfree =
devenv.nixpkgs.per-platform."${targetSystem}".allowUnfree or devenv.nixpkgs.allowUnfree
or devenv.allowUnfree or false;
allowBroken =
devenv.nixpkgs.per-platform."${targetSystem}".allowBroken or devenv.nixpkgs.allowBroken
or devenv.allowBroken or false;
cudaSupport =
devenv.nixpkgs.per-platform."${targetSystem}".cudaSupport or devenv.nixpkgs.cudaSupport or false;
cudaCapabilities =
devenv.nixpkgs.per-platform."${targetSystem}".cudaCapabilities or devenv.nixpkgs.cudaCapabilities
or [ ];
permittedInsecurePackages =
devenv.nixpkgs.per-platform."${targetSystem}".permittedInsecurePackages
or devenv.nixpkgs.permittedInsecurePackages or devenv.permittedInsecurePackages or [ ];
allowUnfreePredicate =
if (permittedUnfreePackages != [ ]) then
(pkg: builtins.elem (nixpkgs.lib.getName pkg) permittedUnfreePackages)
else
(_: false);
};
inherit overlays;
};
inherit (pkgs) lib;
importModule =
path:
if lib.hasPrefix "./" path then
if lib.hasSuffix ".nix" path then
./. + (builtins.substring 1 255 path)
else
./. + (builtins.substring 1 255 path) + "/devenv.nix"
else if lib.hasPrefix "../" path then
# For parent directory paths, concatenate with /.
# ./. refers to the directory containing this file (project root)
# So ./. + "/../shared" = <project-root>/../shared
if lib.hasSuffix ".nix" path then ./. + "/${path}" else ./. + "/${path}/devenv.nix"
else
let
paths = lib.splitString "/" path;
name = builtins.head paths;
input = inputs.${name} or (throw "Unknown input ${name}");
subpath = "/${lib.concatStringsSep "/" (builtins.tail paths)}";
devenvpath = "${input}" + subpath;
devenvdefaultpath = devenvpath + "/devenv.nix";
in
if lib.hasSuffix ".nix" devenvpath then
devenvpath
else if builtins.pathExists devenvdefaultpath then
devenvdefaultpath
else
throw (devenvdefaultpath + " file does not exist for input ${name}.");
# Phase 1: Base evaluation to extract profile definitions
baseProject = pkgs.lib.evalModules {
specialArgs = inputs // {
inherit inputs;
};
modules = [
(
{ config, ... }:
{
_module.args.pkgs = pkgs.appendOverlays (config.overlays or [ ]);
}
)
(inputs.devenv.modules + /top-level.nix)
(
{ options, ... }:
{
config.devenv = lib.mkMerge [
{
cliVersion = vars.version;
root = vars.devenv_root;
dotfile = vars.devenv_dotfile;
}
(pkgs.lib.optionalAttrs (builtins.hasAttr "tmpdir" options.devenv) {
tmpdir = vars.devenv_tmpdir;
})
(pkgs.lib.optionalAttrs (builtins.hasAttr "isTesting" options.devenv) {
isTesting = vars.devenv_istesting;
})
(pkgs.lib.optionalAttrs (builtins.hasAttr "runtime" options.devenv) {
runtime = vars.devenv_runtime;
})
(pkgs.lib.optionalAttrs (builtins.hasAttr "direnvrcLatestVersion" options.devenv) {
direnvrcLatestVersion = vars.devenv_direnvrc_latest_version;
})
];
}
)
(
{ options, ... }:
{
config = lib.mkMerge [
(pkgs.lib.optionalAttrs (builtins.hasAttr "git" options) {
git.root = vars.git_root;
})
];
}
)
(pkgs.lib.optionalAttrs (vars.container_name != null) {
container.isBuilding = pkgs.lib.mkForce true;
containers.${vars.container_name}.isBuilding = true;
})
]
++ (map importModule (devenv.imports or [ ]))
++ [
(if builtins.pathExists ./devenv.nix then ./devenv.nix else { })
(devenv.devenv or { })
(if builtins.pathExists ./devenv.local.nix then ./devenv.local.nix else { })
(
if builtins.pathExists (vars.devenv_dotfile_path + "/cli-options.nix") then
import (vars.devenv_dotfile_path + "/cli-options.nix")
else
{ }
)
];
};
# Phase 2: Extract and apply profiles using extendModules with priority overrides
project =
let
# Build ordered list of profile names: hostname -> user -> manual
manualProfiles = vars.active_profiles;
currentHostname = vars.hostname;
currentUsername = vars.username;
hostnameProfiles = lib.optional (
currentHostname != ""
&& builtins.hasAttr currentHostname (baseProject.config.profiles.hostname or { })
) "hostname.${currentHostname}";
userProfiles = lib.optional (
currentUsername != "" && builtins.hasAttr currentUsername (baseProject.config.profiles.user or { })
) "user.${currentUsername}";
# Ordered list of profiles to activate
orderedProfiles = hostnameProfiles ++ userProfiles ++ manualProfiles;
# Resolve profile extends with cycle detection
resolveProfileExtends =
profileName: visited:
if builtins.elem profileName visited then
throw "Circular dependency detected in profile extends: ${lib.concatStringsSep " -> " visited} -> ${profileName}"
else
let
profile = getProfileConfig profileName;
extends = profile.extends or [ ];
newVisited = visited ++ [ profileName ];
extendedProfiles = lib.flatten (map (name: resolveProfileExtends name newVisited) extends);
in
extendedProfiles ++ [ profileName ];
# Get profile configuration by name from baseProject
getProfileConfig =
profileName:
if lib.hasPrefix "hostname." profileName then
let
name = lib.removePrefix "hostname." profileName;
in
baseProject.config.profiles.hostname.${name}
else if lib.hasPrefix "user." profileName then
let
name = lib.removePrefix "user." profileName;
in
baseProject.config.profiles.user.${name}
else
let
availableProfiles = builtins.attrNames (baseProject.config.profiles or { });
hostnameProfiles = map (n: "hostname.${n}") (
builtins.attrNames (baseProject.config.profiles.hostname or { })
);
userProfiles = map (n: "user.${n}") (builtins.attrNames (baseProject.config.profiles.user or { }));
allAvailableProfiles = availableProfiles ++ hostnameProfiles ++ userProfiles;
in
baseProject.config.profiles.${profileName}
or (throw "Profile '${profileName}' not found. Available profiles: ${lib.concatStringsSep ", " allAvailableProfiles}");
# Fold over ordered profiles to build final list with extends
expandedProfiles = lib.foldl' (
acc: profileName:
let
allProfileNames = resolveProfileExtends profileName [ ];
in
acc ++ allProfileNames
) [ ] orderedProfiles;
# Map over expanded profiles and apply priorities
allPrioritizedModules = lib.imap0 (
index: profileName:
let
# Decrement priority for each profile (lower = higher precedence)
# Start with the next lowest priority after the default priority for values (100)
profilePriority = (lib.modules.defaultOverridePriority - 1) - index;
profileConfig = getProfileConfig profileName;
# Check if an option type needs explicit override to resolve conflicts
# Only apply overrides to LEAF values (scalars), not collection types that can merge
typeNeedsOverride =
type:
if type == null then
false
else
let
typeName = type.name or type._type or "";
# True leaf types that need priority resolution when they conflict
isLeafType = builtins.elem typeName [
"str"
"int"
"bool"
"enum"
"path"
"package"
"float"
"anything"
];
in
if isLeafType then
true
else if typeName == "nullOr" then
# For nullOr, check the wrapped type recursively
let
innerType =
type.elemType
or (if type ? nestedTypes && type.nestedTypes ? elemType then type.nestedTypes.elemType else null);
in
if innerType != null then typeNeedsOverride innerType else false
else
# Everything else (collections, submodules, etc.) should merge naturally
false;
# Check if a config path needs explicit override
pathNeedsOverride =
optionPath:
let
# Try direct option first
directOption = lib.attrByPath optionPath null baseProject.options;
in
if directOption != null && lib.isOption directOption then
typeNeedsOverride directOption.type
else if optionPath != [ ] then
# Check parent for freeform type
let
parentPath = lib.init optionPath;
parentOption = lib.attrByPath parentPath null baseProject.options;
in
if parentOption != null && lib.isOption parentOption then
let
# Look for freeform type:
# 1. Standard location: type.freeformType (primary)
# 2. Nested location: type.nestedTypes.freeformType (evaluated form)
freeformType = parentOption.type.freeformType or parentOption.type.nestedTypes.freeformType or null;
elementType =
if freeformType ? elemType then
freeformType.elemType
else if freeformType ? nestedTypes && freeformType.nestedTypes ? elemType then
freeformType.nestedTypes.elemType
else
freeformType;
in
typeNeedsOverride elementType
else
false
else
false;
# Support overriding both plain attrset modules and functions
applyModuleOverride =
config:
if builtins.isFunction config then
let
wrapper = args: applyOverrideRecursive (config args) [ ];
in
lib.mirrorFunctionArgs config wrapper
else
applyOverrideRecursive config [ ];
# Apply overrides recursively based on option types
applyOverrideRecursive =
config: optionPath:
if lib.isAttrs config && config ? _type then
config # Don't touch values with existing type metadata
else if lib.isAttrs config then
lib.mapAttrs (name: value: applyOverrideRecursive value (optionPath ++ [ name ])) config
else if pathNeedsOverride optionPath then
lib.mkOverride profilePriority config
else
config;
# Apply priority overrides recursively to the deferredModule imports structure
prioritizedConfig = (
profileConfig.module
// {
imports = lib.map (
importItem:
importItem
// {
imports = lib.map (nestedImport: applyModuleOverride nestedImport) (importItem.imports or [ ]);
}
) (profileConfig.module.imports or [ ]);
}
);
in
prioritizedConfig
) expandedProfiles;
in
if allPrioritizedModules == [ ] then
baseProject
else
baseProject.extendModules { modules = allPrioritizedModules; };
config = project.config;
options = pkgs.nixosOptionsDoc {
options = builtins.removeAttrs project.options [ "_module" ];
warningsAreErrors = false;
# Unpack Nix types, e.g. literalExpression, mDoc.
transformOptions =
let
isDocType =
v:
builtins.elem v [
"literalDocBook"
"literalExpression"
"literalMD"
"mdDoc"
];
in
lib.attrsets.mapAttrs (
_: v:
if v ? _type && isDocType v._type then
v.text
else if v ? _type && v._type == "derivation" then
v.name
else
v
);
};
# Recursively search for outputs in the config.
# This is used when not building a specific output by attrpath.
build =
options: config:
lib.concatMapAttrs (
name: option:
if lib.isOption option then
let
typeName = option.type.name or "";
in
if
builtins.elem typeName [
"output"
"outputOf"
]
then
{ ${name} = config.${name}; }
else
{ }
else if builtins.isAttrs option && !lib.isDerivation option then
let
v = build option config.${name};
in
if v != { } then
{
${name} = v;
}
else
{ }
else
{ }
) options;
in
{
inherit
config
options
build
project
;
shell = config.shell;
packages = {
optionsJSON = options.optionsJSON;
# deprecated
inherit (config)
info
procfileScript
procfileEnv
procfile
;
ci = config.ciDerivation;
};
};
# Generate per-system devenv configurations
perSystem = nixpkgs.lib.genAttrs systems mkDevenvForSystem;
# Default devenv for the current system
currentSystemDevenv = perSystem.${vars.system};
in
{
devShell = nixpkgs.lib.genAttrs systems (s: perSystem.${s}.shell);
packages = nixpkgs.lib.genAttrs systems (s: perSystem.${s}.packages);
# Per-system devenv configurations
devenv = {
# Default devenv for the current system
inherit (currentSystemDevenv)
config
options
build
shell
packages
project
;
# Per-system devenv configurations
inherit perSystem;
};
# Legacy build output
build = currentSystemDevenv.build currentSystemDevenv.options currentSystemDevenv.config;
};
}

5
.gitignore vendored
View file

@ -55,13 +55,10 @@ apps/machine/src/services/*.js
# devenv # devenv
.devenv/ .devenv/
.devenv.flake.nix
.direnv/ .direnv/
.pre-commit-config.yaml .pre-commit-config.yaml
# Docker
docker/**/data/
docker/.state/
# Nix build outputs # Nix build outputs
result result
result-* result-*

139
CLAUDE.md
View file

@ -4,7 +4,7 @@ Guidance for Claude Code when working in this repo. Read this before touching co
## Project Overview ## Project Overview
**bitSpire** is a Nostr-native Lightning ATM. Production ATMs (`batm3`, `douro`) currently run from `main` against Lightning.Pub; the `dev` branch — which is what this file describes — has been migrated to **LNbits over the nostr-native-transport**. **bitSpire** is a Nostr-native Lightning ATM running **LNbits over the nostr-native-transport**. The `dev` branch, which this file describes, is what the machines run.
Core principles: Core principles:
@ -15,16 +15,100 @@ Core principles:
## Provenance + legal status ## Provenance + legal status
The HAL drivers (validators / dispensers / printers) and the cash-flow state machine derive from Lamassu Industries AG's open-source `lamassu-machine` and `lamassu-server` repositories, **only up to v8.1.5** — the last release published under a fully-open license. Lamassu transitioned to a proprietary, source-available license (their custom "Appendix A SLA") on 2024-01-26 and gated v8.1.6+ behind a paid OSA subscription. The HAL drivers (validators / dispensers / printers) and the cash-flow state machine derive from Lamassu Industries AG's `lamassu-machine` repository, **only up to commit `c0b69d1ed196d396c5f057478c2ea290babd58ab`** ("chore: v8.6.0-beta.9", 2023-09-19) — the last commit published into the public domain (`UNLICENSE` in tree). The very next commit, `a9234d124d` ("chore: add LICENSE (#1019)", 2023-09-19), removed `UNLICENSE` and added Lamassu's proprietary "Appendix A SLA". **The `v8.1.5` tag (2023-09-21) already ships the Appendix A license** — the previously documented "8.1.5 is the open boundary" was wrong (verified against GitHub history 2026-07-04). Note the public-domain boundary sits on the 8.6-beta line, which is *further along* than 8.1.5 feature-wise.
**Hard rule when working in this repo:** do not pull, port, or copy code from lamassu-machine / lamassu-server at v8.1.6 or later. If a HAL bug fix or feature exists upstream past 8.1.5, either (a) reimplement from protocol docs / hardware specs without looking at v8.1.6+ source, or (b) raise the question with the maintainer first. The 8.1.5 tree is fair game; everything after is licensed code we have no rights to. **Reference permission (2026-10-09).** The maintainer taking over the Lamassu
codebase has given us permission to use lamassu-machine and lamassu-server — including
post-boundary code — as prior art in any way that improves this codebase (relayed by
padreug, 2026-10-09; the earlier hard rule — reference only `c0b69d1` or earlier — is
superseded). Prefer to *reference* over
*port*: read it, take the behaviour model, reimplement in our idiom. When a block is
ported verbatim, say so in the commit, with the source commit, so the provenance is in
`git log`. The pre-boundary tree needs no permission at all and is the first place to look.
**Boundaries, for the record.** lamassu-machine's last public-domain commit is `c0b69d1`
(2023-09-19, v8.6.0-beta.9); `a9234d124d` added Appendix A the same day, so every 8.1.5+
tag is proprietary. lamassu-server's last public-domain commit is `adbc9709` (2023-09-19,
v8.6.0-beta.9), licence added in `d06a8f54` the same day. Both GitHub repos are gone
(404); full history survives in `~/dev/repos/` and at Software Heritage (crawls 2026-03-02
and 2026-07-19, tips identical to the local mirrors). **`~/lamassu/lamassu-server` is a
squashed v12 repo** whose first commit (`e2c49ea`, 2025-12-31) already carries Appendix A —
it has no open era to reference; use `~/dev/repos/` for that. `~/lamassu/` also holds the
33 surviving github.com/lamassu dependency repos (cloned 2026-09-27) and
`bnr-xfs-salvage/` (the MIT bnr-xfs / bnr crates, checksums verified). The curated
*Lamassu Port Backlog* (claude.ai artifact `61af38f6`, 2026-09-27) ranks what is worth
taking and tags each item's provenance.
bitSpire is an independent project under AGPL-3.0 and is not affiliated with Lamassu Industries AG. bitSpire is an independent project under AGPL-3.0 and is not affiliated with Lamassu Industries AG.
## Branch model ## Branch model
- `main` — production. Lightning.Pub backend. The two production ATMs auto-pull from here daily at 04:00 (`flake.nix:152-160`). **DO NOT** push to `main` casually — a wrong commit gets baked into prod ATMs the next morning. - `dev` — **what every live machine runs.** Not a staging branch any more. Verified
- `dev` — staging. LNbits backend. The Sintra dev unit auto-pulls from here (`?ref=dev` pin on this branch's `flake.nix`). Push freely; tag `pre-bitspire-cutover` is the rollback target if the migration ever needs to be reverted on prod. 2026-09-24 on batm3, whose `nixos-upgrade` unit pulls
`git+ssh://…/bitspire.git?ref=dev#batm3-installed` daily at 04:00. "Push freely
to dev" is no longer safe advice: a bad commit reaches production hardware the
next morning, unattended.
- `main` — Lightning.Pub era, historical. Tag `pre-bitspire-cutover` is the
rollback target if the migration ever has to be reverted.
> This section previously said the production ATMs ran `main` against
> Lightning.Pub and that only Sintra was on `dev`. That was stale and it was
> repeatedly taken at face value. Check the machine, not this file, before
> relying on which stack a given box runs: `systemctl cat nixos-upgrade` gives
> the branch, `/var/lib/bitspire` vs `/var/lib/lamassu-atm` gives the era.
### Fleet state (surveyed 2026-09-24)
| Machine | Reachable | Stack | GPU | Notes |
|---|---|---|---|---|
| `sintra` | LAN `192.168.0.252` | dev / LNbits | Braswell `8086:22b0` → crocus | dev unit; ethernet `r8169`. **Its nightly upgrade failed every night 2026-10-06 → 10-09** on the same 60 s timeout as batm3 (below); nothing merged that week reached it until a cachix push on 10-10 |
| `batm3` | wg `10.0.0.5` | dev / LNbits | Haswell GT2 `8086:0412` → crocus | **networks over WiFi**, `iwlwifi` 7260; ethernet down |
| `douro` | **down** | — | Bay Trail (Gen7) | needs reflashing with the current image and reconnecting to WireGuard |
| `tejo` | wg `10.0.0.3` | **Debian** (`ubilinux4`, kernel 4.9) | Braswell `8086:22b0` | never had bitspire installed; a flake target, not a deployment |
Two consequences worth holding onto. Every GPU in the fleet binds **crocus**, not
iris — sintra's Braswell does so despite being Gen8. And batm3's only working
network path is Intel WiFi, so `intel/iwlwifi` firmware is load-bearing there;
trimming it would strand the machine with no way back in.
### The nightly upgrade fails on any machine that has to build the app (batm3, and sintra too)
Confirmed on batm3 2026-09-24 and on sintra 2026-10-10 (failing since 10-06). The run dies at:
```
04:03:26 building '…-bitspire-atm-app-0.1.0.drv'...
04:04:28 error: timed out after 60 seconds
```
The ATM app is built in-house and is **not in `aiolabs.cachix.org` or
`cache.nixos.org`**, so batm3 has to build it locally, and `nix.settings.timeout
= 60` in `flake.nix` kills it. The comment there assumes heavy derivations are
"effectively cache-only … upstream-cached", which is true of nixpkgs and false of
our own app.
So the machine is pinned to whatever generation last succeeded, and nothing
merged to `dev` reaches it. This is the same class of silent-updater failure as
#98, in a new form. The fix is pushing `atm-app-*` to the aiolabs cachix as part
of releasing, not raising the timeout — a 60s ceiling on ATM hardware is correct.
**Interim rule (2026-10-10): every push to `dev` is followed by
`./deploy/push-cache.sh sintra && ./deploy/push-cache.sh batm3` from bohm**
(cachix is authenticated there). `mkAtmApp` takes `src = self` — the whole flake
tree — so *any* commit, docs included, changes the app derivation and a cached
toplevel no longer matches what `?ref=dev` resolves to. Three more things that
bit on 10-10:
- **`pnpm-lock.yaml` changes require re-deriving `pnpmDeps.hash` in
`nix/mkAtmApp.nix`** (blank it, build, paste the `got:` value). Nix reuses the
stale fixed-output store otherwise and the sandboxed `pnpm install --offline`
fails with `ERR_PNPM_NO_OFFLINE_TARBALL`. A passing local `pnpm build` says
nothing about the nix build.
- **Activation scripts run with a minimal PATH** — coreutils yes, `grep`/`sed`
no. Reference `${pkgs.gnugrep}/bin/grep` / `${pkgs.gnused}/bin/sed` by store
path; the `.env` migration printed success and then died 127.
- `nixos-rebuild switch --flake .#<model>-installed --target-host <model> --sudo
--use-substitutes` from bohm is the fast manual path once the cache has the
toplevel: store hit here, closure copied, nothing built on the UP board.
## Architecture ## Architecture
@ -82,13 +166,34 @@ Renderer reads (Electron IPC or Vite `import.meta.env`):
| Var | Required | Notes | | Var | Required | Notes |
|---|---|---| |---|---|---|
| `VITE_RELAY_URL` | yes | `ws://...` of the relay both ATM and LNbits subscribe to. Dev: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) | | `VITE_RELAY_URL` | no (seed-provided) | Relay both ATM and LNbits subscribe to. **Comes from the pairing seed** (aiolabs/bitspire#70); set this only as an override — it WINS over the seed via env-first precedence. Dev override: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) |
| `VITE_LNBITS_SERVER_PUBKEY` | yes | 64-char hex pubkey LNbits prints on startup (`docker logs lnbits \| grep 'Public key (share this)'`) | | `VITE_LNBITS_SERVER_PUBKEY` | no (seed-provided) | 64-char hex transport pubkey. **Comes from the seed's `lnbits_npub`** (#70); env override only. LNbits prints it on startup (`docker logs lnbits \| grep 'Public key (share this)'`) |
| `VITE_ATM_PRIVATE_KEY` | yes (prod) | 64-char hex. The ATM's nostr identity. Generates ephemeral on first boot if unset (dev only) | | `VITE_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:<base64url>`) from spirekeeper. Carries the relay(s), the LNbits transport pubkey (`lnbits_npub`), the spire signing pubkey (`spire_npub`), and a one-shot NIP-46 connect token (#70 slimmed the shape). First boot redeems it and persists the binding to `state.db`; later boots resume by fingerprint. A changed seed re-pairs. Provisioning it up front is optional — an unpaired machine renders an on-screen QR-pairing wizard that scans the seed off the camera (see below). See aiolabs/bitspire#52. |
| `VITE_ATM_PRIVATE_KEY` | dev only | 64-char hex raw nsec fallback for running without a bunker. Ignored when `VITE_SPIRE_SEED` or a stored binding exists. |
| `VITE_OPERATOR_PUBKEYS` | optional | Comma-separated hex pubkeys allowed to send kind-21003 management commands | | `VITE_OPERATOR_PUBKEYS` | optional | Comma-separated hex pubkeys allowed to send kind-21003 management commands |
The LP-era vars (`VITE_LIGHTNING_PUB_PUBKEY`, `VITE_LIGHTNING_PUB_API_URL`, `VITE_EXTENSION_API_URL`, `VITE_ADMIN_TOKEN`) are gone from the dev branch's `.env.example` and `LightningConfig` interface. The LP-era vars (`VITE_LIGHTNING_PUB_PUBKEY`, `VITE_LIGHTNING_PUB_API_URL`, `VITE_EXTENSION_API_URL`, `VITE_ADMIN_TOKEN`) are gone from the dev branch's `.env.example` and `LightningConfig` interface.
## Pairing (on-machine QR wizard)
A machine with no seed **and** no stored binding boots `unpaired` and, under
Electron, renders an interactive wizard (`src/components/PairingWizard.vue`)
instead of a dead-end fault screen. The operator displays the `spire-seed`
QR (minted by spirekeeper's `/pair`) to the machine's camera; the wizard:
1. captures + decodes via a `PairingSource` (`src/services/pairing/`) — camera
today (decode through `qr`, paulmillr's zero-dep lib), NFC scaffolded;
2. validates the scan parses as a spire-seed (`ingestScannedSeed`), rejecting
a stray QR;
3. persists it as `VITE_SPIRE_SEED` via the `state:save-spire-seed` IPC and
relaunches (`app:relaunch`).
Pairing itself is **not** done in the wizard — relaunch lets the normal boot
path (`signer-resolver` → `connectNewSeed`) redeem the one-shot token, so
there's one tested pairing path. A revoked/expired binding lands on the same
wizard (re-pair = scan a fresh seed). Provisioning `VITE_SPIRE_SEED` up front
still works and skips the wizard.
## Commands ## Commands
```bash ```bash
@ -164,10 +269,15 @@ TypeScript drivers in `packages/hal/`. Coverage by device class:
| Category | Drivers | | Category | Drivers |
|---|---| |---|---|
| Validators | id003, ccnet, cashflow_sc, bnr_advance, genmega, hcm2, gsr50 | | Validators | id003, ebds |
| Dispensers | puloon, f56, genmega, hcm2, gsr50 | | Dispensers | f56, puloon |
| Recyclers | MEI SCR (planned — hardware in BATM3, no driver yet) | | Recyclers | none — MEI SCR hardware in BATM3; a clean-room BNR Advance route exists via the MIT `bnr-xfs` crate (see the port backlog) |
| Printers | nippon, zebra, genmega | | Printers | none — `packages/hal/src/printers/` does not exist |
This table previously listed ccnet, cashflow_sc, bnr_advance, genmega, hcm2, gsr50 and
three printers that are not in the tree (corrected 2026-10-09). `packages/hal/src` also
carries orphaned `*.rs` files (`lib.rs`, `error.rs`, `mod.rs`, `traits.rs`, `mock.rs`) from
an abandoned Rust HAL; they are not built.
### Sintra hardware specifics (Aaeon UP Board) ### Sintra hardware specifics (Aaeon UP Board)
@ -188,7 +298,7 @@ UP Board enumerates its eMMC controller via ACPI, not PCI. `upboard.nix` force-l
## Security priorities ## Security priorities
1. **Private keys** — Never log nsec. The ATM's `VITE_ATM_PRIVATE_KEY` lives in `/var/lib/bitspire/.env` with mode 0600, owned by `bitspire:bitspire`. 1. **Private keys** — Never log nsec. In production the ATM holds no signing nsec: `VITE_SPIRE_SEED` (in `/var/lib/bitspire/.env`, mode 0600) carries a one-shot connect token, and the ATM's own NIP-46 *transport* key (`client_secret_hex`) lives in `state.db` (`bunker_binding`). The operator's signing key stays in the bunker. The legacy `VITE_ATM_PRIVATE_KEY` is a dev-only fallback.
2. **Payments** — Validate the bolt11 amount on cash-out before exposing the QR. Decode `payment_hash` from the bolt11 (cheap, avoids a roundtrip) and use it as the `subscribe_payments` filter. 2. **Payments** — Validate the bolt11 amount on cash-out before exposing the QR. Decode `payment_hash` from the bolt11 (cheap, avoids a roundtrip) and use it as the `subscribe_payments` filter.
3. **Replay** — LNURL-withdraw links use `uses:1` and are deleted on session abort. 3. **Replay** — LNURL-withdraw links use `uses:1` and are deleted on session abort.
4. **Encryption** — All RPC content is NIP-44 v2. NIP-04 is forbidden. 4. **Encryption** — All RPC content is NIP-44 v2. NIP-04 is forbidden.
@ -196,7 +306,8 @@ UP Board enumerates its eMMC controller via ACPI, not PCI. `upboard.nix` force-l
## Useful invariants when debugging ## Useful invariants when debugging
- The renderer logs prefix every line with a tag: `[Lightning]`, `[ATM]`, `[ATM Service]`, `[LNURL Session]`, `[CLINK]`, `[StateStore]`. `journalctl -u bitspire | grep '\['` is your friend. - The renderer logs prefix every line with a tag: `[Lightning]`, `[ATM]`, `[ATM Service]`, `[LNURL Session]`, `[CLINK]`, `[StateStore]`. `journalctl -u bitspire | grep '\['` is your friend.
- `bitspire.service` runs as the `lamassu` user; `/var/lib/bitspire` is its `dataDir` (ReadWritePaths). DB lives at `/var/lib/bitspire/state.db` (we previously had `/var/lib/lamassu-atm` — that path is gone on dev, see commit `9c455d6`). - **Never pass an object as a console argument in the renderer.** Electron's console bridge stringifies each argument, so `console.log('msg:', { a, b })` reaches the journal as `msg: [object Object]` and every field is lost. Interpolate instead. Cost a debugging session on 2026-09-23, when a cassette publish that had worked looked like it had done nothing.
- `bitspire.service` runs as the `bitspire` user (verified on sintra 2026-09-24; this line used to say `lamassu`, left over from the rename in `46e52f6`); `/var/lib/bitspire` is its `dataDir` (ReadWritePaths). DB lives at `/var/lib/bitspire/state.db` (we previously had `/var/lib/lamassu-atm` — that path is gone on dev, see commit `9c455d6`).
- The `lightning.lightningPub` field on `LightningServices` is a `LightningBackend` *adapter*, not a `LightningPubClient`. Don't try to call LP-only methods on it. - The `lightning.lightningPub` field on `LightningServices` is a `LightningBackend` *adapter*, not a `LightningPubClient`. Don't try to call LP-only methods on it.
## Related documentation ## Related documentation

View file

@ -2,7 +2,7 @@
A Nostr-native Lightning ATM. KYC-free, open source, auditable. Talks to its Lightning backend over the nostr-native-transport (kind-21000 NIP-44 v2) on a relay — never HTTP — so the kiosk has no admin tokens to leak and no API surface to attack. A Nostr-native Lightning ATM. KYC-free, open source, auditable. Talks to its Lightning backend over the nostr-native-transport (kind-21000 NIP-44 v2) on a relay — never HTTP — so the kiosk has no admin tokens to leak and no API surface to attack.
> Originally `lamassu-next`. Renamed during the LNbits-backend transition on the `dev` branch (commits leading up to 2026-05-13). Production ATMs (`batm3`, `douro`) still run from `main` against Lightning.Pub until cutover; this README describes the `dev` branch state. > Originally `lamassu-next`. Renamed during the LNbits-backend transition on the `dev` branch (commits leading up to 2026-05-13). Every live machine now runs `dev` against LNbits; `main` is the Lightning.Pub-era history (see CLAUDE.md → Branch model).
## What the ATM actually does ## What the ATM actually does
@ -26,8 +26,8 @@ The `nostrrelay` extension inside LNbits is what the ATM connects to — there i
```bash ```bash
# 1. Clone # 1. Clone
git clone ssh://forgejo@git.atitlan.io/aiolabs/lamassu-next.git git clone ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git
cd lamassu-next # repo name kept for now — rename to bitSpire is a follow-up cd bitspire
git checkout dev git checkout dev
# 2. Enter the dev environment # 2. Enter the dev environment

View file

@ -6,24 +6,28 @@
# ============================================================================= # =============================================================================
# Machine model preset (sintra, gaia, or custom) # Machine model preset (sintra, gaia, or custom)
VITE_LAMASSU_MACHINE_MODEL=sintra VITE_BITSPIRE_MACHINE_MODEL=sintra
# Fiat currency code (ISO 4217) # Fiat currency code (ISO 4217)
VITE_LAMASSU_FIAT_CODE=USD VITE_BITSPIRE_FIAT_CODE=USD
# Custom device paths (optional - uses preset defaults if not set) # Custom device paths (optional - uses preset defaults if not set)
# VITE_LAMASSU_VALIDATOR_DEVICE=/dev/ttyJ5 # VITE_BITSPIRE_VALIDATOR_DEVICE=/dev/ttyJ5
# VITE_LAMASSU_DISPENSER_DEVICE=/dev/ttyJ7 # VITE_BITSPIRE_DISPENSER_DEVICE=/dev/ttyJ7
# Cassette configuration (optional - JSON array) # Cassette configuration (optional - JSON array)
# VITE_LAMASSU_CASSETTES='[{"denomination":20,"count":100}]' # VITE_BITSPIRE_CASSETTES='[{"denomination":20,"count":100}]'
# ============================================================================= # =============================================================================
# LNbits Connection (Required) — nostr-native-transport # LNbits Connection (dev override — normally seed-provided) — nostr-native-transport
# ============================================================================= # =============================================================================
# On a real machine the pairing SEED (VITE_SPIRE_SEED) carries the relay AND the
# server pubkey (aiolabs/bitspire#70), so leave both blank there. Set them here
# only for browser dev without a seed/bunker — they WIN over the seed.
# Nostr relay WebSocket URL — relay LNbits is subscribed to. # Nostr relay WebSocket URL. Dev stack uses LNbits's bundled nostrrelay:
VITE_RELAY_URL=ws://localhost:7777 # VITE_RELAY_URL=ws://localhost:5001/nostrrelay/test
VITE_RELAY_URL=
# LNbits nostr-transport server pubkey (hex, 64 chars). # LNbits nostr-transport server pubkey (hex, 64 chars).
# Printed by the LNbits server on startup: # Printed by the LNbits server on startup:
@ -36,16 +40,23 @@ VITE_LNBITS_SERVER_PUBKEY=
# aiolabs/withdraw#1 / commit e9d911e.) # aiolabs/withdraw#1 / commit e9d911e.)
# ============================================================================= # =============================================================================
# ATM Identity # ATM Identity — spire pairing seed (NIP-46 bunker; aiolabs/bitspire#52)
# ============================================================================= # =============================================================================
# The spire pairing seed produced by the operator dashboard (spirekeeper):
# spire-seed:v1:<base64url>
# It carries a one-shot NIP-46 connect token + the spire's signing pubkey +
# the bunker URL. On first boot the ATM redeems the token, generates its own
# transport key, and persists the binding to state.db; thereafter it resumes
# from the binding (the seed can stay set — it's matched by fingerprint).
# A changed seed re-pairs (and re-publishes the cassette-state hello).
VITE_SPIRE_SEED=
# pragma: allowlist secret # pragma: allowlist secret
# ATM's Nostr private key (hex format, 64 characters). This signing # DEV ONLY fallback — a raw Nostr private key (hex, 64 chars) for running
# key IS the credential — LNbits derives the account from it on first # without a bunker. Ignored when VITE_SPIRE_SEED or a stored binding exists.
# contact (issue aiolabs/lnbits#9 alignment).
# Generate with: openssl rand -hex 32 # Generate with: openssl rand -hex 32
# If not set, generates ephemeral identity on each restart (dev only). # VITE_ATM_PRIVATE_KEY=
VITE_ATM_PRIVATE_KEY=
# ============================================================================= # =============================================================================
# Operator Identity # Operator Identity
@ -62,6 +73,18 @@ VITE_ATM_PRIVATE_KEY=
# Show "Under Service" screen and block all transactions # Show "Under Service" screen and block all transactions
# VITE_MAINTENANCE_MODE=true # VITE_MAINTENANCE_MODE=true
# =============================================================================
# Public Web Demo
# =============================================================================
# Set ONLY for the browser demo build (atm.demo.aiolabs.dev). Leave blank on
# every real machine. When set it:
# - keeps the mouse cursor visible (kiosk builds hide it)
# - mints one extra, never-used LNbits wallet named with this exact string,
# so the throwaway accounts the demo creates (one per page load, each with
# its own ephemeral identity) can be swept by name instead of guessed at.
# VITE_DEMO_TAG=bitspire-web-demo
# ============================================================================= # =============================================================================
# Mock Fallback (Production Safety) # Mock Fallback (Production Safety)
# ============================================================================= # =============================================================================
@ -70,3 +93,31 @@ VITE_ATM_PRIVATE_KEY=
# Set to 'true' for development/demo environments only # Set to 'true' for development/demo environments only
# When false (production default), initialization failures show a maintenance screen # When false (production default), initialization failures show a maintenance screen
# VITE_ALLOW_MOCK_FALLBACK=true # VITE_ALLOW_MOCK_FALLBACK=true
# =============================================================================
# Access Control (ADR-003)
# =============================================================================
# Tap-to-enter gate. When disabled (default), the machine boots straight to
# idle exactly as before. When enabled, it boots into a locked screen and a
# Bolt Card tap (read by the main-process NFC service over pcscd) unlocks it
# and loads the card for the session, so buy/sell finish with one Complete.
# ACCESS_CONTROL_ENABLED=true
# Admit ANY Bolt Card when the allow-list has no match. With this on the gate
# only keeps casual users off the menu — any NDEF tag with a /scan/<id> URL
# unlocks it; money still moves only on a valid SUN at Complete. Turn OFF once
# a real allow-list (/var/lib/bitspire/access.json) is provisioned.
# ACCESS_OPEN_ENROLLMENT=true
# Show the on-screen runtime dev/operator unlock button on the locked screen.
# Default OFF — it bypasses the gate, so enable only on a bench/dev machine.
# ACCESS_DEV_UNLOCK=true
# Per-machine salt for hashing credentials/PINs. Provision a real value in
# production (or in access.json); a fixed default is used if unset.
# ACCESS_SALT=change-me-per-machine
# Build/dev bypass — forces the gate OPEN even when enabled (browser dev / CI).
# Renderer-side (Vite) flag, never set in a production image.
# VITE_SKIP_ACCESS_GATE=true

View file

@ -0,0 +1,69 @@
/**
* Tests for bunker-binding persistence in state-store (aiolabs/bitspire#52,
* transport config added in #70).
*
* Validates the round-trip of the binding singleton, including the v11→v12
* transport columns (relays JSON + lnbits_server_pubkey) and their absence on
* a pre-#70 binding.
*
* Uses an in-memory SQLite database — fresh per test, no on-disk artifacts.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
clearBunkerBinding,
closeDatabase,
getBunkerBinding,
initDatabase,
saveBunkerBinding,
type StoredBunkerBinding,
} from '../state-store.js'
const BASE: StoredBunkerBinding = {
clientSecretHex: 'aa'.repeat(32),
spirePubkey: 'bb'.repeat(32),
bunkerUrl: 'bunker://bb?relay=wss%3A%2F%2Fr%2F&secret=deadbeef',
seedFingerprint: 'cc'.repeat(32),
pairedAt: 1_780_000_000,
}
beforeEach(() => {
initDatabase(':memory:')
})
afterEach(() => {
closeDatabase()
})
describe('bunker binding persistence', () => {
it('round-trips a binding carrying transport config (#70)', () => {
const binding: StoredBunkerBinding = {
...BASE,
relays: ['wss://one.relay/', 'wss://two.relay/'],
lnbitsServerPubkey: 'dd'.repeat(32),
}
saveBunkerBinding(binding)
expect(getBunkerBinding()).toEqual(binding)
})
it('round-trips a pre-#70 binding (no transport config) as undefined fields', () => {
saveBunkerBinding(BASE)
const got = getBunkerBinding()
expect(got).toEqual(BASE)
expect(got?.relays).toBeUndefined()
expect(got?.lnbitsServerPubkey).toBeUndefined()
})
it('upserts transport config in place (re-pair overwrites)', () => {
saveBunkerBinding({ ...BASE, relays: ['wss://old/'], lnbitsServerPubkey: 'ee'.repeat(32) })
saveBunkerBinding({ ...BASE, relays: ['wss://new/'], lnbitsServerPubkey: 'ff'.repeat(32) })
const got = getBunkerBinding()
expect(got?.relays).toEqual(['wss://new/'])
expect(got?.lnbitsServerPubkey).toBe('ff'.repeat(32))
})
it('returns null after clear', () => {
saveBunkerBinding(BASE)
clearBunkerBinding()
expect(getBunkerBinding()).toBeNull()
})
})

View file

@ -0,0 +1,495 @@
/**
* Tests for recordTransaction inventory accounting.
*
* Regression coverage for the position-vs-denomination decrement bug:
* position is the cassettes PK (v9) and duplicate denominations across
* bays are legal, so cash-out decrements MUST address bays by position.
* A denomination-keyed UPDATE would drain every matching bay at once.
*
* Uses an in-memory SQLite database — fresh per test, no on-disk
* artifacts, no parallel-test interference.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
applyOperatorCassetteOps,
closeDatabase,
getAppliedOpIds,
getCassetteStateSeq,
getCashbox,
getCountsUncertainSince,
getInventory,
initDatabase,
loadCassettes,
markCountsUncertain,
recordTransaction,
setCassettes,
} from '../state-store.js'
const TX_BASE = {
fiatCents: 4000,
sats: 100_000,
feeSats: 5_000,
feeFraction: 0.05,
exchangeRate: 2500,
currency: 'USD',
}
/** Two $20 bays plus one $50 bay — the duplicate-denomination layout. */
function seedDuplicateDenomBays() {
setCassettes([
{ position: 1, denomination: 20, count: 50 },
{ position: 2, denomination: 20, count: 50 },
{ position: 3, denomination: 50, count: 30 },
])
}
function countsByPosition(): Record<number, number> {
const out: Record<number, number> = {}
for (const row of loadCassettes()) out[row.position] = row.count
return out
}
beforeEach(() => {
initDatabase(':memory:')
seedDuplicateDenomBays()
})
afterEach(() => {
closeDatabase()
})
describe('state-store: recordTransaction cash_out inventory', () => {
it('decrements only the bay that actually dispensed (duplicate denominations)', () => {
recordTransaction({
...TX_BASE,
txid: 'tx-single-bay',
type: 'cash_out',
status: 'complete',
bills: [{ denomination: 20, count: 3 }],
cassettes: [
{
name: 'cassette1',
position: 1,
denomination: 20,
provisioned: 3,
dispensed: 3,
rejected: 0,
},
{
name: 'cassette2',
position: 2,
denomination: 20,
provisioned: 0,
dispensed: 0,
rejected: 0,
},
],
})
expect(countsByPosition()).toEqual({ 1: 47, 2: 50, 3: 30 })
})
it('decrements each bay by its own dispensed count on a split dispense', () => {
recordTransaction({
...TX_BASE,
txid: 'tx-split-bays',
type: 'cash_out',
status: 'complete',
bills: [{ denomination: 20, count: 60 }],
cassettes: [
{
name: 'cassette1',
position: 1,
denomination: 20,
provisioned: 50,
dispensed: 50,
rejected: 0,
},
{
name: 'cassette2',
position: 2,
denomination: 20,
provisioned: 10,
dispensed: 10,
rejected: 0,
},
],
})
expect(countsByPosition()).toEqual({ 1: 0, 2: 40, 3: 30 })
})
it('fallback without cassette results drains matching bays greedily by position', () => {
recordTransaction({
...TX_BASE,
txid: 'tx-fallback',
type: 'cash_out',
status: 'complete',
bills: [{ denomination: 20, count: 60 }],
})
// Bay 1 (50 bills) drains fully, bay 2 covers the remaining 10.
expect(countsByPosition()).toEqual({ 1: 0, 2: 40, 3: 30 })
})
it('never drives a bay count below zero', () => {
recordTransaction({
...TX_BASE,
txid: 'tx-overdispense',
type: 'cash_out',
status: 'complete',
bills: [{ denomination: 50, count: 35 }],
cassettes: [
{
name: 'cassette3',
position: 3,
denomination: 50,
provisioned: 35,
dispensed: 35,
rejected: 0,
},
],
})
expect(countsByPosition()).toEqual({ 1: 50, 2: 50, 3: 0 })
})
})
describe('state-store: recordTransaction cash_in cashbox', () => {
it('adds inserted bills to the cashbox and leaves cassettes untouched', () => {
recordTransaction({
...TX_BASE,
txid: 'tx-cash-in',
type: 'cash_in',
status: 'complete',
bills: [
{ denomination: 20, count: 2 },
{ denomination: 50, count: 1 },
],
})
const cashbox = getCashbox()
expect(cashbox.totalBills).toBe(3)
expect(cashbox.totalFiatCents).toBe(TX_BASE.fiatCents)
expect(countsByPosition()).toEqual({ 1: 50, 2: 50, 3: 30 })
})
})
describe('state-store: recordTransaction manual_dispense inventory (#76)', () => {
it('decrements the bays an operator remediation actually emptied', () => {
recordTransaction({
...TX_BASE,
txid: 'tx-manual',
type: 'manual_dispense',
status: 'complete',
bills: [{ denomination: 20, count: 2 }],
cassettes: [
{
name: 'cassette1',
position: 1,
denomination: 20,
provisioned: 2,
dispensed: 2,
rejected: 0,
},
],
})
// Bills physically left bay 1; before #76 this row was untouched and the
// inflated count became truth on the next boot.
expect(countsByPosition()).toEqual({ 1: 48, 2: 50, 3: 30 })
})
it('decrements again when remediating a partly-dispensed cash-out', () => {
// Original cash-out managed 1 of the 2 notes it provisioned.
recordTransaction({
...TX_BASE,
txid: 'tx-partial',
type: 'cash_out',
status: 'partial',
bills: [{ denomination: 50, count: 1 }],
cassettes: [
{
name: 'cassette3',
position: 3,
denomination: 50,
provisioned: 2,
dispensed: 1,
rejected: 0,
},
],
})
expect(countsByPosition()[3]).toBe(29)
// The operator dispenses the missing note by hand. That is a second lot of
// bills leaving the bay, so it debits again — the original only ever
// debited what physically left.
recordTransaction({
...TX_BASE,
txid: 'tx-remediate',
type: 'manual_dispense',
status: 'complete',
bills: [{ denomination: 50, count: 1 }],
cassettes: [
{
name: 'cassette3',
position: 3,
denomination: 50,
provisioned: 1,
dispensed: 1,
rejected: 0,
},
],
})
expect(countsByPosition()[3]).toBe(28)
})
it('leaves the cashbox alone (bills leave, they do not arrive)', () => {
const before = getCashbox()
recordTransaction({
...TX_BASE,
txid: 'tx-manual-cashbox',
type: 'manual_dispense',
status: 'complete',
bills: [{ denomination: 20, count: 1 }],
cassettes: [
{
name: 'cassette2',
position: 2,
denomination: 20,
provisioned: 1,
dispensed: 1,
rejected: 0,
},
],
})
expect(getCashbox()).toEqual(before)
expect(countsByPosition()[2]).toBe(49)
})
})
describe('state-store: getInventory represents a drained machine', () => {
it('keeps configured bays at zero rather than dropping them', () => {
recordTransaction({
...TX_BASE,
txid: 'tx-drain-50s',
type: 'cash_out',
status: 'complete',
bills: [{ denomination: 50, count: 30 }],
cassettes: [
{
name: 'cassette3',
position: 3,
denomination: 50,
provisioned: 30,
dispensed: 30,
rejected: 0,
},
],
})
// The $50 bay is empty but still configured. Dropping the key made this
// look like "no inventory known", and callers then fell back to a stale
// snapshot or to HAL.
expect(getInventory()).toEqual({ 20: 100, 50: 0 })
})
it('reports every bay at zero when the machine is fully drained', () => {
for (const [txid, position, denomination, count] of [
['d1', 1, 20, 50],
['d2', 2, 20, 50],
['d3', 3, 50, 30],
] as const) {
recordTransaction({
...TX_BASE,
txid,
type: 'cash_out',
status: 'complete',
bills: [{ denomination, count }],
cassettes: [
{
name: `cassette${position}`,
position,
denomination,
provisioned: count,
dispensed: count,
rejected: 0,
},
],
})
}
expect(getInventory()).toEqual({ 20: 0, 50: 0 })
})
it('returns an empty map only when no cassettes are configured', () => {
// A fresh DB with no bays at all — the one case that should read as
// "nothing known", so callers may legitimately defer to the hardware.
closeDatabase()
initDatabase(':memory:')
expect(getInventory()).toEqual({})
})
})
describe('state-store: unverified counts after a silent dispense', () => {
it('starts clear, latches the first time, and keeps the earliest time', () => {
expect(getCountsUncertainSince()).toBeNull()
markCountsUncertain(1000)
expect(getCountsUncertainSince()).toBe(1000)
// A second failure does not move the clock forward — the question is how
// long the numbers have been untrustworthy, not when we last noticed.
markCountsUncertain(2000)
expect(getCountsUncertainSince()).toBe(1000)
})
it('clears on a recount, because that is what a recount is', () => {
markCountsUncertain(1000)
const result = applyOperatorCassetteOps([
{ id: 'op-1', at: 1_700_000_000, type: 'recount', position: 1, count: 40 },
])
expect(result.applied).toEqual(['op-1'])
expect(getCountsUncertainSince()).toBeNull()
})
it('does not clear on a refill', () => {
// A refill adds to a number still known to be wrong. Only someone
// opening the bay and counting it resolves that.
markCountsUncertain(1000)
const result = applyOperatorCassetteOps([
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 10 },
])
expect(result.applied).toEqual(['op-1'])
expect(getCountsUncertainSince()).toBe(1000)
})
it('leaves the flag alone when the op is rejected', () => {
markCountsUncertain(1000)
// Bay 9 does not exist — the layout is hardware-determined.
const result = applyOperatorCassetteOps([
{ id: 'op-1', at: 1_700_000_000, type: 'recount', position: 9, count: 40 },
])
expect(result.applied).toEqual([])
expect(result.rejected).toHaveLength(1)
expect(getCountsUncertainSince()).toBe(1000)
})
})
describe('state-store: operator cassette operations (ADR-004)', () => {
beforeEach(() => {
seedDuplicateDenomBays()
})
it('applies a refill as a delta, not a total', () => {
applyOperatorCassetteOps([
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 30 },
])
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(80)
})
it('is a no-op on a re-delivered operation', () => {
// Addressable events are re-delivered on every relay reconnect and the
// operator republishes a WINDOW, so the same op arrives many times. A
// delta applied twice is simply wrong, which is why every op carries an
// id and this table records the ones already applied.
const op = {
id: 'op-1',
at: 1_700_000_000,
type: 'refill' as const,
position: 1,
bills: 30,
}
expect(applyOperatorCassetteOps([op]).applied).toEqual(['op-1'])
expect(applyOperatorCassetteOps([op]).applied).toEqual([])
expect(applyOperatorCassetteOps([op, op]).applied).toEqual([])
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(80)
})
it('applies only the unseen ops from a window that mixes both', () => {
applyOperatorCassetteOps([
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 10 },
])
const result = applyOperatorCassetteOps([
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 10 },
{ id: 'op-2', at: 1_700_000_001, type: 'refill', position: 1, bills: 5 },
])
expect(result.applied).toEqual(['op-2'])
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(65)
})
it('applies a window oldest-first regardless of arrival order', () => {
// A recount then a refill is not the same as the reverse, so ordering is
// load-bearing and cannot be left to however the array arrived.
applyOperatorCassetteOps([
{ id: 'op-b', at: 1_700_000_002, type: 'refill', position: 1, bills: 7 },
{ id: 'op-a', at: 1_700_000_001, type: 'recount', position: 1, count: 3 },
])
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(10)
})
it('empties a bay and sets a denomination', () => {
applyOperatorCassetteOps([
{ id: 'op-1', at: 1_700_000_000, type: 'empty', position: 2 },
{ id: 'op-2', at: 1_700_000_001, type: 'set_denomination', position: 2, denomination: 10 },
])
const bay = loadCassettes().find((c) => c.position === 2)!
expect(bay.count).toBe(0)
expect(bay.denomination).toBe(10)
})
it('rejects a malformed op without applying or recording it', () => {
// Unrecorded on purpose: it stays pending on the operator's dashboard,
// which is the honest outcome. Recording it as applied would silence the
// noise by telling the operator their refill landed.
const result = applyOperatorCassetteOps([
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: -5 },
])
expect(result.applied).toEqual([])
expect(result.rejected[0]!.id).toBe('op-1')
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(50)
expect(getAppliedOpIds()).not.toContain('op-1')
})
it('echoes applied ids back, newest first', () => {
applyOperatorCassetteOps([
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 1 },
{ id: 'op-2', at: 1_700_000_001, type: 'refill', position: 1, bills: 1 },
])
expect(getAppliedOpIds()).toContain('op-1')
expect(getAppliedOpIds()).toContain('op-2')
})
it('advances the sequence on an applied op but not on a duplicate', () => {
const op = {
id: 'op-1',
at: 1_700_000_000,
type: 'refill' as const,
position: 1,
bills: 1,
}
const before = getCassetteStateSeq()
applyOperatorCassetteOps([op])
const after = getCassetteStateSeq()
expect(after).toBeGreaterThan(before)
applyOperatorCassetteOps([op])
expect(getCassetteStateSeq()).toBe(after)
})
it('advances the sequence on a dispense', () => {
const before = getCassetteStateSeq()
recordTransaction({
...TX_BASE,
txid: 'tx-seq',
type: 'cash_out',
status: 'complete',
bills: [{ denomination: 20, count: 1 }],
cassettes: [
{
name: 'cassette1',
position: 1,
denomination: 20,
provisioned: 1,
dispensed: 1,
rejected: 0,
},
],
})
expect(getCassetteStateSeq()).toBeGreaterThan(before)
})
})

View file

@ -0,0 +1,125 @@
import { describe, it, expect, vi } from 'vitest'
import { openCardSession, scanUrlToSessionUrl } from './boltcard-session'
const LNURLW =
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
/** Mock fetch returning the given JSON bodies per call, in order (status 200). */
function mockFetch(bodies: unknown[], status = 200) {
const calls: string[] = []
const impl = vi.fn(async (url: string | URL) => {
calls.push(url.toString())
const body = bodies[calls.length - 1]
return { status, json: async () => body } as Response
})
return { impl: impl as unknown as typeof fetch, calls }
}
const SESSION = {
authenticated: true,
external_id: 'abc123',
card_name: 'Alice',
balance_msat: 123_456_789,
currency: 'usd',
fiat: 98.76,
withdraw: {
callback: 'https://lnbits.l484.com/boltcards/api/v1/lnurl/cb/hit1',
k1: 'hit1',
minWithdrawable: 1000,
maxWithdrawable: 50_000_000,
},
withdraw_blocked_reason: null,
pay: {
callback: 'https://lnbits.l484.com/boltcards/api/v1/pay/cb/hit1',
minSendable: 1000,
maxSendable: 50_000_000,
metadata: '[["text/plain","Bolt Card top-up"]]',
},
}
describe('scanUrlToSessionUrl', () => {
it('rewrites /scan/ to /session/ and preserves p + c', () => {
const u = scanUrlToSessionUrl(LNURLW)
expect(u).toContain('https://lnbits.l484.com/boltcards/api/v1/session/abc123')
expect(u).toContain('p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF')
expect(u).toContain('c=1122334455667788')
})
it('returns null for a non-scan URL', () => {
expect(scanUrlToSessionUrl('lnurlw://host/somethingelse?p=1&c=2')).toBeNull()
expect(scanUrlToSessionUrl('http://host/boltcards/api/v1/scan/x')).toBeNull()
})
})
describe('openCardSession', () => {
it('opens a session: balance in sats, upper-cased currency, both steps', async () => {
const f = mockFetch([SESSION])
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
expect(f.calls).toHaveLength(1)
expect(f.calls[0]).toContain('/session/abc123')
expect(out).toEqual({
ok: true,
session: {
externalId: 'abc123',
cardName: 'Alice',
balanceSats: 123_456,
currency: 'USD',
fiat: 98.76,
withdraw: SESSION.withdraw,
withdrawBlockedReason: null,
pay: SESSION.pay,
},
})
})
it('carries a withheld withdraw step with its reason', async () => {
const f = mockFetch([
{ ...SESSION, withdraw: null, withdraw_blocked_reason: 'Max daily limit spent.' },
])
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
expect(out.ok).toBe(true)
if (!out.ok) return
expect(out.session.withdraw).toBeNull()
expect(out.session.withdrawBlockedReason).toBe('Max daily limit spent.')
expect(out.session.pay.callback).toBe(SESSION.pay.callback)
})
it('has no fiat when the server sent no currency', async () => {
const f = mockFetch([{ ...SESSION, currency: null, fiat: null }])
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
expect(out.ok && out.session.currency).toBeNull()
expect(out.ok && out.session.fiat).toBeNull()
})
it('surfaces the server reason on a rejected tap', async () => {
const f = mockFetch([{ authenticated: false, reason: 'This link is already used.' }])
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
expect(out).toEqual({ ok: false, reason: 'This link is already used.' })
})
it('rejects an incomplete session (no pay step)', async () => {
const f = mockFetch([{ ...SESSION, pay: undefined }])
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
expect(out).toEqual({ ok: false, reason: 'card server returned an incomplete session' })
})
it('names an old card server that has no /session', async () => {
const f = mockFetch([{ detail: 'Not Found' }], 404)
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
expect(out).toEqual({ ok: false, reason: 'card server does not support sessions' })
})
it('rejects a non-card tag without a network call', async () => {
const f = mockFetch([])
const out = await openCardSession('https://host/not/a/card', { fetchImpl: f.impl })
expect(out.ok).toBe(false)
expect(f.calls).toHaveLength(0)
})
it('reports an unreachable card server', async () => {
const impl = vi.fn(async () => {
throw new TypeError('fetch failed')
}) as unknown as typeof fetch
const out = await openCardSession(LNURLW, { fetchImpl: impl })
expect(out).toEqual({ ok: false, reason: 'could not reach the card: fetch failed' })
})
})

View file

@ -0,0 +1,167 @@
/**
* Bolt Card session (tap-to-enter) — one tap, one verified visit.
*
* A Bolt Card tap yields a single-use SUN `p`/`c`; anything that verifies it
* spends it. The access gate (ADR-003) wants to verify the card at entry AND
* let the holder finish a buy or sell later without tapping again, so the
* aiolabs `boltcards` fork exposes `/session/<external_id>?p=&c=` — a sibling
* of `/scan` and `/pay` that verifies once, records one hit, and returns:
* - the card wallet's balance and fiat equivalent (display only),
* - the LUD-03 second step (withdraw callback + k1 = hit) for cash-out,
* - the LUD-06 second step (pay callback) for cash-in.
* Both callbacks are keyed by the hit — the same single-use bearer `/scan`
* and `/pay` hand out — so the ATM holds no p/c for the rest of the visit.
* The withdraw step is withheld (with a reason) once the card's daily limit is
* spent, exactly as `/scan` would refuse.
*
* Runs in the MAIN process (Node fetch) to avoid renderer CORS, like the other
* LNURL modules. See docs/boltcard-session.md for the wire contract.
*/
import { lnurlwToHttps, type WithdrawStep } from './lnurl-withdraw.js'
import type { PayStep } from './lnurl-pay.js'
export interface CardSession {
externalId: string
cardName: string
balanceSats: number
/** ISO currency the card server priced the balance in; null → no fiat. */
currency: string | null
/** Balance in `currency` at the card server's rate; null when unknown. */
fiat: number | null
/** LUD-03 second step, or null when the card server withheld it. */
withdraw: WithdrawStep | null
/** Why `withdraw` is null (e.g. daily limit spent); safe to show on-screen. */
withdrawBlockedReason: string | null
/** LUD-06 second step for topping the card wallet up. */
pay: PayStep
}
export type OpenCardSessionResult =
| { ok: true; session: CardSession }
| { ok: false; reason: string }
type FetchLike = typeof fetch
export interface OpenCardSessionOptions {
/** Injected for tests; defaults to global fetch. */
fetchImpl?: FetchLike
/** Per-request timeout (default 15s). */
timeoutMs?: number
}
/**
* Derive the session URL from a tapped card's `lnurlw`: the card presents
* `…/boltcards/api/v1/scan/<id>?p=&c=`; the session endpoint is its sibling
* `…/boltcards/api/v1/session/<id>?p=&c=` with the same SUN.
*/
export function scanUrlToSessionUrl(lnurlw: string): string | null {
const https = lnurlwToHttps(lnurlw)
if (!https) return null
const u = new URL(https)
if (!u.pathname.includes('/scan/')) return null
u.pathname = u.pathname.replace('/scan/', '/session/')
return u.toString()
}
/** Wire shape of a `/session` reply (any of the fields may be missing/odd). */
interface SessionWire {
authenticated?: unknown
reason?: unknown
external_id?: unknown
card_name?: unknown
balance_msat?: unknown
currency?: unknown
fiat?: unknown
withdraw?: unknown
withdraw_blocked_reason?: unknown
pay?: unknown
}
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
const optNum = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
const optStr = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
function parseWithdraw(v: unknown): WithdrawStep | null {
if (!isObj(v)) return null
const callback = optStr(v.callback)
const k1 = optStr(v.k1)
if (!callback || !k1) return null
return {
callback,
k1,
minWithdrawable: optNum(v.minWithdrawable),
maxWithdrawable: optNum(v.maxWithdrawable),
}
}
function parsePay(v: unknown): PayStep | null {
if (!isObj(v)) return null
const callback = optStr(v.callback)
if (!callback) return null
return {
callback,
minSendable: optNum(v.minSendable),
maxSendable: optNum(v.maxSendable),
metadata: optStr(v.metadata),
}
}
function errMsg(e: unknown): string {
if (e instanceof Error)
return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
return String(e)
}
/**
* Open a session for a tapped card. Spends the tap's SUN. Never throws —
* every failure returns `{ ok: false, reason }` (reasons come from the card
* server verbatim and are safe to show).
*/
export async function openCardSession(
lnurlw: string,
opts: OpenCardSessionOptions = {}
): Promise<OpenCardSessionResult> {
const doFetch = opts.fetchImpl ?? fetch
const timeoutMs = opts.timeoutMs ?? 15_000
const url = scanUrlToSessionUrl(lnurlw)
if (!url) return { ok: false, reason: 'not a valid Bolt Card (lnurlw) tag' }
let wire: SessionWire
try {
const res = await doFetch(url, { signal: AbortSignal.timeout(timeoutMs) })
if (res.status === 404) {
// Older fork without /session — say so rather than "card rejected".
return { ok: false, reason: 'card server does not support sessions' }
}
wire = (await res.json()) as SessionWire
} catch (e) {
return { ok: false, reason: `could not reach the card: ${errMsg(e)}` }
}
if (wire.authenticated !== true) {
return { ok: false, reason: optStr(wire.reason) || 'card rejected the tap' }
}
const externalId = optStr(wire.external_id)
const pay = parsePay(wire.pay)
if (!externalId || !pay) {
return { ok: false, reason: 'card server returned an incomplete session' }
}
const balanceMsat = optNum(wire.balance_msat) ?? 0
const currency = optStr(wire.currency)?.toUpperCase() ?? null
const fiat = optNum(wire.fiat)
return {
ok: true,
session: {
externalId,
cardName: optStr(wire.card_name) ?? '',
balanceSats: Math.floor(balanceMsat / 1000),
currency,
fiat: currency && fiat !== undefined ? fiat : null,
withdraw: parseWithdraw(wire.withdraw),
withdrawBlockedReason: optStr(wire.withdraw_blocked_reason) ?? null,
pay,
},
}
}

View file

@ -13,8 +13,15 @@
*/ */
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { NostrClient, loadIdentityFromHex } from '@bitSpire/nostr-client' import {
NostrClient,
LocalSigner,
loadIdentityFromHex,
resumeFromBinding,
type Signer,
} from '@bitSpire/nostr-client'
import { LnbitsClient } from '@bitSpire/lnbits' import { LnbitsClient } from '@bitSpire/lnbits'
import { initDatabase, getBunkerBinding } from './state-store.js'
// @ts-ignore — qrcode is a transitive dep (via qrcode.vue), no types needed // @ts-ignore — qrcode is a transitive dep (via qrcode.vue), no types needed
import QRCode from 'qrcode' import QRCode from 'qrcode'
@ -56,19 +63,38 @@ async function main() {
const lnbitsServerPubkey = env['VITE_LNBITS_SERVER_PUBKEY'] const lnbitsServerPubkey = env['VITE_LNBITS_SERVER_PUBKEY']
const atmPrivateKey = env['VITE_ATM_PRIVATE_KEY'] const atmPrivateKey = env['VITE_ATM_PRIVATE_KEY']
if (!relayUrl || !lnbitsServerPubkey || !atmPrivateKey) { if (!relayUrl || !lnbitsServerPubkey) {
console.error('Missing required config in', envPath) console.error('Missing required config in', envPath)
console.error('Need: VITE_RELAY_URL, VITE_LNBITS_SERVER_PUBKEY, VITE_ATM_PRIVATE_KEY') console.error('Need: VITE_RELAY_URL, VITE_LNBITS_SERVER_PUBKEY')
process.exit(1) process.exit(1)
} }
console.error(`Generating invoice for ${amountSats} sats...`) console.error(`Generating invoice for ${amountSats} sats...`)
const identity = loadIdentityFromHex(atmPrivateKey) // Resolve the signer. Prod: resume the bunker binding from state.db (the
// ATM's transport key — the connect token was already redeemed by the main
// app, so we can't re-pair here). Dev: a local nsec via VITE_ATM_PRIVATE_KEY.
let signer: Signer
if (atmPrivateKey) {
signer = new LocalSigner(loadIdentityFromHex(atmPrivateKey))
} else {
initDatabase()
const binding = getBunkerBinding()
if (!binding) {
console.error('ATM is not paired (no bunker binding in state.db) and no')
console.error('VITE_ATM_PRIVATE_KEY set. Pair the ATM via the main app first.')
process.exit(1)
}
signer = await resumeFromBinding({
clientSecretHex: binding.clientSecretHex,
spirePubkey: binding.spirePubkey,
bunkerUrl: binding.bunkerUrl,
})
}
const nostrClient = new NostrClient({ const nostrClient = new NostrClient({
relays: [{ url: relayUrl }], relays: [{ url: relayUrl }],
identity, signer,
}) })
await nostrClient.connect() await nostrClient.connect()
@ -76,7 +102,7 @@ async function main() {
serverPubkey: lnbitsServerPubkey, serverPubkey: lnbitsServerPubkey,
relays: [relayUrl], relays: [relayUrl],
}) })
lnbits.initialize(nostrClient, identity) lnbits.initialize(nostrClient, signer)
const wallets = await lnbits.listWallets() const wallets = await lnbits.listWallets()
const wallet = wallets[0] const wallet = wallets[0]

View file

@ -6,7 +6,8 @@
* so it's available at runtime (unlike src/ which is only for Vite). * so it's available at runtime (unlike src/ which is only for Vite).
*/ */
import type { BillValidator, BillDispenser } from '@bitSpire/hal' import type { BillValidator, BillDispenser, DispenseErrorClass } from '@bitSpire/hal'
import { isDispenseError } from '@bitSpire/hal'
export interface CassetteConfig { export interface CassetteConfig {
/** /**
@ -36,6 +37,11 @@ export interface HalConfig {
export interface ValidatorCallbacks { export interface ValidatorCallbacks {
shouldAcceptBill: (denomination: number) => boolean | 'hold' shouldAcceptBill: (denomination: number) => boolean | 'hold'
onBillRead?: (denomination: number) => void onBillRead?: (denomination: number) => void
/**
* Fires on the validator's stacked-confirmation (`billsValid`) — the
* bill physically reached the stacker. This is the CREDIT event; it is
* NOT emitted at stack-command time (a stack can still fail/return).
*/
onBillInserted: (denomination: number) => void onBillInserted: (denomination: number) => void
onBillRejected: (reason: string) => void onBillRejected: (reason: string) => void
onError: (error: string) => void onError: (error: string) => void
@ -43,8 +49,20 @@ export interface ValidatorCallbacks {
export interface DispenseResult { export interface DispenseResult {
bills: { denomination: number; dispensed: number; rejected: number }[] bills: { denomination: number; dispensed: number; rejected: number }[]
dispensed: boolean /**
* Σ(denomination × dispensed) === Σ(denomination × requested). Computed
* here on VALUE (ADR-005 §3) — never a driver boolean. Only this routes
* the state machine to `complete`.
*/
dispenseConfirmed: boolean
/** Human message when not confirmed */
error?: string error?: string
/** The error's NAME — 'F56DispenseError', 'InsufficientInventory', … */
errorCode?: string
/** Driver-native code, e.g. '78 42' */
rawCode?: string
/** terminal | recoverable | inventory — see @bitSpire/hal error-codes */
errorClass?: DispenseErrorClass
cassettes?: { cassettes?: {
name: string name: string
position: number position: number
@ -142,6 +160,14 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
count: c.count ?? 0, count: c.count ?? 0,
})) }))
// Escrow / in-flight bookkeeping (legacy brain.js `billsRead` interlock):
// `escrowDenomination` = bill held in escrow awaiting a stack/reject
// decision; `inFlightDenomination` = stack commanded, awaiting the
// validator's `billsValid` stacked-confirmation. onBillInserted (the
// credit event) fires only on that confirmation.
let escrowDenomination: number | null = null
let inFlightDenomination: number | null = null
return { return {
connectValidator: (callbacks: ValidatorCallbacks) => { connectValidator: (callbacks: ValidatorCallbacks) => {
if (!validator) { if (!validator) {
@ -153,10 +179,11 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
const decision = callbacks.shouldAcceptBill(data.denomination) const decision = callbacks.shouldAcceptBill(data.denomination)
if (decision === 'hold') { if (decision === 'hold') {
console.log('[HAL] Bill in escrow:', data.denomination) console.log('[HAL] Bill in escrow:', data.denomination)
escrowDenomination = data.denomination
callbacks.onBillRead?.(data.denomination) callbacks.onBillRead?.(data.denomination)
} else if (decision) { } else if (decision) {
inFlightDenomination = data.denomination
validator.stack() validator.stack()
callbacks.onBillInserted(data.denomination)
} else { } else {
console.log('[HAL] Bill rejected: insufficient balance for', data.denomination) console.log('[HAL] Bill rejected: insufficient balance for', data.denomination)
validator.reject() validator.reject()
@ -168,7 +195,23 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
} }
}) })
// Stacked-confirmation → the credit event.
validator.on('billsValid', () => {
if (inFlightDenomination === null) {
console.warn('[HAL] billsValid with no bill in flight — ignoring')
return
}
const denomination = inFlightDenomination
inFlightDenomination = null
console.log('[HAL] Bill stacked (confirmed):', denomination)
callbacks.onBillInserted(denomination)
})
validator.on('billsRejected', (data?: { reason: string; code: number | null }) => { validator.on('billsRejected', (data?: { reason: string; code: number | null }) => {
// Covers both an escrow refusal and a failed/returned stack —
// either way nothing was credited and nothing is in flight.
escrowDenomination = null
inFlightDenomination = null
callbacks.onBillRejected(data?.reason ?? 'unknown') callbacks.onBillRejected(data?.reason ?? 'unknown')
}) })
@ -191,12 +234,32 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
}, },
disableValidator: () => { disableValidator: () => {
// If a note is sitting in escrow when we disable (inactivity timeout,
// cancel, or leaving the insert screen), return it to the customer.
// Disabling alone does NOT release an escrowed note on EBDS — it would
// be stranded in the transport until the next power cycle.
if (escrowDenomination !== null) {
console.log('[HAL] Returning escrowed bill on disable:', escrowDenomination)
escrowDenomination = null
validator?.reject()
}
validator?.disable() validator?.disable()
validator?.lightOff() validator?.lightOff()
}, },
stackBill: () => validator?.stack(), stackBill: () => {
rejectBill: () => validator?.reject(), if (escrowDenomination === null) {
console.warn('[HAL] stackBill with no bill in escrow — ignoring')
return
}
inFlightDenomination = escrowDenomination
escrowDenomination = null
validator?.stack()
},
rejectBill: () => {
escrowDenomination = null
validator?.reject()
},
dispenseCash: async (amounts): Promise<DispenseResult> => { dispenseCash: async (amounts): Promise<DispenseResult> => {
console.log('[HAL] Dispensing:', amounts) console.log('[HAL] Dispensing:', amounts)
@ -227,6 +290,8 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
notes[i] = (notes[i] ?? 0) + take notes[i] = (notes[i] ?? 0) + take
remaining -= take remaining -= take
} }
// Nothing has been asked of the hardware in either refusal below:
// errorClass 'inventory' routes to outOfCash, not the fault screen.
if (!matched) { if (!matched) {
return { return {
bills: amounts.map((a) => ({ bills: amounts.map((a) => ({
@ -234,8 +299,10 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
dispensed: 0, dispensed: 0,
rejected: 0, rejected: 0,
})), })),
dispensed: false, dispenseConfirmed: false,
error: `No cassette loaded with denomination: ${denomination}`, error: `No cassette loaded with denomination: ${denomination}`,
errorCode: 'NoCassetteForDenomination',
errorClass: 'inventory',
} }
} }
if (remaining > 0) { if (remaining > 0) {
@ -245,8 +312,10 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
dispensed: 0, dispensed: 0,
rejected: 0, rejected: 0,
})), })),
dispensed: false, dispenseConfirmed: false,
error: `Insufficient inventory for denomination ${denomination}: short ${remaining}`, error: `Insufficient inventory for denomination ${denomination}: short ${remaining}`,
errorCode: 'InsufficientInventory',
errorClass: 'inventory',
} }
} }
} }
@ -294,11 +363,44 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
} }
const bills = Array.from(billsByDenom.values()) const bills = Array.from(billsByDenom.values())
const totalRequested = amounts.reduce((s, a) => s + a.count, 0) // ADR-005 §3: confirmation is VALUE equality — what left the bays is
// worth exactly what was asked — not a count, and not the driver's
// opinion. lamassu computed the same thing (`tx.fiat.eq(Σ denomination
// × dispensed)`); our previous count-based check was only equivalent
// while every bay dispensed its own denomination.
const requestedValue = amounts.reduce((s, a) => s + a.denomination * a.count, 0)
const dispensedValue = cassetteResults.reduce((s, c) => s + c.denomination * c.dispensed, 0)
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0) const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
const dispenseConfirmed = requestedValue === dispensedValue
if (result.error) { if (result.error) {
return { bills, cassettes: cassetteResults, dispensed: false, error: result.error.message } const e = result.error
const info = isDispenseError(e)
? { errorCode: e.errorCode, rawCode: e.rawCode, errorClass: e.errorClass, human: e.human }
: {
// Unreachable by type (drivers always tag), kept as a defensive
// fallback for a driver that slips an untagged Error through.
errorCode: (e as Error).name || 'DispenseError',
rawCode: undefined,
errorClass: 'terminal' as const,
human: (e as Error).message,
}
console.error(
`[HAL] Dispense error ${info.errorCode}${info.rawCode ? ` ${info.rawCode}` : ''} (${info.errorClass}): ${info.human} — requested ${requestedValue}, dispensed ${dispensedValue}`
)
// A dispensed value of zero WITH an error is not evidence that nothing
// left the bay — a note stopped in the transport completes neither
// counter (sintra, 2026-10-09). The store reads this combination and
// flags counts unverified; we just report faithfully here.
return {
bills,
cassettes: cassetteResults,
dispenseConfirmed: false,
error: info.human,
errorCode: info.errorCode,
rawCode: info.rawCode,
errorClass: info.errorClass,
}
} }
// Wait for customer to take bills // Wait for customer to take bills
@ -307,7 +409,20 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
console.log('[HAL] Bills removed by customer') console.log('[HAL] Bills removed by customer')
} }
return { bills, cassettes: cassetteResults, dispensed: totalRequested === totalDispensed } if (!dispenseConfirmed) {
// Short with no hardware error — the dispenser simply gave less.
console.warn(`[HAL] Dispense short with no error: requested ${requestedValue}, dispensed ${dispensedValue}`)
return {
bills,
cassettes: cassetteResults,
dispenseConfirmed: false,
error: `Dispensed ${dispensedValue} of ${requestedValue} with no dispenser error`,
errorCode: 'DispenseShort',
errorClass: 'inventory',
}
}
return { bills, cassettes: cassetteResults, dispenseConfirmed: true }
}, },
/** /**
@ -326,12 +441,10 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
setCassettes: async (cassettes: CassetteConfig[]): Promise<void> => { setCassettes: async (cassettes: CassetteConfig[]): Promise<void> => {
console.log( console.log(
'[HAL] Hot-reloading cassette layout:', '[HAL] Hot-reloading cassette layout:',
cassettes cassettes.map((c) => `bay${c.position}:${c.denomination}×${c.count ?? 0}`).join(', ')
.map((c) => `bay${c.position}:${c.denomination}×${c.count ?? 0}`)
.join(', ')
) )
// Rebuild bays first so subsequent dispense calls see the new layout const previousBays = bays
// even if the dispenser re-init is slow / fails. const previousInitData = dispenserInitData
bays = cassettes bays = cassettes
.slice() .slice()
.sort((a, b) => a.position - b.position) .sort((a, b) => a.position - b.position)
@ -341,31 +454,40 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
count: c.count ?? 0, count: c.count ?? 0,
})) }))
dispenserInitData = { fiatCode: valConfig.fiatCode, cassettes } dispenserInitData = { fiatCode: valConfig.fiatCode, cassettes }
// Close + re-init the dispenser so its internal per-bay state matches // Close + re-init the dispenser so its internal per-bay state matches the
// the new layout. Errors here surface to the caller (operator-config // new layout. close() now resolves only once the port is really closed,
// consumer) — the renderer can decide whether to retry. // so the re-open below cannot race it (aiolabs/bitspire#118).
//
// On failure, roll the in-memory layout back. This reverses an earlier
// deliberate choice to keep the new bays "even if the dispenser re-init
// is slow / fails": with the re-init failing every time on douro, the app
// kept a layout the device had never taken and the operator-config
// consumer went on to publish a cassettes-state event advertising it. A
// subsequent dispense would then pick bays by a layout the hardware does
// not share. Better to surface the failure and stay truthful about what
// the device is actually running.
try { try {
dispenser.close() await dispenser.close()
await dispenser.init(dispenserInitData)
} catch (err) { } catch (err) {
console.warn('[HAL] Dispenser close during setCassettes raised:', err) bays = previousBays
dispenserInitData = previousInitData
console.error('[HAL] Dispenser re-init failed; keeping the previous cassette layout:', err)
throw err
} }
await dispenser.init(dispenserInitData)
console.log('[HAL] Dispenser re-initialized with new cassettes') console.log('[HAL] Dispenser re-initialized with new cassettes')
}, },
cleanup: async () => { cleanup: async () => {
return new Promise<void>((resolve) => { validator?.disable()
validator?.disable() validator?.lightOff()
validator?.lightOff() await dispenser.close()
dispenser.close() if (!validator) return
if (validator) { await new Promise<void>((resolve) => {
validator.close((err?: Error) => { validator?.close((err?: Error) => {
if (err) console.error('[HAL] Validator close error:', err) if (err) console.error('[HAL] Validator close error:', err)
resolve()
})
} else {
resolve() resolve()
} })
}) })
}, },
} }

View file

@ -0,0 +1,165 @@
import { describe, it, expect, vi } from 'vitest'
import {
resolveCardInvoice,
resolveInvoiceFromPayStep,
scanUrlToResolver,
lnAddressToLnurlp,
} from './lnurl-pay'
const LNURLW =
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
const BOLT11 = 'lnbc10u1p3xyz...'
/** Mock fetch that returns the given JSON bodies per call, in order. */
function mockFetch(bodies: unknown[]) {
const calls: string[] = []
const impl = vi.fn(async (url: string | URL) => {
calls.push(url.toString())
const body = bodies[calls.length - 1]
return { json: async () => body } as Response
})
return { impl: impl as unknown as typeof fetch, calls }
}
describe('scanUrlToResolver', () => {
it('rewrites /scan/ to /pay/ and preserves p + c', () => {
const r = scanUrlToResolver(LNURLW)
expect(r).toContain('https://lnbits.l484.com/boltcards/api/v1/pay/abc123')
expect(r).toContain('p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF')
expect(r).toContain('c=1122334455667788')
})
it('returns null for a non-scan URL', () => {
expect(scanUrlToResolver('lnurlw://host/somethingelse?p=1&c=2')).toBeNull()
expect(scanUrlToResolver('http://host/boltcards/api/v1/scan/x')).toBeNull()
})
})
describe('lnAddressToLnurlp', () => {
it('maps name@host to the well-known lnurlp URL', () => {
expect(lnAddressToLnurlp('cardname@l484.com')).toBe(
'https://l484.com/.well-known/lnurlp/cardname'
)
})
it('rejects non-addresses', () => {
expect(lnAddressToLnurlp('not-an-address')).toBeNull()
expect(lnAddressToLnurlp('')).toBeNull()
})
})
describe('resolveCardInvoice', () => {
const payReq = {
tag: 'payRequest',
callback: 'https://lnbits.l484.com/lnurlp/api/v1/lnurl/cb',
minSendable: 1000,
maxSendable: 100_000_000,
metadata: '[["text/plain","bolt card top-up"]]',
}
it('resolver returns a payRequest inline → fetches the invoice', async () => {
const { impl, calls } = mockFetch([payReq, { pr: BOLT11 }])
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
expect(res).toEqual({ ok: true, bolt11: BOLT11 })
// 1st call = the /pay resolver; 2nd = the callback with amount in msat.
expect(calls[0]).toContain('/boltcards/api/v1/pay/abc123')
expect(calls[1]).toContain('amount=21000')
})
it('resolver returns a Lightning Address → LUD-16 → invoice', async () => {
const { impl, calls } = mockFetch([
{ lightningAddress: 'cardname@l484.com' },
payReq,
{ pr: BOLT11 },
])
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
expect(res).toEqual({ ok: true, bolt11: BOLT11 })
expect(calls[1]).toBe('https://l484.com/.well-known/lnurlp/cardname')
expect(calls[2]).toContain('amount=21000')
})
it('rejects a non-lnurlw tag', async () => {
const { impl } = mockFetch([])
const res = await resolveCardInvoice('http://nope', 21_000, { fetchImpl: impl })
expect(res).toMatchObject({ ok: false })
expect(res.reason).toMatch(/not a valid Bolt Card/i)
})
it('rejects a zero amount', async () => {
const { impl } = mockFetch([])
const res = await resolveCardInvoice(LNURLW, 0, { fetchImpl: impl })
expect(res).toMatchObject({ ok: false, reason: 'no amount to send' })
})
it('surfaces an ERROR from the resolver (bad SUN)', async () => {
const { impl } = mockFetch([{ status: 'ERROR', reason: 'invalid card' }])
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
expect(res).toMatchObject({ ok: false, reason: 'invalid card' })
})
it('rejects (without calling the callback) when the amount exceeds maxSendable', async () => {
const { impl, calls } = mockFetch([{ ...payReq, maxSendable: 5000 }])
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
expect(res).toMatchObject({ ok: false, reason: 'amount is above the card wallet maximum' })
expect(calls).toHaveLength(1) // callback never hit
})
it('surfaces an ERROR from the pay callback', async () => {
const { impl } = mockFetch([payReq, { status: 'ERROR', reason: 'wallet frozen' }])
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
expect(res).toMatchObject({ ok: false, reason: 'wallet frozen' })
})
it('rejects when the card wallet has no receive address', async () => {
const { impl } = mockFetch([{ foo: 'bar' }])
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
expect(res).toMatchObject({ ok: false, reason: 'card wallet has no receive address' })
})
it('handles a network failure gracefully', async () => {
const impl = vi.fn(async () => {
throw new Error('ECONNREFUSED')
}) as unknown as typeof fetch
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
expect(res.ok).toBe(false)
expect(res.reason).toMatch(/could not reach the card/i)
})
})
describe('resolveInvoiceFromPayStep (session second step, no tap)', () => {
const step = {
callback: 'https://lnbits.l484.com/boltcards/api/v1/pay/cb/hit1',
minSendable: 1000,
maxSendable: 50_000_000,
metadata: '[["text/plain","Bolt Card top-up"]]',
}
it('fetches an invoice for the amount from the callback', async () => {
const f = mockFetch([{ pr: BOLT11 }])
const out = await resolveInvoiceFromPayStep(step, 25_000, { fetchImpl: f.impl })
expect(out).toEqual({ ok: true, bolt11: BOLT11 })
expect(f.calls).toHaveLength(1)
expect(f.calls[0]).toContain('amount=25000')
})
it('enforces the step bounds without calling out', async () => {
const f = mockFetch([])
expect(await resolveInvoiceFromPayStep(step, 500, { fetchImpl: f.impl })).toEqual({
ok: false,
reason: 'amount is below the card wallet minimum',
})
expect(await resolveInvoiceFromPayStep(step, 60_000_000, { fetchImpl: f.impl })).toEqual({
ok: false,
reason: 'amount is above the card wallet maximum',
})
expect(await resolveInvoiceFromPayStep(step, 0, { fetchImpl: f.impl })).toEqual({
ok: false,
reason: 'no amount to send',
})
expect(f.calls).toHaveLength(0)
})
it('surfaces a callback decline', async () => {
const f = mockFetch([{ status: 'ERROR', reason: 'Card is disabled.' }])
const out = await resolveInvoiceFromPayStep(step, 25_000, { fetchImpl: f.impl })
expect(out).toEqual({ ok: false, reason: 'Card is disabled.' })
})
})

View file

@ -0,0 +1,252 @@
/**
* LNURL-pay resolver (LUD-06 / LUD-16) — the ATM as the *paying* party.
*
* Bolt Card tap-to-RECEIVE for the cash-in (buy) flow. A Bolt Card only ever
* emits its `lnurlw://…?p=…&c=…` voucher — a *withdraw* (spend) credential — so
* we can't push sats into it directly. Instead the tap is used as an
* authenticated identity (external_id + SUN p/c) to look up the card wallet's
* *pay* target, then the ATM fetches an invoice for the payout amount:
* 1. resolveCardPayTarget — GET the boltcards `/pay/<id>?p=&c=` resolver
* (a sibling of `/scan`); it verifies the same SUN and returns the card
* wallet's Lightning Address / lnurlp (or a LUD-06 payRequest directly).
* 2. toPayRequest → LUD-16 (Lightning Address) or LUD-06 fetch → payRequest.
* 3. requestInvoice — GET `callback?amount=<msat>` → a BOLT11 for the amount.
* The returned BOLT11 is handed back to the renderer, which pays it over the
* ATM's existing LNbits/nostr transport (stores/atm.ts `payInvoice`), so
* settlement + PAYMENT_RECEIVED reuse the tested cash-in completion path.
*
* Runs in the MAIN process (Node fetch) to avoid renderer CORS, exactly like
* lnurl-withdraw.ts.
*
* Transport seam: `resolveCardPayTarget()` is the single HTTPS-today /
* Nostr-tomorrow swap point. The rest is standard LNURL-pay against whatever
* pay target it returns and is transport-independent.
*/
import { lnurlwToHttps } from './lnurl-withdraw.js'
export interface ResolveCardInvoiceResult {
ok: boolean
/** BOLT11 to pay when ok; the renderer settles it over the nostr transport. */
bolt11?: string
/** Human-readable reason when ok is false (safe to surface on-screen). */
reason?: string
}
type FetchLike = typeof fetch
export interface ResolveCardInvoiceOptions {
/** Injected for tests; defaults to global fetch. */
fetchImpl?: FetchLike
/** Per-request timeout (default 15s). */
timeoutMs?: number
}
/** Resolver response — any of these shapes is accepted (see the spec doc). */
interface CardPayTarget {
status?: string
reason?: string
// (a) a LUD-06 payRequest, inline
tag?: string
callback?: string
minSendable?: number
maxSendable?: number
metadata?: string
// (b) a Lightning Address, e.g. "cardname@l484.com"
lightningAddress?: string
// (c) an lnurlp pointer (https or lnurl://)
lnurlp?: string
lnurl?: string
}
/**
* The LUD-06 second step on its own: what a `payRequest` (or a Bolt Card
* session, see boltcard-session.ts) hands us to fetch an invoice.
*/
export interface PayStep {
callback: string
minSendable?: number
maxSendable?: number
metadata?: string
}
/** LUD-06 payRequest (subset) + error shape. */
interface PayRequest {
tag?: string
callback?: string
minSendable?: number
maxSendable?: number
metadata?: string
status?: string
reason?: string
}
/** LUD-06 second-response (the callback body). */
interface PayValues {
pr?: string
status?: string
reason?: string
}
interface Ctx {
doFetch: FetchLike
timeoutMs: number
}
function errMsg(e: unknown): string {
if (e instanceof Error)
return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
return String(e)
}
function appendQuery(url: string, params: Record<string, string>): string {
const u = new URL(url)
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v)
return u.toString()
}
/**
* Derive the boltcards *pay* resolver URL from a tapped card's `lnurlw`.
* The card presents `…/boltcards/api/v1/scan/<id>?p=&c=` (a withdraw voucher);
* the receive resolver is its sibling `…/boltcards/api/v1/pay/<id>?p=&c=`,
* carrying the same SUN p/c. This is the HTTPS transport seam — a future
* nostr-native card would resolve the same identity over nostr instead.
*/
export function scanUrlToResolver(lnurlw: string): string | null {
const https = lnurlwToHttps(lnurlw)
if (!https) return null
const u = new URL(https)
if (!u.pathname.includes('/scan/')) return null
u.pathname = u.pathname.replace('/scan/', '/pay/')
return u.toString()
}
/** LUD-16: map a Lightning Address `name@host` to its lnurlp URL. */
export function lnAddressToLnurlp(addr: string): string | null {
const m = addr.trim().match(/^([a-z0-9._%+-]+)@([a-z0-9.-]+)$/i)
if (!m) return null
return `https://${m[2]}/.well-known/lnurlp/${m[1]}`
}
async function fetchPayRequest(
url: string,
ctx: Ctx
): Promise<{ ok: true; payRequest: PayRequest } | { ok: false; reason: string }> {
let body: PayRequest
try {
const res = await ctx.doFetch(url, { signal: AbortSignal.timeout(ctx.timeoutMs) })
body = (await res.json()) as PayRequest
} catch (e) {
return { ok: false, reason: `could not reach the card wallet: ${errMsg(e)}` }
}
if (body.status === 'ERROR') {
return { ok: false, reason: body.reason || 'card wallet rejected the request' }
}
if (body.tag !== 'payRequest' || !body.callback) {
return { ok: false, reason: 'card wallet did not return a pay request' }
}
return { ok: true, payRequest: body }
}
/** Turn a resolver response into a LUD-06 payRequest (fetching if needed). */
async function toPayRequest(
target: CardPayTarget,
ctx: Ctx
): Promise<{ ok: true; payRequest: PayRequest } | { ok: false; reason: string }> {
// (a) resolver returned a LUD-06 payRequest inline.
if (target.tag === 'payRequest' && target.callback) {
return { ok: true, payRequest: target }
}
// (b) resolver returned a Lightning Address (the common case here).
if (typeof target.lightningAddress === 'string') {
const url = lnAddressToLnurlp(target.lightningAddress)
if (!url) return { ok: false, reason: 'card wallet address is invalid' }
return fetchPayRequest(url, ctx)
}
// (c) resolver returned an lnurlp pointer.
const pointer = target.lnurlp ?? target.lnurl
if (typeof pointer === 'string') {
const url = lnurlwToHttps(pointer)
if (!url) return { ok: false, reason: 'card wallet lnurlp is invalid' }
return fetchPayRequest(url, ctx)
}
return { ok: false, reason: 'card wallet has no receive address' }
}
async function requestInvoice(
pr: PayStep,
amountMsat: number,
ctx: Ctx
): Promise<ResolveCardInvoiceResult> {
if (!(amountMsat > 0)) return { ok: false, reason: 'no amount to send' }
if (typeof pr.minSendable === 'number' && amountMsat < pr.minSendable) {
return { ok: false, reason: 'amount is below the card wallet minimum' }
}
if (typeof pr.maxSendable === 'number' && amountMsat > pr.maxSendable) {
return { ok: false, reason: 'amount is above the card wallet maximum' }
}
const cbUrl = appendQuery(pr.callback, { amount: String(amountMsat) })
let vals: PayValues
try {
const res = await ctx.doFetch(cbUrl, { signal: AbortSignal.timeout(ctx.timeoutMs) })
vals = (await res.json()) as PayValues
} catch (e) {
return { ok: false, reason: `could not fetch the invoice: ${errMsg(e)}` }
}
if (vals.status === 'ERROR') {
return { ok: false, reason: vals.reason || 'card wallet declined' }
}
if (!vals.pr || !/^ln[a-z0-9]/i.test(vals.pr.trim())) {
return { ok: false, reason: 'card wallet returned no invoice' }
}
return { ok: true, bolt11: vals.pr.trim() }
}
/**
* Resolve a tapped Bolt Card + a payout amount to a BOLT11 the ATM can pay.
* Never throws — every failure returns `{ ok: false, reason }`.
*/
export async function resolveCardInvoice(
lnurlw: string,
amountMsat: number,
opts: ResolveCardInvoiceOptions = {}
): Promise<ResolveCardInvoiceResult> {
const ctx: Ctx = { doFetch: opts.fetchImpl ?? fetch, timeoutMs: opts.timeoutMs ?? 15_000 }
const resolverUrl = scanUrlToResolver(lnurlw)
if (!resolverUrl) return { ok: false, reason: 'not a valid Bolt Card (lnurlw) tag' }
if (!(amountMsat > 0)) return { ok: false, reason: 'no amount to send' }
// 1) Resolve card → pay target (the transport seam: HTTPS today).
let target: CardPayTarget
try {
const res = await ctx.doFetch(resolverUrl, { signal: AbortSignal.timeout(ctx.timeoutMs) })
target = (await res.json()) as CardPayTarget
} catch (e) {
return { ok: false, reason: `could not reach the card: ${errMsg(e)}` }
}
if (target.status === 'ERROR') {
return { ok: false, reason: target.reason || 'card rejected the tap' }
}
// 2) Normalize to a LUD-06 payRequest.
const pr = await toPayRequest(target, ctx)
if (!pr.ok) return pr
// 3) Ask for an invoice for the payout amount.
return requestInvoice({ ...pr.payRequest, callback: pr.payRequest.callback! }, amountMsat, ctx)
}
/**
* The LUD-06 second step alone: fetch a BOLT11 for `amountMsat` from an
* already-obtained pay step (from a Bolt Card session opened at tap-to-enter).
* Never throws — every failure returns `{ ok: false, reason }`.
*/
export async function resolveInvoiceFromPayStep(
step: PayStep,
amountMsat: number,
opts: ResolveCardInvoiceOptions = {}
): Promise<ResolveCardInvoiceResult> {
const ctx: Ctx = { doFetch: opts.fetchImpl ?? fetch, timeoutMs: opts.timeoutMs ?? 15_000 }
return requestInvoice(step, amountMsat, ctx)
}

View file

@ -0,0 +1,144 @@
import { describe, it, expect, vi } from 'vitest'
import { executeLnurlWithdraw, executeWithdrawCallback, lnurlwToHttps } from './lnurl-withdraw'
const BOLT11 = 'lnbc10u1p3xyz...'
const LNURLW =
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
/** Build a mock fetch that returns the given JSON bodies per call, in order. */
function mockFetch(bodies: unknown[]) {
const calls: string[] = []
const impl = vi.fn(async (url: string | URL) => {
calls.push(url.toString())
const body = bodies[calls.length - 1]
return { json: async () => body } as Response
})
return { impl: impl as unknown as typeof fetch, calls }
}
describe('lnurlwToHttps', () => {
it('maps lnurlw:// and lnurl:// to https://', () => {
expect(lnurlwToHttps('lnurlw://host/p?x=1')).toBe('https://host/p?x=1')
expect(lnurlwToHttps('lnurl://host/p')).toBe('https://host/p')
})
it('strips a lightning: prefix', () => {
expect(lnurlwToHttps('lightning:lnurlw://host/p')).toBe('https://host/p')
})
it('passes https:// through and trims', () => {
expect(lnurlwToHttps(' https://host/p ')).toBe('https://host/p')
})
it('rejects http://, bech32 lnurl1…, and empty', () => {
expect(lnurlwToHttps('http://host/p')).toBeNull()
expect(lnurlwToHttps('LNURL1DP68GURN8GHJ7')).toBeNull()
expect(lnurlwToHttps('')).toBeNull()
})
})
describe('executeLnurlWithdraw', () => {
const withdrawReq = {
tag: 'withdrawRequest',
callback: 'https://lnbits.l484.com/boltcards/api/v1/scan/cb',
k1: 'K1TOKEN',
minWithdrawable: 1000,
maxWithdrawable: 5_000_000,
}
it('completes the two-step withdraw and passes k1 + pr to the callback', async () => {
const { impl, calls } = mockFetch([withdrawReq, { status: 'OK' }])
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl })
expect(res).toEqual({ ok: true })
// First call = the lnurlw as https; second = callback with k1 + pr.
expect(calls[0]).toContain('https://lnbits.l484.com/boltcards/api/v1/scan/abc123')
expect(calls[1]).toContain('k1=K1TOKEN')
expect(calls[1]).toContain(`pr=${encodeURIComponent(BOLT11)}`)
})
it('rejects a non-lnurlw tag', async () => {
const { impl } = mockFetch([])
const res = await executeLnurlWithdraw('http://nope', BOLT11, { fetchImpl: impl })
expect(res.ok).toBe(false)
expect(res.reason).toMatch(/not a valid Bolt Card/i)
})
it('rejects when there is no invoice', async () => {
const { impl } = mockFetch([])
const res = await executeLnurlWithdraw(LNURLW, '', { fetchImpl: impl })
expect(res).toMatchObject({ ok: false, reason: 'no invoice to charge' })
})
it('surfaces an ERROR from the withdraw request', async () => {
const { impl } = mockFetch([{ status: 'ERROR', reason: 'spent today limit' }])
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl })
expect(res).toMatchObject({ ok: false, reason: 'spent today limit' })
})
it('rejects a response that is not a withdrawRequest', async () => {
const { impl } = mockFetch([{ tag: 'payRequest', callback: 'x' }])
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl })
expect(res).toMatchObject({ ok: false })
expect(res.reason).toMatch(/withdraw voucher/i)
})
it('rejects (without calling the callback) when the amount exceeds the card limit', async () => {
const { impl, calls } = mockFetch([{ ...withdrawReq, maxWithdrawable: 2000 }])
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl, amountMsat: 5000 })
expect(res).toMatchObject({ ok: false, reason: 'card limit is below this amount' })
expect(calls).toHaveLength(1) // callback never hit
})
it('surfaces an ERROR from the callback (card declined)', async () => {
const { impl } = mockFetch([withdrawReq, { status: 'ERROR', reason: 'insufficient funds' }])
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl })
expect(res).toMatchObject({ ok: false, reason: 'insufficient funds' })
})
it('handles a network failure gracefully', async () => {
const impl = vi.fn(async () => {
throw new Error('ECONNREFUSED')
}) as unknown as typeof fetch
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl })
expect(res.ok).toBe(false)
expect(res.reason).toMatch(/could not reach the card/i)
})
})
describe('executeWithdrawCallback (session second step, no tap)', () => {
const step = {
callback: 'https://lnbits.l484.com/boltcards/api/v1/lnurl/cb/hit1',
k1: 'hit1',
maxWithdrawable: 5_000_000,
}
it('hands the invoice straight to the callback with k1', async () => {
const f = mockFetch([{ status: 'OK' }])
const out = await executeWithdrawCallback(step, BOLT11, { fetchImpl: f.impl })
expect(out).toEqual({ ok: true })
expect(f.calls).toHaveLength(1)
expect(f.calls[0]).toContain('k1=hit1')
expect(f.calls[0]).toContain('pr=' + BOLT11)
})
it('refuses an amount above the step limit without calling out', async () => {
const f = mockFetch([])
const out = await executeWithdrawCallback(step, BOLT11, {
fetchImpl: f.impl,
amountMsat: 6_000_000,
})
expect(out).toEqual({ ok: false, reason: 'card limit is below this amount' })
expect(f.calls).toHaveLength(0)
})
it('surfaces a callback decline', async () => {
const f = mockFetch([{ status: 'ERROR', reason: 'Payment already claimed.' }])
const out = await executeWithdrawCallback(step, BOLT11, { fetchImpl: f.impl })
expect(out).toEqual({ ok: false, reason: 'Payment already claimed.' })
})
it('rejects a missing invoice', async () => {
const f = mockFetch([])
expect(await executeWithdrawCallback(step, '', { fetchImpl: f.impl })).toEqual({
ok: false,
reason: 'no invoice to charge',
})
})
})

View file

@ -0,0 +1,169 @@
/**
* LNURL-withdraw executor (LUD-03) — the ATM as the *withdrawing* party.
*
* Bolt Card tap-to-pay for the cash-out flow: a Bolt Card presents an
* `lnurlw://…?p=…&c=…` voucher (NTAG424 SUN — fresh p/c per tap). The ATM has
* already generated its cash-out BOLT11; here it asks the card's wallet to pay
* that invoice:
* 1. GET the lnurlw URL → a `withdrawRequest` (callback, k1, max/min).
* 2. GET `callback?k1=…&pr=<our bolt11>` → the card's wallet pays it.
* Settlement itself is observed elsewhere (the existing invoice watcher over
* nostr), so a returned `{ ok: true }` means "the card accepted the pull", not
* "cash dispensed" — the state machine still waits for PAYMENT_RECEIVED.
*
* Runs in the MAIN process (Node fetch) to avoid renderer CORS: LNURL
* endpoints don't send CORS headers, so a renderer fetch to the card's host
* would be blocked.
*/
export interface LnurlWithdrawResult {
ok: boolean
/** Human-readable reason when ok is false (safe to surface on-screen). */
reason?: string
}
/** LUD-03 withdrawRequest (subset we consume) + LUD-06 error shape. */
interface WithdrawRequest {
tag?: string
callback?: string
k1?: string
minWithdrawable?: number
maxWithdrawable?: number
defaultDescription?: string
status?: string
reason?: string
}
type FetchLike = typeof fetch
/**
* The LUD-03 second step on its own: what a `withdrawRequest` (or a Bolt Card
* session, see boltcard-session.ts) hands us to actually pull a payment.
*/
export interface WithdrawStep {
callback: string
k1: string
minWithdrawable?: number
maxWithdrawable?: number
}
export interface ExecuteLnurlWithdrawOptions {
/** Injected for tests; defaults to global fetch. */
fetchImpl?: FetchLike
/**
* Our invoice amount in millisats. When set, we reject early if it exceeds
* the voucher's maxWithdrawable (defensive; the callback would reject anyway).
*/
amountMsat?: number
/** Per-request timeout (default 15s). */
timeoutMs?: number
}
/**
* Normalize a Bolt Card / LNURL-withdraw pointer to an https URL.
* Bolt Cards emit `lnurlw://host/path?query`; we also accept `lnurl://` and a
* bare `https://`. Bech32 `LNURL1…` is intentionally unsupported (Bolt Cards
* never use it) and rejected with a clear reason.
*/
export function lnurlwToHttps(raw: string): string | null {
let s = raw.trim()
if (!s) return null
if (s.toLowerCase().startsWith('lightning:')) s = s.slice('lightning:'.length)
const lower = s.toLowerCase()
if (lower.startsWith('lnurlw://')) return 'https://' + s.slice('lnurlw://'.length)
if (lower.startsWith('lnurl://')) return 'https://' + s.slice('lnurl://'.length)
if (lower.startsWith('https://')) return s
// Reject http:// (must be TLS) and bech32 lnurl1… (not a Bolt Card).
return null
}
function appendQuery(url: string, params: Record<string, string>): string {
const u = new URL(url)
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v)
return u.toString()
}
function errMsg(e: unknown): string {
if (e instanceof Error)
return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
return String(e)
}
export async function executeLnurlWithdraw(
lnurlw: string,
bolt11: string,
opts: ExecuteLnurlWithdrawOptions = {}
): Promise<LnurlWithdrawResult> {
const doFetch = opts.fetchImpl ?? fetch
const timeoutMs = opts.timeoutMs ?? 15_000
const paramsUrl = lnurlwToHttps(lnurlw)
if (!paramsUrl) return { ok: false, reason: 'not a valid Bolt Card (lnurlw) tag' }
if (!bolt11 || !/^ln[a-z0-9]/i.test(bolt11.trim())) {
return { ok: false, reason: 'no invoice to charge' }
}
// 1) Fetch the withdraw request.
let params: WithdrawRequest
try {
const res = await doFetch(paramsUrl, { signal: AbortSignal.timeout(timeoutMs) })
params = (await res.json()) as WithdrawRequest
} catch (e) {
return { ok: false, reason: `could not reach the card: ${errMsg(e)}` }
}
if (params.status === 'ERROR') {
return { ok: false, reason: params.reason || 'card rejected the tap' }
}
if (params.tag !== 'withdrawRequest' || !params.callback || !params.k1) {
return { ok: false, reason: 'card did not return a withdraw voucher' }
}
// 2) Hand our invoice to the callback — the card's wallet pays it.
return executeWithdrawCallback(
{
callback: params.callback,
k1: params.k1,
minWithdrawable: params.minWithdrawable,
maxWithdrawable: params.maxWithdrawable,
},
bolt11,
opts
)
}
/**
* The LUD-03 second step alone: hand our invoice to an already-obtained
* withdraw step (from a `/scan` withdrawRequest, or from a Bolt Card session
* opened at tap-to-enter) — the card's wallet pays it. `{ ok: true }` means the
* card accepted the pull; settlement is observed by the invoice watcher.
*/
export async function executeWithdrawCallback(
step: WithdrawStep,
bolt11: string,
opts: ExecuteLnurlWithdrawOptions = {}
): Promise<LnurlWithdrawResult> {
const doFetch = opts.fetchImpl ?? fetch
const timeoutMs = opts.timeoutMs ?? 15_000
if (!bolt11 || !/^ln[a-z0-9]/i.test(bolt11.trim())) {
return { ok: false, reason: 'no invoice to charge' }
}
if (
opts.amountMsat != null &&
typeof step.maxWithdrawable === 'number' &&
opts.amountMsat > step.maxWithdrawable
) {
return { ok: false, reason: 'card limit is below this amount' }
}
const cbUrl = appendQuery(step.callback, { k1: step.k1, pr: bolt11.trim() })
let cb: { status?: string; reason?: string }
try {
const res = await doFetch(cbUrl, { signal: AbortSignal.timeout(timeoutMs) })
cb = (await res.json()) as { status?: string; reason?: string }
} catch (e) {
return { ok: false, reason: `card payment failed: ${errMsg(e)}` }
}
if (cb.status === 'OK') return { ok: true }
return { ok: false, reason: cb.reason || 'card declined the payment' }
}

View file

@ -24,18 +24,44 @@ import {
markCommandExecuting, markCommandExecuting,
completeCommand, completeCommand,
getLastKnownConfigCreatedAt, getLastKnownConfigCreatedAt,
getBootstrapPublishedAt, getCountsUncertainSince,
markBootstrapPublished, getLastStatePublishedAt,
applyOperatorCassettesConfig, markCountsUncertain,
getCashOutHold,
setCashOutHold,
clearCashOutHold,
type CashOutHold,
pendingDispenseReports,
markDispenseReportAcked,
noteDispenseReportAttempt,
markStatePublished,
resetStatePublishWatermark,
resetForRepair,
applyOperatorCassetteOps,
getAppliedOpIds,
getCassetteStateSeq,
getFeeConfig, getFeeConfig,
getLastKnownFeeConfigCreatedAt, getLastKnownFeeConfigCreatedAt,
applyFeeConfig, applyFeeConfig,
type OperatorCassettesPayload, getBunkerBinding,
saveBunkerBinding,
clearBunkerBinding,
type CassetteOp,
type ApplyOpsResult,
type FeeConfigPayload, type FeeConfigPayload,
type FeeConfigRow, type FeeConfigRow,
type ApplyResult, type ApplyResult,
type StoredBunkerBinding,
} from './state-store.js' } from './state-store.js'
import { initializeHal, type HalInstance } from './hal-service.js' import { initializeHal, type HalInstance } from './hal-service.js'
import {
executeLnurlWithdraw,
executeWithdrawCallback,
type WithdrawStep,
} from './lnurl-withdraw.js'
import { resolveCardInvoice, resolveInvoiceFromPayStep, type PayStep } from './lnurl-pay.js'
import { openCardSession, type OpenCardSessionResult } from './boltcard-session.js'
import { startNfcReader, type NfcStatus } from './nfc-service.js'
// ESM equivalent of __dirname // ESM equivalent of __dirname
const __filename = fileURLToPath(import.meta.url) const __filename = fileURLToPath(import.meta.url)
@ -81,16 +107,6 @@ type BrandingConfig = {
logoDarkDataUrl: string | null logoDarkDataUrl: string | null
} }
const VALID_THEMES = new Set([
'gruvbox',
'catppuccin',
'cyberpunk',
'dracula',
'nord',
'tokyo-night',
'custom',
])
function loadBranding(): BrandingConfig | null { function loadBranding(): BrandingConfig | null {
const brandingDir = path.join( const brandingDir = path.join(
fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd(), fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd(),
@ -110,7 +126,10 @@ function loadBranding(): BrandingConfig | null {
try { try {
const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')) const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
if (typeof raw.title === 'string') title = raw.title if (typeof raw.title === 'string') title = raw.title
if (typeof raw.theme === 'string' && VALID_THEMES.has(raw.theme)) theme = raw.theme // No theme-name validation here: the renderer's `themes` list (plus its
// 'custom' branch) is the single source of truth. Pass the string through
// and let useTheme's applyBrandingTheme ignore anything it doesn't know.
if (typeof raw.theme === 'string') theme = raw.theme
if (raw.custom_colors && typeof raw.custom_colors === 'object') { if (raw.custom_colors && typeof raw.custom_colors === 'object') {
const { dark, ...flat } = raw.custom_colors as Record<string, unknown> const { dark, ...flat } = raw.custom_colors as Record<string, unknown>
const colors = Object.fromEntries( const colors = Object.fromEntries(
@ -119,9 +138,7 @@ function loadBranding(): BrandingConfig | null {
if (Object.keys(colors).length > 0) customColors = colors if (Object.keys(colors).length > 0) customColors = colors
if (dark && typeof dark === 'object') { if (dark && typeof dark === 'object') {
const darkColors = Object.fromEntries( const darkColors = Object.fromEntries(
Object.entries(dark as Record<string, unknown>).filter( Object.entries(dark as Record<string, unknown>).filter(([, v]) => typeof v === 'string')
([, v]) => typeof v === 'string'
)
) as Record<string, string> ) as Record<string, string>
if (Object.keys(darkColors).length > 0) customColorsDark = darkColors if (Object.keys(darkColors).length > 0) customColorsDark = darkColors
} }
@ -164,6 +181,62 @@ function loadBranding(): BrandingConfig | null {
return { title, theme, customColors, customColorsDark, logoDataUrl, logoDarkDataUrl } return { title, theme, customColors, customColorsDark, logoDataUrl, logoDarkDataUrl }
} }
// Access-control config loader (ADR-003). Env toggles the gate; an optional
// /var/lib/bitspire/access.json carries the salt + allow-list. Defaults OFF —
// a machine with neither env nor file behaves as if there is no access layer.
// The allow-list shape mirrors the renderer's AllowListEntry (authorize.ts);
// duplicated here to avoid a cross-project (electron↔renderer) import.
interface AccessAllowListEntry {
idHash: string
role: 'user' | 'operator'
pinHash?: string
label?: string
}
function loadAccessControl() {
// Env provides defaults; access.json (writable, operator-provisioned — same
// spirit as branding/) overrides them, so the gate can be toggled on a
// deployed machine by dropping a file + restarting the service, with no image
// rebuild. Defaults OFF.
let enabled = process.env.ACCESS_CONTROL_ENABLED === 'true'
// Dev unlock is OFF unless explicitly enabled: a gated machine must not ship
// a visible bypass button by default.
let devUnlock = process.env.ACCESS_DEV_UNLOCK === 'true'
let openEnrollment = process.env.ACCESS_OPEN_ENROLLMENT === 'true'
let salt = process.env.ACCESS_SALT || ''
let allowList: AccessAllowListEntry[] = []
const jsonPath = path.join(
fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd(),
'access.json'
)
if (fs.existsSync(jsonPath)) {
try {
const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
if (typeof raw.enabled === 'boolean') enabled = raw.enabled
if (typeof raw.devUnlock === 'boolean') devUnlock = raw.devUnlock
if (typeof raw.openEnrollment === 'boolean') openEnrollment = raw.openEnrollment
if (typeof raw.salt === 'string' && raw.salt) salt = raw.salt
if (Array.isArray(raw.allowList)) {
allowList = (raw.allowList as unknown[]).filter(
(e): e is AccessAllowListEntry =>
!!e &&
typeof (e as AccessAllowListEntry).idHash === 'string' &&
((e as AccessAllowListEntry).role === 'user' ||
(e as AccessAllowListEntry).role === 'operator')
)
}
} catch (e) {
console.warn('[Electron] Failed to parse access.json:', e)
}
}
// A gated machine needs a stable salt for deterministic hashing. Fall back to
// a fixed default (prototype); production should provision a real salt.
if (!salt) salt = 'bitspire-access-v1'
return { enabled, devUnlock, openEnrollment, salt, allowList }
}
// Determine if we're in development // Determine if we're in development
const isDev = const isDev =
process.env.ELECTRON_FORCE_PROD !== '1' && process.env.ELECTRON_FORCE_PROD !== '1' &&
@ -280,17 +353,20 @@ ipcMain.handle('watchdog:pong', () => {
// pragma: allowlist secret end // pragma: allowlist secret end
ipcMain.handle('get-config', () => { ipcMain.handle('get-config', () => {
return { return {
// LNbits nostr-transport connection (public info only) // LNbits nostr-transport connection (public info only). Empty when
relayUrl: process.env.VITE_RELAY_URL || 'ws://localhost:7777', // unprovisioned — the renderer then falls through to the pairing seed's
// relay (aiolabs/bitspire#70). A non-empty default here would win via the
// env-first precedence and override the seed.
relayUrl: process.env.VITE_RELAY_URL || '',
lnbitsServerPubkey: process.env.VITE_LNBITS_SERVER_PUBKEY || '', lnbitsServerPubkey: process.env.VITE_LNBITS_SERVER_PUBKEY || '',
appId: process.env.VITE_APP_ID || '', appId: process.env.VITE_APP_ID || '',
// Hardware configuration // Hardware configuration
machineModel: process.env.VITE_LAMASSU_MACHINE_MODEL || 'sintra', machineModel: process.env.VITE_BITSPIRE_MACHINE_MODEL || 'sintra',
fiatCode: process.env.VITE_LAMASSU_FIAT_CODE || 'USD', fiatCode: process.env.VITE_BITSPIRE_FIAT_CODE || 'USD',
validatorDevice: process.env.VITE_LAMASSU_VALIDATOR_DEVICE, validatorDevice: process.env.VITE_BITSPIRE_VALIDATOR_DEVICE,
dispenserDevice: process.env.VITE_LAMASSU_DISPENSER_DEVICE, dispenserDevice: process.env.VITE_BITSPIRE_DISPENSER_DEVICE,
cassettes: process.env.VITE_LAMASSU_CASSETTES, cassettes: process.env.VITE_BITSPIRE_CASSETTES,
// SECURITY: In production (packaged app), mock fallback is always disabled. // SECURITY: In production (packaged app), mock fallback is always disabled.
// Only allow it in development mode, and only when explicitly opted in via env. // Only allow it in development mode, and only when explicitly opted in via env.
allowMockFallback: isDev && process.env.VITE_ALLOW_MOCK_FALLBACK === 'true', allowMockFallback: isDev && process.env.VITE_ALLOW_MOCK_FALLBACK === 'true',
@ -308,6 +384,9 @@ ipcMain.handle('get-config', () => {
// Operator branding (logo/title/theme) — null when no override // Operator branding (logo/title/theme) — null when no override
branding: loadBranding(), branding: loadBranding(),
// Access-control gate (ADR-003) — `enabled` defaults false (no gate).
accessControl: loadAccessControl(),
} }
}) })
@ -330,14 +409,148 @@ let secretsConsumed = false
ipcMain.handle('get-atm-secrets', () => { ipcMain.handle('get-atm-secrets', () => {
if (secretsConsumed) { if (secretsConsumed) {
console.warn('[Electron] SECURITY: get-atm-secrets called after secrets already consumed') console.warn('[Electron] SECURITY: get-atm-secrets called after secrets already consumed')
return { atmPrivateKey: '' } return { spireSeed: '', bunkerBinding: null }
} }
secretsConsumed = true secretsConsumed = true
// The spire pairing seed (one-shot connect token inside) + the persisted
// bunker binding (transport key). The renderer resolves these into a
// BunkerSigner; see services/signer-resolver.ts (aiolabs/bitspire#52).
return { return {
atmPrivateKey: process.env.VITE_ATM_PRIVATE_KEY || '', spireSeed: process.env.VITE_SPIRE_SEED || '',
bunkerBinding: getBunkerBinding(),
} }
}) })
// Bunker binding persistence — the renderer writes the binding after a
// successful pairing (connectNewSeed), and resets the publish watermark so the
// new operator receives the spire's hello-event (aiolabs/bitspire#52 / #56).
ipcMain.handle('state:save-bunker-binding', (_event, binding: StoredBunkerBinding): void => {
saveBunkerBinding(binding)
})
ipcMain.handle('state:clear-bunker-binding', (): void => {
clearBunkerBinding()
})
ipcMain.handle('state:reset-state-publish-watermark', (): void => {
resetStatePublishWatermark()
})
ipcMain.handle('state:reset-for-repair', (): void => {
resetForRepair()
})
// QR-pairing wizard (aiolabs/bitspire#52): an unpaired machine scans a
// spire-seed off its camera, and we persist it as VITE_SPIRE_SEED in the
// runtime .env so the next boot's signer-resolver redeems it (connectNewSeed)
// exactly as if it had been provisioned. We deliberately do NOT pair here —
// persisting + relaunching reuses the single, tested pairing path rather than
// duplicating it in the renderer.
function runtimeEnvPath(): string {
const base = fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd()
return path.join(base, '.env')
}
ipcMain.handle('state:save-spire-seed', (_event, seed: string): void => {
const trimmed = (seed || '').trim()
if (!trimmed) throw new Error('save-spire-seed: empty seed')
const envPath = runtimeEnvPath()
const line = `VITE_SPIRE_SEED=${trimmed}`
let lines: string[] = []
if (fs.existsSync(envPath)) {
lines = fs.readFileSync(envPath, 'utf8').split('\n')
}
const idx = lines.findIndex((l) => l.startsWith('VITE_SPIRE_SEED='))
if (idx >= 0) {
lines[idx] = line
} else {
// Drop a trailing empty element so we don't accumulate blank lines.
if (lines.length && lines[lines.length - 1] === '') lines.pop()
lines.push(line)
}
fs.writeFileSync(envPath, lines.join('\n') + '\n', { mode: 0o600 })
// Keep this process's view in sync so get-atm-secrets reflects the new seed
// even before relaunch (belt-and-suspenders; relaunch re-reads from disk).
process.env.VITE_SPIRE_SEED = trimmed
console.log('[Pairing] Spire seed persisted to', envPath)
})
// Relaunch the kiosk so the new seed is picked up by a clean boot. Under
// systemd (bitspire.service) the exit triggers an automatic restart; in dev
// Electron's relaunch re-spawns the process.
ipcMain.handle('app:relaunch', (): void => {
console.log('[Pairing] Relaunching to apply new pairing')
app.relaunch()
app.exit(0)
})
// Connectivity recovery: reload the renderer to re-run init from a clean slate
// (fresh JS context → no leaked actors/subscriptions), while preserving HAL in
// this main process (reloadRenderer resets secretsConsumed so get-atm-secrets
// works again, and hal:init is idempotent). The renderer calls this when it's
// stuck on a connectivity-type "ATM Unavailable" and the network returns, or
// when the operator taps the on-screen Retry (ADR-002 amendment 2026-08-04).
ipcMain.handle('app:recover', (): void => {
console.log('[Recovery] Reloading renderer to re-attempt initialization')
reloadRenderer()
})
// Bolt Card cash-out: pull payment for the current invoice from a tapped card
// via LNURL-withdraw. Runs in the main process (Node fetch) to dodge renderer
// CORS. Returns once the card accepts; settlement arrives via the invoice
// watcher. See lnurl-withdraw.ts.
ipcMain.handle(
'lnurl:withdraw',
async (
_event,
args: { lnurlw: string; bolt11: string; amountMsat?: number }
): Promise<{ ok: boolean; reason?: string }> => {
return executeLnurlWithdraw(args.lnurlw, args.bolt11, { amountMsat: args.amountMsat })
}
)
// Bolt Card cash-in (receive): resolve a tapped card + payout amount to a
// BOLT11 on the card wallet, which the renderer then pays over the nostr
// transport (stores/atm.ts payInvoice). HTTPS to the card host runs here in the
// main process to dodge renderer CORS. See lnurl-pay.ts.
ipcMain.handle(
'lnurl:pay-card',
async (
_event,
args: { lnurlw: string; amountMsat: number }
): Promise<{ ok: boolean; bolt11?: string; reason?: string }> => {
return resolveCardInvoice(args.lnurlw, args.amountMsat)
}
)
// Bolt Card tap-to-enter (ADR-003): open a verified session for a tapped card.
// Spends the tap's SUN once and returns balance + fiat + the withdraw/pay
// second steps the session reuses at Complete. See boltcard-session.ts.
ipcMain.handle(
'lnurl:open-card-session',
async (_event, args: { lnurlw: string }): Promise<OpenCardSessionResult> => {
return openCardSession(args.lnurlw)
}
)
// Session variants of the two Complete paths: no tap, no p/c — just the
// hit-keyed second step the session already holds.
ipcMain.handle(
'lnurl:withdraw-session',
async (
_event,
args: { withdraw: WithdrawStep; bolt11: string; amountMsat?: number }
): Promise<{ ok: boolean; reason?: string }> => {
return executeWithdrawCallback(args.withdraw, args.bolt11, { amountMsat: args.amountMsat })
}
)
ipcMain.handle(
'lnurl:pay-session',
async (
_event,
args: { pay: PayStep; amountMsat: number }
): Promise<{ ok: boolean; bolt11?: string; reason?: string }> => {
return resolveInvoiceFromPayStep(args.pay, args.amountMsat)
}
)
// State persistence IPC handlers // State persistence IPC handlers
ipcMain.handle('state:load-cassettes', () => loadCassettes()) ipcMain.handle('state:load-cassettes', () => loadCassettes())
ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes)) ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes))
@ -354,17 +567,47 @@ ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedB
ipcMain.handle('state:get-last-known-config-created-at', (): number => ipcMain.handle('state:get-last-known-config-created-at', (): number =>
getLastKnownConfigCreatedAt() getLastKnownConfigCreatedAt()
) )
ipcMain.handle('state:get-bootstrap-published-at', (): number | null => ipcMain.handle('state:get-last-state-published-at', (): number | null => getLastStatePublishedAt())
getBootstrapPublishedAt() ipcMain.handle('state:get-counts-uncertain-since', (): number | null => getCountsUncertainSince())
ipcMain.handle('state:mark-counts-uncertain', (_event, unixTimestamp: number): void => {
markCountsUncertain(unixTimestamp)
})
// Cash-out hold (ADR-005 §5)
ipcMain.handle('state:get-cash-out-hold', (): CashOutHold | null => getCashOutHold())
ipcMain.handle('state:set-cash-out-hold', (_event, hold: CashOutHold): CashOutHold => {
if (!hold || typeof hold.reason !== 'string' || typeof hold.since !== 'number') {
throw new Error('Invalid cash-out hold')
}
return setCashOutHold(hold)
})
ipcMain.handle('state:clear-cash-out-hold', (): boolean => clearCashOutHold())
// Dispense-report outbox (ADR-005 §2) — at-least-once to spirekeeper
ipcMain.handle('state:pending-dispense-reports', (_event, limit?: number) =>
pendingDispenseReports(typeof limit === 'number' ? limit : 20)
) )
ipcMain.handle('state:mark-bootstrap-published', (_event, unixTimestamp: number): void => { ipcMain.handle('state:ack-dispense-report', (_event, txid: string): boolean => {
markBootstrapPublished(unixTimestamp) if (typeof txid !== 'string' || !txid) throw new Error('Invalid txid')
return markDispenseReportAcked(txid)
}) })
ipcMain.handle( ipcMain.handle(
'state:apply-operator-cassettes-config', 'state:note-dispense-report-attempt',
(_event, payload: OperatorCassettesPayload, eventCreatedAt: number): ApplyResult => (_event, txid: string, error: string | null): void => {
applyOperatorCassettesConfig(payload, eventCreatedAt) if (typeof txid !== 'string' || !txid) throw new Error('Invalid txid')
noteDispenseReportAttempt(txid, typeof error === 'string' ? error.slice(0, 512) : null)
}
) )
ipcMain.handle('state:mark-state-published', (_event, unixTimestamp: number): void => {
markStatePublished(unixTimestamp)
})
ipcMain.handle(
'state:apply-operator-cassette-ops',
(_event, ops: CassetteOp[]): ApplyOpsResult => applyOperatorCassetteOps(ops)
)
ipcMain.handle('state:get-applied-op-ids', (_event, limit?: number): string[] =>
getAppliedOpIds(limit)
)
ipcMain.handle('state:get-cassette-state-seq', (): number => getCassetteStateSeq())
// Operator-fees consumer (aiolabs/lamassu-next#57) — persisted singleton // Operator-fees consumer (aiolabs/lamassu-next#57) — persisted singleton
// fee config + per-d-tag replay watermark + atomic apply for kind-30078 // fee config + per-d-tag replay watermark + atomic apply for kind-30078
@ -414,6 +657,17 @@ let pendingBillDenomination: number | null = null
ipcMain.handle('hal:init', async (_event, config) => { ipcMain.handle('hal:init', async (_event, config) => {
try { try {
// Idempotent: HAL lives in this (long-lived) main process, but the renderer
// re-runs full init on every reload — the watchdog's crash-recovery reload
// and the connectivity-recovery reload (app:recover) both re-invoke this.
// initializeHal opens serial ports without closing prior handles, so
// re-entering it would double-open the validator/dispenser. Reuse the
// existing instance instead; its validator event wiring already targets the
// (reloaded) mainWindow, so the reloaded renderer keeps receiving bill events.
if (halInstance) {
console.log('[Electron] HAL already initialized — reusing existing instance')
return { success: true }
}
// Override cassette config with DB values (operator may have changed them via atm-tui // Override cassette config with DB values (operator may have changed them via atm-tui
// or via an operator-config publish from satmachineadmin). Pass per-position so the // or via an operator-config publish from satmachineadmin). Pass per-position so the
// HAL knows about every bay including duplicates of the same denomination — real // HAL knows about every bay including duplicates of the same denomination — real
@ -519,10 +773,12 @@ ipcMain.handle('hal:stack-bill', () => {
console.warn('[Electron] hal:stack-bill called with no bill in escrow — ignoring') console.warn('[Electron] hal:stack-bill called with no bill in escrow — ignoring')
return return
} }
const denomination = pendingBillDenomination
pendingBillDenomination = null pendingBillDenomination = null
// Credit is NOT sent here. hal-service fires onBillInserted (forwarded
// as 'hal:bill-inserted') only on the validator's `billsValid`
// stacked-confirmation — a stack command can still fail or return the
// bill (aiolabs/bitspire#58).
halInstance.stackBill() halInstance.stackBill()
mainWindow?.webContents.send('hal:bill-inserted', denomination)
}) })
ipcMain.handle('hal:reject-bill', () => { ipcMain.handle('hal:reject-bill', () => {
@ -612,12 +868,12 @@ function startCommandPoller(): void {
const result = await halInstance.dispenseCash(parsed.bills) const result = await halInstance.dispenseCash(parsed.bills)
const txid = `manual-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const txid = `manual-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const totalFiatCents = parsed.bills.reduce((s, b) => s + b.denomination * b.count * 100, 0) const totalFiatCents = parsed.bills.reduce((s, b) => s + b.denomination * b.count * 100, 0)
const fiatCode = process.env.VITE_LAMASSU_FIAT_CODE || 'USD' const fiatCode = process.env.VITE_BITSPIRE_FIAT_CODE || 'USD'
recordTransaction({ recordTransaction({
txid, txid,
type: 'manual_dispense', type: 'manual_dispense',
status: result.dispensed ? 'complete' : 'dispense_error', status: result.dispenseConfirmed ? 'complete' : 'dispense_error',
fiatCents: totalFiatCents, fiatCents: totalFiatCents,
sats: 0, sats: 0,
feeSats: 0, feeSats: 0,
@ -629,9 +885,14 @@ function startCommandPoller(): void {
error: result.error, error: result.error,
}) })
// This dispense happened entirely in the main process, so the renderer
// has no idea the bays moved — it would keep serving a stale inventory
// and would never republish the operator's view. Tell it.
mainWindow?.webContents.send('cassettes:changed')
// Only remediate the original tx if ALL requested bills were dispensed // Only remediate the original tx if ALL requested bills were dispensed
let refRemediated = false let refRemediated = false
if (parsed.ref_txid && result.dispensed) { if (parsed.ref_txid && result.dispenseConfirmed) {
refRemediated = remediateTransaction(parsed.ref_txid, txid) refRemediated = remediateTransaction(parsed.ref_txid, txid)
} }
@ -639,7 +900,13 @@ function startCommandPoller(): void {
cmd.id, cmd.id,
JSON.stringify({ JSON.stringify({
txid, txid,
dispensed: result.dispensed, // Wire key kept as `dispensed` — spirekeeper's command poller
// reads it. Value is the ADR-005 value-equality confirmation.
dispensed: result.dispenseConfirmed,
dispense_confirmed: result.dispenseConfirmed,
error_code: result.errorCode,
raw_code: result.rawCode,
error_class: result.errorClass,
ref_remediated: refRemediated, ref_remediated: refRemediated,
error: result.error, error: result.error,
}) })
@ -679,19 +946,19 @@ app.whenReady().then(() => {
if (existing.length === 0) { if (existing.length === 0) {
let seedCassettes: { denomination: number; count: number }[] = [] let seedCassettes: { denomination: number; count: number }[] = []
// Priority 1: explicit VITE_LAMASSU_CASSETTES env var // Priority 1: explicit VITE_BITSPIRE_CASSETTES env var
const cassettesJson = process.env.VITE_LAMASSU_CASSETTES const cassettesJson = process.env.VITE_BITSPIRE_CASSETTES
if (cassettesJson) { if (cassettesJson) {
try { try {
seedCassettes = JSON.parse(cassettesJson) seedCassettes = JSON.parse(cassettesJson)
} catch (e) { } catch (e) {
console.warn('[Electron] Failed to parse VITE_LAMASSU_CASSETTES:', e) console.warn('[Electron] Failed to parse VITE_BITSPIRE_CASSETTES:', e)
} }
} }
// Priority 2: default presets per model // Priority 2: default presets per model
if (seedCassettes.length === 0) { if (seedCassettes.length === 0) {
const model = process.env.VITE_LAMASSU_MACHINE_MODEL || 'sintra' const model = process.env.VITE_BITSPIRE_MACHINE_MODEL || 'sintra'
const presets: Record<string, { denomination: number; count: number }[]> = { const presets: Record<string, { denomination: number; count: number }[]> = {
douro: [ douro: [
{ denomination: 100, count: 50 }, { denomination: 100, count: 50 },
@ -723,6 +990,30 @@ app.whenReady().then(() => {
startWatchdog() startWatchdog()
startCommandPoller() startCommandPoller()
// Bolt Card reader — forwards taps (lnurlw) + status to the renderer. Opt-in
// per machine via services.bitspire.nfc.enable, which is off unless a CCID
// reader is actually fitted: nfc-pcsc's pcsclite binding busy-spins this very
// thread when pcscd is absent and wedges the whole app (see nfc-service.ts).
// nfc-service also re-checks the pcscd socket, so this flag is the coarse
// gate, not the only defence. Cash-out via QR never depends on any of it.
if (process.env.BITSPIRE_NFC_ENABLED === 'true') {
void startNfcReader(
(lnurlw) => {
// Don't log the value — it carries the card's single-use SUN p/c.
console.log(`[NFC] card tapped — lnurlw (${lnurlw.length} chars) → renderer`)
mainWindow?.webContents.send('nfc:card-tapped', lnurlw)
},
(status: NfcStatus) => {
console.log(
`[NFC] status=${status.state}${status.reader ? ` reader="${status.reader}"` : ''}${status.message ? ` — ${status.message}` : ''}`
)
mainWindow?.webContents.send('nfc:status', status)
}
)
} else {
console.log('[NFC] no reader configured (BITSPIRE_NFC_ENABLED not "true") — skipping init')
}
app.on('activate', () => { app.on('activate', () => {
// macOS: re-create window when dock icon clicked // macOS: re-create window when dock icon clicked
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {

View file

@ -0,0 +1,74 @@
import { describe, it, expect, vi } from 'vitest'
import { extractLnurlw, readNdefLnurlw } from './nfc-service'
const LNURLW =
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
/** Build a Type-4 NDEF message with a single URI record carrying `uri`. */
function ndefUriMessage(uri: string): Buffer {
const uriBytes = Buffer.from(uri, 'ascii')
const payload = Buffer.concat([Buffer.from([0x00]), uriBytes]) // 0x00 = no prefix
// D1 = MB|ME|SR, TNF=well-known; type length 1; payload length; 'U'
return Buffer.concat([Buffer.from([0xd1, 0x01, payload.length, 0x55]), payload])
}
describe('extractLnurlw', () => {
it('pulls an lnurlw:// URI out of an NDEF record', () => {
expect(extractLnurlw(ndefUriMessage(LNURLW))).toBe(LNURLW)
})
it('pulls a boltcards https scan URL', () => {
const https = 'https://lnbits.l484.com/boltcards/api/v1/scan/x?p=aa&c=bb'
expect(extractLnurlw(ndefUriMessage(https))).toBe(https)
})
it('stops at the record boundary (no trailing binary)', () => {
const msg = Buffer.concat([ndefUriMessage(LNURLW), Buffer.from([0x00, 0xfe, 0x01])])
expect(extractLnurlw(msg)).toBe(LNURLW)
})
it('returns null when there is no lnurl', () => {
expect(extractLnurlw(Buffer.from('just some text', 'ascii'))).toBeNull()
})
})
describe('readNdefLnurlw', () => {
const SW_OK = Buffer.from([0x90, 0x00])
const SW_NOTFOUND = Buffer.from([0x6a, 0x82])
// Capability Container advertising the NDEF file id E104 (TLV 04 06 at [7,8]).
const CC = Buffer.from([
0x00, 0x0f, 0x20, 0x00, 0x3b, 0x00, 0x34, 0x04, 0x06, 0xe1, 0x04, 0x00, 0xff, 0x00, 0xff,
])
/** Route APDUs by content so the CC-read + fallback loop is exercised. */
function cardMock(opts: { noApp?: boolean; nlen0?: boolean; uri?: string } = {}) {
const msg = ndefUriMessage(opts.uri ?? LNURLW)
const nlen = msg.length
return vi.fn(async (apdu: Buffer) => {
const hex = apdu.toString('hex')
if (hex.includes('d2760000850101')) return opts.noApp ? SW_NOTFOUND : SW_OK // select app
if (hex.startsWith('00a4000c02e103')) return SW_OK // select CC
if (hex.startsWith('00b000000f')) return Buffer.concat([CC, SW_OK]) // read CC
if (hex.startsWith('00a4000c02e104')) return SW_OK // select NDEF file (E104)
if (hex.startsWith('00a4000c020004')) return SW_NOTFOUND // fallback file id: absent
if (hex.startsWith('00b0000002'))
return opts.nlen0
? Buffer.concat([Buffer.from([0x00, 0x00]), SW_OK])
: Buffer.concat([Buffer.from([(nlen >> 8) & 0xff, nlen & 0xff]), SW_OK]) // NLEN
if (hex.startsWith('00b0')) return Buffer.concat([msg, SW_OK]) // read message
return SW_NOTFOUND
})
}
it('reads CC → NDEF file (E104) and returns the lnurlw', async () => {
const transmit = cardMock()
expect(await readNdefLnurlw(transmit)).toBe(LNURLW)
// First APDU selects the NDEF application (AID D2760000850101).
expect((transmit.mock.calls[0][0] as Buffer).toString('hex')).toContain('d2760000850101')
})
it('returns null if selecting the NDEF app fails', async () => {
expect(await readNdefLnurlw(cardMock({ noApp: true }))).toBeNull()
})
it('returns null on an empty NDEF file', async () => {
expect(await readNdefLnurlw(cardMock({ nlen0: true }))).toBeNull()
})
})

View file

@ -0,0 +1,273 @@
/**
* NFC reader driver (main process) for Bolt Card tap-to-pay.
*
* Wraps `nfc-pcsc` (PC/SC via the Feitian KP382 CCID reader). On each card
* tap it reads the NTAG424 Type-4 NDEF file over ISO7816 APDUs and extracts
* the `lnurlw://…?p=…&c=…` voucher (the card computes fresh SUN p/c per tap),
* then hands it to the renderer over IPC. The renderer, when showing a
* cash-out invoice, pays it via LNURL-withdraw (see lnurl-withdraw.ts).
*
* Everything here is best-effort and lazy: `nfc-pcsc` is a native addon, so it
* is dynamically imported and every failure is swallowed into a status
* callback. If the reader/library is absent, NFC is simply unavailable and the
* QR path keeps working — cash-out never depends on this.
*/
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
export type NfcState = 'ready' | 'reading' | 'error' | 'card-removed' | 'unavailable'
export interface NfcStatus {
state: NfcState
reader?: string
message?: string
}
type CardHandler = (lnurlw: string) => void
type StatusHandler = (status: NfcStatus) => void
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e)
}
/** Pull the lnurlw (or a boltcards https scan URL) out of a Type-4 NDEF blob. */
export function extractLnurlw(ndef: Buffer): string | null {
// Robust to record framing: the URI record embeds the literal string; grab
// it directly, bounded to URL-safe characters so we stop at the record end.
const text = ndef.toString('latin1')
const urlChars = "[A-Za-z0-9._~:/?#\\[\\]@!$&'()*+,;=%-]+"
const m =
text.match(new RegExp('lnurlw://' + urlChars, 'i')) ||
text.match(new RegExp('https://' + urlChars + '/boltcards/' + urlChars, 'i'))
return m ? m[0] : null
}
const swOk = (r: Buffer) => r.length >= 2 && r[r.length - 2] === 0x90 && r[r.length - 1] === 0x00
/** Select an EF by its 2-byte file id and read + parse its NDEF message. */
async function readNdefFile(
send: (bytes: number[]) => Promise<Buffer>,
fid: [number, number]
): Promise<string | null> {
if (!swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, fid[0], fid[1]]))) return null
// 2-byte NLEN header at offset 0.
const lenResp = await send([0x00, 0xb0, 0x00, 0x00, 0x02])
if (!swOk(lenResp)) return null
const nlen = (lenResp[0] << 8) | lenResp[1]
if (nlen <= 0 || nlen > 0x2000) return null
// NDEF message starts at offset 2; read in <=250-byte chunks.
const chunks: Buffer[] = []
let offset = 2
let remaining = nlen
while (remaining > 0) {
const toRead = Math.min(remaining, 0xfa)
const resp = await send([0x00, 0xb0, (offset >> 8) & 0xff, offset & 0xff, toRead])
if (!swOk(resp)) break
const data = resp.subarray(0, resp.length - 2)
if (data.length === 0) break
chunks.push(data)
offset += data.length
remaining -= data.length
}
return extractLnurlw(Buffer.concat(chunks))
}
/**
* Read the NDEF of a Type-4 tag and return the extracted lnurlw, or null.
* `transmit(apdu, maxLen) => Buffer` including the trailing SW1 SW2.
*
* Select the NDEF Tag Application, read the Capability Container to learn the
* real NDEF FileID (NTAG424 Bolt Cards use E104, not the 0004 some tags use),
* then read that file. Falls back to E104/0004 if the CC read is unavailable.
*/
export async function readNdefLnurlw(
transmit: (apdu: Buffer, maxLen: number) => Promise<Buffer>
): Promise<string | null> {
const send = (bytes: number[]) => transmit(Buffer.from(bytes), 256)
// Select the NDEF Tag Application (AID D2760000850101).
if (
!swOk(
await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00])
)
) {
return null
}
// NTAG424 Bolt Cards use NDEF FileID E104. Try it (and 0004) directly to
// minimise APDU round-trips over a flaky RF link; only fall back to reading
// the Capability Container to discover the id if both direct reads fail.
for (const fid of [[0xe1, 0x04] as [number, number], [0x00, 0x04] as [number, number]]) {
const found = await readNdefFile(send, fid)
if (found) return found
}
if (swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, 0xe1, 0x03]))) {
const cc = await send([0x00, 0xb0, 0x00, 0x00, 0x0f])
// CC layout: …[07]=TLV tag 0x04, [08]=len, [09..10]=NDEF FileID.
if (swOk(cc) && cc.length >= 13 && cc[7] === 0x04) {
const found = await readNdefFile(send, [cc[9], cc[10]])
if (found) return found
}
}
return null
}
let stopFn: (() => void) | null = null
// ── Wedge auto-recovery ───────────────────────────────────────────────────
// Cheap CCID readers (the Feitian R502-CL especially) occasionally wedge: they
// keep detecting a card but every APDU returns "card absent or mute", and ONLY
// a USB power-cycle clears it — pcscd/app restarts do NOT. When we see a run of
// consecutive read failures we trigger nfc-reader-reset.service (a root oneshot
// that re-binds the reader's USB device = a software replug); nfc-pcsc then
// re-detects the reader on hotplug with no app restart. The trigger is gated by
// a cooldown so a still-wedged reader can't reset-loop. A quality reader (e.g.
// ACR1252U) wedges far less; this is belt-and-suspenders for any reader.
const WEDGE_FAILURE_THRESHOLD = 3
const RESET_COOLDOWN_MS = 30_000
// Persist across reader re-enumerations (a reset spawns a fresh reader closure).
let lastReaderResetAt = 0
/** Trigger the privileged USB power-cycle of the reader. Best-effort. */
function resetWedgedReader(): void {
// NixOS: the app runs unprivileged as `bitspire`; a polkit rule authorises it
// to start this one unit. systemctl lives at a stable path on the device.
execFile('/run/current-system/sw/bin/systemctl', ['start', 'nfc-reader-reset.service'], () => {
/* best-effort — if it fails the reader stays wedged until a manual reset */
})
}
/**
* Start listening for Bolt Card taps. Idempotent. Returns a stop function.
* Never throws — failures surface via onStatus.
*/
/**
* pcsc-lite's client socket, created by pcscd.
*
* When pcscd is NOT running, the pcsclite binding inside nfc-pcsc does not
* fail — it retries SCardEstablishContext in a tight loop on the calling
* thread (~12k stat()s per second on this path), and that thread is Electron's
* main thread. The event loop then stops turning entirely: the window never
* paints, the dead renderer is never reaped, and main.ts's watchdog can't fire
* either, so nothing recovers it. A douro with no reader fitted sat wedged
* like that for 11 hours, ignoring SIGTERM.
*
* Checking for the socket first is what makes the "best-effort" contract in
* this module's header actually true. It also covers pcscd dying at runtime on
* a machine that does have a reader.
*/
const PCSCD_SOCKET = '/run/pcscd/pcscd.comm'
export async function startNfcReader(
onCard: CardHandler,
onStatus: StatusHandler
): Promise<() => void> {
if (stopFn) return stopFn
if (!existsSync(PCSCD_SOCKET)) {
onStatus({ state: 'unavailable', message: `pcscd not running (${PCSCD_SOCKET} absent)` })
return () => {}
}
let mod: unknown
try {
// Non-literal specifier: nfc-pcsc ships no types; keep it `any` to tsc
// while resolving normally at runtime.
const pkg = 'nfc-pcsc'
mod = (await import(pkg)) as unknown
} catch (e) {
onStatus({ state: 'unavailable', message: `NFC library unavailable: ${errMsg(e)}` })
return () => {}
}
const NFC =
(mod as { NFC?: unknown }).NFC ?? (mod as { default?: { NFC?: unknown } }).default?.NFC
if (typeof NFC !== 'function') {
onStatus({ state: 'unavailable', message: 'NFC library has no NFC export' })
return () => {}
}
let nfc: { on: (e: string, cb: (...a: unknown[]) => void) => void; close?: () => void }
try {
nfc = new (NFC as new () => typeof nfc)()
} catch (e) {
onStatus({ state: 'unavailable', message: `NFC init failed: ${errMsg(e)}` })
return () => {}
}
nfc.on('reader', (reader: unknown) => {
const r = reader as {
name?: string
reader?: { name?: string }
autoProcessing?: boolean
on: (e: string, cb: (...a: unknown[]) => void) => void
transmit: (data: Buffer, maxLen: number) => Promise<Buffer>
}
const name = r.name ?? r.reader?.name ?? 'reader'
// We do our own NDEF APDU read, not nfc-pcsc's UID auto-processing.
r.autoProcessing = false
onStatus({ state: 'ready', reader: name })
// Cooldown after a failed read: these cheap CCID readers can get wedged into
// a present↔empty storm when hammered, so ignore re-detections for a beat
// after a failure. Successful reads don't cool down.
let cooldownUntil = 0
// Consecutive failed reads → wedge detection (see resetWedgedReader above).
// A completed read (Bolt Card or not) proves the reader is healthy and
// clears the count; only a run of thrown transmits trips the reset.
let consecutiveFailures = 0
r.on('card', async () => {
if (Date.now() < cooldownUntil) return
onStatus({ state: 'reading', reader: name })
// Single attempt: retrying hammers a flaky RF link. A read is a few APDU
// round-trips; if the card shifts mid-read the transmit fails and the
// user simply re-taps.
try {
const lnurlw = await readNdefLnurlw((apdu, maxLen) => r.transmit(apdu, maxLen))
consecutiveFailures = 0
if (lnurlw) {
onCard(lnurlw)
return
}
onStatus({ state: 'error', reader: name, message: 'not a Bolt Card' })
} catch (e) {
consecutiveFailures++
if (
consecutiveFailures >= WEDGE_FAILURE_THRESHOLD &&
Date.now() - lastReaderResetAt > RESET_COOLDOWN_MS
) {
// Reader looks wedged — auto power-cycle it (only fix that works).
lastReaderResetAt = Date.now()
consecutiveFailures = 0
onStatus({ state: 'error', reader: name, message: 'reader stuck — auto-resetting…' })
resetWedgedReader()
} else {
onStatus({
state: 'error',
reader: name,
message: 'card read failed — hold steady & retap',
})
}
void e
}
cooldownUntil = Date.now() + 1500
})
r.on('card.off', () => onStatus({ state: 'card-removed', reader: name }))
r.on('error', (err: unknown) =>
onStatus({ state: 'error', reader: name, message: errMsg(err) })
)
r.on('end', () =>
onStatus({ state: 'unavailable', reader: name, message: 'reader disconnected' })
)
})
nfc.on('error', (err: unknown) => onStatus({ state: 'error', message: errMsg(err) }))
stopFn = () => {
try {
nfc.close?.()
} catch {
/* idempotent */
}
stopFn = null
}
return stopFn
}

View file

@ -7,6 +7,24 @@
import { contextBridge, ipcRenderer } from 'electron' import { contextBridge, ipcRenderer } from 'electron'
/** Mirrors state-store.CashOutHold (ADR-005 §5) — preload can't import main-process modules. */
interface CashOutHold {
reason: string
errorCode: string | null
rawCode: string | null
since: number
}
/** Mirrors state-store.PendingDispenseReport (ADR-005 §2). */
interface PendingDispenseReport {
txid: string
payload: unknown
createdAt: number
attempts: number
lastAttemptAt: number | null
lastError: string | null
}
/** /**
* Runtime configuration interface (public info only) * Runtime configuration interface (public info only)
* These values are read from environment variables at runtime (not build time) * These values are read from environment variables at runtime (not build time)
@ -17,10 +35,6 @@ export interface RuntimeConfig {
relayUrl: string relayUrl: string
/** LNbits nostr-transport server pubkey (hex, 64 chars). */ /** LNbits nostr-transport server pubkey (hex, 64 chars). */
lnbitsServerPubkey: string lnbitsServerPubkey: string
/** Legacy LP fields — retained until 3d removes the LP backend. Optional. */
lightningPubPubkey?: string
lightningPubApiUrl?: string
extensionApiUrl?: string
appId: string appId: string
machineModel: string machineModel: string
fiatCode: string fiatCode: string
@ -42,13 +56,29 @@ export interface BrandingConfig {
logoDarkDataUrl: string | null logoDarkDataUrl: string | null
} }
/**
* Persisted NIP-46 bunker binding (mirror of state-store's StoredBunkerBinding).
*/
export interface BunkerBindingRecord {
clientSecretHex: string
spirePubkey: string
bunkerUrl: string
seedFingerprint: string
pairedAt: number
/** LNbits transport relays from the seed (#70); absent on pre-#70 bindings. */
relays?: string[]
/** LNbits nostr-transport server pubkey (hex) from the seed (#70). */
lnbitsServerPubkey?: string
}
/** /**
* ATM secrets — returned once by getAtmSecrets(), then empty on subsequent calls. * ATM secrets — returned once by getAtmSecrets(), then empty on subsequent calls.
* The spire pairing seed (carries the one-shot connect token) plus the persisted
* bunker binding; the renderer resolves these into a signer.
*/ */
export interface AtmSecrets { export interface AtmSecrets {
atmPrivateKey: string spireSeed: string
/** Legacy LP admin token — retained until 3d removes the LP backend. */ bunkerBinding: BunkerBindingRecord | null
adminToken?: string
} }
// Expose protected methods to renderer // Expose protected methods to renderer
@ -96,17 +126,90 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Operator-config consumer (aiolabs/lamassu-next#56) // Operator-config consumer (aiolabs/lamassu-next#56)
getLastKnownConfigCreatedAt: (): Promise<number> => getLastKnownConfigCreatedAt: (): Promise<number> =>
ipcRenderer.invoke('state:get-last-known-config-created-at'), ipcRenderer.invoke('state:get-last-known-config-created-at'),
getBootstrapPublishedAt: (): Promise<number | null> => getLastStatePublishedAt: (): Promise<number | null> =>
ipcRenderer.invoke('state:get-bootstrap-published-at'), ipcRenderer.invoke('state:get-last-state-published-at'),
markBootstrapPublished: (unixTimestamp: number): Promise<void> => getCountsUncertainSince: (): Promise<number | null> =>
ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp), ipcRenderer.invoke('state:get-counts-uncertain-since'),
applyOperatorCassettesConfig: ( markCountsUncertain: (unixTimestamp: number): Promise<void> =>
payload: { ipcRenderer.invoke('state:mark-counts-uncertain', unixTimestamp),
positions: Record<string, { denomination: number; count: number }> // Cash-out hold (ADR-005 §5)
}, getCashOutHold: (): Promise<CashOutHold | null> => ipcRenderer.invoke('state:get-cash-out-hold'),
eventCreatedAt: number setCashOutHold: (hold: CashOutHold): Promise<CashOutHold> =>
): Promise<{ applied: true } | { applied: false; reason: string }> => ipcRenderer.invoke('state:set-cash-out-hold', hold),
ipcRenderer.invoke('state:apply-operator-cassettes-config', payload, eventCreatedAt), clearCashOutHold: (): Promise<boolean> => ipcRenderer.invoke('state:clear-cash-out-hold'),
// Dispense-report outbox (ADR-005 §2)
pendingDispenseReports: (limit?: number): Promise<PendingDispenseReport[]> =>
ipcRenderer.invoke('state:pending-dispense-reports', limit),
ackDispenseReport: (txid: string): Promise<boolean> =>
ipcRenderer.invoke('state:ack-dispense-report', txid),
noteDispenseReportAttempt: (txid: string, error: string | null): Promise<void> =>
ipcRenderer.invoke('state:note-dispense-report-attempt', txid, error),
markStatePublished: (unixTimestamp: number): Promise<void> =>
ipcRenderer.invoke('state:mark-state-published', unixTimestamp),
// Bunker binding persistence (aiolabs/bitspire#52)
saveBunkerBinding: (binding: BunkerBindingRecord): Promise<void> =>
ipcRenderer.invoke('state:save-bunker-binding', binding),
clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'),
resetStatePublishWatermark: (): Promise<void> =>
ipcRenderer.invoke('state:reset-state-publish-watermark'),
resetForRepair: (): Promise<void> => ipcRenderer.invoke('state:reset-for-repair'),
// QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed,
// then relaunch so the normal boot flow pairs it.
saveSpireSeed: (seed: string): Promise<void> => ipcRenderer.invoke('state:save-spire-seed', seed),
relaunchApp: (): Promise<void> => ipcRenderer.invoke('app:relaunch'),
// Reload the renderer to re-attempt initialization (connectivity recovery).
recoverApp: (): Promise<void> => ipcRenderer.invoke('app:recover'),
// Bolt Card cash-out: pull payment for the current invoice from a tapped card.
lnurlWithdraw: (args: {
lnurlw: string
bolt11: string
amountMsat?: number
}): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('lnurl:withdraw', args),
// Bolt Card cash-in: resolve a tapped card + amount to a BOLT11 to pay.
resolveCardInvoice: (args: {
lnurlw: string
amountMsat: number
}): Promise<{ ok: boolean; bolt11?: string; reason?: string }> =>
ipcRenderer.invoke('lnurl:pay-card', args),
// Bolt Card tap-to-enter: one verified session per tap (balance + fiat +
// the withdraw/pay second steps reused at Complete). Payload shapes are
// declared in src/types/electron.d.ts (CardSession).
openCardSession: (args: { lnurlw: string }): Promise<unknown> =>
ipcRenderer.invoke('lnurl:open-card-session', args),
withdrawWithSession: (args: {
withdraw: { callback: string; k1: string; minWithdrawable?: number; maxWithdrawable?: number }
bolt11: string
amountMsat?: number
}): Promise<{ ok: boolean; reason?: string }> =>
ipcRenderer.invoke('lnurl:withdraw-session', args),
resolveSessionInvoice: (args: {
pay: { callback: string; minSendable?: number; maxSendable?: number; metadata?: string }
amountMsat: number
}): Promise<{ ok: boolean; bolt11?: string; reason?: string }> =>
ipcRenderer.invoke('lnurl:pay-session', args),
applyOperatorCassetteOps: (
ops: {
id: string
at: number
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
position: number
bills?: number
count?: number
denomination?: number
}[]
): Promise<{
applied: string[]
rejected: { id: string; reason: string }[]
}> => ipcRenderer.invoke('state:apply-operator-cassette-ops', ops),
getAppliedOpIds: (limit?: number): Promise<string[]> =>
ipcRenderer.invoke('state:get-applied-op-ids', limit),
getCassetteStateSeq: (): Promise<number> => ipcRenderer.invoke('state:get-cassette-state-seq'),
// Operator-fees consumer (aiolabs/lamassu-next#57) // Operator-fees consumer (aiolabs/lamassu-next#57)
getFeeConfig: (): Promise<{ getFeeConfig: (): Promise<{
@ -161,6 +264,27 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('hal:error', (_event, error) => callback(error)) ipcRenderer.on('hal:error', (_event, error) => callback(error))
}, },
// Bolt Card reader (main process → renderer). removeAllListeners first: a
// renderer reload re-runs this, and a duplicated card-tap listener would
// trigger the LNURL-withdraw twice.
// The main process changed the cassettes table (an operator-command dispense,
// boot seeding). The renderer reloads its inventory and republishes state.
onCassettesChanged: (callback: () => void) => {
ipcRenderer.removeAllListeners('cassettes:changed')
ipcRenderer.on('cassettes:changed', () => callback())
},
onNfcCardTapped: (callback: (lnurlw: string) => void) => {
ipcRenderer.removeAllListeners('nfc:card-tapped')
ipcRenderer.on('nfc:card-tapped', (_event, lnurlw) => callback(lnurlw))
},
onNfcStatus: (
callback: (status: { state: string; reader?: string; message?: string }) => void
) => {
ipcRenderer.removeAllListeners('nfc:status')
ipcRenderer.on('nfc:status', (_event, status) => callback(status))
},
// Watchdog heartbeat (main process → renderer → main process) // Watchdog heartbeat (main process → renderer → main process)
onWatchdogPing: (callback: () => void) => { onWatchdogPing: (callback: () => void) => {
ipcRenderer.on('watchdog:ping', () => callback()) ipcRenderer.on('watchdog:ping', () => callback())
@ -210,12 +334,48 @@ declare global {
emptyCashbox: () => Promise<void> emptyCashbox: () => Promise<void>
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean> remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
getLastKnownConfigCreatedAt: () => Promise<number> getLastKnownConfigCreatedAt: () => Promise<number>
getBootstrapPublishedAt: () => Promise<number | null> getLastStatePublishedAt: () => Promise<number | null>
markBootstrapPublished: (unixTimestamp: number) => Promise<void> getCountsUncertainSince: () => Promise<number | null>
applyOperatorCassettesConfig: ( markCountsUncertain: (unixTimestamp: number) => Promise<void>
payload: { positions: Record<string, { denomination: number; count: number }> }, getCashOutHold: () => Promise<CashOutHold | null>
eventCreatedAt: number setCashOutHold: (hold: CashOutHold) => Promise<CashOutHold>
) => Promise<{ applied: true } | { applied: false; reason: string }> clearCashOutHold: () => Promise<boolean>
pendingDispenseReports: (limit?: number) => Promise<PendingDispenseReport[]>
ackDispenseReport: (txid: string) => Promise<boolean>
noteDispenseReportAttempt: (txid: string, error: string | null) => Promise<void>
markStatePublished: (unixTimestamp: number) => Promise<void>
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void>
resetStatePublishWatermark: () => Promise<void>
resetForRepair: () => Promise<void>
saveSpireSeed: (seed: string) => Promise<void>
relaunchApp: () => Promise<void>
recoverApp: () => Promise<void>
lnurlWithdraw: (args: {
lnurlw: string
bolt11: string
amountMsat?: number
}) => Promise<{ ok: boolean; reason?: string }>
resolveCardInvoice: (args: {
lnurlw: string
amountMsat: number
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
applyOperatorCassetteOps: (
ops: {
id: string
at: number
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
position: number
bills?: number
count?: number
denomination?: number
}[]
) => Promise<{
applied: string[]
rejected: { id: string; reason: string }[]
}>
getAppliedOpIds: (limit?: number) => Promise<string[]>
getCassetteStateSeq: () => Promise<number>
getFeeConfig: () => Promise<{ getFeeConfig: () => Promise<{
cashInFeeFraction: number cashInFeeFraction: number
cashOutFeeFraction: number cashOutFeeFraction: number
@ -249,6 +409,10 @@ declare global {
onHalBillInserted: (callback: (denomination: number) => void) => void onHalBillInserted: (callback: (denomination: number) => void) => void
onHalBillRejected: (callback: (reason: string) => void) => void onHalBillRejected: (callback: (reason: string) => void) => void
onHalError: (callback: (error: string) => void) => void onHalError: (callback: (error: string) => void) => void
onNfcCardTapped: (callback: (lnurlw: string) => void) => void
onNfcStatus: (
callback: (status: { state: string; reader?: string; message?: string }) => void
) => void
onWatchdogPing: (callback: () => void) => void onWatchdogPing: (callback: () => void) => void
watchdogPong: () => Promise<void> watchdogPong: () => Promise<void>
platform: NodeJS.Platform platform: NodeJS.Platform

View file

@ -10,12 +10,13 @@
*/ */
import Database from 'better-sqlite3' import Database from 'better-sqlite3'
import type { DispenseReportBody } from '@bitSpire/lnbits'
import path from 'node:path' import path from 'node:path'
import fs from 'node:fs' import fs from 'node:fs'
let db: Database.Database | null = null let db: Database.Database | null = null
const SCHEMA_VERSION = '10' const SCHEMA_VERSION = '14'
function getDbPath(): string { function getDbPath(): string {
const prodDir = '/var/lib/bitspire' const prodDir = '/var/lib/bitspire'
@ -57,6 +58,17 @@ export function initDatabase(dbPath?: string): void {
count INTEGER NOT NULL DEFAULT 0 count INTEGER NOT NULL DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS cassette_ops (
id TEXT PRIMARY KEY,
position INTEGER NOT NULL,
op_type TEXT NOT NULL,
bills INTEGER,
count INTEGER,
denomination INTEGER,
op_at INTEGER NOT NULL,
applied_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS cashbox ( CREATE TABLE IF NOT EXISTS cashbox (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),
total_bills INTEGER NOT NULL DEFAULT 0, total_bills INTEGER NOT NULL DEFAULT 0,
@ -114,6 +126,27 @@ export function initDatabase(dbPath?: string): void {
event_created_at INTEGER NOT NULL, event_created_at INTEGER NOT NULL,
applied_at INTEGER NOT NULL applied_at INTEGER NOT NULL
); );
CREATE TABLE IF NOT EXISTS bunker_binding (
id INTEGER PRIMARY KEY CHECK (id = 1),
client_secret_hex TEXT NOT NULL,
spire_pubkey TEXT NOT NULL,
bunker_url TEXT NOT NULL,
seed_fingerprint TEXT NOT NULL,
paired_at INTEGER NOT NULL,
relays TEXT,
lnbits_server_pubkey TEXT
);
CREATE TABLE IF NOT EXISTS dispense_reports (
txid TEXT PRIMARY KEY REFERENCES transactions(txid),
payload TEXT NOT NULL,
created_at INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt_at INTEGER,
last_error TEXT,
acked_at INTEGER
);
`) `)
// Seed meta + cashbox if first run, or run migrations // Seed meta + cashbox if first run, or run migrations
@ -284,7 +317,9 @@ export function initDatabase(dbPath?: string): void {
`) `)
db.pragma('foreign_keys = ON') db.pragma('foreign_keys = ON')
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('9', 'schema_version') db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('9', 'schema_version')
console.log('[StateStore] Migrated schema v8 → v9 (cassettes PK position; allow duplicate denominations)') console.log(
'[StateStore] Migrated schema v8 → v9 (cassettes PK position; allow duplicate denominations)'
)
existing.value = '9' existing.value = '9'
} }
@ -320,6 +355,104 @@ export function initDatabase(dbPath?: string): void {
) )
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('10', 'schema_version') db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('10', 'schema_version')
console.log('[StateStore] Migrated schema v9 → v10 (added fee_config + watermark)') console.log('[StateStore] Migrated schema v9 → v10 (added fee_config + watermark)')
existing.value = '10'
}
if (existing && existing.value === '10') {
// Migration v10 → v11: NIP-46 bunker binding (aiolabs/bitspire#52).
// - bunker_binding singleton — the ATM's own NIP-46 transport key
// (client_nsec) plus the spire signing identity, bunker URL, and a
// fingerprint of the seed it was paired from. Persisted so a restart
// resumes the bunker session without re-redeeming the one-shot connect
// secret. A new/changed seed_fingerprint signals a re-pair (which also
// resets bootstrapPublishedAt — see lightning.ts / bitspire#56).
db.exec(`
CREATE TABLE IF NOT EXISTS bunker_binding (
id INTEGER PRIMARY KEY CHECK (id = 1),
client_secret_hex TEXT NOT NULL,
spire_pubkey TEXT NOT NULL,
bunker_url TEXT NOT NULL,
seed_fingerprint TEXT NOT NULL,
paired_at INTEGER NOT NULL
);
`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('11', 'schema_version')
console.log('[StateStore] Migrated schema v10 → v11 (added bunker_binding)')
existing.value = '11'
}
if (existing && existing.value === '11') {
// Migration v11 → v12: carry the LNbits transport config in the binding
// (aiolabs/bitspire#70). relays (JSON array) + lnbits_server_pubkey let a
// paired machine reach the backend from the pairing alone — no VITE_RELAY_URL
// / VITE_LNBITS_SERVER_PUBKEY provisioning. Nullable: bindings written before
// this (the seed didn't carry them) resume fine and fall back to env.
db.exec(`
ALTER TABLE bunker_binding ADD COLUMN relays TEXT;
ALTER TABLE bunker_binding ADD COLUMN lnbits_server_pubkey TEXT;
`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('12', 'schema_version')
console.log('[StateStore] Migrated schema v11 → v12 (bunker_binding transport config)')
}
if (existing && existing.value === '12') {
// Migration v12 → v13: operator OPERATIONS replace operator counts
// (aiolabs/bitspire ADR-004).
//
// The operator used to publish absolute counts and this machine applied
// them outright. Both sides wrote the same value over a transport that
// never tells a writer it lost, so a dashboard form loaded before a
// dispense silently discarded that dispense — and nothing on either side
// could detect it afterwards. The operator now publishes what it DID and
// this machine, which holds the notes, owns the running total.
//
// `cassette_ops` is the dedup ledger. A delta applied twice is wrong, and
// addressable events are re-delivered on every reconnect, so the operator
// mints an id per operation and we record the ones we have applied. The
// operator's window is a slice of recent operations rather than just the
// newest, so one we missed arrives with the next publish; dedup is what
// makes re-delivery free instead of dangerous.
db.exec(`
CREATE TABLE IF NOT EXISTS cassette_ops (
id TEXT PRIMARY KEY,
position INTEGER NOT NULL,
op_type TEXT NOT NULL,
bills INTEGER,
count INTEGER,
denomination INTEGER,
op_at INTEGER NOT NULL,
applied_at INTEGER NOT NULL
);
`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('13', 'schema_version')
console.log('[StateStore] Migrated schema v12 → v13 (added cassette_ops)')
existing.value = '13'
}
if (existing && existing.value === '13') {
// Migration v13 → v14: the dispense-report outbox (ADR-005 §2).
//
// Every cash-out's outcome — success or failure — is reported to
// spirekeeper over a kind-21000 RPC, and that report is what lets the
// server capture (distribute) the settlement or surface a customer who
// is owed cash. A relay gives the publisher no delivery guarantee, so the
// report is written here, in the SAME transaction as the transactions
// row, and resent until the server acknowledges it. Idempotent on txid
// server-side; `attempts` / `last_error` drive the resend backoff.
db.exec(`
CREATE TABLE IF NOT EXISTS dispense_reports (
txid TEXT PRIMARY KEY REFERENCES transactions(txid),
payload TEXT NOT NULL,
created_at INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt_at INTEGER,
last_error TEXT,
acked_at INTEGER
);
`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('14', 'schema_version')
console.log('[StateStore] Migrated schema v13 → v14 (added dispense_reports outbox)')
existing.value = '14'
} }
// Defensive: a fresh install at SCHEMA_VERSION skips all migrations. // Defensive: a fresh install at SCHEMA_VERSION skips all migrations.
@ -328,6 +461,7 @@ export function initDatabase(dbPath?: string): void {
seedMeta.run('lastKnownConfigCreatedAt', '0') seedMeta.run('lastKnownConfigCreatedAt', '0')
seedMeta.run('bootstrapPublishedAt', '') seedMeta.run('bootstrapPublishedAt', '')
seedMeta.run('lastKnownFeeConfigCreatedAt', '0') seedMeta.run('lastKnownFeeConfigCreatedAt', '0')
seedMeta.run('cassetteStateSeq', '0')
const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get() const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get()
if (!cashboxRow) { if (!cashboxRow) {
@ -348,32 +482,237 @@ export function initDatabase(dbPath?: string): void {
*/ */
export function getLastKnownConfigCreatedAt(): number { export function getLastKnownConfigCreatedAt(): number {
if (!db) throw new Error('Database not initialized') if (!db) throw new Error('Database not initialized')
const row = db const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('lastKnownConfigCreatedAt') as
.prepare('SELECT value FROM meta WHERE key = ?') | { value: string }
.get('lastKnownConfigCreatedAt') as { value: string } | undefined | undefined
return row ? Number(row.value) || 0 : 0 return row ? Number(row.value) || 0 : 0
} }
/** /**
* Read the one-shot bootstrap-publish gate. Returns null if the ATM has * The `created_at` of the last `bitspire-cassettes-state` event this machine
* not yet published its `bitspire-cassettes-state:<machine_id>` hello-event. * published, or null if it has never published one.
*
* This used to be a one-shot gate ("have we said hello yet"), which meant a
* layout change after first boot was never announced (#94). It is now a
* high-water mark: every publish records its stamp, and the next one is forced
* strictly above it. Addressable events are ordered by `created_at` at second
* granularity, and a relay silently keeps the higher one, so a clock that steps
* backwards would otherwise make this machine's reports vanish with an `OK`.
*
* Stored under the original `bootstrapPublishedAt` meta key so no migration is
* needed; the name is historical, the meaning is not.
*/ */
export function getBootstrapPublishedAt(): number | null { export function getLastStatePublishedAt(): number | null {
if (!db) throw new Error('Database not initialized') if (!db) throw new Error('Database not initialized')
const row = db const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('bootstrapPublishedAt') as
.prepare('SELECT value FROM meta WHERE key = ?') | { value: string }
.get('bootstrapPublishedAt') as { value: string } | undefined | undefined
if (!row || row.value === '') return null if (!row || row.value === '') return null
const n = Number(row.value) const n = Number(row.value)
return Number.isFinite(n) ? n : null return Number.isFinite(n) ? n : null
} }
/** /**
* Mark the bootstrap hello-event as published. Idempotent — only takes * Whether the bay counts are known to be unverified, and since when.
* effect the first time it's set. Subsequent calls overwrite the *
* timestamp (harmless; the gate just needs to be non-null). * Set when a dispense ends without the dispenser reporting what it moved — a
* driver throw, or the dispense timeout. Bills may well have reached the
* customer, but nothing knows how many, so neither the rows here nor HAL's
* bays were debited and both now read high. Reporting that number as fact is
* the worst option available; saying the number is unverified is honest and
* tells the operator to open the machine and recount.
*
* Cleared when an operator asserts authoritative counts (a config apply),
* which is precisely what a recount is. Uses an upsert so no migration is
* needed for machines whose meta table predates the key.
*/ */
export function markBootstrapPublished(unixTimestamp: number): void { export function getCountsUncertainSince(): number | null {
if (!db) throw new Error('Database not initialized')
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('countsUncertainSince') as
| { value: string }
| undefined
if (!row || row.value === '') return null
const n = Number(row.value)
return Number.isFinite(n) ? n : null
}
/** Flag the counts as unverified. Keeps the earliest time it went bad. */
export function markCountsUncertain(unixTimestamp: number): void {
if (!db) throw new Error('Database not initialized')
if (getCountsUncertainSince() !== null) return
db.prepare(
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run('countsUncertainSince', String(unixTimestamp))
console.warn('[StateStore] Cassette counts flagged unverified at', unixTimestamp)
}
/** Clear the flag — an operator has asserted real counts. */
export function clearCountsUncertain(): void {
if (!db) throw new Error('Database not initialized')
db.prepare(
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run('countsUncertainSince', '')
}
// ---------------------------------------------------------------------------
// Cash-out hold (ADR-005 §5)
// ---------------------------------------------------------------------------
//
// A terminal dispenser fault latches cash-out off. The latch is machine
// health, so it lives in `meta` (one JSON value) and survives restarts; the
// renderer restores it into the state machine on boot and the operator
// releases it with a `recount` or `resume_cash_out` op. Re-initialising the
// dispenser never clears it — re-init does not move a stuck note.
export interface CashOutHold {
reason: string
errorCode: string | null
rawCode: string | null
/** unix seconds of the FIRST fault — kept across repeat faults */
since: number
}
export function getCashOutHold(): CashOutHold | null {
if (!db) throw new Error('Database not initialized')
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('cashOutHeld') as
| { value: string }
| undefined
if (!row || row.value === '') return null
try {
const parsed = JSON.parse(row.value) as Partial<CashOutHold>
if (typeof parsed.since !== 'number' || typeof parsed.reason !== 'string') return null
return {
reason: parsed.reason,
errorCode: typeof parsed.errorCode === 'string' ? parsed.errorCode : null,
rawCode: typeof parsed.rawCode === 'string' ? parsed.rawCode : null,
since: parsed.since,
}
} catch {
return null
}
}
/** Latch cash-out off. Idempotent: an existing hold (and its `since`) is kept. */
export function setCashOutHold(hold: CashOutHold): CashOutHold {
if (!db) throw new Error('Database not initialized')
const existing = getCashOutHold()
if (existing) return existing
db.prepare(
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run('cashOutHeld', JSON.stringify(hold))
console.warn(
`[StateStore] Cash-out HELD: ${hold.errorCode ?? 'fault'}${hold.rawCode ? ` ${hold.rawCode}` : ''} — ${hold.reason}`
)
return hold
}
/** Release the latch — an operator has cleared the machine. */
export function clearCashOutHold(): boolean {
if (!db) throw new Error('Database not initialized')
const had = getCashOutHold() !== null
db.prepare(
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run('cashOutHeld', '')
if (had) console.log('[StateStore] Cash-out hold released')
return had
}
// ---------------------------------------------------------------------------
// Dispense-report outbox (ADR-005 §2)
// ---------------------------------------------------------------------------
export interface PendingDispenseReport {
txid: string
payload: DispenseReportBody
createdAt: number
attempts: number
lastAttemptAt: number | null
lastError: string | null
}
/** Unacknowledged reports, oldest first. The renderer applies the backoff. */
export function pendingDispenseReports(limit = 20): PendingDispenseReport[] {
if (!db) throw new Error('Database not initialized')
const rows = db
.prepare(
'SELECT txid, payload, created_at, attempts, last_attempt_at, last_error FROM dispense_reports WHERE acked_at IS NULL ORDER BY created_at ASC LIMIT ?'
)
.all(limit) as Array<{
txid: string
payload: string
created_at: number
attempts: number
last_attempt_at: number | null
last_error: string | null
}>
const out: PendingDispenseReport[] = []
for (const r of rows) {
try {
out.push({
txid: r.txid,
payload: JSON.parse(r.payload) as DispenseReportBody,
createdAt: r.created_at,
attempts: r.attempts,
lastAttemptAt: r.last_attempt_at,
lastError: r.last_error,
})
} catch {
console.error('[StateStore] dispense_reports row has unparseable payload:', r.txid)
}
}
return out
}
/** The server acknowledged this report. Returns whether a row changed. */
export function markDispenseReportAcked(txid: string): boolean {
if (!db) throw new Error('Database not initialized')
const res = db
.prepare('UPDATE dispense_reports SET acked_at = ? WHERE txid = ? AND acked_at IS NULL')
.run(Date.now(), txid)
if (res.changes > 0) console.log('[StateStore] Dispense report acked:', txid)
return res.changes > 0
}
/** A send was attempted and did not get an OK. Drives the resend backoff. */
export function noteDispenseReportAttempt(txid: string, error: string | null): void {
if (!db) throw new Error('Database not initialized')
db.prepare(
'UPDATE dispense_reports SET attempts = attempts + 1, last_attempt_at = ?, last_error = ? WHERE txid = ?'
).run(Date.now(), error, txid)
}
/**
* A counter bumped on every local change to a bay count, from any cause.
*
* It rides along in the state document so a reader can reject a regression
* without trusting a clock. `created_at` cannot carry that: it has
* second granularity, so two publishes in the same second are ordered by
* whichever event id hashes lower — and a machine whose clock stepped
* backwards would otherwise have every later report look older than the one
* already on the relay.
*/
export function getCassetteStateSeq(): number {
if (!db) throw new Error('Database not initialized')
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('cassetteStateSeq') as
| { value: string }
| undefined
return row ? Number(row.value) || 0 : 0
}
/**
* Bump the counter. Safe to call inside an open transaction — every caller
* that mutates a count does, so the bump commits or rolls back with it.
*/
export function bumpCassetteStateSeq(): void {
if (!db) throw new Error('Database not initialized')
db.prepare(
'INSERT INTO meta (key, value) VALUES (?, ?) ' +
'ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(meta.value AS INTEGER) + 1 AS TEXT)'
).run('cassetteStateSeq', '1')
}
/** Record the `created_at` just published, as the next publish's floor. */
export function markStatePublished(unixTimestamp: number): void {
if (!db) throw new Error('Database not initialized') if (!db) throw new Error('Database not initialized')
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run( db.prepare('UPDATE meta SET value = ? WHERE key = ?').run(
String(unixTimestamp), String(unixTimestamp),
@ -381,108 +720,332 @@ export function markBootstrapPublished(unixTimestamp: number): void {
) )
} }
export type OperatorCassettesPayload = { // ---------------------------------------------------------------------------
positions: Record<string, { denomination: number; count: number }> // Bunker binding — NIP-46 transport key + spire identity (aiolabs/bitspire#52)
// ---------------------------------------------------------------------------
export interface StoredBunkerBinding {
/** The ATM's own NIP-46 transport secret key (`client_nsec`), hex. */
clientSecretHex: string
/** The spire's signing pubkey (hex) — the identity events are signed as. */
spirePubkey: string
/** `bunker://…` URL, re-parsed into a pointer on resume. */
bunkerUrl: string
/** Fingerprint of the seed this binding was paired from (re-pair detection). */
seedFingerprint: string
/** Unix seconds when the pairing was redeemed. */
pairedAt: number
/**
* LNbits transport relays from the pairing seed (aiolabs/bitspire#70). Lets a
* resumed (seedless) boot reach the backend without env provisioning.
* Undefined for bindings written before the seed carried them.
*/
relays?: string[]
/** LNbits nostr-transport server pubkey (hex) from the seed (#70). */
lnbitsServerPubkey?: string
} }
export type ApplyResult = /** Read the persisted bunker binding, or null if the ATM is unpaired. */
| { applied: true } export function getBunkerBinding(): StoredBunkerBinding | null {
| { applied: false; reason: string } if (!db) throw new Error('Database not initialized')
const row = db
.prepare(
'SELECT client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey FROM bunker_binding WHERE id = 1'
)
.get() as
| {
client_secret_hex: string
spire_pubkey: string
bunker_url: string
seed_fingerprint: string
paired_at: number
relays: string | null
lnbits_server_pubkey: string | null
}
| undefined
if (!row) return null
return {
clientSecretHex: row.client_secret_hex,
spirePubkey: row.spire_pubkey,
bunkerUrl: row.bunker_url,
seedFingerprint: row.seed_fingerprint,
pairedAt: row.paired_at,
relays: parseRelaysColumn(row.relays),
lnbitsServerPubkey: row.lnbits_server_pubkey ?? undefined,
}
}
/** Decode the JSON-array `relays` column, tolerating null/legacy/garbage. */
function parseRelaysColumn(value: string | null): string[] | undefined {
if (!value) return undefined
try {
const parsed = JSON.parse(value)
if (Array.isArray(parsed) && parsed.every((r) => typeof r === 'string')) {
return parsed as string[]
}
} catch {
// fall through
}
return undefined
}
/** Upsert the bunker binding after a successful (re-)pairing. */
export function saveBunkerBinding(binding: StoredBunkerBinding): void {
if (!db) throw new Error('Database not initialized')
db.prepare(
`INSERT INTO bunker_binding (id, client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey)
VALUES (1, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
client_secret_hex = excluded.client_secret_hex,
spire_pubkey = excluded.spire_pubkey,
bunker_url = excluded.bunker_url,
seed_fingerprint = excluded.seed_fingerprint,
paired_at = excluded.paired_at,
relays = excluded.relays,
lnbits_server_pubkey = excluded.lnbits_server_pubkey`
).run(
binding.clientSecretHex,
binding.spirePubkey,
binding.bunkerUrl,
binding.seedFingerprint,
binding.pairedAt,
binding.relays ? JSON.stringify(binding.relays) : null,
binding.lnbitsServerPubkey ?? null
)
}
/** Drop the bunker binding (e.g. after an operator revoke → force re-pair). */
export function clearBunkerBinding(): void {
if (!db) throw new Error('Database not initialized')
db.prepare('DELETE FROM bunker_binding WHERE id = 1').run()
}
/** /**
* Atomic apply of an operator-published cassette config (aiolabs/lamassu-next#56). * Forget the publish high-water mark. Called on a re-pair (new seed): the
* * next publish is then free to use the wall clock, which is what a fresh
* Caller has already verified the event signature and decrypted the * operator relationship wants. The state itself is republished on startup
* content. This function: * regardless, so the new operator always receives current counts.
*
* 1. Rechecks replay-protection against `meta.lastKnownConfigCreatedAt`
* (defense-in-depth — caller should have done this too).
* 2. Validates the payload's `positions` key set is *exactly* the set of
* positions currently in the `cassettes` table. The bay count is
* hardware-determined and can't be added to or removed from via this
* path; only the per-bay denomination and count are operator-mutable.
* 3. Validates per-entry `denomination` is a positive int, `count` is a
* non-negative int. **Duplicate denominations across positions are
* intentionally permitted** — real machines load multiple cassettes
* with the same denomination for cash-out throughput.
* 4. In a single SQLite transaction: updates `cassettes` rows by position
* (denomination + count both mutable per row) AND advances
* `meta.lastKnownConfigCreatedAt` to `eventCreatedAt`.
*
* Mid-write crashes roll back cleanly; on restart the same event is
* re-delivered by the relay and the watermark check drops it as already
* consumed (or the watermark is pre-event because the tx rolled back,
* and the apply runs again from scratch).
*/ */
export function applyOperatorCassettesConfig( export function resetStatePublishWatermark(): void {
payload: OperatorCassettesPayload,
eventCreatedAt: number
): ApplyResult {
if (!db) throw new Error('Database not initialized') if (!db) throw new Error('Database not initialized')
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt')
}
const watermark = getLastKnownConfigCreatedAt() /**
if (eventCreatedAt <= watermark) { * Wipe operator-scoped CONFIG/TRUST state on a re-pair to a new operator/backend,
return { * so stale policy from the previous pairing can't linger or silently reject the
applied: false, * new operator's config.
reason: `event.created_at (${eventCreatedAt}) <= lastKnownConfigCreatedAt (${watermark})`, *
* Clears the fee config and resets BOTH replay watermarks to 0. The watermark
* reset is the load-bearing part: without it, 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 — the exact remnant trap where re-pairing a long-lived
* install to a fresh backend appears to "work" but never picks up new config.
*
* Deliberately does NOT touch cassettes / cashbox / transactions: those track
* PHYSICAL cash, which survives an operator handover. A full wipe (decommission
* or a truly-fresh test) is the factory-reset path, not this.
*/
export function resetForRepair(): void {
if (!db) throw new Error('Database not initialized')
const database = db
database.transaction(() => {
database.prepare('DELETE FROM fee_config').run()
const setWatermark = database.prepare('UPDATE meta SET value = ? WHERE key = ?')
setWatermark.run('0', 'lastKnownFeeConfigCreatedAt')
setWatermark.run('0', 'lastKnownConfigCreatedAt')
})()
}
/**
* Outcome of applying an operator-authored absolute config. Still the right
* shape for fee config, where the operator is the only writer of the value
* and a later event simply supersedes an earlier one. Cassette counts left
* this model in ADR-004 precisely because they had two writers.
*/
export type ApplyResult = { applied: true } | { applied: false; reason: string }
/** One operator-authored operation, as it arrives on the wire. */
export type CassetteOp = {
id: string
at: number
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
position: number
bills?: number
count?: number
denomination?: number
}
export type ApplyOpsResult = {
/** Ids applied by this call. Empty when every op was already on file. */
applied: string[]
/** Ids rejected, with why. These stay unapplied and unrecorded. */
rejected: { id: string; reason: string }[]
}
const CASSETTE_OP_TYPES = new Set(['refill', 'empty', 'recount', 'set_denomination'])
/**
* Validate one operation in isolation. Returns null when it is well-formed.
*
* Shape errors and unknown positions are treated the same way by the caller:
* the op is neither applied nor recorded, so it stays pending on the
* operator's dashboard. That is the honest outcome — it did not happen — and
* it beats recording it as applied to stop the noise, which would tell the
* operator their refill landed when the notes are unaccounted for.
*/
function validateCassetteOp(op: CassetteOp, knownPositions: Set<number>): string | null {
if (typeof op.id !== 'string' || op.id.length === 0) return 'missing id'
if (!CASSETTE_OP_TYPES.has(op.type)) return `unknown type ${String(op.type)}`
if (!Number.isInteger(op.position)) return `position must be an integer (got ${op.position})`
if (!knownPositions.has(op.position)) return `unknown position ${op.position}`
if (!Number.isFinite(op.at)) return 'missing at'
if (op.type === 'refill') {
if (!Number.isInteger(op.bills) || (op.bills as number) <= 0) {
return `refill needs a positive integer bills (got ${op.bills})`
} }
} }
if (op.type === 'recount') {
const currentRows = db if (!Number.isInteger(op.count) || (op.count as number) < 0) {
.prepare('SELECT position FROM cassettes') return `recount needs a non-negative integer count (got ${op.count})`
.all() as { position: number }[]
const currentPositions = new Set(currentRows.map((r) => r.position))
const payloadPositions = new Set(Object.keys(payload.positions).map((k) => Number(k)))
if (currentPositions.size !== payloadPositions.size) {
return {
applied: false,
reason: `position count mismatch: state.db has ${currentPositions.size}, payload has ${payloadPositions.size}`,
} }
} }
for (const p of currentPositions) { if (op.type === 'set_denomination') {
if (!payloadPositions.has(p)) { if (!Number.isInteger(op.denomination) || (op.denomination as number) <= 0) {
return { applied: false, reason: `payload missing position ${p}` } return `set_denomination needs a positive integer denomination (got ${op.denomination})`
}
}
for (const p of payloadPositions) {
if (!currentPositions.has(p)) {
return { applied: false, reason: `payload includes unknown position ${p}` }
} }
} }
return null
}
for (const [posKey, entry] of Object.entries(payload.positions)) { /**
if (!Number.isInteger(entry.denomination) || entry.denomination <= 0) { * Apply an operator's cassette operations, skipping any already on file.
return { *
applied: false, * This replaces applying absolute counts. The operator authors what it DID —
reason: `denomination must be positive int (position ${posKey}, got ${entry.denomination})`, * a refill in notes added, an empty, a recount, a denomination change — and
} * this machine, which holds the physical notes, keeps the running total.
} * Nobody but this process writes a count any more, so there is no second
if (!Number.isInteger(entry.count) || entry.count < 0) { * writer to lose a race to.
return { *
applied: false, * Deltas are not idempotent and addressable events ARE re-delivered on every
reason: `count must be non-negative int (position ${posKey}, got ${entry.count})`, * relay reconnect, so idempotency is carried explicitly: the operator mints an
} * id per operation, `cassette_ops` records the ones applied, and a repeat is a
} * no-op. That is also why there is no `created_at` watermark here any more.
} * Under absolute counts the watermark was the only replay defence; with
* per-op ids it is strictly weaker than the dedup and would do active harm,
* because an event that arrives out of order may still carry an operation this
* machine has never seen.
*
* Applied oldest-first by `at`, ties broken by id so two operations stamped in
* the same second still order the same way on every machine. Ordering matters
* because a recount followed by a refill is not the same as the reverse.
*
* The whole batch runs in one SQLite transaction with the sequence bump, so a
* crash mid-apply rolls back to a coherent count and the next publish re-offers
* every op in the window.
*/
export function applyOperatorCassetteOps(ops: CassetteOp[]): ApplyOpsResult {
if (!db) throw new Error('Database not initialized')
const database = db
const result: ApplyOpsResult = { applied: [], rejected: [] }
if (ops.length === 0) return result
const updateCassette = db.prepare( const knownPositions = new Set(
'UPDATE cassettes SET denomination = ?, count = ? WHERE position = ?' (database.prepare('SELECT position FROM cassettes').all() as { position: number }[]).map(
(r) => r.position
)
) )
const setWatermark = db.prepare('UPDATE meta SET value = ? WHERE key = ?') const seen = database.prepare('SELECT 1 FROM cassette_ops WHERE id = ?')
const run = db.transaction(() => { const pending: CassetteOp[] = []
for (const [posKey, entry] of Object.entries(payload.positions)) { for (const op of ops) {
updateCassette.run(entry.denomination, entry.count, Number(posKey)) if (op && typeof op.id === 'string' && seen.get(op.id)) continue
const reason = validateCassetteOp(op, knownPositions)
if (reason) {
result.rejected.push({ id: op?.id ?? '<no id>', reason })
continue
} }
setWatermark.run(String(eventCreatedAt), 'lastKnownConfigCreatedAt') pending.push(op)
}) }
if (pending.length === 0) return result
pending.sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const addBills = database.prepare(
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?'
)
const setCount = database.prepare('UPDATE cassettes SET count = ? WHERE position = ?')
const setDenomination = database.prepare(
'UPDATE cassettes SET denomination = ? WHERE position = ?'
)
const recordOp = database.prepare(
'INSERT INTO cassette_ops (id, position, op_type, bills, count, denomination, op_at, applied_at) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
)
const upsertMeta = database.prepare(
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
)
const appliedAt = Math.floor(Date.now() / 1000)
let sawRecount = false
database.transaction(() => {
for (const op of pending) {
if (op.type === 'refill') addBills.run(op.bills, op.position)
else if (op.type === 'empty') setCount.run(0, op.position)
else if (op.type === 'recount') {
setCount.run(op.count, op.position)
sawRecount = true
} else setDenomination.run(op.denomination, op.position)
recordOp.run(
op.id,
op.position,
op.type,
op.bills ?? null,
op.count ?? null,
op.denomination ?? null,
Math.floor(op.at),
appliedAt
)
result.applied.push(op.id)
}
bumpCassetteStateSeq()
// A recount is an operator opening the bay and counting it, which is
// exactly what resolves an unverified count. Nothing else does: a refill
// adds to a number still known to be wrong.
if (sawRecount) {
upsertMeta.run('countsUncertainSince', '')
// ADR-005 §5: a recount is an operator at the open machine — the one
// gesture that also releases a cash-out hold.
upsertMeta.run('cashOutHeld', '')
}
})()
run()
console.log( console.log(
`[StateStore] Applied operator cassettes config @ created_at=${eventCreatedAt} (${Object.keys(payload.positions).length} positions)` `[StateStore] Applied ${result.applied.length} cassette op(s)` +
(result.rejected.length ? `, rejected ${result.rejected.length}` : '')
) )
return { applied: true } return result
}
/**
* The ids most recently applied, newest first — the acknowledgement leg of
* the protocol.
*
* An addressable event gives its publisher no failure signal at all: the relay
* returns OK for an event it then discards, and a losing writer is never told.
* Echoing the ids back in this machine's own state document is the only way
* the operator can distinguish an operation that landed from one that was
* merely sent.
*/
export function getAppliedOpIds(limit = 50): string[] {
if (!db) throw new Error('Database not initialized')
const rows = db
.prepare('SELECT id FROM cassette_ops ORDER BY applied_at DESC, rowid DESC LIMIT ?')
.all(limit) as { id: string }[]
return rows.map((r) => r.id)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -567,10 +1130,7 @@ export interface FeeConfigPayload {
*/ */
const FEE_CAP_PER_DIRECTION = 0.15 const FEE_CAP_PER_DIRECTION = 0.15
export function applyFeeConfig( export function applyFeeConfig(payload: FeeConfigPayload, eventCreatedAt: number): ApplyResult {
payload: FeeConfigPayload,
eventCreatedAt: number
): ApplyResult {
if (!db) throw new Error('Database not initialized') if (!db) throw new Error('Database not initialized')
const watermark = getLastKnownFeeConfigCreatedAt() const watermark = getLastKnownFeeConfigCreatedAt()
@ -663,6 +1223,7 @@ export function setCassettes(
const row = rows[i]! const row = rows[i]!
upsert.run(row.position ?? i + 1, row.denomination, row.count) upsert.run(row.position ?? i + 1, row.denomination, row.count)
} }
bumpCassetteStateSeq()
} }
) )
@ -677,11 +1238,14 @@ export function setCassettes(
*/ */
export function updateCassetteCountByPosition(position: number, delta: number): void { export function updateCassetteCountByPosition(position: number, delta: number): void {
if (!db) throw new Error('Database not initialized') if (!db) throw new Error('Database not initialized')
const database = db
db.prepare('UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?').run( database.transaction(() => {
delta, database
position .prepare('UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?')
) .run(delta, position)
bumpCassetteStateSeq()
})()
} }
/** /**
@ -694,9 +1258,14 @@ export function getInventory(): Record<number, number> {
const rows = loadCassettes() const rows = loadCassettes()
const inv: Record<number, number> = {} const inv: Record<number, number> = {}
for (const row of rows) { for (const row of rows) {
if (row.count > 0) { // Zero-count bays are KEPT. Dropping them made a drained machine
inv[row.denomination] = (inv[row.denomination] ?? 0) + row.count // indistinguishable from an unconfigured one, and every caller reads an
} // empty map as "I don't know, ask the hardware" — so the last non-empty
// snapshot stuck and the availability beacon went on advertising bills
// that had already been dispensed. An empty map now means exactly one
// thing: no cassettes are configured. Consumers already filter for
// `> 0` before offering a denomination (CashOutView, machine.ts).
inv[row.denomination] = (inv[row.denomination] ?? 0) + row.count
} }
return inv return inv
} }
@ -773,6 +1342,11 @@ interface TransactionInput {
rejected: number rejected: number
}[] }[]
error?: string | null error?: string | null
/**
* ADR-005 §2: dispense outcome to queue for spirekeeper. Inserted in the
* same transaction as the row so a crash between them cannot lose it.
*/
report?: DispenseReportBody
} }
/** /**
@ -794,11 +1368,20 @@ export function recordTransaction(tx: TransactionInput): void {
const insertBill = db.prepare( const insertBill = db.prepare(
'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)' 'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)'
) )
// Outbox row (ADR-005 §2). REPLACE: a re-record of the same txid (should not
// happen, but a crash-replay could) refreshes the payload and resets the
// delivery state rather than failing the whole transaction.
const insertReport = db.prepare(
'INSERT OR REPLACE INTO dispense_reports (txid, payload, created_at, attempts, last_attempt_at, last_error, acked_at) VALUES (?, ?, ?, 0, NULL, NULL, NULL)'
)
const insertCassetteBill = db.prepare( const insertCassetteBill = db.prepare(
'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)' 'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)'
) )
const updateCassette = db.prepare( const updateCassetteByPosition = db.prepare(
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE denomination = ?' 'UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?'
)
const selectBaysByDenom = db.prepare(
'SELECT position, count FROM cassettes WHERE denomination = ? ORDER BY position'
) )
const updateCashboxStmt = db.prepare( const updateCashboxStmt = db.prepare(
'UPDATE cashbox SET total_bills = total_bills + ?, total_fiat_cents = total_fiat_cents + ? WHERE id = 1' 'UPDATE cashbox SET total_bills = total_bills + ?, total_fiat_cents = total_fiat_cents + ? WHERE id = 1'
@ -823,6 +1406,10 @@ export function recordTransaction(tx: TransactionInput): void {
insertBill.run(t.txid, bill.denomination, bill.count) insertBill.run(t.txid, bill.denomination, bill.count)
} }
if (t.report) {
insertReport.run(t.txid, JSON.stringify(t.report), Date.now())
}
// Insert per-cassette detail when available // Insert per-cassette detail when available
if (t.cassettes) { if (t.cassettes) {
for (const c of t.cassettes) { for (const c of t.cassettes) {
@ -838,20 +1425,48 @@ export function recordTransaction(tx: TransactionInput): void {
} }
} }
if (t.type === 'cash_out') { // Any dispense empties bays, whoever asked for it. `manual_dispense`
// Decrement cassettes by ACTUALLY dispensed count (not requested) // (operator remediation, via the command poller or a kind-21003 command)
// used to fall outside this branch: HAL decremented its in-memory bays but
// the rows here did not move, and on the next boot HAL re-seeds from these
// rows — so the machine came back believing it still held bills a customer
// had already been handed (#76). A remediation against a partly-dispensed
// original decrements again on purpose: the original only ever debited what
// physically left, and this is a second lot of bills leaving.
if (t.type === 'cash_out' || t.type === 'manual_dispense') {
// Decrement cassettes by ACTUALLY dispensed count (not requested).
// Position is the addressable unit (v9): duplicate denominations
// across bays are legal, so a denomination-keyed UPDATE would
// decrement every matching bay.
if (t.cassettes) { if (t.cassettes) {
for (const c of t.cassettes) { for (const c of t.cassettes) {
if (c.dispensed > 0) { if (c.dispensed > 0) {
updateCassette.run(-c.dispensed, c.denomination) updateCassetteByPosition.run(-c.dispensed, c.position)
} }
} }
} else { } else {
// Fallback: use bill counts (backward compat for mocks without cassette data) // Fallback: per-denomination bill counts (mocks without per-bay
// results). Drain matching bays greedily in position order —
// the dispenser's own fill order.
for (const bill of t.bills) { for (const bill of t.bills) {
updateCassette.run(-bill.count, bill.denomination) let remaining = bill.count
const bays = selectBaysByDenom.all(bill.denomination) as {
position: number
count: number
}[]
for (const bay of bays) {
if (remaining <= 0) break
const take = Math.min(remaining, bay.count)
if (take <= 0) continue
updateCassetteByPosition.run(-take, bay.position)
remaining -= take
}
} }
} }
// The counts moved, so the sequence must move with them, inside this
// same transaction. It rides in the state document as the operator's
// way to reject a regression without trusting either clock.
bumpCassetteStateSeq()
} }
if (t.type === 'cash_in') { if (t.type === 'cash_in') {

View file

@ -2,7 +2,7 @@
<html lang="en" class="dark"> <html lang="en" class="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/png" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<!-- <!--
Content Security Policy: Content Security Policy:
@ -18,7 +18,7 @@
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss: http: https:; img-src 'self' data: blob:; font-src 'self'; frame-src 'none'; object-src 'none'" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss: http: https:; img-src 'self' data: blob:; font-src 'self'; frame-src 'none'; object-src 'none'"
/> />
<title>Lamassu ATM</title> <title>bitSpire ATM</title>
<style> <style>
/* Prevent text selection and context menu on kiosk */ /* Prevent text selection and context menu on kiosk */
* { * {
@ -33,12 +33,17 @@
padding: 0; padding: 0;
background: #000; background: #000;
} }
/* Kiosk-only: lock overflow and hide cursor */ /* Kiosk-only: lock overflow. The cursor is NOT hidden here — that is
src/style.css's `.kiosk` rule, which main.ts applies at runtime unless
VITE_DEMO_TAG is set. This block can't make that distinction (static
HTML, and the CSP forbids an inline script to read the env), so a
`cursor: none` here would also blank the pointer on the public web
demo, where people drive the kiosk with a mouse. One owner for cursor
hiding, and it is the one that knows whether this is a real machine. */
@media (min-width: 1024px) { @media (min-width: 1024px) {
html, html,
body { body {
overflow: hidden; overflow: hidden;
cursor: none;
} }
} }
</style> </style>

View file

@ -14,7 +14,8 @@
"dev": "concurrently -n vite,electron \"vite\" \"pnpm run electron:dev\"", "dev": "concurrently -n vite,electron \"vite\" \"pnpm run electron:dev\"",
"dev:vite": "vite", "dev:vite": "vite",
"electron:dev": "tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && electron dist-electron/main.js", "electron:dev": "tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && electron dist-electron/main.js",
"build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && npx esbuild electron/fund-atm.ts --bundle --platform=node --format=cjs --outfile=dist-electron/fund-atm.bundle.cjs", "build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && npx esbuild electron/fund-atm.ts --bundle --platform=node --format=cjs --external:better-sqlite3 --outfile=dist-electron/fund-atm.bundle.cjs",
"build:web": "vite build",
"build:electron": "pnpm build && electron-builder", "build:electron": "pnpm build && electron-builder",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "vue-tsc --noEmit", "typecheck": "vue-tsc --noEmit",
@ -35,8 +36,10 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-vue-next": "^0.563.0", "lucide-vue-next": "^0.563.0",
"marked": "^17.0.5", "marked": "^17.0.5",
"nfc-pcsc": "^0.8.1",
"nostr-tools": "^2.10.0", "nostr-tools": "^2.10.0",
"pinia": "^2.2.0", "pinia": "^2.2.0",
"qr": "^0.6.0",
"qrcode.vue": "^3.6.0", "qrcode.vue": "^3.6.0",
"reka-ui": "^2.7.0", "reka-ui": "^2.7.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
@ -47,11 +50,13 @@
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@types/better-sqlite3": "^7.0.0", "@types/better-sqlite3": "^7.0.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/qrcode": "^1.5.6",
"@vitejs/plugin-vue": "^5.2.0", "@vitejs/plugin-vue": "^5.2.0",
"concurrently": "^9.0.0", "concurrently": "^9.0.0",
"electron": "^33.0.0", "electron": "^33.0.0",
"electron-builder": "^25.0.0", "electron-builder": "^25.0.0",
"esbuild": "^0.27.4", "esbuild": "^0.27.4",
"qrcode": "^1.5.4",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",

View file

@ -1,16 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref, computed } from 'vue' import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAtmStore } from '@/stores/atm' import { useAtmStore } from '@/stores/atm'
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme'
import { useSessionSecurity } from '@/composables/useSessionSecurity'
import { setBranding } from '@/composables/useBranding' import { setBranding } from '@/composables/useBranding'
import { classifyInitError } from '@/services/init-error'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Sun, Moon } from 'lucide-vue-next' import PairingWizard from '@/components/PairingWizard.vue'
import LockedView from '@/views/LockedView.vue'
import ColorModeToggle from '@/components/ColorModeToggle.vue'
const atmStore = useAtmStore() const atmStore = useAtmStore()
const route = useRoute() const route = useRoute()
const router = useRouter()
const { current: currentTheme, themes, colorMode } = useTheme() const { current: currentTheme, themes, colorMode } = useTheme()
// ADR-003 session security: idle-inactivity re-lock (resets on touch) + an
// absolute hard session cap. Enforced here at the always-mounted shell so it
// spans the whole unlocked session, not just the idle view.
useSessionSecurity()
// When the machine re-locks after a transaction (access gate enabled), the
// router is still on /cash-in or /cash-out under the LockedView overlay. Reset
// it to home so that when the gate reopens to `idle`, IdleView shows — not the
// stale transaction view (e.g. a completed cash-out's collect screen). Runs
// while locked, so the router-view is hidden; no flicker. The disabled-gate path
// (never dwells in `locked`) still returns home via each view's isIdle watch.
watch(
() => atmStore.isLocked,
(locked) => {
if (locked && route.path !== '/') void router.push('/')
}
)
const debugExpanded = ref(false) const debugExpanded = ref(false)
const isSupport = computed(() => route.path === '/support') const isSupport = computed(() => route.path === '/support')
// Network detected dynamically from Lightning invoice prefix // Network detected dynamically from Lightning invoice prefix
@ -20,6 +43,54 @@ function formatSats(sats: number): string {
return sats.toLocaleString() return sats.toLocaleString()
} }
/**
* Maintenance-screen copy keyed by the `initError` sentinel. Falls back to a
* generic out-of-service message (the raw error text shows under debug only).
*/
const MAINTENANCE_SCREENS: Record<string, { title: string; message: string }> = {
maintenance: {
title: 'Under Service',
message: 'This machine is currently being serviced. We will be back shortly.',
},
'awaiting-fees': {
title: 'Awaiting Configuration',
message:
'Awaiting fee configuration from operator. Contact operator to publish initial fee config.',
},
unpaired: {
title: 'Pairing Required',
message:
'This machine needs to be re-paired by the operator before it can accept transactions.',
},
'signer-unreachable': {
title: 'Signer Unreachable',
message: 'Cannot reach the signing service right now. This usually resolves shortly.',
},
}
const GENERIC_SCREEN = {
title: 'ATM Unavailable',
message:
'This machine is temporarily out of service. Please try again later or use another machine.',
}
const maintenanceScreen = computed(() =>
atmStore.initError ? (MAINTENANCE_SCREENS[atmStore.initError] ?? GENERIC_SCREEN) : GENERIC_SCREEN
)
/** True when the screen is a known sentinel (hide the raw debug error line). */
const isKnownMaintenanceScreen = computed(
() => !!atmStore.initError && atmStore.initError in MAINTENANCE_SCREENS
)
/**
* `unpaired` is interactive, not a dead-end: render the QR-pairing wizard so
* the operator can scan a spire-seed on-machine (aiolabs/bitspire#52). The
* wizard only works under Electron (needs the seed-persist + relaunch bridge);
* in browser dev it falls back to the static card.
*/
const showPairingWizard = computed(() => atmStore.initError === 'unpaired' && isElectron)
const formattedBtcPrice = computed(() => { const formattedBtcPrice = computed(() => {
if (atmStore.btcPrice === null) return null if (atmStore.btcPrice === null) return null
const local = `${atmStore.fiatCode}/BTC: ${atmStore.fiatSymbol}${Math.round(atmStore.btcPrice).toLocaleString()}` const local = `${atmStore.fiatCode}/BTC: ${atmStore.fiatSymbol}${Math.round(atmStore.btcPrice).toLocaleString()}`
@ -51,18 +122,21 @@ onMounted(async () => {
atmStore.initError = 'maintenance' atmStore.initError = 'maintenance'
// Publish maintenance beacon — minimal Nostr connection only (no Lightning.Pub) // Publish maintenance beacon — minimal Nostr connection only (no Lightning.Pub)
try { try {
const { NostrClient, loadIdentityFromHex, createSignedEvent } = await import( const { NostrClient, createSignedEvent } = await import('@bitSpire/nostr-client')
'@bitSpire/nostr-client' const { resolveSigner } = await import('@/services/signer-resolver')
) // Best-effort: resolve a signer (bunker resume / pairing, or dev nsec).
const secrets = isElectron ? await window.electronAPI?.getAtmSecrets() : null // If the ATM isn't paired yet, skip the beacon rather than fail the screen.
const privKey = secrets?.atmPrivateKey || import.meta.env.VITE_ATM_PRIVATE_KEY const resolved = await resolveSigner({ allowEphemeral: true }).catch(() => null)
const relayUrl = config?.relayUrl || import.meta.env.VITE_RELAY_URL const signer = resolved?.signer ?? null
if (privKey && relayUrl) { // Same env → pairing-seed precedence as lightning.ts: on a blank-.env
const identity = loadIdentityFromHex(privKey) // seed-driven machine the relay comes from the pairing transport, not env.
const client = new NostrClient({ relays: [{ url: relayUrl }], identity }) const relayUrl =
config?.relayUrl || import.meta.env.VITE_RELAY_URL || resolved?.transport?.relays?.[0]
if (signer && relayUrl) {
const client = new NostrClient({ relays: [{ url: relayUrl }], signer })
await client.connect() await client.connect()
const publishBeacon = () => { const publishBeacon = async () => {
const event = createSignedEvent(identity, { const event = await createSignedEvent(signer, {
kind: 30078, kind: 30078,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [['d', 'atm-availability']], tags: [['d', 'atm-availability']],
@ -77,8 +151,8 @@ onMounted(async () => {
}) })
client.publish(event).catch(() => {}) client.publish(event).catch(() => {})
} }
publishBeacon() void publishBeacon()
setInterval(publishBeacon, 5 * 60 * 1000) setInterval(() => void publishBeacon(), 5 * 60 * 1000)
} }
} catch (e) { } catch (e) {
console.warn('[App] Failed to start maintenance beacon:', e) console.warn('[App] Failed to start maintenance beacon:', e)
@ -97,14 +171,77 @@ onMounted(async () => {
atmStore.startPricePolling() atmStore.startPricePolling()
} catch (error) { } catch (error) {
console.error('[App] Initialization failed:', error) console.error('[App] Initialization failed:', error)
atmStore.initError = error instanceof Error ? error.message : 'Initialization failed' atmStore.initError = classifyInitError(error)
} }
}) })
onUnmounted(() => { onUnmounted(() => {
atmStore.stopPricePolling() atmStore.stopPricePolling()
stopRecoveryWatch()
}) })
// ── Connectivity recovery (ADR-002 amendment 2026-08-04) ──────────────────
// A connectivity-type init failure lands on "ATM Unavailable" and, without
// this, stays there forever (init is one-shot; the nostr reconnect only helps
// AFTER a first successful connect). We recover by reloading the renderer —
// which re-runs this whole init from a clean JS context while the main process
// keeps HAL (see main.ts app:recover). Not for the operator/self-clearing
// states: `unpaired` shows the pairing wizard, `awaiting-fees` clears itself on
// the operator's fee-config event, `maintenance` is operator-set.
const NON_RECOVERABLE = new Set(['maintenance', 'awaiting-fees', 'unpaired'])
const isRecoverable = computed(
() => !!atmStore.initError && !NON_RECOVERABLE.has(atmStore.initError)
)
const recovering = ref(false)
const RECOVERY_RETRY_MS = 45_000
let recoveryTimer: ReturnType<typeof setInterval> | null = null
function triggerRecovery() {
if (recovering.value) return
recovering.value = true
console.log('[App] Attempting connectivity recovery (renderer reload)')
if (window.electronAPI?.recoverApp) {
void window.electronAPI.recoverApp() // main reloads renderer → fresh init
} else {
location.reload() // browser-dev fallback
}
}
function onOnline() {
// Network came back — recover immediately rather than waiting for the timer.
triggerRecovery()
}
function startRecoveryWatch() {
stopRecoveryWatch()
window.addEventListener('online', onOnline)
// Safety net for the online-but-relay-unreachable case (navigator.onLine only
// reflects a local route, not relay reachability).
recoveryTimer = setInterval(triggerRecovery, RECOVERY_RETRY_MS)
}
function stopRecoveryWatch() {
window.removeEventListener('online', onOnline)
if (recoveryTimer !== null) {
clearInterval(recoveryTimer)
recoveryTimer = null
}
}
/** Operator-facing "Retry" button on the maintenance screen. */
function retryNow() {
triggerRecovery()
}
watch(
isRecoverable,
(recoverable) => {
if (recoverable) startRecoveryWatch()
else stopRecoveryWatch()
},
{ immediate: true }
)
function toggleLiveServices() { function toggleLiveServices() {
if (atmStore.useLiveServices) { if (atmStore.useLiveServices) {
// Switch to mock // Switch to mock
@ -120,9 +257,12 @@ function toggleLiveServices() {
<div <div
class="flex min-h-dvh lg:h-dvh w-screen flex-col overflow-y-auto lg:overflow-hidden bg-background font-sans text-foreground" class="flex min-h-dvh lg:h-dvh w-screen flex-col overflow-y-auto lg:overflow-hidden bg-background font-sans text-foreground"
> >
<!-- Unpaired: interactive QR-pairing wizard (aiolabs/bitspire#52) -->
<PairingWizard v-if="showPairingWizard" />
<!-- Maintenance screen: shown when initialization fails in production --> <!-- Maintenance screen: shown when initialization fails in production -->
<div <div
v-if="atmStore.initError" v-else-if="atmStore.initError"
class="flex flex-1 flex-col items-center justify-center gap-6 p-8" class="flex flex-1 flex-col items-center justify-center gap-6 p-8"
> >
<svg <svg
@ -142,35 +282,37 @@ function toggleLiveServices() {
<line x1="12" y1="17" x2="12.01" y2="17" /> <line x1="12" y1="17" x2="12.01" y2="17" />
</svg> </svg>
<h1 class="text-2xl lg:text-[3.5rem] font-bold"> <h1 class="text-2xl lg:text-[3.5rem] font-bold">
{{ {{ maintenanceScreen.title }}
atmStore.initError === 'maintenance'
? 'Under Service'
: atmStore.initError === 'awaiting-fees'
? 'Awaiting Configuration'
: 'ATM Unavailable'
}}
</h1> </h1>
<p class="max-w-md text-center text-base lg:text-2xl text-muted-foreground"> <p class="max-w-md text-center text-base lg:text-2xl text-muted-foreground">
{{ {{ maintenanceScreen.message }}
atmStore.initError === 'maintenance'
? 'This machine is currently being serviced. We will be back shortly.'
: atmStore.initError === 'awaiting-fees'
? 'Awaiting fee configuration from operator. Contact operator to publish initial fee config.'
: 'This machine is temporarily out of service. Please try again later or use another machine.'
}}
</p> </p>
<p <p
v-if=" v-if="atmStore.debugMode && !isKnownMaintenanceScreen"
atmStore.debugMode &&
atmStore.initError !== 'maintenance' &&
atmStore.initError !== 'awaiting-fees'
"
class="max-w-lg text-center font-mono text-sm text-destructive" class="max-w-lg text-center font-mono text-sm text-destructive"
> >
{{ atmStore.initError }} {{ atmStore.initError }}
</p> </p>
<!-- Manual recovery for a connectivity failure; auto-recovery also runs
in the background (online event + backoff). Not shown for operator/
self-clearing states (maintenance / awaiting-fees / unpaired). -->
<Button
v-if="isRecoverable"
size="kiosk"
:disabled="recovering"
class="mt-4"
@click="retryNow"
>
{{ recovering ? 'Retrying…' : 'Retry' }}
</Button>
</div> </div>
<!-- Access gate (ADR-003): shown when the machine is healthy but locked,
below the init/maintenance gates above. Never renders when access
control is disabled (the machine never dwells in `locked`). -->
<LockedView v-else-if="atmStore.isLocked" />
<template v-else> <template v-else>
<router-view /> <router-view />
@ -222,16 +364,10 @@ function toggleLiveServices() {
</div> </div>
<!-- Light/dark toggle (production only — debug panel has this in dev) --> <!-- Light/dark toggle (production only — debug panel has this in dev) -->
<Button <ColorModeToggle
v-if="!atmStore.allowMockFallback" v-if="!atmStore.allowMockFallback"
variant="outline" class="fixed bottom-3 right-3 z-50 lg:bottom-6 lg:right-6"
class="fixed bottom-3 right-3 lg:bottom-6 lg:right-6 z-50 h-10 px-3 py-1 text-sm rounded-lg lg:h-[7vh] lg:min-h-[70px] lg:px-8 lg:py-3 lg:text-2xl lg:rounded-xl gap-2 lg:gap-3" />
@click="colorMode = colorMode === 'dark' ? 'light' : 'dark'"
>
<Sun v-if="colorMode === 'dark'" class="w-5 h-5 lg:w-7 lg:h-7" />
<Moon v-else class="w-5 h-5 lg:w-7 lg:h-7" />
{{ colorMode === 'dark' ? 'Light' : 'Dark' }}
</Button>
<!-- Debug overlay (dev only) --> <!-- Debug overlay (dev only) -->
<div <div

View file

@ -0,0 +1,78 @@
<script setup lang="ts">
/**
* The Bolt Card loaded for this session (ADR-003 tap-to-enter): card label,
* and the card wallet's balance HIDDEN BY DEFAULT behind an eye toggle — a
* kiosk in a public space must not show a stranger's balance unasked. The
* revealed line mirrors the LNbits wallet page: sats, then the fiat
* equivalent in the wallet's own currency (Intl currency formatting), falling
* back to the ATM's fiat at its display rate when the card server priced
* nothing. Reveal state lives in the store and resets on re-lock.
*/
import { computed } from 'vue'
import { useAtmStore } from '@/stores/atm'
import { Button } from '@/components/ui/button'
import { Nfc, Eye, EyeOff } from 'lucide-vue-next'
const atmStore = useAtmStore()
const card = computed(() => atmStore.loadedBoltCard)
const label = computed(() => {
const c = card.value
if (!c) return ''
return c.cardName
? `${c.cardName} · ••${c.externalId.slice(-4)}`
: `Card ••${c.externalId.slice(-4)}`
})
const sats = computed(() =>
card.value ? new Intl.NumberFormat().format(card.value.balanceSats) : ''
)
const fiat = computed(() => {
const f = atmStore.loadedCardFiat
if (!f) return null
try {
return new Intl.NumberFormat(undefined, { style: 'currency', currency: f.currency }).format(
f.amount
)
} catch {
return `${f.amount.toFixed(2)} ${f.currency}`
}
})
</script>
<template>
<div
v-if="card"
class="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-2 text-left lg:gap-4 lg:px-6 lg:py-3"
>
<Nfc class="size-6 shrink-0 text-primary lg:size-8" />
<div class="flex min-w-0 flex-col leading-tight">
<span class="truncate text-xs uppercase tracking-wide text-muted-foreground lg:text-sm">
{{ label }}
</span>
<span
v-if="atmStore.cardBalanceRevealed"
class="text-base font-semibold text-foreground lg:text-2xl"
>
{{ sats }} sats
<span v-if="fiat" class="ml-2 font-normal text-muted-foreground">≈ {{ fiat }}</span>
</span>
<span
v-else
class="text-base font-semibold tracking-widest text-muted-foreground lg:text-2xl"
aria-label="Balance hidden"
>
••••••
</span>
</div>
<Button
variant="ghost"
size="icon"
class="ml-auto h-10 w-10 shrink-0 rounded-full lg:h-14 lg:w-14"
:aria-label="atmStore.cardBalanceRevealed ? 'Hide balance' : 'Show balance'"
@click="atmStore.toggleCardBalance()"
>
<EyeOff v-if="atmStore.cardBalanceRevealed" class="size-5 lg:size-7" />
<Eye v-else class="size-5 lg:size-7" />
</Button>
</div>
</template>

View file

@ -0,0 +1,33 @@
<script setup lang="ts">
/**
* Light/dark toggle — the single reusable control for switching color mode.
*
* Kiosk-sized by default (large touch target for a public display). colorMode
* is global + persisted (toggles `.dark` on <html>, and dark mode pulls
* branding.json's dark palette + logo-dark.png), so this stays in sync
* wherever it's used. Position it via a fallthrough `class` on the consumer,
* e.g. `<ColorModeToggle class="fixed bottom-6 right-6" />`.
*/
import { useTheme } from '@/composables/useTheme'
import { Button } from '@/components/ui/button'
import { Sun, Moon } from 'lucide-vue-next'
const { colorMode } = useTheme()
function toggle() {
colorMode.value = colorMode.value === 'dark' ? 'light' : 'dark'
}
</script>
<template>
<Button
variant="outline"
class="h-10 gap-2 rounded-lg px-3 py-1 text-sm lg:h-[7vh] lg:min-h-[70px] lg:gap-3 lg:rounded-xl lg:px-8 lg:py-3 lg:text-2xl"
:aria-label="colorMode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
@click="toggle"
>
<Sun v-if="colorMode === 'dark'" class="h-5 w-5 lg:h-7 lg:w-7" />
<Moon v-else class="h-5 w-5 lg:h-7 lg:w-7" />
{{ colorMode === 'dark' ? 'Light' : 'Dark' }}
</Button>
</template>

View file

@ -0,0 +1,287 @@
<script setup lang="ts">
/**
* QR-pairing wizard (aiolabs/bitspire#52).
*
* Shown in place of the "Pairing Required" maintenance screen when the machine
* is unpaired. The operator displays the spire-seed QR (minted by spirekeeper)
* to the machine's camera; we decode it, persist it as VITE_SPIRE_SEED, and
* relaunch so the normal boot path performs the bunker pairing.
*
* Capture is abstracted behind PairingSource, so NFC (or a HAL scanner) can be
* offered later without changing this view.
*/
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'
import {
availablePairingSources,
ingestScannedSeed,
parseScannedSeed,
testRelay,
type PairingSource,
type RelayTestResult,
type StopCapture,
} from '@/services/pairing'
type Phase = 'probing' | 'scanning' | 'review' | 'no-source' | 'pairing' | 'error'
const phase = ref<Phase>('probing')
const errorMessage = ref('')
const videoEl = ref<HTMLVideoElement | null>(null)
const sources = shallowRef<PairingSource[]>([])
const activeSource = shallowRef<PairingSource | null>(null)
let stopCapture: StopCapture | null = null
// Review-step state: the scanned-but-not-yet-committed seed + relay tests.
const scannedRaw = ref('')
const previewSpire = ref('')
const previewRelays = ref<string[]>([])
type RelayState = { status: 'idle' | 'testing' | 'done'; result?: RelayTestResult }
const relayTests = ref<Record<string, RelayState>>({})
const testingRelays = ref(false)
const committing = ref(false)
const anyRelayFailed = computed(() =>
Object.values(relayTests.value).some((s) => s.status === 'done' && s.result != null && !s.result.ok),
)
async function startWith(source: PairingSource) {
await teardown()
activeSource.value = source
errorMessage.value = ''
phase.value = 'scanning'
try {
stopCapture = await source.start({
video: source.kind === 'qr' ? (videoEl.value ?? undefined) : undefined,
onScan: handleScan,
onError: (e) => console.warn('[Pairing] capture glitch:', e),
})
} catch (e) {
phase.value = 'error'
errorMessage.value =
e instanceof Error ? e.message : 'Could not start the camera. Check permissions.'
}
}
let handling = false
async function handleScan(raw: string) {
if (handling) return
handling = true
// Validate only — don't commit yet. Show a review step with the decoded
// relay + a "test relay" button so a well-formed but unreachable relay is
// caught before we relaunch into a pairing crash-loop (aiolabs/bitspire#70).
const preview = parseScannedSeed(raw)
if (preview.ok) {
await teardown() // camera off during review
scannedRaw.value = raw.trim()
previewSpire.value = preview.spirePubkey
previewRelays.value = preview.relays
relayTests.value = Object.fromEntries(preview.relays.map((r) => [r, { status: 'idle' }]))
errorMessage.value = ''
phase.value = 'review'
return
}
// Reject non-seed / malformed scans (a stray QR, a corrupted relay) and resume.
console.warn('[Pairing] rejected scan:', preview.reason, preview.message)
errorMessage.value = 'That code is not a valid pairing code. Show the operator pairing QR.'
handling = false
if (activeSource.value) await startWith(activeSource.value)
}
/** Probe every relay in the scanned seed and record reachability. */
async function testRelays() {
testingRelays.value = true
await Promise.all(
previewRelays.value.map(async (url) => {
relayTests.value[url] = { status: 'testing' }
const result = await testRelay(url)
relayTests.value[url] = { status: 'done', result }
}),
)
testingRelays.value = false
}
/** Commit the reviewed seed: persist + relaunch into the real pairing path. */
async function confirmPair() {
committing.value = true
const result = await ingestScannedSeed(scannedRaw.value)
if (result.ok) {
phase.value = 'pairing' // relaunch in flight
return
}
committing.value = false
errorMessage.value = result.message
phase.value = 'error'
}
/** Discard the scan and go back to scanning. */
async function rescan() {
scannedRaw.value = ''
previewRelays.value = []
relayTests.value = {}
handling = false
if (activeSource.value) await startWith(activeSource.value)
}
async function teardown() {
if (stopCapture) {
try {
stopCapture()
} catch {
/* idempotent */
}
stopCapture = null
}
}
onMounted(async () => {
const available = await availablePairingSources()
sources.value = available
const first = available[0]
if (!first) {
phase.value = 'no-source'
return
}
await startWith(first)
})
onUnmounted(teardown)
</script>
<template>
<div class="flex flex-1 flex-col items-center justify-center gap-6 p-8">
<h1 class="text-2xl lg:text-[3.5rem] font-bold">Pair This Machine</h1>
<!-- Camera viewfinder -->
<div
v-show="phase === 'scanning' && activeSource?.kind === 'qr'"
class="relative overflow-hidden rounded-2xl border-4 border-primary/40 bg-black"
style="width: min(80vw, 28rem); aspect-ratio: 1 / 1"
>
<!-- The Sintra's camera is mounted rotated, so rotate the preview 90° CCW
for an upright image. Preview-only: qr-source decodes the raw (un-
rotated) frame and QR decoding is rotation-invariant. The container is
square + overflow-hidden, so the rotated square stays in the box. -->
<video
ref="videoEl"
class="h-full w-full -rotate-90 object-cover"
muted
autoplay
playsinline
></video>
<!-- Reticle -->
<div class="pointer-events-none absolute inset-6 rounded-xl border-2 border-white/70"></div>
</div>
<p
v-if="phase === 'scanning'"
class="max-w-md text-center text-base lg:text-2xl text-muted-foreground"
>
Hold the operator's pairing QR up to the camera.
</p>
<p v-if="phase === 'probing'" class="text-base lg:text-2xl text-muted-foreground">
Starting camera…
</p>
<div v-if="phase === 'pairing'" class="flex flex-col items-center gap-4">
<p class="text-base lg:text-2xl text-muted-foreground">Pairing accepted — restarting…</p>
</div>
<!-- Review: confirm the scanned relay is reachable before committing -->
<div v-if="phase === 'review'" class="flex w-full max-w-md flex-col items-center gap-5">
<p class="text-base lg:text-2xl text-muted-foreground">
Pairing code scanned. Test the relay, then pair.
</p>
<div class="w-full rounded-xl border border-border p-4 text-left">
<p class="text-xs uppercase text-muted-foreground">Spire</p>
<p class="mb-3 break-all font-mono text-sm">{{ previewSpire.slice(0, 16) }}…</p>
<p class="text-xs uppercase text-muted-foreground">Relay(s)</p>
<ul class="flex flex-col gap-2">
<li
v-for="url in previewRelays"
:key="url"
class="flex items-center justify-between gap-3"
>
<span class="break-all font-mono text-xs">{{ url }}</span>
<span class="shrink-0 text-sm">
<template v-if="relayTests[url]?.status === 'testing'">
<span class="text-muted-foreground">testing…</span>
</template>
<template v-else-if="relayTests[url]?.status === 'done'">
<span v-if="relayTests[url]?.result?.ok" class="text-green-500"
>✓ {{ relayTests[url]?.result?.ms }}ms</span
>
<span v-else class="text-destructive">✗ unreachable</span>
</template>
</span>
</li>
</ul>
</div>
<div class="flex flex-wrap justify-center gap-3">
<button
class="rounded-lg border border-border px-4 py-2 text-sm disabled:opacity-50"
:disabled="testingRelays || committing"
@click="testRelays"
>
{{ testingRelays ? 'Testing…' : 'Test relay' }}
</button>
<button
class="rounded-lg border border-border px-4 py-2 text-sm disabled:opacity-50"
:disabled="committing"
@click="rescan"
>
Rescan
</button>
<button
class="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50"
:disabled="committing"
@click="confirmPair"
>
{{ committing ? 'Pairing…' : 'Pair this machine' }}
</button>
</div>
<p v-if="anyRelayFailed" class="max-w-md text-center text-sm text-warning">
A relay looks unreachable from this machine — pairing will fail unless it can reach the
relay. Check the URL/network, or rescan a corrected code.
</p>
</div>
<p
v-if="phase === 'no-source'"
class="max-w-md text-center text-base lg:text-2xl text-muted-foreground"
>
No camera or NFC reader is available on this machine. Pair by provisioning
<span class="font-mono">VITE_SPIRE_SEED</span> instead.
</p>
<p
v-if="phase === 'error'"
class="max-w-md text-center text-base lg:text-xl text-destructive"
>
{{ errorMessage }}
</p>
<!-- Transient rejected-scan hint while still scanning -->
<p
v-if="phase === 'scanning' && errorMessage"
class="max-w-md text-center text-sm lg:text-base text-warning"
>
{{ errorMessage }}
</p>
<!-- Alternate sources (e.g. NFC) when more than one is available -->
<div v-if="sources.length > 1" class="flex gap-3">
<button
v-for="source in sources"
:key="source.kind"
class="rounded-lg border border-border px-4 py-2 text-sm"
:class="activeSource?.kind === source.kind ? 'bg-primary text-primary-foreground' : ''"
@click="startWith(source)"
>
{{ source.label }}
</button>
</div>
</div>
</template>

View file

@ -13,7 +13,7 @@
import { watch, type Ref } from 'vue' import { watch, type Ref } from 'vue'
import { useDebounceFn } from '@vueuse/core' import { useDebounceFn } from '@vueuse/core'
import type { NostrClient, MachineIdentity } from '@bitSpire/nostr-client' import type { NostrClient, Signer } from '@bitSpire/nostr-client'
import { createSignedEvent } from '@bitSpire/nostr-client' import { createSignedEvent } from '@bitSpire/nostr-client'
type CashLevel = 'none' | 'low' | 'good' | 'full' type CashLevel = 'none' | 'low' | 'good' | 'full'
@ -26,11 +26,17 @@ interface AvailabilitySnapshot {
interface UseAvailabilityBroadcastOptions { interface UseAvailabilityBroadcastOptions {
nostrClient: NostrClient nostrClient: NostrClient
identity: MachineIdentity signer: Signer
/** Reactive inventory: denomination -> count */ /** Reactive inventory: denomination -> count */
inventory: Ref<Record<number, number>> inventory: Ref<Record<number, number>>
/** Reactive Lightning.Pub balance in sats (null = unknown) */ /** Reactive wallet balance in sats (null = unknown) */
balanceSats: Ref<number | null> balanceSats: Ref<number | null>
/**
* ADR-005 §5: cash-out is latched off after a terminal dispenser fault.
* A machine with full bays and a jammed transport must not advertise
* cash-out — that is exactly what sintra did for an hour on 2026-10-09.
*/
cashOutHeld?: Ref<boolean>
/** Fiat currency code */ /** Fiat currency code */
fiatCode: string fiatCode: string
/** Machine model */ /** Machine model */
@ -38,7 +44,7 @@ interface UseAvailabilityBroadcastOptions {
} }
export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOptions) { export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOptions) {
const { nostrClient, identity, inventory, balanceSats, fiatCode, model } = options const { nostrClient, signer, inventory, balanceSats, cashOutHeld, fiatCode, model } = options
let lastSnapshot: AvailabilitySnapshot | null = null let lastSnapshot: AvailabilitySnapshot | null = null
@ -53,7 +59,7 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption
function computeSnapshot(): AvailabilitySnapshot { function computeSnapshot(): AvailabilitySnapshot {
const totalBills = Object.values(inventory.value).reduce((s, c) => s + c, 0) const totalBills = Object.values(inventory.value).reduce((s, c) => s + c, 0)
return { return {
cashOut: totalBills > 0, cashOut: totalBills > 0 && !(cashOutHeld?.value ?? false),
cashIn: (balanceSats.value ?? 0) > 0, cashIn: (balanceSats.value ?? 0) > 0,
cashLevel: computeCashLevel(), cashLevel: computeCashLevel(),
} }
@ -73,19 +79,22 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption
model, model,
}) })
const event = createSignedEvent(identity, { // Signing goes through the bunker, so it can throw BunkerTimeoutError /
kind: 30078, // BunkerRejectedError — keep it INSIDE the try so a transient signer blip
created_at: Math.floor(Date.now() / 1000), // is swallowed (the beacon re-publishes every interval) rather than
tags: [['d', 'atm-availability']], // surfacing as an uncaught rejection. `publish()` is fire-and-forget.
content,
})
try { try {
const event = await createSignedEvent(signer, {
kind: 30078,
created_at: Math.floor(Date.now() / 1000),
tags: [['d', 'atm-availability']],
content,
})
await nostrClient.publish(event) await nostrClient.publish(event)
lastSnapshot = snap lastSnapshot = snap
console.log('[Availability] Published:', content) console.log('[Availability] Published:', content)
} catch (e) { } catch (e) {
console.warn('[Availability] Failed to publish:', e) console.warn('[Availability] Publish failed (sign or relay):', e)
} }
} }
@ -98,7 +107,7 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption
// Watch reactive sources // Watch reactive sources
watch( watch(
[inventory, balanceSats], cashOutHeld ? [inventory, balanceSats, cashOutHeld] : [inventory, balanceSats],
() => { () => {
debouncedPublish() debouncedPublish()
}, },

View file

@ -0,0 +1,103 @@
import { watch } from 'vue'
import { useEventListener, useIntervalFn } from '@vueuse/core'
import { useAtmStore } from '@/stores/atm'
/**
* Session security timeouts for the ADR-003 access gate.
*
* Enforced at the DOM layer on purpose: the state machine can't observe raw
* pointer events, so an XState `after` delay can only count from state entry —
* it never resets on a screen touch and therefore can't measure *inactivity*.
* Two independent, fail-closed limits, both of which re-lock via the machine's
* root-level END_SESSION transition:
*
* - SOFT idle (SOFT_IDLE_MS): re-lock after this long with no trusted user
* input while on the idle menu. Resets on every genuine pointer/touch/key
* event. Scoped to `idle` so it never interrupts an in-flight cash-in/out
* (those carry their own, longer machine timeouts).
* - HARD cap (HARD_CAP_MS): re-lock this long after the session began,
* regardless of activity. Anchored to unlock time and never reset — an
* absolute ceiling a forgotten or relayed card can't hold open. Like the
* soft limit it only fires while on the idle menu: locking mid-transaction
* would strand stacked bills or an in-flight dispense, and every
* transaction already returns to `locked` on its own, so the cap simply
* takes effect the moment the machine is back at idle.
*
* Security properties:
* - Only `event.isTrusted` input resets the soft timer, so synthetic/scripted
* events in the renderer can't keep a session alive.
* - Limits are wall-clock deadline comparisons, not chained setTimeouts: a
* suspended/resumed renderer re-locks on the very next tick instead of
* silently extending the session past its deadline.
* - Both limits only ever *lock*. The machine's accessGateActive guard makes
* END_SESSION a no-op when the gate is off, so this is inert on a
* gate-disabled machine.
* - One-shot per session: after firing, it disarms until the next unlock, so
* a lock that (under dev bypass) doesn't take can't spin.
*
* Call once from the always-mounted App shell.
*/
const SOFT_IDLE_MS = 60_000 // 60s of no interaction on the idle menu
const HARD_CAP_MS = 600_000 // 10min absolute session ceiling
export function useSessionSecurity() {
const atm = useAtmStore()
// Wall-clock anchors. `null` sessionStartedAt == disarmed (no live session).
let sessionStartedAt: number | null = null
let lastActivityAt = 0
function arm() {
const now = Date.now()
sessionStartedAt = now
lastActivityAt = now
}
function disarm() {
sessionStartedAt = null
}
// Arm on each locked → unlocked edge; disarm on lock. Anchored to the
// isLocked transition so the hard cap starts at unlock and does NOT restart
// when moving idle → cashIn → idle within a single session.
watch(
() => atm.isLocked,
(locked, wasLocked) => {
if (wasLocked && !locked && atm.accessControl.enabled) arm()
else if (locked) disarm()
}
)
// Only genuine hardware input counts as activity. Passive + capture so it
// observes every touch without interfering with handling. useEventListener
// auto-detaches on unmount.
const onActivity = (e: Event) => {
if (e.isTrusted) lastActivityAt = Date.now()
}
for (const type of ['pointerdown', 'touchstart', 'keydown', 'wheel'] as const) {
useEventListener(document, type, onActivity, { passive: true, capture: true })
}
// Single 1s evaluator — cheap, and coarse enough that timer drift/suspend
// can only ever make it fire late-then-immediately, never early.
useIntervalFn(() => {
if (sessionStartedAt === null || atm.isLocked || !atm.accessControl.enabled) return
// Never lock out from under a transaction (the machine only accepts
// END_SESSION from idle anyway); the deadlines keep counting meanwhile, so
// an expired session re-locks on the first tick back at the menu.
if (atm.currentState !== 'idle') return
const now = Date.now()
// Hard cap first — absolute, activity-independent.
if (now - sessionStartedAt >= HARD_CAP_MS) {
disarm() // one-shot; re-arms on next unlock
atm.endSession('session-cap')
return
}
// Soft inactivity — resets on trusted input.
if (now - lastActivityAt >= SOFT_IDLE_MS) {
disarm()
atm.endSession('inactivity')
}
}, 1000)
}

View file

@ -35,8 +35,8 @@ export const themes: ThemeOption[] = [
{ id: 'starrynight', label: 'Starry Night' }, { id: 'starrynight', label: 'Starry Night' },
] ]
const THEME_KEY = 'lamassu-theme' const THEME_KEY = 'bitspire-theme'
const MODE_KEY = 'lamassu-color-mode' const MODE_KEY = 'bitspire-color-mode'
const isElectron = !!(window as unknown as Record<string, unknown>).electronAPI const isElectron = !!(window as unknown as Record<string, unknown>).electronAPI

View file

@ -6,7 +6,7 @@
* *
* Configuration priority (highest to lowest): * Configuration priority (highest to lowest):
* 1. Runtime overrides (passed directly to functions) * 1. Runtime overrides (passed directly to functions)
* 2. Environment variables (LAMASSU_*) * 2. Environment variables (BITSPIRE_*)
* 3. Machine preset defaults * 3. Machine preset defaults
*/ */
@ -139,11 +139,21 @@ export const MACHINE_PRESETS: Record<MachineModel, Omit<DeviceConfig, 'fiatCode'
model: 'batm3', model: 'batm3',
validator: { validator: {
type: 'ebds', type: 'ebds',
device: '/dev/ttyACM0', // MEI bill acceptor (EBDS) on a USB-serial bridge, exposed via the
// stable udev symlink /dev/ttyMEI (batm3.nix, serial A9YW78OC). The old
// /dev/ttyACM0 default assumed a CDC-ACM BNR; this hardware enumerates as
// ttyUSB* instead, so ACM0 never existed and cash-in was silently
// disabled ("[HAL] No validator"). Per-box override: VITE_BITSPIRE_VALIDATOR_DEVICE.
device: '/dev/ttyMEI',
}, },
dispenser: { dispenser: {
type: 'f56', type: 'f56',
device: '/dev/ttyUSB0', // Fujitsu F56 on a USB-serial bridge, via the stable udev symlink
// /dev/ttyF56 (batm3.nix, serial DDDLb103Y23). Avoids the raw
// /dev/ttyUSB0, which is enumeration-order dependent and could point at
// the wrong adapter after a re-plug/reboot. Per-box override:
// VITE_BITSPIRE_DISPENSER_DEVICE.
device: '/dev/ttyF56',
cassettes: [ cassettes: [
{ denomination: 20, count: 400 }, { denomination: 20, count: 400 },
{ denomination: 1, count: 400 }, { denomination: 1, count: 400 },
@ -204,31 +214,31 @@ export function getDeviceConfig(
* Load device configuration from environment variables * Load device configuration from environment variables
* *
* Supported variables: * Supported variables:
* - LAMASSU_MACHINE_MODEL: Machine model preset (sintra, gaia, custom) * - BITSPIRE_MACHINE_MODEL: Machine model preset (sintra, gaia, custom)
* - LAMASSU_FIAT_CODE: Fiat currency code (USD, EUR, etc.) * - BITSPIRE_FIAT_CODE: Fiat currency code (USD, EUR, etc.)
* - LAMASSU_VALIDATOR_DEVICE: Bill validator serial device path * - BITSPIRE_VALIDATOR_DEVICE: Bill validator serial device path
* - LAMASSU_DISPENSER_DEVICE: Bill dispenser serial device path * - BITSPIRE_DISPENSER_DEVICE: Bill dispenser serial device path
* - LAMASSU_CASSETTES: JSON array of cassette configs * - BITSPIRE_CASSETTES: JSON array of cassette configs
* *
* @example * @example
* LAMASSU_MACHINE_MODEL=sintra * BITSPIRE_MACHINE_MODEL=sintra
* LAMASSU_FIAT_CODE=USD * BITSPIRE_FIAT_CODE=USD
* LAMASSU_CASSETTES='[{"denomination":20,"count":100},{"denomination":50,"count":50}]' * BITSPIRE_CASSETTES='[{"denomination":20,"count":100},{"denomination":50,"count":50}]'
*/ */
export function loadDeviceConfigFromEnv(): DeviceConfig { export function loadDeviceConfigFromEnv(): DeviceConfig {
const model = (import.meta.env.VITE_LAMASSU_MACHINE_MODEL as MachineModel) || 'sintra' const model = (import.meta.env.VITE_BITSPIRE_MACHINE_MODEL as MachineModel) || 'sintra'
const fiatCode = import.meta.env.VITE_LAMASSU_FIAT_CODE || 'USD' const fiatCode = import.meta.env.VITE_BITSPIRE_FIAT_CODE || 'USD'
const overrides: Partial<DeviceConfig> = {} const overrides: Partial<DeviceConfig> = {}
// Validator device override (preserve validator type from preset) // Validator device override (preserve validator type from preset)
const validatorDevice = import.meta.env.VITE_LAMASSU_VALIDATOR_DEVICE const validatorDevice = import.meta.env.VITE_BITSPIRE_VALIDATOR_DEVICE
if (validatorDevice) { if (validatorDevice) {
overrides.validator = { type: MACHINE_PRESETS[model].validator.type, device: validatorDevice } overrides.validator = { type: MACHINE_PRESETS[model].validator.type, device: validatorDevice }
} }
// Dispenser device override // Dispenser device override
const dispenserDevice = import.meta.env.VITE_LAMASSU_DISPENSER_DEVICE const dispenserDevice = import.meta.env.VITE_BITSPIRE_DISPENSER_DEVICE
if (dispenserDevice) { if (dispenserDevice) {
overrides.dispenser = { overrides.dispenser = {
type: MACHINE_PRESETS[model].dispenser.type, type: MACHINE_PRESETS[model].dispenser.type,
@ -238,7 +248,7 @@ export function loadDeviceConfigFromEnv(): DeviceConfig {
} }
// Cassettes override (JSON) // Cassettes override (JSON)
const cassettesJson = import.meta.env.VITE_LAMASSU_CASSETTES const cassettesJson = import.meta.env.VITE_BITSPIRE_CASSETTES
if (cassettesJson) { if (cassettesJson) {
try { try {
const cassettes = JSON.parse(cassettesJson) as CassetteConfig[] const cassettes = JSON.parse(cassettesJson) as CassetteConfig[]
@ -252,7 +262,7 @@ export function loadDeviceConfigFromEnv(): DeviceConfig {
} }
} }
} catch (e) { } catch (e) {
console.error('[Config] Failed to parse LAMASSU_CASSETTES:', e) console.error('[Config] Failed to parse BITSPIRE_CASSETTES:', e)
} }
} }

View file

@ -5,3 +5,26 @@ import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
/**
* Render a denomination/count list for the journal.
*
* Electron's console bridge stringifies every console argument on its way to
* the journal, so passing the array itself arrives as `[object Object]` and
* the numbers are lost. Interpolate one of these instead. See CLAUDE.md,
* "Useful invariants when debugging".
*/
export function formatBays(rows: { denomination: number; count?: number }[]): string {
if (!rows?.length) return '(none)'
// `count` is optional on a device-config cassette: a preset can declare the
// denomination a bay holds without claiming how many notes are in it. Show
// that as unknown rather than as zero, which would read as a drained bay.
return rows.map((r) => `${r.denomination}x${r.count ?? '?'}`).join(' ')
}
/** Same, for a denomination-keyed count map as `getInventory()` returns. */
export function formatInventory(inv: Record<number, number>): string {
const entries = Object.entries(inv ?? {})
if (!entries.length) return '(none)'
return entries.map(([denom, count]) => `${denom}x${count}`).join(' ')
}

View file

@ -24,6 +24,13 @@ const router = createRouter({
], ],
}) })
// Kiosk chrome (hidden cursor) is the default — every real machine is a
// touchscreen. The public web demo (VITE_DEMO_TAG) runs in a normal browser,
// where an invisible pointer just reads as broken.
if (!import.meta.env.VITE_DEMO_TAG) {
document.documentElement.classList.add('kiosk')
}
// Create Pinia store // Create Pinia store
const pinia = createPinia() const pinia = createPinia()

View file

@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
import { BunkerRejectedError, BunkerTimeoutError } from '@bitSpire/nostr-client'
import { classifyInitError } from '../init-error.js'
describe('classifyInitError', () => {
it('maps a bunker rejection (revoke / TTL / off-policy) to "unpaired"', () => {
expect(classifyInitError(new BunkerRejectedError('revoked'))).toBe('unpaired')
})
it('maps a bunker timeout to "signer-unreachable"', () => {
expect(classifyInitError(new BunkerTimeoutError('no response'))).toBe('signer-unreachable')
})
it('classifies by error name across bundle boundaries (no instanceof)', () => {
// A structurally-equivalent error from a different module copy still maps.
const lookalike = Object.assign(new Error('x'), { name: 'BunkerRejectedError' })
expect(classifyInitError(lookalike)).toBe('unpaired')
})
it('surfaces a generic error message unchanged', () => {
expect(classifyInitError(new Error('relay down'))).toBe('relay down')
})
it('uses the fallback for non-Error throws', () => {
expect(classifyInitError('boom', 'Lightning initialization failed')).toBe(
'Lightning initialization failed'
)
expect(classifyInitError(undefined)).toBe('Initialization failed')
})
})

View file

@ -0,0 +1,148 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { initialContext, type ATMContext } from '@bitSpire/state-machine'
import type { LnbitsClient, LnbitsPayment } from '@bitSpire/lnbits'
import { createATMServices } from '../lightning'
/**
* The cash-out settlement watch (2026-09-22 regression).
*
* A one-tap Bolt Card Complete settles in about a second; subscribing over
* nostr takes several. When the watch was armed at display time the push —
* an ephemeral event with no replay — fired before anything listened, and the
* machine sat on a paid invoice until it timed out, taking the sats without
* dispensing. These pin the three defences: arm before the invoice is handed
* out, latch a settlement that still beats the consumer, and poll so a push
* that never arrives cannot strand a payment.
*/
const BOLT11 = 'lnbc265u1p4t9gthpp5td44vd9a0s5er'
const HASH = 'aa'.repeat(32)
const paid = (preimage = 'PREIMAGE'): LnbitsPayment =>
({ payment_hash: HASH, status: 'success', preimage }) as LnbitsPayment
function makeLnbits(over: Partial<Record<string, unknown>> = {}) {
let pushTo: ((p: LnbitsPayment) => void) | null = null
const api = {
createInvoice: vi.fn(async () => ({ payment_request: BOLT11, payment_hash: HASH })),
subscribePayments: vi.fn(
async (_w: unknown, _f: unknown, onPush: (p: LnbitsPayment) => void) => {
pushTo = onPush
return 'sub-1'
}
),
getPayment: vi.fn(async (): Promise<LnbitsPayment | null> => null),
unsubscribe: vi.fn(async () => true),
decodePayment: vi.fn(async () => ({ payment_hash: HASH })),
...over,
}
return { api, push: (p: LnbitsPayment) => pushTo?.(p) }
}
const ctx = (): ATMContext => ({ ...initialContext, satsAmount: 26_500, exchangeRate: 1325 })
const services = (l: { api: Record<string, unknown> }) =>
createATMServices(vi.fn(), l.api as unknown as LnbitsClient, 'wallet-1')
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
describe('cash-out settlement watch', () => {
it('is armed before the invoice is handed out, without decoding it back', async () => {
const l = makeLnbits()
const invoice = await services(l).generateInvoice(ctx())
expect(invoice).toBe(BOLT11)
// Armed during generateInvoice, not later at display time.
expect(l.api.subscribePayments).toHaveBeenCalledTimes(1)
expect(l.api.subscribePayments.mock.calls[0]![1]).toMatchObject({ payment_hash: HASH })
// The hash came from the creation response, so no round trip to recover it.
expect(l.api.decodePayment).not.toHaveBeenCalled()
})
it('replays a settlement that beat the consumer (the race that lost payments)', async () => {
const l = makeLnbits()
const svc = services(l)
const invoice = await svc.generateInvoice(ctx())
// Card pays before the machine reaches displayingInvoice.
l.push(paid())
const onPaid = vi.fn()
svc.watchInvoice(invoice, onPaid)
await vi.advanceTimersByTimeAsync(0)
expect(onPaid).toHaveBeenCalledWith('PREIMAGE')
})
it('delivers a push that arrives while the consumer is attached', async () => {
const l = makeLnbits()
const svc = services(l)
const invoice = await svc.generateInvoice(ctx())
const onPaid = vi.fn()
svc.watchInvoice(invoice, onPaid)
l.push(paid('LATER'))
expect(onPaid).toHaveBeenCalledWith('LATER')
})
it('settles from the poll when no push ever arrives', async () => {
const l = makeLnbits()
l.api.getPayment = vi.fn(async () => paid('VIA-POLL'))
const svc = services(l)
const invoice = await svc.generateInvoice(ctx())
const onPaid = vi.fn()
svc.watchInvoice(invoice, onPaid)
expect(onPaid).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(7_000)
expect(onPaid).toHaveBeenCalledWith('VIA-POLL')
})
it('polls even when arming the subscription fails', async () => {
const l = makeLnbits()
l.api.subscribePayments = vi.fn(async () => {
throw new Error('relay down')
})
l.api.getPayment = vi.fn(async () => paid('VIA-POLL'))
const svc = services(l)
const invoice = await svc.generateInvoice(ctx())
const onPaid = vi.fn()
svc.watchInvoice(invoice, onPaid)
await vi.advanceTimersByTimeAsync(7_000)
expect(onPaid).toHaveBeenCalledWith('VIA-POLL')
})
it('reports each settlement once, whichever path saw it first', async () => {
const l = makeLnbits()
l.api.getPayment = vi.fn(async () => paid('VIA-POLL'))
const svc = services(l)
const invoice = await svc.generateInvoice(ctx())
const onPaid = vi.fn()
svc.watchInvoice(invoice, onPaid)
l.push(paid('VIA-PUSH'))
await vi.advanceTimersByTimeAsync(20_000)
expect(onPaid).toHaveBeenCalledTimes(1)
expect(onPaid).toHaveBeenCalledWith('VIA-PUSH')
})
it('stops polling and unsubscribes when the transaction ends', async () => {
const l = makeLnbits()
const svc = services(l)
const invoice = await svc.generateInvoice(ctx())
const stop = svc.watchInvoice(invoice, vi.fn())
stop()
expect(l.api.unsubscribe).toHaveBeenCalledWith(undefined, 'sub-1')
const pollsAfterStop = (l.api.getPayment as ReturnType<typeof vi.fn>).mock.calls.length
await vi.advanceTimersByTimeAsync(30_000)
expect((l.api.getPayment as ReturnType<typeof vi.fn>).mock.calls.length).toBe(pollsAfterStop)
})
})

View file

@ -0,0 +1,164 @@
import { describe, it, expect } from 'vitest'
import { npubEncode, nprofileEncode } from 'nostr-tools/nip19'
import { authorize, hashId, hashPin, type AllowListEntry } from '../authorize'
import { parseBoltcardLnurlw } from '../boltcard'
const SALT = 'test-salt'
const HEX_A = 'aa'.repeat(32)
const HEX_B = 'bb'.repeat(32)
const NPUB_A = npubEncode(HEX_A)
const NPUB_B = npubEncode(HEX_B)
describe('access authorize (ADR-003)', () => {
describe('open enrollment (prototype)', () => {
it('grants any valid npub as user', async () => {
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [], {
salt: SALT,
openEnrollment: true,
})
expect(out.status).toBe('granted')
expect(out).toMatchObject({ status: 'granted', role: 'user' })
})
it('rejects a stray / non-npub QR', async () => {
const out = await authorize({ kind: 'npub', npub: 'https://example.com/not-an-npub' }, [], {
salt: SALT,
openEnrollment: true,
})
expect(out.status).toBe('denied')
expect(out).toMatchObject({ reason: 'not a valid npub' })
})
it('accepts a `nostr:` URI prefix (with surrounding whitespace)', async () => {
const out = await authorize({ kind: 'npub', npub: ` nostr:${NPUB_A}\n` }, [], {
salt: SALT,
openEnrollment: true,
})
expect(out.status).toBe('granted')
})
it('accepts an nprofile and resolves to the same identity as its npub', async () => {
const nprofile = nprofileEncode({ pubkey: HEX_A, relays: ['wss://relay.example'] })
const viaNprofile = await authorize({ kind: 'npub', npub: nprofile }, [], {
salt: SALT,
openEnrollment: true,
})
const viaNpub = await authorize({ kind: 'npub', npub: NPUB_A }, [], {
salt: SALT,
openEnrollment: true,
})
expect(viaNprofile.status).toBe('granted')
// Same underlying pubkey → same credential hash.
expect(viaNprofile.credentialIdHash).toBe(viaNpub.credentialIdHash)
})
})
describe('allow-list (closed)', () => {
it('denies an unlisted npub when not open-enrollment', async () => {
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [], { salt: SALT })
expect(out).toMatchObject({ status: 'denied', reason: 'not authorized' })
})
it('grants a listed npub with its role, no PIN', async () => {
const entry: AllowListEntry = { idHash: await hashId(HEX_A, SALT), role: 'operator' }
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [entry], { salt: SALT })
expect(out).toMatchObject({ status: 'granted', role: 'operator' })
})
it('does not match npub B against npub A entry', async () => {
const entry: AllowListEntry = { idHash: await hashId(HEX_A, SALT), role: 'user' }
const out = await authorize({ kind: 'npub', npub: NPUB_B }, [entry], { salt: SALT })
expect(out.status).toBe('denied')
})
})
describe('PIN second factor', () => {
const makeEntry = async (): Promise<AllowListEntry> => ({
idHash: await hashId(HEX_A, SALT),
role: 'user',
pinHash: await hashPin('1234', SALT),
})
it('asks for a PIN when one is configured and none supplied', async () => {
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], {
salt: SALT,
})
expect(out.status).toBe('pin-required')
})
it('grants on correct PIN', async () => {
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], {
salt: SALT,
pin: '1234',
})
expect(out).toMatchObject({ status: 'granted', role: 'user' })
})
it('denies on wrong PIN', async () => {
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], {
salt: SALT,
pin: '9999',
})
expect(out).toMatchObject({ status: 'denied', reason: 'incorrect PIN' })
})
})
describe('challenge credential (v2 seam)', () => {
it('is not yet authorized', async () => {
const out = await authorize({ kind: 'challenge', pubkey: HEX_A, nonce: 'n', sig: 's' }, [], {
salt: SALT,
openEnrollment: true,
})
expect(out.status).toBe('denied')
})
})
describe('boltcard credential (tap-to-enter)', () => {
it('open-enrollment grants any card as user', async () => {
const out = await authorize({ kind: 'boltcard', externalId: 'abc123' }, [], {
salt: SALT,
openEnrollment: true,
})
expect(out).toMatchObject({ status: 'granted', role: 'user' })
})
it('rejects a card with no external_id', async () => {
const out = await authorize({ kind: 'boltcard', externalId: '' }, [], {
salt: SALT,
openEnrollment: true,
})
expect(out).toMatchObject({ status: 'denied', reason: 'not a valid card' })
})
it('allow-list matches by external_id hash', async () => {
const idHash = await hashId('abc123', SALT)
const list: AllowListEntry[] = [{ idHash, role: 'operator' }]
const out = await authorize({ kind: 'boltcard', externalId: 'abc123' }, list, {
salt: SALT,
openEnrollment: false,
})
expect(out).toMatchObject({ status: 'granted', role: 'operator' })
})
})
})
describe('parseBoltcardLnurlw', () => {
it('extracts external_id from a tapped lnurlw', () => {
expect(
parseBoltcardLnurlw('lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEAD&c=BEEF')
).toEqual({ externalId: 'abc123' })
})
it('strips a lightning: prefix and accepts https', () => {
expect(parseBoltcardLnurlw('lightning:lnurlw://h/boltcards/api/v1/scan/xyz?p=1')).toEqual({
externalId: 'xyz',
})
expect(parseBoltcardLnurlw('https://h/boltcards/api/v1/scan/xyz?p=1')).toEqual({
externalId: 'xyz',
})
})
it('returns null for non-card / malformed input', () => {
expect(parseBoltcardLnurlw('https://h/something/else')).toBeNull()
expect(parseBoltcardLnurlw('not a url')).toBeNull()
expect(parseBoltcardLnurlw('')).toBeNull()
})
})

View file

@ -0,0 +1,150 @@
/**
* Credential authorization (ADR-003).
*
* Decides whether a presented credential may unlock the terminal. Matching is
* against a local allow-list of salted identity hashes, optionally behind a
* PIN second factor; `openEnrollment` admits any well-formed credential when
* the allow-list has no match (the current posture — see the ADR amendment:
* with it on, the gate is a convenience, not a security boundary). Only
* salted hashes are compared, stored or logged — never the raw id (KYC-free).
*
* Identity id per scan kind:
* - boltcard → the card's boltcards `external_id` (parsed locally from the
* lnurlw; the SUN p/c are NOT verified here — that happens at
* payment time, where the voucher is actually spent)
* - npub → hex pubkey (decoded, canonical)
* - challenge → v2 seam, not yet authorized
*/
import { decode as nip19Decode } from 'nostr-tools/nip19'
import type { AccessRole } from '@bitSpire/state-machine'
import type { AccessScan } from './types'
/** One authorized identity. `idHash` = hashId(<canonical id>, salt). */
export interface AllowListEntry {
idHash: string
role: AccessRole
/** When set, access requires this PIN (hashPin(pin, salt)) as a 2nd factor. */
pinHash?: string
/** Optional operator-facing label (never a person's real identity). */
label?: string
}
export interface AuthorizeOptions {
/** Per-machine salt for all hashing. */
salt: string
/** Admit any valid credential when the allow-list has no match (prototype). */
openEnrollment?: boolean
/** PIN supplied on the follow-up call after a `pin-required` outcome. */
pin?: string
}
/**
* Three outcomes, so the caller can drive a two-step flow:
* - `granted` → send ACCESS_GRANTED
* - `pin-required` → prompt for a PIN, then call authorize() again with `pin`
* - `denied` → send ACCESS_DENIED(reason)
*/
export type AuthorizeOutcome =
| { status: 'granted'; role: AccessRole; credentialIdHash: string }
| { status: 'pin-required'; credentialIdHash: string }
| { status: 'denied'; credentialIdHash: string; reason: string }
/** Salted SHA-256, hex-encoded. */
async function sha256Hex(input: string): Promise<string> {
const data = new TextEncoder().encode(input)
const digest = await crypto.subtle.digest('SHA-256', data)
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
}
export const hashId = (id: string, salt: string): Promise<string> => sha256Hex(`id:${salt}:${id}`)
export const hashPin = (pin: string, salt: string): Promise<string> =>
sha256Hex(`pin:${salt}:${pin}`)
/**
* Resolve a scan to a canonical identity string, or `null` if malformed.
* npub is decoded to its hex pubkey so npub/hex forms compare equal and a
* stray (non-npub) string is rejected.
*/
function canonicalId(scan: AccessScan): string | null {
if (scan.kind === 'boltcard') return scan.externalId || null
if (scan.kind === 'challenge') return null // v2 — handled separately
// Tolerate real-world nostr QR shapes: a bare `npub1…`, a `nostr:` URI
// prefix, and `nprofile1…` (npub + relay hints, what many clients export).
const raw = scan.npub.trim().replace(/^nostr:/i, '')
try {
const decoded = nip19Decode(raw)
if (decoded.type === 'npub' && typeof decoded.data === 'string') {
return decoded.data
}
if (
decoded.type === 'nprofile' &&
decoded.data &&
typeof (decoded.data as { pubkey?: unknown }).pubkey === 'string'
) {
return (decoded.data as { pubkey: string }).pubkey
}
return null
} catch {
return null
}
}
/**
* Decide whether a scanned credential is authorized.
* Always resolves (never throws) so the caller can uniformly react.
*/
export async function authorize(
scan: AccessScan,
allowList: AllowListEntry[],
opts: AuthorizeOptions
): Promise<AuthorizeOutcome> {
if (scan.kind === 'challenge') {
return {
status: 'denied',
credentialIdHash: '',
reason: 'challenge-response credentials not yet supported',
}
}
const id = canonicalId(scan)
if (!id) {
return {
status: 'denied',
credentialIdHash: '',
reason:
scan.kind === 'npub'
? 'not a valid npub'
: scan.kind === 'boltcard'
? 'not a valid card'
: 'invalid credential',
}
}
const credentialIdHash = await hashId(id, opts.salt)
const entry = allowList.find((e) => e.idHash === credentialIdHash)
if (!entry) {
if (opts.openEnrollment) {
return { status: 'granted', role: 'user', credentialIdHash }
}
return { status: 'denied', credentialIdHash, reason: 'not authorized' }
}
// No PIN configured → single-factor grant.
if (!entry.pinHash) {
return { status: 'granted', role: entry.role, credentialIdHash }
}
// PIN configured but not yet supplied → ask for it.
if (opts.pin === undefined) {
return { status: 'pin-required', credentialIdHash }
}
// PIN supplied → verify.
const pinHash = await hashPin(opts.pin, opts.salt)
if (pinHash !== entry.pinHash) {
return { status: 'denied', credentialIdHash, reason: 'incorrect PIN' }
}
return { status: 'granted', role: entry.role, credentialIdHash }
}

View file

@ -0,0 +1,27 @@
/**
* Bolt Card lnurlw parsing for the access gate (ADR-003).
*
* The tap-to-enter flow reads a Bolt Card's `lnurlw://…/scan/<external_id>?p=&c=`
* voucher and needs the `external_id` for the session identity — WITHOUT hitting
* the server (that would burn the single-use SUN p/c we want to reuse at
* Complete). So this is a purely local parse: extract the id from the URL path;
* the p/c ride along in the stored lnurlw and are only spent at payment time.
*/
/** Extract a Bolt Card's `external_id` from its tapped lnurlw. Null if not one. */
export function parseBoltcardLnurlw(lnurlw: string): { externalId: string } | null {
let s = lnurlw.trim()
if (!s) return null
if (s.toLowerCase().startsWith('lightning:')) s = s.slice('lightning:'.length)
const https = s.replace(/^lnurlw:\/\//i, 'https://').replace(/^lnurl:\/\//i, 'https://')
if (!/^https:\/\//i.test(https)) return null
try {
const u = new URL(https)
// …/boltcards/api/v1/scan/<external_id>
const m = u.pathname.match(/\/scan\/([^/?#]+)/)
if (!m || !m[1]) return null
return { externalId: decodeURIComponent(m[1]) }
} catch {
return null
}
}

View file

@ -0,0 +1,13 @@
/**
* Access-control module surface (ADR-003).
*
* Credential capture is NOT here: the reader is the main-process NFC service
* (`electron/nfc-service.ts`, over the `nfc:card-tapped` IPC), and the store
* turns a tapped lnurlw into a `boltcard` scan. This module only decides —
* parse the card, hash the identity, match the allow-list.
*/
export type { AccessScan, AccessRole } from './types'
export { authorize, hashId, hashPin } from './authorize'
export type { AllowListEntry, AuthorizeOptions, AuthorizeOutcome } from './authorize'
export { parseBoltcardLnurlw } from './boltcard'

View file

@ -0,0 +1,31 @@
/**
* Access-control credential types (ADR-003).
*
* A credential is captured elsewhere — for Bolt Cards by the main-process NFC
* reader (`electron/nfc-service.ts`), which hands the tapped lnurlw to the
* store over IPC — and arrives here as a RAW `AccessScan`. Hashing and
* authorization (and the optional PIN second factor) happen in `authorize.ts`,
* so raw ids never leave this layer (KYC-free).
*/
import type { AccessRole } from '@bitSpire/state-machine'
export type { AccessRole }
/**
* A raw credential. Discriminated union so new factors are additive:
* - `boltcard` — what ships: a tapped Bolt Card, already verified by the
* card server's `/session` (the tap's SUN was spent there).
* `externalId` is the identity as the server returned it;
* it is the only thing hashed/authorized. The session's
* payment steps stay in the store, never in this layer.
* - `npub` — a Nostr pubkey (bare npub, `nostr:` URI or nprofile).
* No reader emits it today; kept, with the PIN second
* factor, for a future non-card credential.
* - `challenge` — card-signed nonce, challenge-response. v2 seam; not yet
* authorized.
*/
export type AccessScan =
| { kind: 'boltcard'; externalId: string }
| { kind: 'npub'; npub: string }
| { kind: 'challenge'; pubkey: string; nonce: string; sig: string }

View file

@ -15,6 +15,7 @@
*/ */
import type { ATMServices } from '@bitSpire/state-machine' import type { ATMServices } from '@bitSpire/state-machine'
import { formatBays } from '@/lib/utils'
export interface CassetteConfig { export interface CassetteConfig {
denomination: number denomination: number
@ -109,6 +110,12 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
}) })
console.log('[HAL] Validator started') console.log('[HAL] Validator started')
// Escrow / in-flight bookkeeping: credit (onBillInserted) fires only on
// the validator's `billsValid` stacked-confirmation, never at
// stack-command time (mirrors electron/hal-service.ts).
let escrowDenomination: number | null = null
let inFlightDenomination: number | null = null
// Track inventory (decremented on dispense) // Track inventory (decremented on dispense)
const inventory: Record<number, number> = {} const inventory: Record<number, number> = {}
for (const cassette of dispConfig.cassettes) { for (const cassette of dispConfig.cassettes) {
@ -120,7 +127,7 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
const atmServices: Pick<ATMServices, 'dispenseCash' | 'getInventory'> = { const atmServices: Pick<ATMServices, 'dispenseCash' | 'getInventory'> = {
dispenseCash: async (amounts) => { dispenseCash: async (amounts) => {
console.log('[HAL] Dispensing:', amounts) console.log(`[HAL] Dispensing: ${formatBays(amounts)}`)
// Re-initialize dispenser if it was closed after a previous error // Re-initialize dispenser if it was closed after a previous error
if (!dispenser.initialized) { if (!dispenser.initialized) {
@ -140,8 +147,10 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
dispensed: 0, dispensed: 0,
rejected: 0, rejected: 0,
})), })),
dispensed: false, dispenseConfirmed: false,
error: `No cassette loaded with denomination: ${denomination}`, error: `No cassette loaded with denomination: ${denomination}`,
errorCode: 'NoCassetteForDenomination',
errorClass: 'inventory',
} }
} }
notes[idx] = count notes[idx] = count
@ -175,11 +184,23 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
rejected: c.rejected, rejected: c.rejected,
})) }))
const totalRequested = amounts.reduce((s, a) => s + a.count, 0) // ADR-005 §3: confirmation on VALUE. Same contract as electron/hal-service.ts.
const requestedValue = amounts.reduce((s, a) => s + a.denomination * a.count, 0)
const dispensedValue = cassetteResults.reduce((s, c) => s + c.denomination * c.dispensed, 0)
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0) const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
const dispenseConfirmed = requestedValue === dispensedValue
if (result.error) { if (result.error) {
return { bills, cassettes: cassetteResults, dispensed: false, error: result.error.message } const e = result.error
return {
bills,
cassettes: cassetteResults,
dispenseConfirmed: false,
error: e.human ?? e.message,
errorCode: e.errorCode ?? e.name,
rawCode: e.rawCode,
errorClass: e.errorClass ?? 'terminal',
}
} }
// Wait for customer to take bills (only if bills were dispensed) // Wait for customer to take bills (only if bills were dispensed)
@ -188,7 +209,18 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
console.log('[HAL] Bills removed by customer') console.log('[HAL] Bills removed by customer')
} }
return { bills, cassettes: cassetteResults, dispensed: totalRequested === totalDispensed } if (!dispenseConfirmed) {
return {
bills,
cassettes: cassetteResults,
dispenseConfirmed: false,
error: `Dispensed ${dispensedValue} of ${requestedValue} with no dispenser error`,
errorCode: 'DispenseShort',
errorClass: 'inventory',
}
}
return { bills, cassettes: cassetteResults, dispenseConfirmed: true }
}, },
getInventory: async () => { getInventory: async () => {
@ -206,10 +238,13 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
if (decision === 'hold') { if (decision === 'hold') {
// Hold in escrow — caller will call stackBill() or rejectBill() // Hold in escrow — caller will call stackBill() or rejectBill()
console.log('[HAL] Bill in escrow:', data.denomination) console.log('[HAL] Bill in escrow:', data.denomination)
escrowDenomination = data.denomination
callbacks.onBillRead?.(data.denomination) callbacks.onBillRead?.(data.denomination)
} else if (decision) { } else if (decision) {
// Credit waits for the validator's stacked-confirmation
// (`billsValid`) — see the handler below.
inFlightDenomination = data.denomination
validator.stack() validator.stack()
callbacks.onBillInserted(data.denomination)
} else { } else {
console.log('[HAL] Bill rejected: insufficient ATM balance for', data.denomination) console.log('[HAL] Bill rejected: insufficient ATM balance for', data.denomination)
validator.reject() validator.reject()
@ -221,7 +256,21 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
} }
}) })
// Stacked-confirmation → the credit event.
validator.on('billsValid', () => {
if (inFlightDenomination === null) {
console.warn('[HAL] billsValid with no bill in flight — ignoring')
return
}
const denomination = inFlightDenomination
inFlightDenomination = null
console.log('[HAL] Bill stacked (confirmed):', denomination)
callbacks.onBillInserted(denomination)
})
validator.on('billsRejected', (data?: { reason: string; code: number | null }) => { validator.on('billsRejected', (data?: { reason: string; code: number | null }) => {
escrowDenomination = null
inFlightDenomination = null
callbacks.onBillRejected(data?.reason ?? 'unknown') callbacks.onBillRejected(data?.reason ?? 'unknown')
}) })
@ -248,8 +297,19 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
validator.lightOff() validator.lightOff()
}, },
stackBill: () => validator.stack(), stackBill: () => {
rejectBill: () => validator.reject(), if (escrowDenomination === null) {
console.warn('[HAL] stackBill with no bill in escrow — ignoring')
return
}
inFlightDenomination = escrowDenomination
escrowDenomination = null
validator.stack()
},
rejectBill: () => {
escrowDenomination = null
validator.reject()
},
cleanup: async () => { cleanup: async () => {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {

View file

@ -0,0 +1,20 @@
/**
* Classify an initialization failure into a maintenance-screen sentinel
* (see App.vue's MAINTENANCE_SCREENS).
*
* Bunker failures (aiolabs/bitspire#52) get dedicated screens:
* - `NoPairingError` (fresh machine, never paired) → `unpaired` — render the
* interactive QR-pairing wizard so the operator can scan a spire-seed.
* - `BunkerRejectedError` (revoked / TTL-expired / off-policy binding) →
* `unpaired` too — re-pairing is the same scan-a-fresh-seed flow.
* - `BunkerTimeoutError` (signer/relay unreachable) → `signer-unreachable`,
* a transient condition.
* Everything else surfaces its raw message (or the caller's fallback).
*/
export function classifyInitError(error: unknown, fallback = 'Initialization failed'): string {
const name = (error as { name?: string } | null)?.name
if (name === 'NoPairingError') return 'unpaired'
if (name === 'BunkerRejectedError') return 'unpaired'
if (name === 'BunkerTimeoutError') return 'signer-unreachable'
return error instanceof Error ? error.message : fallback
}

View file

@ -12,19 +12,16 @@
* the customer's invoice. * the customer's invoice.
*/ */
import { import { NostrClient, type Signer } from '@bitSpire/nostr-client'
NostrClient, import { resolveSigner } from './signer-resolver.js'
generateIdentity, import { LnbitsClient, type DispenseReportBody, type DispenseReportAck } from '@bitSpire/lnbits'
loadIdentityFromHex,
type MachineIdentity,
} from '@bitSpire/nostr-client'
import { LnbitsClient } from '@bitSpire/lnbits'
import { CLINKClient } from '@bitSpire/clink' import { CLINKClient } from '@bitSpire/clink'
import type { OfferRequest, ManagementRequest, ManagementResponse } from '@bitSpire/clink' import type { OfferRequest, ManagementRequest, ManagementResponse } from '@bitSpire/clink'
import type { ATMServices, ATMContext } from '@bitSpire/state-machine' import type { ATMServices, ATMContext } from '@bitSpire/state-machine'
// Import Electron types // Import Electron types
import type {} from '@/types/electron' import type {} from '@/types/electron'
import { formatBays, formatInventory } from '@/lib/utils'
// Check if we're running in Electron (electronAPI is exposed via preload) // Check if we're running in Electron (electronAPI is exposed via preload)
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
@ -37,14 +34,12 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
* *
* Environment variables: * Environment variables:
* - VITE_RELAY_URL: Nostr relay WebSocket URL * - VITE_RELAY_URL: Nostr relay WebSocket URL
* - VITE_LIGHTNING_PUB_PUBKEY: Lightning.Pub's Nostr pubkey (hex or npub) * - VITE_LNBITS_SERVER_PUBKEY: LNbits nostr-transport server pubkey (hex)
* - VITE_LIGHTNING_PUB_API_URL: Lightning.Pub HTTP API URL * - VITE_SPIRE_SEED: spire pairing seed (NIP-46 bunker); see signer-resolver.ts
* - VITE_ATM_PRIVATE_KEY: ATM's Nostr private key (hex or nsec) // pragma: allowlist secret * - VITE_OPERATOR_PUBKEYS: comma-separated operator pubkeys (hex)
* - VITE_ADMIN_TOKEN: Lightning.Pub admin token (dev only)
*/ */
interface LightningConfig { interface LightningConfig {
relayUrl: string relayUrl: string
atmPrivateKey: string
appId: string appId: string
operatorPubkeys: string[] operatorPubkeys: string[]
/** LNbits nostr-transport server pubkey (hex, 64 chars). */ /** LNbits nostr-transport server pubkey (hex, 64 chars). */
@ -60,8 +55,10 @@ interface LightningConfig {
*/ */
async function loadLightningConfig(): Promise<LightningConfig> { async function loadLightningConfig(): Promise<LightningConfig> {
const defaults: LightningConfig = { const defaults: LightningConfig = {
relayUrl: 'ws://localhost:7777', // Empty when unset (not the dev relay) so initializeLightningServices can
atmPrivateKey: '', // tell "operator gave us a relay" from "fall back to the pairing seed". See
// aiolabs/bitspire#70 and DEV_DEFAULT_RELAY.
relayUrl: '',
appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID
operatorPubkeys: [], operatorPubkeys: [],
lnbitsServerPubkey: '', lnbitsServerPubkey: '',
@ -70,10 +67,8 @@ async function loadLightningConfig(): Promise<LightningConfig> {
if (isElectron && window.electronAPI) { if (isElectron && window.electronAPI) {
try { try {
const rc = await window.electronAPI.getConfig() const rc = await window.electronAPI.getConfig()
const sec = await window.electronAPI.getAtmSecrets()
return { return {
relayUrl: rc.relayUrl || defaults.relayUrl, relayUrl: rc.relayUrl || defaults.relayUrl,
atmPrivateKey: sec.atmPrivateKey || defaults.atmPrivateKey,
appId: rc.appId || defaults.appId, appId: rc.appId || defaults.appId,
operatorPubkeys: rc.operatorPubkeys operatorPubkeys: rc.operatorPubkeys
? rc.operatorPubkeys ? rc.operatorPubkeys
@ -90,7 +85,6 @@ async function loadLightningConfig(): Promise<LightningConfig> {
return { return {
relayUrl: import.meta.env.VITE_RELAY_URL || defaults.relayUrl, relayUrl: import.meta.env.VITE_RELAY_URL || defaults.relayUrl,
atmPrivateKey: import.meta.env.VITE_ATM_PRIVATE_KEY || defaults.atmPrivateKey,
appId: import.meta.env.VITE_APP_ID || defaults.appId, appId: import.meta.env.VITE_APP_ID || defaults.appId,
lnbitsServerPubkey: lnbitsServerPubkey:
(import.meta.env.VITE_LNBITS_SERVER_PUBKEY as string | undefined) || (import.meta.env.VITE_LNBITS_SERVER_PUBKEY as string | undefined) ||
@ -107,6 +101,10 @@ async function loadLightningConfig(): Promise<LightningConfig> {
// Config is loaded async now - will be set in initializeLightningServices // Config is loaded async now - will be set in initializeLightningServices
let CONFIG: LightningConfig let CONFIG: LightningConfig
/** Dev-only relay used when neither env nor the pairing supplies one. Matches
* the dev stack — LNbits's bundled nostrrelay (no separate strfry container). */
const DEV_DEFAULT_RELAY = 'ws://localhost:5001/nostrrelay/test'
/** Safety timeout in ms (15 minutes) — absolute maximum LNURL session lifetime. /** Safety timeout in ms (15 minutes) — absolute maximum LNURL session lifetime.
* Sessions are normally cleaned up by the state machine on idle transition. * Sessions are normally cleaned up by the state machine on idle transition.
* This is a safety net in case the state machine doesn't clean up properly. */ * This is a safety net in case the state machine doesn't clean up properly. */
@ -119,33 +117,27 @@ const SESSION_SAFETY_TIMEOUT_MS = 15 * 60 * 1000
/** Active LNURL-withdraw session */ /** Active LNURL-withdraw session */
interface LnurlSession { interface LnurlSession {
sessionId: string sessionId: string
/** Link ID for management operations (delete/update) */ /** Link ID — the management + settlement-watch key (delete/subscribe). */
linkId: string linkId: string
uniqueHash: string
satsAmount: number satsAmount: number
status: 'active' | 'claimed' | 'expired' status: 'active' | 'claimed' | 'expired'
createdAt: number createdAt: number
cleanup?: () => void cleanup?: () => void
} }
/** Map of uniqueHash -> LNURL session data */ /** Map of linkId -> LNURL session data. Keyed on link_id since the secure
* `create_withdraw` response (spirekeeper#31) carries no `unique_hash`. */
const lnurlSessions = new Map<string, LnurlSession>() const lnurlSessions = new Map<string, LnurlSession>()
/** /**
* Register a new LNURL-withdraw session * Register a new LNURL-withdraw session, keyed by linkId.
*/ */
function registerLnurlSession( function registerLnurlSession(sessionId: string, linkId: string, satsAmount: number): void {
sessionId: string, console.log('[LNURL Session] Registering:', linkId, 'for', satsAmount, 'sats')
linkId: string,
uniqueHash: string,
satsAmount: number,
): void {
console.log('[LNURL Session] Registering:', uniqueHash, 'for', satsAmount, 'sats')
lnurlSessions.set(uniqueHash, { lnurlSessions.set(linkId, {
sessionId, sessionId,
linkId, linkId,
uniqueHash,
satsAmount, satsAmount,
status: 'active', status: 'active',
createdAt: Date.now(), createdAt: Date.now(),
@ -153,10 +145,10 @@ function registerLnurlSession(
// Safety timeout — normally cleaned up by state machine on idle transition. // Safety timeout — normally cleaned up by state machine on idle transition.
setTimeout(() => { setTimeout(() => {
const session = lnurlSessions.get(uniqueHash) const session = lnurlSessions.get(linkId)
if (session && session.status === 'active') { if (session && session.status === 'active') {
console.warn('[LNURL Session] Safety timeout reached, expiring:', uniqueHash) console.warn('[LNURL Session] Safety timeout reached, expiring:', linkId)
expireLnurlSession(uniqueHash) expireLnurlSession(linkId)
} }
}, SESSION_SAFETY_TIMEOUT_MS) }, SESSION_SAFETY_TIMEOUT_MS)
} }
@ -164,23 +156,23 @@ function registerLnurlSession(
/** Invalidate an active LNURL session by cash-in sessionId. The session's /** Invalidate an active LNURL session by cash-in sessionId. The session's
* cleanup closure unsubscribes from LNbits and deletes the link. */ * cleanup closure unsubscribes from LNbits and deletes the link. */
function invalidateLnurlSessionBySessionId(sessionId: string): void { function invalidateLnurlSessionBySessionId(sessionId: string): void {
for (const [hash, session] of lnurlSessions.entries()) { for (const [linkId, session] of lnurlSessions.entries()) {
if (session.sessionId === sessionId && session.status === 'active') { if (session.sessionId === sessionId && session.status === 'active') {
console.log('[LNURL Session] Invalidating previous session:', hash) console.log('[LNURL Session] Invalidating previous session:', linkId)
expireLnurlSession(hash) expireLnurlSession(linkId)
} }
} }
} }
/** Expire a single LNURL session via its cleanup closure. */ /** Expire a single LNURL session via its cleanup closure. */
function expireLnurlSession(uniqueHash: string): void { function expireLnurlSession(linkId: string): void {
const session = lnurlSessions.get(uniqueHash) const session = lnurlSessions.get(linkId)
if (!session || session.status !== 'active') return if (!session || session.status !== 'active') return
console.log('[LNURL Session] Expiring:', uniqueHash) console.log('[LNURL Session] Expiring:', linkId)
session.status = 'expired' session.status = 'expired'
if (session.cleanup) session.cleanup() if (session.cleanup) session.cleanup()
setTimeout(() => lnurlSessions.delete(uniqueHash), 60000) setTimeout(() => lnurlSessions.delete(linkId), 60000)
} }
let _lnbitsRef: LnbitsClient | null = null let _lnbitsRef: LnbitsClient | null = null
@ -226,15 +218,17 @@ export interface LightningBackend {
}): Promise<{ paymentRequest: string; paymentHash?: string }> }): Promise<{ paymentRequest: string; paymentHash?: string }>
payInvoice( payInvoice(
bolt11: string, bolt11: string,
amountSats: number, amountSats: number
): Promise<{ success: boolean; preimage?: string; error?: string }> ): Promise<{ success: boolean; preimage?: string; error?: string }>
} }
interface LightningServices { interface LightningServices {
/** ADR-005 §2: send one cash-out's dispense outcome to spirekeeper (outbox-driven). */
reportDispense: (body: DispenseReportBody) => Promise<DispenseReportAck>
nostrClient: NostrClient nostrClient: NostrClient
lightningPub: LightningBackend lightningPub: LightningBackend
clink: CLINKClient clink: CLINKClient
identity: MachineIdentity signer: Signer
/** Operator pubkeys (hex) authorized for kind-21003 management + operator-config events. */ /** Operator pubkeys (hex) authorized for kind-21003 management + operator-config events. */
operatorPubkeys: string[] operatorPubkeys: string[]
atmServices: ATMServices atmServices: ATMServices
@ -411,50 +405,85 @@ export async function initializeLightningServices(options?: {
// Load configuration (async for Electron runtime config) // Load configuration (async for Electron runtime config)
CONFIG = await loadLightningConfig() CONFIG = await loadLightningConfig()
console.log('[Lightning] Relay URL:', CONFIG.relayUrl) // Resolve the signing identity BEFORE validating the LNbits transport
console.log('[Lightning] LNbits server pubkey:', CONFIG.lnbitsServerPubkey || '(not configured)') // config. An unpaired machine must reach the QR-pairing wizard regardless
// of relay/server-pubkey provisioning — pairing is what provides those — so
// resolveSigner (which throws NoPairingError → 'unpaired' → wizard for a
// machine with no seed and no binding) has to run ahead of the config
// checks below. The relay/pubkey validation then only gates a *paired*
// machine that's actually trying to talk to LNbits. See aiolabs/bitspire#70.
//
// In production this is a BunkerSigner over NIP-46 (the ATM holds only a
// transport key; the operator's nsecbunkerd holds the signing key); in dev
// it falls back to an in-process LocalSigner. The Phase-A Signer seam means
// nothing downstream changes. See aiolabs/bitspire#52.
const { signer, transport } = await resolveSigner({ allowEphemeral: !options?.strict })
console.log('[Lightning] ATM pubkey:', signer.pubkey)
// Strict mode: validate config is production-ready (no localhost, no ephemeral identity) // Resolve the effective LNbits transport. Precedence: explicit env wins (dev
// + operator override), else the pairing (seed/binding) supplies it (#70) so
// a blank-.env paired machine reaches the backend from the seed alone, else a
// dev-only localhost fallback. CONFIG is mutated to the resolved values so
// downstream (and the exported CONFIG) see a single source of truth.
const envRelay = CONFIG.relayUrl
const envPubkey = CONFIG.lnbitsServerPubkey
const relays: string[] = envRelay
? [envRelay]
: transport && transport.relays.length > 0
? transport.relays
: [DEV_DEFAULT_RELAY]
CONFIG.relayUrl = relays[0]!
CONFIG.lnbitsServerPubkey = envPubkey || transport?.lnbitsServerPubkey || ''
console.log(
'[Lightning] Relay(s):',
relays.join(', '),
envRelay ? '(env)' : transport?.relays.length ? '(pairing)' : '(default)'
)
console.log(
'[Lightning] LNbits server pubkey:',
CONFIG.lnbitsServerPubkey || '(not configured)',
envPubkey ? '(env)' : transport?.lnbitsServerPubkey ? '(pairing)' : ''
)
// Operator pubkey provenance. Today the ONLY source is VITE_OPERATOR_PUBKEYS
// (env). An empty set disables the fees/operator-config services → the machine
// sits at "awaiting configuration" — so log it loudly rather than fail silent.
// (aiolabs/bitspire#70 P1 will source this from LNbits over the transport.)
console.log(
'[Lightning] Operator pubkey(s):',
CONFIG.operatorPubkeys.length
? CONFIG.operatorPubkeys.join(', ') + ' (env)'
: '(none — fee/operator config gated until a server-delivered operator pubkey; #70 P1)'
)
// Strict mode: validate the RESOLVED config is production-ready (no
// localhost). Values may come from env or the pairing seed (#70).
if (options?.strict) { if (options?.strict) {
const errors: string[] = [] const errors: string[] = []
if (/localhost|127\.0\.0\.1/.test(CONFIG.relayUrl)) { if (/localhost|127\.0\.0\.1/.test(CONFIG.relayUrl)) {
errors.push('VITE_RELAY_URL contains localhost') errors.push('relay resolves to localhost (VITE_RELAY_URL / seed relays)')
}
if (!CONFIG.atmPrivateKey) {
errors.push('VITE_ATM_PRIVATE_KEY is not set (ephemeral identity not allowed in production)')
} }
if (!CONFIG.lnbitsServerPubkey) { if (!CONFIG.lnbitsServerPubkey) {
errors.push('VITE_LNBITS_SERVER_PUBKEY is not set') errors.push('no LNbits server pubkey (VITE_LNBITS_SERVER_PUBKEY / seed lnbits_npub)')
} }
if (errors.length > 0) { if (errors.length > 0) {
throw new Error('[Lightning] Production config validation failed:\n- ' + errors.join('\n- ')) throw new Error('[Lightning] Production config validation failed:\n- ' + errors.join('\n- '))
} }
} }
// Validate required configuration // Validate required configuration. Reached only for a paired machine (an
// unpaired one threw NoPairingError above) — it needs the LNbits server
// pubkey to talk to the transport, from either env or the pairing seed.
if (!CONFIG.lnbitsServerPubkey) { if (!CONFIG.lnbitsServerPubkey) {
throw new Error( throw new Error(
'[Lightning] VITE_LNBITS_SERVER_PUBKEY is required. ' + '[Lightning] LNbits server pubkey is required — set VITE_LNBITS_SERVER_PUBKEY ' +
'Get it from: docker logs lnbits | grep nostr_transport pubkey', 'or pair with a seed that carries lnbits_npub (aiolabs/bitspire#70).'
) )
} }
// Load or generate ATM identity
let identity: MachineIdentity
if (CONFIG.atmPrivateKey) {
identity = loadIdentityFromHex(CONFIG.atmPrivateKey)
console.log('[Lightning] Loaded ATM identity from config')
} else {
identity = generateIdentity()
console.warn('[Lightning] No VITE_ATM_PRIVATE_KEY configured - generated ephemeral identity')
console.warn('[Lightning] Set VITE_ATM_PRIVATE_KEY for persistent identity across restarts')
}
console.log('[Lightning] ATM pubkey:', identity.publicKey)
// Create Nostr client // Create Nostr client
const nostrClient = new NostrClient({ const nostrClient = new NostrClient({
relays: [{ url: CONFIG.relayUrl }], relays: relays.map((url) => ({ url })),
identity, signer,
}) })
await nostrClient.connect() await nostrClient.connect()
@ -463,9 +492,9 @@ export async function initializeLightningServices(options?: {
// LNbits nostr-transport client. // LNbits nostr-transport client.
const lnbits = new LnbitsClient({ const lnbits = new LnbitsClient({
serverPubkey: CONFIG.lnbitsServerPubkey, serverPubkey: CONFIG.lnbitsServerPubkey,
relays: [CONFIG.relayUrl], relays,
}) })
lnbits.initialize(nostrClient, identity) lnbits.initialize(nostrClient, signer)
_lnbitsRef = lnbits _lnbitsRef = lnbits
console.log('[Lightning] LNbits client initialized') console.log('[Lightning] LNbits client initialized')
@ -479,14 +508,83 @@ export async function initializeLightningServices(options?: {
} }
console.log('[Lightning] LNbits wallet:', lnbitsWalletId) console.log('[Lightning] LNbits wallet:', lnbitsWalletId)
// ── Public web demo: stamp the throwaway account so it can be swept ──────
// The browser demo (atm.demo.aiolabs.dev) runs with an EPHEMERAL identity —
// a fresh keypair per page load — so LNbits mints a new account + a fresh
// auto-credited wallet for every visitor. That isolation is the point (a
// single baked-in key would be credited exactly once and then drain), but it
// leaves throwaway accounts behind, and nothing in an auto-created row says
// "demo": pubkey-set/prvkey-NULL also describes a real ATM.
//
// A nostr pubkey can't carry a marker (you'd have to grind a vanity prefix,
// far too slow to do on page load), and the account/wallet the server
// auto-creates isn't nameable by the client. So we mint one extra,
// never-used wallet whose NAME is the tag: sweeping is then an exact string
// match on wallet name rather than a heuristic about what looks disposable.
//
// Unset on every real machine, so this is inert outside the demo build. The
// call is fire-and-forget: losing the marker degrades cleanup, not the demo.
const demoTag = (import.meta.env.VITE_DEMO_TAG as string | undefined)?.trim()
if (demoTag) {
void lnbits
.createWallet(demoTag)
// Never log the reply — create_wallet returns adminkey/inkey.
.then(() => console.log('[Lightning] Demo marker wallet created:', demoTag))
.catch((e) => console.warn('[Lightning] Demo marker wallet failed:', e))
}
// #70 P1: pull operator pubkey + fee config from LNbits over the authenticated
// transport (spirekeeper#41 `get_machine_config`). A seed-only machine has no
// VITE_OPERATOR_PUBKEYS, so without this it can't trust its fee config and sits
// at "awaiting configuration". Only for the seed-only case — an explicit
// VITE_OPERATOR_PUBKEYS override keeps the env/kind-30078 path untouched.
// Soft-fail: an older spirekeeper (no RPC) or a transport error falls back to
// whatever the operator services can pull from kind-30078.
if (CONFIG.operatorPubkeys.length === 0) {
try {
const mc = await lnbits.getMachineConfig()
if (mc.operator_pubkey) {
CONFIG.operatorPubkeys = [mc.operator_pubkey]
console.log(
'[Lightning] Operator pubkey(s):',
mc.operator_pubkey,
'(server-delivered, #70 P1)'
)
}
if (mc.fee_config && isElectron && window.electronAPI) {
// Persist the server-delivered fee config so atm.ts's awaiting-fees gate
// (getFeeConfig) clears immediately — robust to the replaceable kind-30078
// event not being fetchable from the relay. The live kind-30078
// subscription still handles mid-run fee updates.
const applied = await window.electronAPI.applyFeeConfig(
{
cashInFeeFraction: mc.fee_config.cash_in_fee_fraction,
cashOutFeeFraction: mc.fee_config.cash_out_fee_fraction,
schemaVersion: mc.fee_config.schema_version,
},
mc.created_at
)
console.log(
'[Lightning] Server-delivered fee config:',
applied.applied ? 'applied' : `skipped (${applied.reason})`
)
}
} catch (e) {
console.warn(
'[Lightning] get_machine_config unavailable; falling back to env/kind-30078 for operator config:',
(e as Error).message
)
}
}
// CLINK client — kept in tree but not actively wired into LNbits flows. // CLINK client — kept in tree but not actively wired into LNbits flows.
// operatorPubkey is the operator allowlist for kind-21003 management // operatorPubkey is the operator allowlist for kind-21003 management
// commands; it has no Lightning.Pub dependency. // commands; it has no Lightning.Pub dependency.
const clink = new CLINKClient({ const clink = new CLINKClient({
nostrClient, nostrClient,
identity, signer,
operatorPubkey: CONFIG.operatorPubkeys, operatorPubkey: CONFIG.operatorPubkeys,
relays: [CONFIG.relayUrl], relays,
}) })
// Callbacks for events // Callbacks for events
@ -574,23 +672,27 @@ export async function initializeLightningServices(options?: {
} }
const atmServices = createATMServices( const atmServices = createATMServices(
identity,
(preimage) => { (preimage) => {
if (paymentReceivedCallback) { if (paymentReceivedCallback) {
paymentReceivedCallback(preimage) paymentReceivedCallback(preimage)
} }
}, },
lnbits, lnbits,
lnbitsWalletId, lnbitsWalletId
) )
return { return {
nostrClient, nostrClient,
lightningPub, lightningPub,
clink, clink,
identity, signer,
operatorPubkeys: CONFIG.operatorPubkeys, operatorPubkeys: CONFIG.operatorPubkeys,
atmServices, atmServices,
/**
* ADR-005 §2: send one cash-out's dispense outcome to spirekeeper. The
* store keeps these in a durable outbox and calls this until it resolves.
*/
reportDispense: (body: DispenseReportBody) => lnbits.reportDispense(body),
onOfferRequest: (callback: OfferRequestCallback) => { onOfferRequest: (callback: OfferRequestCallback) => {
offerRequestCallback = callback offerRequestCallback = callback
}, },
@ -616,14 +718,142 @@ export async function initializeLightningServices(options?: {
/** /**
* Create ATMServices implementation using the LNbits nostr-transport. * Create ATMServices implementation using the LNbits nostr-transport.
*/ */
function createATMServices( export function createATMServices(
_identity: MachineIdentity,
onPaymentSuccess: (preimage: string) => void, onPaymentSuccess: (preimage: string) => void,
lnbits: LnbitsClient, lnbits: LnbitsClient,
lnbitsWalletId: string, lnbitsWalletId: string
): ATMServices { ): ATMServices {
const onPaymentCallback = onPaymentSuccess const onPaymentCallback = onPaymentSuccess
// ── Cash-out settlement watch ───────────────────────────────────────────
/**
* A watch on one cash-out invoice, armed the moment the invoice exists and
* consumed later by the state machine's `displayingInvoice` actor.
*
* Arming at creation rather than at display closes a race that swallowed
* real payments on 2026-09-22 (sintra). Subscribing costs a nostr round
* trip — about four seconds against a remote relay — while a one-tap Bolt
* Card Complete settles in roughly one. The settlement push is an ephemeral
* event with no replay, so it fired before anything was listening and the
* machine sat on a paid invoice until it timed out, taking the sats without
* dispensing. The old two-tap flow only worked because fumbling with the
* card covered the gap.
*
* Three defences, in order: the invoice is not returned until its watch is
* armed; a settlement that still beats the UI is latched and replayed when
* the consumer attaches; and a poll runs alongside the subscription so a
* lost or unsent push cannot strand a payment either way.
*/
interface InvoiceWatch {
paymentHash: string
subId: string | null
/** Preimage seen before a consumer attached; replayed on attach. */
settled: string | null
consumer: ((preimage: string, paymentHash: string) => void) | null
poll: ReturnType<typeof setInterval> | null
released: boolean
}
const invoiceWatches = new Map<string, InvoiceWatch>()
// Backstop cadence. One cheap RPC; on the money path a little extra relay
// traffic is worth far more than a payment that lands with no cash.
const SETTLEMENT_POLL_MS = 6_000
function stopInvoiceWatchPoll(watch: InvoiceWatch): void {
if (watch.poll) {
clearInterval(watch.poll)
watch.poll = null
}
}
/** Deliver a settlement exactly once, to the consumer or into the latch. */
function settleInvoiceWatch(watch: InvoiceWatch, preimage: string, via: string): void {
if (watch.released || watch.settled) return
watch.settled = preimage
stopInvoiceWatchPoll(watch)
console.log(`[ATM Service] Invoice paid (${via})!`)
watch.consumer?.(preimage, watch.paymentHash)
}
function startInvoiceWatchPoll(watch: InvoiceWatch): void {
let inFlight = false
watch.poll = setInterval(() => {
if (inFlight || watch.settled || watch.released) return
inFlight = true
void lnbits
.getPayment(watch.paymentHash)
.then((payment) => {
if (payment?.status === 'success') {
settleInvoiceWatch(watch, payment.preimage ?? 'payment-confirmed', 'poll')
}
})
.catch(() => {
/* transport blip — the next tick retries */
})
.finally(() => {
inFlight = false
})
}, SETTLEMENT_POLL_MS)
}
/** Arm the watch for a freshly created invoice. Resolves once it is live. */
async function armInvoiceWatch(bolt11: string, paymentHash: string): Promise<void> {
const startedAt = Date.now()
const watch: InvoiceWatch = {
paymentHash,
subId: null,
settled: null,
consumer: null,
poll: null,
released: false,
}
invoiceWatches.set(bolt11, watch)
try {
// walletId omitted: payment_hash is the natural primary key for "wait
// for THIS invoice to settle." Under path B
// (NOSTR_TRANSPORT_ROSTER_REQUIRED=true) lnbits routes the payment to
// the operator's wallet, so a subscription scoped to the ATM's
// pre-override wallet_id would AND-filter the settlement out and never
// fire. With wallet_id omitted, lnbits resolves the wallet from
// get_standalone_payment(payment_hash) and ownership-checks against the
// auth'd account — works on both pre/post-override wallets.
// Coordination log 2026-05-31T18:50Z (lnbits) for the confirmation,
// §18:35Z for the joint smoke that surfaced the bug.
watch.subId = await lnbits.subscribePayments(
undefined,
{ payment_hash: paymentHash, max_seconds: 600 },
(push) => {
if (push.payment_hash !== paymentHash || push.status !== 'success') return
settleInvoiceWatch(watch, push.preimage ?? 'payment-confirmed', 'LNbits push')
},
(reason) => console.log(`[ATM Service] Settlement subscription closed (${reason})`)
)
console.log(
`[ATM Service] Settlement watch armed in ${Date.now() - startedAt} ms ` +
`(hash ${paymentHash.slice(0, 12)}…)`
)
} catch (e) {
// The poll below then carries settlement on its own, which is exactly
// why it runs whether or not the subscription came up.
console.error('[ATM Service] Settlement subscribe failed — polling only:', e)
}
startInvoiceWatchPoll(watch)
}
/** Tear a watch down: the transaction ended, one way or another. */
function releaseInvoiceWatch(bolt11: string): void {
const watch = invoiceWatches.get(bolt11)
if (!watch) return
watch.released = true
watch.consumer = null
stopInvoiceWatchPoll(watch)
invoiceWatches.delete(bolt11)
if (watch.subId) {
// wallet_id omitted to match the subscribePayments call above.
void lnbits.unsubscribe(undefined, watch.subId).catch(() => {})
}
}
return { return {
/** /**
* 3b.4: ndebit cash-in path removed. CashInView.vue ignores this * 3b.4: ndebit cash-in path removed. CashInView.vue ignores this
@ -661,57 +891,70 @@ function createATMServices(
* over nostr, we trigger dispense. * over nostr, we trigger dispense.
*/ */
generateLnurlWithdraw: async (context: ATMContext): Promise<string> => { generateLnurlWithdraw: async (context: ATMContext): Promise<string> => {
console.log('[ATM Service] Generating LNURL-withdraw for', context.satsAmount, 'sats') // GROSS principal (fiat × rate, BEFORE commission). The server derives
// fee + NET from this, so we must NOT send the already-fee'd
// context.satsAmount — doing so double-applies the commission (client
// subtracts it in calculateSats, then the server subtracts it again,
// e.g. 12% → 22.6% effective; the customer is short-changed while the
// quote/receipt still read 12%). Mirror calculateSats's principal.
const grossPrincipalSats = Math.floor((context.fiatCents / 100) * context.exchangeRate)
console.log(
`[ATM Service] Generating LNURL-withdraw: gross principal=${grossPrincipalSats} sats ` +
`(net after ${(context.feeFraction * 100).toFixed(2)}% ≈ ${context.satsAmount})`
)
try { try {
if (context.cashInSessionId) { if (context.cashInSessionId) {
invalidateLnurlSessionBySessionId(context.cashInSessionId) invalidateLnurlSessionBySessionId(context.cashInSessionId)
} }
const link = await lnbits.createWithdrawLink(lnbitsWalletId, { // Secure cash-in: the ATM sends only the hardware-attested gross
// principal; the operator side verifies the signer, derives fee + NET,
// and stamps attribution (spirekeeper#31/#32). The ATM no longer sets
// the amount or extra. We display the returned LNURL (for NET) and
// watch link_id for settlement.
const link = await lnbits.createWithdraw(lnbitsWalletId, {
principal_sats: grossPrincipalSats,
fiat_amount: context.fiatCents / 100,
fiat_code: context.currency,
title: `bitSpire Cash-In ${context.cashInSessionId?.slice(0, 8) || 'session'}`, title: `bitSpire Cash-In ${context.cashInSessionId?.slice(0, 8) || 'session'}`,
min_withdrawable: context.satsAmount, client_ref: context.txid ?? context.cashInSessionId ?? undefined,
max_withdrawable: context.satsAmount,
uses: 1,
wait_time: 1,
is_unique: false,
}) })
if (!link.lnurl) { if (!link.lnurl) {
throw new Error( throw new Error(
'[ATM Service] LNbits returned link.lnurl=null — check LNBITS_BASEURL on the server (aiolabs/withdraw#1)' '[ATM Service] create_withdraw returned no lnurl — check withdraw#3 / LNBITS_BASEURL on the server'
) )
} }
const lnurl = link.lnurl.toUpperCase() const lnurl = link.lnurl.toUpperCase()
console.log(
`[ATM Service] create_withdraw: principal=${link.principal_sats} fee=${link.fee_sats} net=${link.net_sats} link=${link.link_id}`
)
if (context.cashInSessionId) { if (context.cashInSessionId) {
registerLnurlSession( // Track the NET (what the customer withdraws); keyed by link_id.
context.cashInSessionId, registerLnurlSession(context.cashInSessionId, link.link_id, link.net_sats)
link.id,
link.unique_hash,
context.satsAmount,
)
const subId = await lnbits.subscribePayments( const subId = await lnbits.subscribePayments(
lnbitsWalletId, lnbitsWalletId,
{ tag: 'withdraw', link_id: link.id, max_seconds: 600 }, { tag: 'withdraw', link_id: link.link_id, max_seconds: 600 },
(push) => { (push) => {
console.log('[ATM Service] LNURL-withdraw claimed (LNbits push)!') console.log('[ATM Service] LNURL-withdraw claimed (LNbits push)!')
const session = lnurlSessions.get(link.unique_hash) const session = lnurlSessions.get(link.link_id)
if (session) { if (session) {
session.status = 'claimed' session.status = 'claimed'
lnurlSessions.delete(link.unique_hash) lnurlSessions.delete(link.link_id)
} }
if (onPaymentCallback) { if (onPaymentCallback) {
onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.unique_hash}`) onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.link_id}`)
} }
}, }
) )
// Wire per-session cleanup so abort/expiry tears it down cleanly. // Wire per-session cleanup so abort/expiry tears it down cleanly.
const session = lnurlSessions.get(link.unique_hash) const session = lnurlSessions.get(link.link_id)
if (session) { if (session) {
session.cleanup = () => { session.cleanup = () => {
void lnbits.unsubscribe(lnbitsWalletId, subId).catch(() => {}) void lnbits.unsubscribe(lnbitsWalletId, subId).catch(() => {})
void lnbits.deleteWithdrawLink(lnbitsWalletId, link.id).catch(() => {}) void lnbits.deleteWithdrawLink(lnbitsWalletId, link.link_id).catch(() => {})
} }
} }
} }
@ -747,15 +990,14 @@ function createATMServices(
* matches machine fiat_code * matches machine fiat_code
* - `type: "cash_out"` / `source: "bitspire"` — discriminators * - `type: "cash_out"` / `source: "bitspire"` — discriminators
*/ */
// (see armInvoiceWatch below — the watch is armed before this resolves)
generateInvoice: async (context: ATMContext): Promise<string> => { generateInvoice: async (context: ATMContext): Promise<string> => {
const amountSats = context.satsAmount const amountSats = context.satsAmount
// Cash-out: satsAmount = principal + commission. principal is // Cash-out: satsAmount = principal + commission. principal is
// derived from the raw market rate (no commission baked in) so a // derived from the raw market rate (no commission baked in) so a
// consumer can independently audit the split. // consumer can independently audit the split.
const principalSats = const principalSats =
context.exchangeRate > 0 context.exchangeRate > 0 ? Math.floor((context.fiatCents / 100) * context.exchangeRate) : 0
? Math.floor((context.fiatCents / 100) * context.exchangeRate)
: 0
const feeSats = Math.max(0, amountSats - principalSats) const feeSats = Math.max(0, amountSats - principalSats)
console.log( console.log(
'[ATM Service] Generating invoice — gross', '[ATM Service] Generating invoice — gross',
@ -795,6 +1037,17 @@ function createATMServices(
if (!payment.payment_request) { if (!payment.payment_request) {
throw new Error('LNbits createInvoice returned empty payment_request') throw new Error('LNbits createInvoice returned empty payment_request')
} }
// Arm the settlement watch BEFORE the invoice reaches the screen, and
// take the payment hash from the response we already have rather than
// spending a round trip decoding it back off the bolt11. See
// armInvoiceWatch for why the timing matters.
if (payment.payment_hash) {
await armInvoiceWatch(payment.payment_request, payment.payment_hash)
} else {
console.error(
'[ATM Service] createInvoice returned no payment_hash — settlement watch will arm late'
)
}
return payment.payment_request return payment.payment_request
}, },
@ -847,7 +1100,7 @@ function createATMServices(
* Dispense cash (mock for development) * Dispense cash (mock for development)
*/ */
dispenseCash: async (amounts) => { dispenseCash: async (amounts) => {
console.log('[ATM Service] Dispensing cash:', amounts) console.log(`[ATM Service] Dispensing cash: ${formatBays(amounts)}`)
// In production, this would interface with the Rust HAL // In production, this would interface with the Rust HAL
// For now, simulate dispense delay // For now, simulate dispense delay
@ -860,7 +1113,7 @@ function createATMServices(
dispensed: a.count, dispensed: a.count,
rejected: 0, rejected: 0,
})), })),
dispensed: true, dispenseConfirmed: true,
} }
}, },
@ -960,16 +1213,34 @@ function createATMServices(
* Watch a BOLT11 invoice for payment via LNbits subscribe_payments * Watch a BOLT11 invoice for payment via LNbits subscribe_payments
* push, filtered by payment_hash. Returns a cleanup function. * push, filtered by payment_hash. Returns a cleanup function.
*/ */
watchInvoice: (invoice: string, callback: (preimage: string) => void): (() => void) => { watchInvoice: (
console.log('[ATM Service] Watching invoice for payment:', invoice.slice(0, 32) + '...') invoice: string,
callback: (preimage: string, paymentHash?: string) => void
): (() => void) => {
if (!invoice.toLowerCase().startsWith('ln')) { if (!invoice.toLowerCase().startsWith('ln')) {
console.error('[ATM Service] Invalid invoice format - expected BOLT11') console.error('[ATM Service] Invalid invoice format - expected BOLT11')
return () => {} return () => {}
} }
console.log('[ATM Service] Watching invoice for payment:', invoice.slice(0, 32) + '...')
const armed = invoiceWatches.get(invoice)
if (armed) {
armed.consumer = callback
// Settled between arming and display (a one-tap card pull can beat the
// state transition): replay the latched settlement instead of waiting
// on a push that has already come and gone.
if (armed.settled) {
const preimage = armed.settled
queueMicrotask(() => callback(preimage, armed.paymentHash))
}
return () => releaseInvoiceWatch(invoice)
}
// No armed watch: an invoice this service didn't create. Recover the
// hash over the wire and arm now. This is the pre-2026-09-22 behaviour
// and carries the race that arming-at-creation fixes, so say so.
console.warn('[ATM Service] No armed settlement watch for this invoice — arming late')
let cancelled = false let cancelled = false
let subId: string | null = null
;(async () => { ;(async () => {
try { try {
const decoded = await lnbits.decodePayment(invoice) const decoded = await lnbits.decodePayment(invoice)
@ -979,36 +1250,18 @@ function createATMServices(
return return
} }
if (cancelled) return if (cancelled) return
// walletId omitted: payment_hash is the natural primary key for await armInvoiceWatch(invoice, paymentHash)
// "wait for THIS invoice to settle." Under path B const late = invoiceWatches.get(invoice)
// (NOSTR_TRANSPORT_ROSTER_REQUIRED=true) lnbits routes the payment if (!late || cancelled) return
// to the operator's wallet, so a subscription scoped to the ATM's late.consumer = callback
// pre-override wallet_id would AND-filter the settlement out and if (late.settled) callback(late.settled, late.paymentHash)
// never fire. With wallet_id omitted, lnbits resolves the wallet
// from get_standalone_payment(payment_hash) and ownership-checks
// against the auth'd account — works on both pre/post-override
// wallets. Coordination log 2026-05-31T18:50Z (lnbits) for the
// confirmation, §18:35Z for the joint smoke that surfaced the bug.
subId = await lnbits.subscribePayments(
undefined,
{ payment_hash: paymentHash, max_seconds: 600 },
(push) => {
if (push.payment_hash !== paymentHash) return
if (push.status !== 'success') return
console.log('[ATM Service] Invoice paid (LNbits push)!')
callback(push.preimage ?? 'payment-confirmed')
},
)
} catch (e) { } catch (e) {
console.error('[ATM Service] LNbits watchInvoice failed:', e) console.error('[ATM Service] LNbits watchInvoice failed:', e)
} }
})() })()
return () => { return () => {
cancelled = true cancelled = true
if (subId) { releaseInvoiceWatch(invoice)
// wallet_id omitted to match the subscribePayments call above.
void lnbits.unsubscribe(undefined, subId).catch(() => {})
}
} }
}, },
@ -1027,7 +1280,7 @@ function createATMServices(
20: 50, // 50 x $20 bills = $1000 capacity 20: 50, // 50 x $20 bills = $1000 capacity
} }
console.log('[ATM Service] Inventory:', inventory) console.log(`[ATM Service] Inventory: ${formatInventory(inventory)}`)
return inventory return inventory
}, },
} }

View file

@ -1,34 +1,39 @@
/** /**
* Operator-config consumer (aiolabs/lamassu-next#56). * Operator-config consumer (aiolabs/lamassu-next#56, v2 per bitspire ADR-004).
* *
* Subscribes to operator-published kind-30078 events carrying cassette * Subscribes to operator-published kind-30078 events carrying cassette
* config updates, validates + applies them to state.db, and hot-reloads * OPERATIONS — a refill, an empty, a recount, a denomination change —
* the HAL dispenser. Also publishes a one-shot ATM-state hello-event on * applies the ones it has not already seen, and hot-reloads the HAL
* first boot so the operator dashboard (satmachineadmin) can auto-populate * dispenser. It also publishes this machine's cassette state, which is
* `cassette_configs` rows for this machine. * what populates the operator dashboard's bay rows.
*
* The operator used to publish absolute counts and this machine applied them
* outright. Both sides wrote the same value over a transport that never tells
* a writer it lost: a dashboard form loaded before a dispense silently
* discarded that dispense, and neither side could detect it. This machine now
* owns the count — it holds the notes — and the operator says what it did.
* *
* Architecture (see ~/dev/coordination/log.md entries on 2026-05-30): * Architecture (see ~/dev/coordination/log.md entries on 2026-05-30):
* *
* - Operator → ATM: `kind=30078`, `["d", "bitspire-cassettes:<machine_id>"]`, * - Operator → ATM: `kind=30078`, `["d", "bitspire-cassettes:<machine_id>"]`,
* `["p", <atm_npub>]`, NIP-44 v2 encrypted content, author = operator pubkey * `["p", <atm_npub>]`, NIP-44 v2 encrypted content, author = operator pubkey
* - ATM bootstrap: `kind=30078`, `["d", "bitspire-cassettes-state:<machine_id>"]`, * - ATM state: `kind=30078`, `["d", "bitspire-cassettes-state:<machine_id>"]`,
* `["p", <operator_pubkey>]`, NIP-44 v2 encrypted content, author = ATM pubkey * `["p", <operator_pubkey>]`, NIP-44 v2 encrypted content, author = ATM pubkey
* *
* The ATM's hex pubkey serves as `<machine_id>` — globally unique, no * The ATM's hex pubkey serves as `<machine_id>` — globally unique, no
* extra provisioning step required. * extra provisioning step required.
* *
* v1 only publishes the one-shot bootstrap hello-event. The continuous * The ATM publishes its state on startup, after every change to the bays, and
* ATM-state reverse channel (publish on every count change + heartbeat) * on a heartbeat. It was once a single hello-event gated on a one-shot flag,
* is v2 territory. * which left the operator validating against a layout the machine no longer
* had (#94), and left a dispense published during a relay outage lost for good.
*/ */
import { import {
type MachineIdentity, type Signer,
type NostrClient, type NostrClient,
type Event, type Event,
createSignedEvent, createSignedEvent,
decryptContentV2,
encryptContentV2,
validateEvent, validateEvent,
} from '@bitSpire/nostr-client' } from '@bitSpire/nostr-client'
@ -36,9 +41,62 @@ import type {} from '@/types/electron'
const KIND_NIP78 = 30078 const KIND_NIP78 = 30078
/** The wire schema this machine speaks. Operations, not counts (ADR-004). */
const CASSETTE_SCHEMA_VERSION = 2
/** One operator-authored cassette operation, as it arrives on the wire. */
type CassetteOp = {
id: string
at: number
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
position: number
bills?: number
count?: number
denomination?: number
}
/**
* ADR-005 §5: the operator releases a cash-out hold without touching a bay
* count. Rides the same operator event as the cassette ops (same id/at shape)
* but is NOT a cassette op: it never reaches `applyOperatorCassetteOps`, which
* would reject the type. Honoured only when stamped AFTER the hold began, so
* a re-delivered resume from before a fresh fault cannot clear that fault.
* A `recount` releases the hold too — it is the same "operator at the open
* machine" gesture and already clears counts-uncertain.
*/
type ResumeCashOutOp = { id: string; at: number; type: 'resume_cash_out' }
/**
* ADR-005 §6: the operator paid the customer by hand, off-machine, for a
* cash-out this machine recorded as dispense_error / partial. Flips that row
* to `remediated` with the note as provenance so both ledgers close on one
* act. Idempotent by nature — remediateTransaction only touches rows still
* in an error state — so re-delivery is harmless.
*/
type SettleTransactionOp = {
id: string
at: number
type: 'settle_transaction'
txid: string
note?: string
}
type OperatorOp = CassetteOp | ResumeCashOutOp | SettleTransactionOp
/** Accept operator events stamped up to this many seconds in the future. */ /** Accept operator events stamped up to this many seconds in the future. */
const MAX_FUTURE_SKEW_S = 60 const MAX_FUTURE_SKEW_S = 60
/**
* Republish the cassette state on this interval even when nothing changed.
*
* A publish is a single fire-and-forget event with no retry: if the relay is
* unreachable at the moment of a dispense, that update is simply gone and the
* operator's view stays wrong until the next customer happens to buy cash. A
* relay also acknowledges an event it then discards, so a publish that returns
* cleanly is not proof of anything. The heartbeat is what makes the channel
* self-healing, and it is also the only way an out-of-band edit to the table
* (atm-tui, direct SQL) ever reaches the operator.
*/
const STATE_HEARTBEAT_MS = 5 * 60 * 1000
const operatorConfigDTag = (machineId: string) => `bitspire-cassettes:${machineId}` const operatorConfigDTag = (machineId: string) => `bitspire-cassettes:${machineId}`
const atmStateDTag = (machineId: string) => `bitspire-cassettes-state:${machineId}` const atmStateDTag = (machineId: string) => `bitspire-cassettes-state:${machineId}`
@ -47,17 +105,34 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
export interface OperatorConfigServiceConfig { export interface OperatorConfigServiceConfig {
/** Connected NostrClient — shared with the Lightning service. */ /** Connected NostrClient — shared with the Lightning service. */
nostrClient: NostrClient nostrClient: NostrClient
/** ATM's nostr identity. Used to decrypt operator events + sign the bootstrap. */ /** Signer for the ATM identity. Decrypts operator events + signs our state. */
identity: MachineIdentity signer: Signer
/** Operator pubkeys (hex) authorized to publish cassette config. From VITE_OPERATOR_PUBKEYS. */ /** Operator pubkeys (hex) authorized to publish cassette config. From VITE_OPERATOR_PUBKEYS. */
operatorPubkeys: string[] operatorPubkeys: string[]
/** Machine identifier for the d-tag. Defaults to identity.publicKey when omitted. */ /** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */
machineId?: string machineId?: string
/**
* ADR-005 §5: called when an operator op (recount, resume_cash_out) has
* released a persisted cash-out hold, so the store can lift the state
* machine's latch. The store wires this to `CASH_OUT_RELEASED`.
*/
onCashOutHoldReleased?: () => void
} }
export interface OperatorConfigService { export interface OperatorConfigService {
/** Unsubscribe from operator events and free resources. */ /** Unsubscribe from operator events and free resources. */
stop(): void stop(): void
/**
* Republish the current cassette state (kind-30078, replaceable). Call after
* a dispense and on a cassette reload so the operator's view tracks reality.
* Best-effort — logs and swallows errors.
*/
publishCassettesState(): Promise<void>
}
const NOOP_SERVICE: OperatorConfigService = {
stop: () => {},
publishCassettesState: async () => {},
} }
export async function startOperatorConfigService( export async function startOperatorConfigService(
@ -65,21 +140,24 @@ export async function startOperatorConfigService(
): Promise<OperatorConfigService> { ): Promise<OperatorConfigService> {
if (cfg.operatorPubkeys.length === 0) { if (cfg.operatorPubkeys.length === 0) {
console.log('[OperatorConfig] No operator pubkeys configured — service disabled') console.log('[OperatorConfig] No operator pubkeys configured — service disabled')
return { stop: () => {} } return NOOP_SERVICE
} }
if (!isElectron || !window.electronAPI) { if (!isElectron || !window.electronAPI) {
console.log('[OperatorConfig] Not in Electron — service disabled (browser dev mode)') console.log('[OperatorConfig] Not in Electron — service disabled (browser dev mode)')
return { stop: () => {} } return NOOP_SERVICE
} }
const api = window.electronAPI const api = window.electronAPI
const machineId = cfg.machineId ?? cfg.identity.publicKey const machineId = cfg.machineId ?? cfg.signer.pubkey
// Bootstrap hello-event on first boot (best-effort — failure leaves the // Announce current state on every start. This used to be gated on a
// gate null so the next boot retries). // one-shot "have we said hello" flag, so any later change to the layout —
// a reseed, an atm-tui edit, direct SQL — was never published and the
// operator's dashboard kept validating against a bay set that no longer
// existed (#94). Best-effort; the heartbeat below is the safety net.
try { try {
await maybePublishBootstrap(cfg, api, machineId) await publishCassettesState(cfg, api, machineId)
} catch (err) { } catch (err) {
console.warn('[OperatorConfig] Bootstrap publish failed (will retry next boot):', err) console.warn('[OperatorConfig] Startup cassettes-state publish failed:', err)
} }
// Subscribe to operator-published cassette config events. // Subscribe to operator-published cassette config events.
@ -88,7 +166,7 @@ export async function startOperatorConfigService(
[ [
{ {
kinds: [KIND_NIP78], kinds: [KIND_NIP78],
'#p': [cfg.identity.publicKey], '#p': [cfg.signer.pubkey],
'#d': [dTag], '#d': [dTag],
authors: cfg.operatorPubkeys, authors: cfg.operatorPubkeys,
}, },
@ -101,10 +179,25 @@ export async function startOperatorConfigService(
}, },
} }
) )
console.log('[OperatorConfig] Subscribed:', { dTag, subscriptionId }) console.log(`[OperatorConfig] Subscribed: d=${dTag} sub=${subscriptionId}`)
const heartbeat = setInterval(() => {
publishCassettesState(cfg, api, machineId).catch((err) =>
console.warn('[OperatorConfig] cassettes-state heartbeat failed:', err)
)
}, STATE_HEARTBEAT_MS)
return { return {
stop: () => cfg.nostrClient.unsubscribe(subscriptionId), stop: () => {
clearInterval(heartbeat)
cfg.nostrClient.unsubscribe(subscriptionId)
},
publishCassettesState: () =>
publishCassettesState(cfg, api, machineId)
.then(() => {})
.catch((err) => {
console.warn('[OperatorConfig] cassettes-state republish failed:', err)
}),
} }
} }
@ -124,17 +217,13 @@ async function handleOperatorConfigEvent(
return return
} }
// 2. Replay protection — drop stale events. NIP-78 replaceable events // 2. There is deliberately no `created_at` watermark here any more.
// DO get re-delivered on reconnect/restart; without this check, the // Under absolute counts it was the only replay defence, and it cost us:
// ATM would re-apply the same payload on every boot and clobber any // an event re-delivered out of order was dropped whole, operations
// cash-out decrements that landed between operator publishes. // included. Idempotency now rides on the operations themselves — the
const watermark = await api.getLastKnownConfigCreatedAt() // operator mints an id per op and this machine records the ones it
if (event.created_at <= watermark) { // applied — which is strictly stronger, because it survives an event
console.log( // that mixes operations we have seen with ones we have not.
`[OperatorConfig] Stale event dropped (created_at=${event.created_at} <= watermark=${watermark})`
)
return
}
// 3. Clock-skew defense — reject events stamped too far in the future. // 3. Clock-skew defense — reject events stamped too far in the future.
// Limits damage from a leaked operator nsec future-stamping a fake // Limits damage from a leaked operator nsec future-stamping a fake
@ -148,30 +237,89 @@ async function handleOperatorConfigEvent(
} }
// 4. Decrypt content (NIP-44 v2). // 4. Decrypt content (NIP-44 v2).
let parsed: { positions: Record<string, { denomination: number; count: number }> } let parsed: { schema_version?: number; ops?: unknown }
try { try {
const plaintext = decryptContentV2(cfg.identity, event.pubkey, event.content) const plaintext = await cfg.signer.nip44Decrypt(event.pubkey, event.content)
parsed = JSON.parse(plaintext) as typeof parsed parsed = JSON.parse(plaintext) as typeof parsed
} catch (err) { } catch (err) {
console.error('[OperatorConfig] Decrypt/parse failed:', err) console.error('[OperatorConfig] Decrypt/parse failed:', err)
return return
} }
if (!parsed || typeof parsed !== 'object' || !parsed.positions) { if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.ops)) {
console.error('[OperatorConfig] Payload missing `positions` field') // A v1 operator publishing absolute counts lands here and is ignored.
// That direction fails safe: the machine keeps its own counts, which it
// is now the only writer of, and simply will not dispense notes it
// believes it lacks. The opposite — applying a count from a form loaded
// before a dispense — is what ADR-004 exists to stop.
console.error('[OperatorConfig] Payload missing `ops` array — dropped')
return return
} }
const allOps = parsed.ops as OperatorOp[]
// 5. Atomic apply (cassettes + meta watermark) via IPC. The state-store // 4b. ADR-005 §5 — split out resume_cash_out before the cassette apply.
// function re-validates watermark + position key-set equality + // Release only if a resume is stamped after the hold began; an idempotent
// per-entry types inside the SQLite transaction. Duplicate // re-delivery of an older resume must not clear a newer fault.
// denominations across positions are allowed — real machines load const holdBefore = await api.getCashOutHold()
// N cassettes of the same denomination for cash-out throughput. const resumeOps = allOps.filter(
const result = await api.applyOperatorCassettesConfig( (o): o is ResumeCashOutOp => !!o && o.type === 'resume_cash_out'
{ positions: parsed.positions },
event.created_at
) )
if (!result.applied) { const settleOps = allOps.filter(
console.warn('[OperatorConfig] Apply rejected:', result.reason) (o): o is SettleTransactionOp =>
!!o && o.type === 'settle_transaction' && typeof (o as SettleTransactionOp).txid === 'string'
)
for (const op of settleOps) {
const provenance = `settled-off-machine:${op.id}${op.note ? `:${op.note}` : ''}`
const changed = await api.remediateTransaction(op.txid, provenance)
console.log(
`[OperatorConfig] settle_transaction ${op.id} for ${op.txid}: ` +
(changed ? 'row marked remediated' : 'no row in an error state (already closed, or unknown)')
)
}
const ops = allOps.filter(
(o): o is CassetteOp =>
!!o && o.type !== 'resume_cash_out' && o.type !== 'settle_transaction'
)
if (holdBefore && resumeOps.some((o) => typeof o.at === 'number' && o.at > holdBefore.since)) {
await api.clearCashOutHold()
console.log(
`[OperatorConfig] Cash-out hold released by operator resume op ` +
`(held since ${holdBefore.since}, ${resumeOps.length} resume op(s))`
)
} else if (resumeOps.length > 0) {
console.log(
`[OperatorConfig] ${resumeOps.length} resume_cash_out op(s) ignored — ` +
(holdBefore ? 'all stamped before the current hold began' : 'no hold in place')
)
}
// 5. Apply the ones we have not seen, in one transaction with the sequence
// bump. No `created_at` watermark: each op carries an operator-minted id
// and the machine records what it applied, so a re-delivered event is a
// no-op on its own merits. The watermark would be strictly weaker and
// actively harmful — an event arriving out of order can still carry an
// operation this machine has never seen.
const result = ops.length
? await api.applyOperatorCassetteOps(ops)
: { applied: [] as string[], rejected: [] as { id: string; reason: string }[] }
for (const bad of result.rejected) {
console.warn(`[OperatorConfig] Op ${bad.id} rejected: ${bad.reason}`)
}
// A recount (applied in the store, which also clears the hold) or the resume
// above may have released the latch: tell the store so the state machine
// lifts its guard. The republishes below carry the cleared state up.
if (holdBefore && (await api.getCashOutHold()) === null) {
cfg.onCashOutHoldReleased?.()
}
if (result.applied.length === 0) {
console.log(`[OperatorConfig] No new ops in event ${event.id.slice(0, 12)}…`)
// Still republish: the operator learns from our applied_ops echo that
// earlier operations landed, and an event carrying nothing new can be the
// first one we successfully answer after a relay outage.
const machineIdNoop = cfg.machineId ?? cfg.signer.pubkey
await publishCassettesState(cfg, api, machineIdNoop).catch((err) =>
console.warn('[OperatorConfig] post-apply cassettes-state republish failed:', err)
)
return return
} }
@ -179,8 +327,8 @@ async function handleOperatorConfigEvent(
// picks up the new per-position mapping. state.db is already updated; // picks up the new per-position mapping. state.db is already updated;
// HAL re-init failure means the renderer's persistedInventory may be // HAL re-init failure means the renderer's persistedInventory may be
// ahead of the HAL until next service restart — log loudly but don't // ahead of the HAL until next service restart — log loudly but don't
// unwind the state.db apply (the operator wants their config landed; // unwind the state.db apply (the operation happened physically; HAL
// HAL can catch up). // can catch up).
const cassettesAfter = await api.loadCassettes() const cassettesAfter = await api.loadCassettes()
const halResult = await api.halReloadCassettes( const halResult = await api.halReloadCassettes(
cassettesAfter.map((c) => ({ cassettesAfter.map((c) => ({
@ -192,51 +340,97 @@ async function handleOperatorConfigEvent(
if (!halResult.ok) { if (!halResult.ok) {
console.error('[OperatorConfig] HAL reload failed:', halResult.error) console.error('[OperatorConfig] HAL reload failed:', halResult.error)
} }
console.log( console.log(`[OperatorConfig] Applied ops: ${result.applied.join(', ')}`)
`[OperatorConfig] Applied — created_at=${event.created_at}, positions=${Object.keys(parsed.positions).join(',')}`
// Republish our resulting cassette state so the operator's view reflects the
// applied config (the "on cassette reload" case). Different d-tag from the
// operator's config event, so no echo loop. Best-effort.
const machineId = cfg.machineId ?? cfg.signer.pubkey
await publishCassettesState(cfg, api, machineId).catch((err) =>
console.warn('[OperatorConfig] post-apply cassettes-state republish failed:', err)
) )
} }
async function maybePublishBootstrap( /**
* Publish the ATM's current cassette state as a replaceable kind-30078 event
* (`bitspire-cassettes-state:<machineId>`), NIP-44-encrypted to the operator.
* Replaceable → latest wins; the operator consumes every update. Call after a
* dispense, on a cassette reload, at startup and on a heartbeat, so the
* operator view tracks reality (coord 2026-06-21 / lamassu-next#56).
*
* Returns whether an event was published (false when there are no cassettes /
* no operator).
*/
async function publishCassettesState(
cfg: OperatorConfigServiceConfig, cfg: OperatorConfigServiceConfig,
api: NonNullable<typeof window.electronAPI>, api: NonNullable<typeof window.electronAPI>,
machineId: string machineId: string
): Promise<void> { ): Promise<boolean> {
const already = await api.getBootstrapPublishedAt()
if (already !== null) {
console.log('[OperatorConfig] Bootstrap already published at unix', already)
return
}
const cassettes = await api.loadCassettes() const cassettes = await api.loadCassettes()
if (cassettes.length === 0) { if (cassettes.length === 0) return false
console.log('[OperatorConfig] state.db.cassettes empty — skipping bootstrap')
return
}
const operatorPubkey = cfg.operatorPubkeys[0] const operatorPubkey = cfg.operatorPubkeys[0]
if (!operatorPubkey) { if (!operatorPubkey) return false
console.log('[OperatorConfig] No operator pubkey — skipping bootstrap')
return
}
const positions: Record<string, { denomination: number; count: number }> = {} const positions: Record<string, { denomination: number; count: number }> = {}
for (const c of cassettes) { for (const c of cassettes) {
positions[String(c.position)] = { denomination: c.denomination, count: c.count } positions[String(c.position)] = { denomination: c.denomination, count: c.count }
} }
const ciphertext = encryptContentV2(cfg.identity, operatorPubkey, { positions }) // Additive field: an operator on the old consumer reads `positions` and
// ignores this, so it needs no coordinated release. When set, the counts
// above are the machine's best guess, not a measurement.
const countsUncertainSince = await api.getCountsUncertainSince()
// `applied_ops` is the acknowledgement leg. An addressable event gives its
// publisher no failure signal — the relay returns OK for an event it then
// discards — so echoing the ids back is the only way the operator can tell
// an operation that landed from one that was merely sent. `seq` lets a
// reader reject a regression without trusting a clock: `created_at` has
// second granularity and ties break on event id, so it cannot order two
// reports from the same second.
const [appliedOps, seq] = await Promise.all([api.getAppliedOpIds(), api.getCassetteStateSeq()])
const payload: Record<string, unknown> = {
schema_version: CASSETTE_SCHEMA_VERSION,
positions,
seq,
applied_ops: appliedOps,
}
if (countsUncertainSince) payload.counts_uncertain_since = countsUncertainSince
// ADR-005 §5 — additive, same contract as counts_uncertain_since: an old
// consumer ignores it. When present, this machine is refusing cash-out
// until an operator recount or resume_cash_out op.
const hold = await api.getCashOutHold()
if (hold) {
payload.cash_out_held_since = hold.since
payload.cash_out_held_reason = hold.reason
payload.cash_out_held_code = hold.rawCode ?? hold.errorCode ?? null
}
const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify(payload))
// Force the stamp strictly above our last one. Addressable events are ordered
// by `created_at` at second granularity, ties broken by lowest event id, and
// the relay keeps one and silently drops the other while acknowledging both.
// So two publishes inside one second would leave the winner decided by a hash,
// permanently — and a clock that stepped backwards would make every report
// from this machine disappear. Neither failure is visible from here.
const lastPublished = (await api.getLastStatePublishedAt()) ?? 0
const createdAt = Math.max(Math.floor(Date.now() / 1000), lastPublished + 1)
const dTag = atmStateDTag(machineId) const dTag = atmStateDTag(machineId)
const event = createSignedEvent(cfg.identity, { const event = await createSignedEvent(cfg.signer, {
kind: KIND_NIP78, kind: KIND_NIP78,
content: ciphertext, content: ciphertext,
tags: [ tags: [
['d', dTag], ['d', dTag],
['p', operatorPubkey], ['p', operatorPubkey],
], ],
created_at: Math.floor(Date.now() / 1000), created_at: createdAt,
}) })
await cfg.nostrClient.publish(event) await cfg.nostrClient.publish(event)
await api.markBootstrapPublished(Math.floor(Date.now() / 1000)) await api.markStatePublished(createdAt)
console.log('[OperatorConfig] Bootstrap hello-event published:', { dTag, eventId: event.id }) console.log(
`[OperatorConfig] cassettes-state published: id=${event.id.slice(0, 12)}… ` +
`created_at=${createdAt} seq=${seq} applied_ops=${appliedOps.length} d=${dTag}`
)
return true
} }

View file

@ -56,10 +56,9 @@
*/ */
import { import {
type MachineIdentity, type Signer,
type NostrClient, type NostrClient,
type Event, type Event,
decryptContentV2,
validateEvent, validateEvent,
} from '@bitSpire/nostr-client' } from '@bitSpire/nostr-client'
@ -80,11 +79,11 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
export interface OperatorFeesServiceConfig { export interface OperatorFeesServiceConfig {
/** Connected NostrClient — shared with the Lightning service. */ /** Connected NostrClient — shared with the Lightning service. */
nostrClient: NostrClient nostrClient: NostrClient
/** ATM's nostr identity. Used to decrypt operator events. */ /** Signer for the ATM identity. Decrypts operator events. */
identity: MachineIdentity signer: Signer
/** Operator pubkeys (hex) authorized to publish fee config. From VITE_OPERATOR_PUBKEYS. */ /** Operator pubkeys (hex) authorized to publish fee config. From VITE_OPERATOR_PUBKEYS. */
operatorPubkeys: string[] operatorPubkeys: string[]
/** Machine identifier for the d-tag. Defaults to identity.publicKey when omitted. */ /** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */
machineId?: string machineId?: string
/** /**
* Called when a valid fee-config event is applied. Renderer should * Called when a valid fee-config event is applied. Renderer should
@ -112,7 +111,7 @@ export async function startOperatorFeesService(
return { stop: () => {} } return { stop: () => {} }
} }
const api = window.electronAPI const api = window.electronAPI
const machineId = cfg.machineId ?? cfg.identity.publicKey const machineId = cfg.machineId ?? cfg.signer.pubkey
// Subscribe to operator-published fee config events. // Subscribe to operator-published fee config events.
const dTag = feeConfigDTag(machineId) const dTag = feeConfigDTag(machineId)
@ -120,7 +119,7 @@ export async function startOperatorFeesService(
[ [
{ {
kinds: [KIND_NIP78], kinds: [KIND_NIP78],
'#p': [cfg.identity.publicKey], '#p': [cfg.signer.pubkey],
'#d': [dTag], '#d': [dTag],
authors: cfg.operatorPubkeys, authors: cfg.operatorPubkeys,
}, },
@ -133,7 +132,7 @@ export async function startOperatorFeesService(
}, },
} }
) )
console.log('[Fees] Subscribed:', { dTag, subscriptionId }) console.log(`[Fees] Subscribed: d=${dTag} sub=${subscriptionId}`)
return { return {
stop: () => cfg.nostrClient.unsubscribe(subscriptionId), stop: () => cfg.nostrClient.unsubscribe(subscriptionId),
@ -189,7 +188,7 @@ async function handleFeeConfigEvent(
// fields (v2 forward-compat — future promo payloads). // fields (v2 forward-compat — future promo payloads).
let parsed: ParsedFeePayload let parsed: ParsedFeePayload
try { try {
const plaintext = decryptContentV2(cfg.identity, event.pubkey, event.content) const plaintext = await cfg.signer.nip44Decrypt(event.pubkey, event.content)
const raw = JSON.parse(plaintext) as Record<string, unknown> const raw = JSON.parse(plaintext) as Record<string, unknown>
parsed = parseV1Payload(raw) parsed = parseV1Payload(raw)
} catch (err) { } catch (err) {

View file

@ -0,0 +1,81 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { ingestScannedSeed } from '../ingest'
import { SPIRE_SEED_SCHEME } from '@bitSpire/nostr-client'
import { npubEncode } from 'nostr-tools/nip19'
/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */
function makeSeed(json: unknown): string {
const b64 = Buffer.from(JSON.stringify(json), 'utf8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
return SPIRE_SEED_SCHEME + b64
}
const SPIRE_PUBKEY = 'a'.repeat(64)
const VALID_SEED = makeSeed({
v: 1,
spire_npub: npubEncode(SPIRE_PUBKEY),
lnbits_npub: npubEncode('b'.repeat(64)),
bunker_secret: 'deadbeef',
relays: ['wss://events.relay/'],
})
describe('ingestScannedSeed', () => {
const originalWindow = globalThis.window
afterEach(() => {
globalThis.window = originalWindow
vi.restoreAllMocks()
})
it('rejects a non-seed scan without touching the bridge', async () => {
const saveSpireSeed = vi.fn()
globalThis.window = { electronAPI: { saveSpireSeed } } as unknown as Window & typeof globalThis
const result = await ingestScannedSeed('https://example.com/not-a-seed')
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe('invalid-seed')
expect(saveSpireSeed).not.toHaveBeenCalled()
})
it('reports no-bridge when Electron is absent', async () => {
globalThis.window = {} as unknown as Window & typeof globalThis
const result = await ingestScannedSeed(VALID_SEED)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe('no-bridge')
})
it('persists the seed and relaunches on a valid scan', async () => {
const saveSpireSeed = vi.fn().mockResolvedValue(undefined)
const relaunchApp = vi.fn().mockResolvedValue(undefined)
globalThis.window = {
electronAPI: { saveSpireSeed, relaunchApp },
} as unknown as Window & typeof globalThis
const result = await ingestScannedSeed(` ${VALID_SEED} `) // tolerate whitespace
expect(result.ok).toBe(true)
if (result.ok) expect(result.spirePubkey).toBe(SPIRE_PUBKEY)
expect(saveSpireSeed).toHaveBeenCalledWith(VALID_SEED)
expect(relaunchApp).toHaveBeenCalledOnce()
})
it('surfaces persist-failed when saveSpireSeed throws', async () => {
const saveSpireSeed = vi.fn().mockRejectedValue(new Error('EACCES'))
globalThis.window = { electronAPI: { saveSpireSeed } } as unknown as Window & typeof globalThis
const result = await ingestScannedSeed(VALID_SEED)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe('persist-failed')
})
})
describe('ingest does not pair in-renderer', () => {
it('never imports connect logic — persistence + relaunch only', () => {
// Guard: the design intentionally reuses the boot-time pairing path.
// If someone wires connectNewSeed here, this comment + the ingest source
// should be revisited together.
expect(ingestScannedSeed).toBeTypeOf('function')
})
})

View file

@ -0,0 +1,32 @@
/**
* Pairing module surface (aiolabs/bitspire#52).
*
* `availablePairingSources()` probes each known source and returns those the
* current device can actually run, in preference order (camera first, NFC if
* present). The wizard renders the first available source and offers the rest
* as alternates.
*/
import { QrPairingSource } from './qr-source'
import { NfcPairingSource } from './nfc-source'
import type { PairingSource } from './types'
export type { PairingSource, PairingSourceKind, PairingSourceStartOptions, StopCapture } from './types'
export { QrPairingSource } from './qr-source'
export { NfcPairingSource } from './nfc-source'
export { ingestScannedSeed, parseScannedSeed } from './ingest'
export type { IngestResult, SeedPreview } from './ingest'
export { testRelay } from './relay-test'
export type { RelayTestResult } from './relay-test'
/** All sources in preference order, regardless of availability. */
export function allPairingSources(): PairingSource[] {
return [new QrPairingSource(), new NfcPairingSource()]
}
/** Only the sources this device can run, in preference order. */
export async function availablePairingSources(): Promise<PairingSource[]> {
const sources = allPairingSources()
const flags = await Promise.all(sources.map((s) => s.isAvailable()))
return sources.filter((_, i) => flags[i])
}

View file

@ -0,0 +1,94 @@
/**
* Seed ingest pipeline (aiolabs/bitspire#52).
*
* Turns a raw scanned payload into a paired machine. The wizard captures a
* string off some PairingSource and hands it here; we:
* 1. validate it parses as a spire-seed (reject anything else — a QR on the
* counter, a URL, a different protocol),
* 2. persist it as VITE_SPIRE_SEED via the Electron bridge,
* 3. relaunch so the normal boot path (signer-resolver → connectNewSeed)
* performs the actual bunker pairing.
*
* We do NOT pair in-renderer here: persisting + relaunching reuses the single,
* hardware-tested pairing path rather than duplicating connect/redeem logic in
* the wizard. The trade-off is a ~kiosk-restart of latency, which is fine for a
* one-time provisioning step.
*/
import { parseSpireSeed, seedFingerprint } from '@bitSpire/nostr-client'
export type IngestResult =
| { ok: true; spirePubkey: string; fingerprint: string; relays: string[] }
| { ok: false; reason: 'invalid-seed' | 'no-bridge' | 'persist-failed'; message: string }
export type SeedPreview =
| { ok: true; spirePubkey: string; fingerprint: string; relays: string[] }
| { ok: false; reason: 'invalid-seed'; message: string }
/**
* Validate-only: parse a scanned payload as a spire-seed WITHOUT persisting or
* relaunching. The wizard uses this to show a review step (decoded relay + a
* "test relay" button) before committing, so a well-formed but unreachable
* relay is caught before the machine relaunches into a pairing crash-loop.
* `parseSpireSeed` already rejects a malformed relay (e.g. a QR misread of
* `ws://` → `As://`); this surfaces that as an invalid-seed rejection.
*/
export function parseScannedSeed(raw: string): SeedPreview {
const trimmed = (raw || '').trim()
try {
const seed = parseSpireSeed(trimmed)
return {
ok: true,
spirePubkey: seed.spirePubkey,
fingerprint: seedFingerprint(trimmed),
relays: seed.relays,
}
} catch (e) {
return {
ok: false,
reason: 'invalid-seed',
message: e instanceof Error ? e.message : 'Not a valid pairing code',
}
}
}
export async function ingestScannedSeed(raw: string): Promise<IngestResult> {
const trimmed = (raw || '').trim()
let spirePubkey: string
let relays: string[]
try {
const seed = parseSpireSeed(trimmed)
spirePubkey = seed.spirePubkey
relays = seed.relays
} catch (e) {
return {
ok: false,
reason: 'invalid-seed',
message: e instanceof Error ? e.message : 'Not a valid pairing code',
}
}
if (typeof window === 'undefined' || !window.electronAPI) {
return {
ok: false,
reason: 'no-bridge',
message: 'Pairing must run on the machine (no kiosk bridge available).',
}
}
try {
await window.electronAPI.saveSpireSeed(trimmed)
} catch (e) {
return {
ok: false,
reason: 'persist-failed',
message: e instanceof Error ? e.message : 'Could not save the pairing.',
}
}
// Fire-and-forget: the relaunch tears this process down.
void window.electronAPI.relaunchApp()
return { ok: true, spirePubkey, fingerprint: seedFingerprint(trimmed), relays }
}

View file

@ -0,0 +1,67 @@
/**
* NFC pairing source — SCAFFOLD (aiolabs/bitspire#52).
*
* The user flagged NFC as a plausible future pairing method (tap a tag/phone
* carrying the spire-seed). This wires the seam against the Web NFC API
* (`NDEFReader`) so a future build can light it up without reworking the
* wizard. It is NOT active on current hardware: Web NFC ships only on Chrome
* for Android, so `isAvailable()` returns false on the Sintra's Linux Electron
* and the wizard simply won't offer it.
*
* When real NFC hardware lands (likely a HAL peripheral rather than Web NFC),
* replace the body of `start()` with that driver — the PairingSource contract
* stays the same.
*/
import type { PairingSource, PairingSourceStartOptions, StopCapture } from './types'
// Minimal structural type for the Web NFC API (not in lib.dom for Electron).
interface NDEFReaderLike {
scan(): Promise<void>
addEventListener(
type: 'reading',
listener: (event: { message: { records: Array<{ recordType: string; data?: BufferSource }> } }) => void
): void
addEventListener(type: 'readingerror', listener: (event: unknown) => void): void
}
function getNDEFReaderCtor(): (new () => NDEFReaderLike) | null {
const ctor = (globalThis as { NDEFReader?: new () => NDEFReaderLike }).NDEFReader
return ctor ?? null
}
export class NfcPairingSource implements PairingSource {
readonly kind = 'nfc' as const
readonly label = 'NFC tap'
async isAvailable(): Promise<boolean> {
return getNDEFReaderCtor() !== null
}
async start(opts: PairingSourceStartOptions): Promise<StopCapture> {
const Ctor = getNDEFReaderCtor()
if (!Ctor) throw new Error('Web NFC unavailable on this device')
const reader = new Ctor()
const decoder = new TextDecoder()
let stopped = false
reader.addEventListener('reading', (event) => {
if (stopped) return
for (const record of event.message.records) {
if (record.recordType === 'text' && record.data) {
const raw = decoder.decode(record.data).trim()
if (raw) opts.onScan(raw)
}
}
})
reader.addEventListener('readingerror', (e) => opts.onError?.(e))
await reader.scan()
// Web NFC has no explicit stop; the AbortController form would, but the
// scaffold just flips a guard so late events are ignored after teardown.
return () => {
stopped = true
}
}
}

View file

@ -0,0 +1,90 @@
/**
* Camera-based QR pairing source (aiolabs/bitspire#52).
*
* Decodes with `qr` (paulmillr) — a zero-dependency, auditable, dual
* MIT/Apache library from the same author as the `@noble`/`@scure` crypto our
* nostr stack already trusts (chosen over the dormant `jsqr` for that ethos +
* active maintenance). Its `qr/dom.js` browser helper wraps getUserMedia and
* the per-frame decode loop, so this source is a thin adapter onto the
* PairingSource contract.
*
* The first successful decode wins; the loop then stops itself so a single
* seed isn't ingested repeatedly.
*/
import { QRCanvas, frontalCamera, frameLoop } from 'qr/dom.js'
import type { PairingSource, PairingSourceStartOptions, StopCapture } from './types'
export class QrPairingSource implements PairingSource {
readonly kind = 'qr' as const
readonly label = 'Camera'
async isAvailable(): Promise<boolean> {
return (
typeof navigator !== 'undefined' &&
!!navigator.mediaDevices &&
typeof navigator.mediaDevices.getUserMedia === 'function'
)
}
async start(opts: PairingSourceStartOptions): Promise<StopCapture> {
const { onScan, onError, video } = opts
if (!video) throw new Error('QrPairingSource requires a <video> element')
const camera = await frontalCamera(video)
// `frontalCamera` requests `ideal: screen.{width,height}`, so on the kiosk's
// 1280x800 panel it otherwise streams at ~720p — and `decodeQR` then
// center-crops to a square, leaving too few pixels-per-module for a dense
// spire-seed QR on this fixed-focus lens. Pin a deliberate 1280x960 capture
// instead: lamassu-machine caps QR scanning at 640x480 for decode speed
// (megapixels just slow the per-frame decode), but our seed QR is denser
// than a lightning invoice, so 1280x960 is the balance — ~14-18px/module at
// frame-fill, still fast, and it meters exposure better than maxing the
// sensor (a frame-filling QR keeps auto-exposure from blowing out on a
// bright phone screen). The stream lives on the <video>'s srcObject
// (QRCamera.stream is private); soft `ideal` so a camera that can't honor
// it degrades to its closest mode instead of throwing.
try {
const stream = video.srcObject
if (stream instanceof MediaStream) {
await stream.getVideoTracks()[0]?.applyConstraints({
width: { ideal: 1280 },
height: { ideal: 960 },
})
}
} catch (e) {
onError?.(e)
}
const canvas = new QRCanvas() // decode-only; no overlay canvases needed
let stopped = false
let cancel: (() => void) | null = null
const stop: StopCapture = () => {
if (stopped) return
stopped = true
cancel?.()
camera.stop()
}
cancel = frameLoop(() => {
if (stopped) return
try {
// `fullSize: true` decodes the camera's intrinsic frame (videoWidth ×
// videoHeight) rather than the <video> element's CSS box — the default
// (`false`) was decoding the few-hundred-px on-screen preview, which
// (compounded by `object-cover` cropping) starved the decoder.
const result = camera.readFrame(canvas, true)
if (result) {
stop()
onScan(result)
}
} catch (e) {
onError?.(e)
}
})
return stop
}
}

View file

@ -0,0 +1,69 @@
/**
* Relay reachability probe for the pairing wizard (aiolabs/bitspire#70).
*
* `parseSpireSeed` catches a MALFORMED relay (e.g. a QR misread of `ws://` into
* `As://`), but a well-formed-yet-unreachable relay — `ws://localhost:…` baked
* into a seed for a remote machine, a wrong LAN IP, or a relay that's simply
* down — still parses fine and would only fail later as a NIP-46 connect
* crash-loop. This opens a WebSocket to the relay (and sends a NIP-01 REQ so a
* real relay answers) so the operator can confirm reachability on-machine,
* before committing the pairing.
*/
export interface RelayTestResult {
url: string
ok: boolean
/** Round-trip time to open (ms), when reachable. */
ms?: number
/** True when the relay answered our REQ — i.e. it's actually a nostr relay. */
answered?: boolean
error?: string
}
/** Open a WebSocket to `url` and report whether it connects within `timeoutMs`. */
export function testRelay(url: string, timeoutMs = 6000): Promise<RelayTestResult> {
return new Promise((resolve) => {
const start = Date.now()
let ws: WebSocket | null = null
let settled = false
const finish = (r: Omit<RelayTestResult, 'url'>): void => {
if (settled) return
settled = true
clearTimeout(timer)
try {
ws?.close()
} catch {
/* already closing */
}
resolve({ url, ...r })
}
const timer = setTimeout(
() => finish({ ok: false, error: `timed out after ${timeoutMs}ms` }),
timeoutMs,
)
try {
ws = new WebSocket(url)
} catch (e) {
finish({ ok: false, error: e instanceof Error ? e.message : 'invalid relay URL' })
return
}
ws.onopen = () => {
// Connected. Probe it as a nostr relay; a genuine relay replies (EOSE /
// notice). If it stays silent we still count the open as reachable.
try {
ws?.send(JSON.stringify(['REQ', 'bitspire-relay-test', { limit: 0 }]))
} catch {
/* send failed, but the socket opened → still reachable */
}
const graceMs = Math.min(600, timeoutMs)
setTimeout(() => finish({ ok: true, ms: Date.now() - start, answered: false }), graceMs)
}
ws.onmessage = () => finish({ ok: true, ms: Date.now() - start, answered: true })
ws.onerror = () =>
finish({ ok: false, error: 'connection failed (unreachable or not a relay)' })
})
}

View file

@ -0,0 +1,42 @@
/**
* Pairing-source abstraction (aiolabs/bitspire#52).
*
* A fresh ATM is paired by getting a `spire-seed:v1:…` onto the device. The
* operator's spirekeeper mints that seed and renders it as a QR (and, later,
* possibly an NFC tag). The machine ingests it via whatever capture hardware
* it has — today a camera, tomorrow maybe an NFC reader or a HAL barcode
* scanner. `PairingSource` is the seam that keeps the wizard UI and the
* ingest pipeline agnostic to *how* the seed arrived.
*
* Implementations live next to this file: `qr-source.ts` (camera + jsQR),
* `nfc-source.ts` (Web NFC scaffold). A HAL-scanner source can be added the
* same way without touching the wizard.
*/
export type PairingSourceKind = 'qr' | 'nfc'
export interface PairingSourceStartOptions {
/** Invoked with each decoded payload (the raw seed string). */
onScan: (raw: string) => void
/** Invoked on a non-fatal capture error (e.g. a frame decode glitch). */
onError?: (error: unknown) => void
/**
* The <video> element the camera preview renders into. Required by
* camera-based sources; ignored by sources that don't show a viewfinder
* (e.g. NFC).
*/
video?: HTMLVideoElement
}
/** Releases capture hardware (camera stream, NFC reader). Idempotent. */
export type StopCapture = () => void
export interface PairingSource {
readonly kind: PairingSourceKind
/** Short label for the wizard's source picker (e.g. "Camera", "NFC tap"). */
readonly label: string
/** Whether this source can run in the current environment. */
isAvailable(): Promise<boolean>
/** Begin capturing; resolves once hardware is live. */
start(opts: PairingSourceStartOptions): Promise<StopCapture>
}

View file

@ -0,0 +1,193 @@
/**
* Signer resolution — turns the ATM's pairing state into a live `Signer`.
*
* Three outcomes, in priority order (aiolabs/bitspire#52, model A1):
* 1. A seed is present whose fingerprint differs from the stored binding
* (first pair or re-pair) → generate a fresh NIP-46 transport key, redeem
* the one-shot connect secret, persist the binding, and reset the
* publish watermark so the (possibly new) operator gets current state (#56).
* 2. A seed is present matching the stored binding, OR no seed but a stored
* binding exists → resume the bunker session with the persisted transport
* key (no re-redeem — the binding is server-persistent).
* 3. Neither → ephemeral LocalSigner, dev only. In strict (production) mode
* this throws instead: no pairing means no signing identity.
*
* Runs in the renderer (where the relay I/O lives); state.db reads/writes go
* through the one-shot get-atm-secrets channel + the binding IPC handlers.
*/
import {
LocalSigner,
connectNewSeed,
resumeFromBinding,
generateClientTransportKey,
generateIdentity,
loadIdentityFromHex,
parseSpireSeed,
seedFingerprint,
type Signer,
type SpireSeed,
} from '@bitSpire/nostr-client'
import type { BunkerBindingRecord } from '@/types/electron'
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
/**
* Thrown in strict mode when the machine has no seed and no binding — it is
* genuinely unpaired, not misconfigured. The renderer catches this to show the
* QR-pairing wizard (camera scan of a spire-seed) rather than a fault screen.
* Distinct `.name` so it survives the bundle boundary (instanceof is fragile
* across the electron/renderer split). See services/init-error.ts.
*/
export class NoPairingError extends Error {
override readonly name = 'NoPairingError'
constructor() {
super('[Signer] Machine is unpaired — no spire seed and no bunker binding.')
}
}
export interface ResolveSignerOptions {
/** Allow an ephemeral LocalSigner when no seed/binding exists (dev only). */
allowEphemeral: boolean
}
/** LNbits transport config carried by the pairing (aiolabs/bitspire#70). */
export interface TransportConfig {
/** LNbits transport relays (kind-21000 / 30078). */
relays: string[]
/** LNbits nostr-transport server pubkey (hex). */
lnbitsServerPubkey: string
}
export interface ResolvedSigner {
signer: Signer
/**
* Transport config sourced from the pairing — the seed on a fresh pair /
* seeded resume, the binding on a seedless resume. Null when unavailable (an
* ephemeral dev signer, or a pre-#70 binding that never stored it); the
* caller then falls back to env provisioning.
*/
transport: TransportConfig | null
}
interface PairingState {
spireSeed: string
binding: BunkerBindingRecord | null
}
/** Gather the seed + persisted binding from Electron, or env in browser dev. */
async function loadPairingState(): Promise<PairingState> {
if (isElectron && window.electronAPI) {
const secrets = await window.electronAPI.getAtmSecrets()
return { spireSeed: secrets.spireSeed || '', binding: secrets.bunkerBinding ?? null }
}
return { spireSeed: (import.meta.env.VITE_SPIRE_SEED as string | undefined) || '', binding: null }
}
export async function resolveSigner(opts: ResolveSignerOptions): Promise<ResolvedSigner> {
const { spireSeed, binding } = await loadPairingState()
const resume = (b: BunkerBindingRecord): Promise<Signer> =>
resumeFromBinding({
clientSecretHex: b.clientSecretHex,
spirePubkey: b.spirePubkey,
bunkerUrl: b.bunkerUrl,
})
// Transport config from a binding — present only when the pairing seed
// carried it (post-#70) and it was persisted. Null on pre-#70 bindings.
const transportFromBinding = (b: BunkerBindingRecord): TransportConfig | null =>
b.relays && b.relays.length > 0 && b.lnbitsServerPubkey
? { relays: b.relays, lnbitsServerPubkey: b.lnbitsServerPubkey }
: null
const transportFromSeed = (s: SpireSeed): TransportConfig => ({
relays: s.relays,
lnbitsServerPubkey: s.lnbitsServerPubkey,
})
if (spireSeed) {
let seed: SpireSeed
let fingerprint: string
try {
seed = parseSpireSeed(spireSeed)
fingerprint = seedFingerprint(spireSeed)
} catch (err) {
// A stored seed we can't parse — e.g. a legacy-shape seed left in .env
// after the seed format changed (bitspire-#70). If we already hold a
// binding it's authoritative (server-persistent), so resume from it
// rather than bricking a paired machine on the next boot. With no
// binding the seed is our only pairing input, so fail closed.
if (binding) {
console.warn(
'[Signer] Stored spire seed is unparseable; resuming from existing binding:',
(err as Error).message
)
return { signer: await resume(binding), transport: transportFromBinding(binding) }
}
throw err
}
if (binding && binding.seedFingerprint === fingerprint) {
console.log('[Signer] Resuming bunker session for spire', seed.spirePubkey)
// Seed present + parsed → prefer its (fresh) transport config over the
// binding's, which may predate the seed carrying transport (pre-#70).
return { signer: await resume(binding), transport: transportFromSeed(seed) }
}
// First pair or re-pair: redeem the one-shot connect secret.
console.log('[Signer] Pairing to bunker for spire', seed.spirePubkey)
const transport = generateClientTransportKey()
const signer = await connectNewSeed({
spirePubkey: seed.spirePubkey,
bunkerUrl: seed.bunkerUrl,
clientSecretHex: transport.secretHex,
})
if (isElectron && window.electronAPI) {
// Re-pair (a NEW seed replacing a prior binding) → wipe the previous
// operator's config/trust state (fee config + replay watermarks) so it
// can't linger or silently replay-block the new operator's config. A
// first pair (no prior binding) has nothing to reset. Cash accounting is
// preserved — see resetForRepair; a full wipe is the factory-reset path.
if (binding) {
console.log(
'[Signer] Re-pair (new seed fingerprint) — clearing prior operator config state'
)
await window.electronAPI.resetForRepair()
}
// Persist the seed's transport config alongside the binding so a later
// seedless resume still reaches the backend without env provisioning.
await window.electronAPI.saveBunkerBinding({
clientSecretHex: transport.secretHex,
spirePubkey: seed.spirePubkey,
bunkerUrl: seed.bunkerUrl,
seedFingerprint: fingerprint,
pairedAt: Math.floor(Date.now() / 1000),
relays: seed.relays,
lnbitsServerPubkey: seed.lnbitsServerPubkey,
})
// Re-pair → re-publish the cassette-state hello to the new operator (#56).
await window.electronAPI.resetStatePublishWatermark()
}
return { signer, transport: transportFromSeed(seed) }
}
// No seed in this boot but a binding survives → resume.
if (binding) {
console.log('[Signer] Resuming bunker session from stored binding (no seed this boot)')
return { signer: await resume(binding), transport: transportFromBinding(binding) }
}
if (opts.allowEphemeral) {
// Dev-only: a hex key gives a stable dev identity; otherwise ephemeral.
const devKey = !isElectron ? (import.meta.env.VITE_ATM_PRIVATE_KEY as string | undefined) : ''
if (devKey) {
console.warn('[Signer] No bunker pairing — using LocalSigner from VITE_ATM_PRIVATE_KEY (dev)')
return { signer: new LocalSigner(loadIdentityFromHex(devKey)), transport: null }
}
console.warn('[Signer] No bunker pairing — generated ephemeral LocalSigner (dev only)')
return { signer: new LocalSigner(generateIdentity()), transport: null }
}
throw new NoPairingError()
}

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,13 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import 'tw-animate-css'; @import 'tw-animate-css';
/* Hide cursor completely on touchscreen kiosk */ /* Hide cursor completely on touchscreen kiosk.
*, Scoped to .kiosk (set on <html> by main.ts) so the public web demo, which
*::before, runs in an ordinary browser with a mouse, keeps a visible pointer. */
*::after { .kiosk,
.kiosk *,
.kiosk *::before,
.kiosk *::after {
cursor: none !important; cursor: none !important;
} }

View file

@ -2,14 +2,61 @@
* Type declarations for Electron API exposed via preload * Type declarations for Electron API exposed via preload
*/ */
import type { AllowListEntry } from '../services/access/authorize'
/**
* Access-control config (ADR-003). Loaded by the main process from env +
* an optional /var/lib/bitspire/access.json. `enabled` defaults false, so a
* machine with no access config behaves exactly as before.
*/
export interface AccessControlConfig {
/** Master switch for the badge-to-enter gate. */
enabled: boolean
/** Allow the runtime dev/operator unlock gesture on the locked screen. */
devUnlock: boolean
/** Prototype: admit any valid npub when the allow-list has no match. */
openEnrollment: boolean
/** Per-machine salt for hashing credentials/PINs. */
salt: string
/** Authorized identities (hashed). Empty in open-enrollment prototype mode. */
allowList: AllowListEntry[]
}
/**
* A Bolt Card session opened at tap-to-enter (ADR-003). Wire payload from the
* main process (electron/boltcard-session.ts) — mirrored here rather than
* imported to avoid a cross-project electron↔renderer import. The withdraw and
* pay steps are keyed by a single-use server-side hit; no card secret is held.
*/
export interface CardSession {
externalId: string
cardName: string
balanceSats: number
/** ISO currency the card server priced the balance in; null → no fiat. */
currency: string | null
/** Balance in `currency` at the card server's rate; null when unknown. */
fiat: number | null
/** LUD-03 second step, or null when the card server withheld it. */
withdraw: {
callback: string
k1: string
minWithdrawable?: number
maxWithdrawable?: number
} | null
/** Why `withdraw` is null (e.g. daily limit spent); safe to show on-screen. */
withdrawBlockedReason: string | null
/** LUD-06 second step for topping the card wallet up. */
pay: { callback: string; minSendable?: number; maxSendable?: number; metadata?: string }
}
export type OpenCardSessionResult =
| { ok: true; session: CardSession }
| { ok: false; reason: string }
export interface RuntimeConfig { export interface RuntimeConfig {
relayUrl: string relayUrl: string
/** LNbits nostr-transport server pubkey (hex, 64 chars). */ /** LNbits nostr-transport server pubkey (hex, 64 chars). */
lnbitsServerPubkey: string lnbitsServerPubkey: string
/** Legacy LP fields — retained until 3d removes the LP backend. Optional. */
lightningPubPubkey?: string
lightningPubApiUrl?: string
extensionApiUrl?: string
appId: string appId: string
machineModel: string machineModel: string
fiatCode: string fiatCode: string
@ -21,6 +68,8 @@ export interface RuntimeConfig {
maintenanceMode: boolean maintenanceMode: boolean
/** Operator branding override loaded from /var/lib/bitspire/branding/. Null when no override. */ /** Operator branding override loaded from /var/lib/bitspire/branding/. Null when no override. */
branding: BrandingConfig | null branding: BrandingConfig | null
/** Access-control gate config (ADR-003). Always present; `enabled` defaults false. */
accessControl: AccessControlConfig
} }
/** Operator branding config. Wire payload from Electron IPC; renderer applies via useBranding(). */ /** Operator branding config. Wire payload from Electron IPC; renderer applies via useBranding(). */
@ -39,10 +88,24 @@ export interface BrandingConfig {
logoDarkDataUrl: string | null logoDarkDataUrl: string | null
} }
/** Persisted NIP-46 bunker binding (mirror of state-store's StoredBunkerBinding). */
export interface BunkerBindingRecord {
clientSecretHex: string
spirePubkey: string
bunkerUrl: string
seedFingerprint: string
pairedAt: number
/** LNbits transport relays from the seed (#70); absent on pre-#70 bindings. */
relays?: string[]
/** LNbits nostr-transport server pubkey (hex) from the seed (#70). */
lnbitsServerPubkey?: string
}
export interface AtmSecrets { export interface AtmSecrets {
atmPrivateKey: string /** Spire pairing seed URL (`spire-seed:v1:…`); carries the one-shot connect token. */
/** Legacy LP admin token — retained until 3d removes the LP backend. */ spireSeed: string
adminToken?: string /** Persisted bunker binding, or null when the ATM is unpaired. */
bunkerBinding: BunkerBindingRecord | null
} }
declare global { declare global {
@ -83,12 +146,86 @@ declare global {
emptyCashbox: () => Promise<void> emptyCashbox: () => Promise<void>
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean> remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
getLastKnownConfigCreatedAt: () => Promise<number> getLastKnownConfigCreatedAt: () => Promise<number>
getBootstrapPublishedAt: () => Promise<number | null> getLastStatePublishedAt: () => Promise<number | null>
markBootstrapPublished: (unixTimestamp: number) => Promise<void> /** When the bay counts became unverified (a dispense that reported nothing), or null. */
applyOperatorCassettesConfig: ( getCountsUncertainSince: () => Promise<number | null>
payload: { positions: Record<string, { denomination: number; count: number }> }, markCountsUncertain: (unixTimestamp: number) => Promise<void>
eventCreatedAt: number // Cash-out hold (ADR-005 §5)
) => Promise<{ applied: true } | { applied: false; reason: string }> getCashOutHold: () => Promise<{
reason: string
errorCode: string | null
rawCode: string | null
since: number
} | null>
setCashOutHold: (hold: {
reason: string
errorCode: string | null
rawCode: string | null
since: number
}) => Promise<{ reason: string; errorCode: string | null; rawCode: string | null; since: number }>
clearCashOutHold: () => Promise<boolean>
// Dispense-report outbox (ADR-005 §2)
pendingDispenseReports: (limit?: number) => Promise<
Array<{
txid: string
payload: unknown
createdAt: number
attempts: number
lastAttemptAt: number | null
lastError: string | null
}>
>
ackDispenseReport: (txid: string) => Promise<boolean>
noteDispenseReportAttempt: (txid: string, error: string | null) => Promise<void>
markStatePublished: (unixTimestamp: number) => Promise<void>
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void>
resetStatePublishWatermark: () => Promise<void>
resetForRepair: () => Promise<void>
saveSpireSeed: (seed: string) => Promise<void>
relaunchApp: () => Promise<void>
/** Reload the renderer to re-attempt initialization (connectivity recovery). */
recoverApp: () => Promise<void>
/** Bolt Card cash-out: pull payment for the current invoice from a tapped card. */
lnurlWithdraw: (args: {
lnurlw: string
bolt11: string
amountMsat?: number
}) => Promise<{ ok: boolean; reason?: string }>
/** Bolt Card cash-in: resolve a tapped card + amount to a BOLT11 to pay. */
resolveCardInvoice: (args: {
lnurlw: string
amountMsat: number
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
/** Bolt Card tap-to-enter: open one verified session for a tapped card (spends the SUN). */
openCardSession: (args: { lnurlw: string }) => Promise<OpenCardSessionResult>
/** Cash-out via a session's withdraw step (no tap). */
withdrawWithSession: (args: {
withdraw: NonNullable<CardSession['withdraw']>
bolt11: string
amountMsat?: number
}) => Promise<{ ok: boolean; reason?: string }>
/** Cash-in via a session's pay step (no tap): a BOLT11 to pay. */
resolveSessionInvoice: (args: {
pay: CardSession['pay']
amountMsat: number
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
applyOperatorCassetteOps: (
ops: {
id: string
at: number
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
position: number
bills?: number
count?: number
denomination?: number
}[]
) => Promise<{
applied: string[]
rejected: { id: string; reason: string }[]
}>
getAppliedOpIds: (limit?: number) => Promise<string[]>
getCassetteStateSeq: () => Promise<number>
getFeeConfig: () => Promise<{ getFeeConfig: () => Promise<{
cashInFeeFraction: number cashInFeeFraction: number
cashOutFeeFraction: number cashOutFeeFraction: number
@ -122,6 +259,14 @@ declare global {
onHalBillInserted: (callback: (denomination: number) => void) => void onHalBillInserted: (callback: (denomination: number) => void) => void
onHalBillRejected: (callback: (reason: string) => void) => void onHalBillRejected: (callback: (reason: string) => void) => void
onHalError: (callback: (error: string) => void) => void onHalError: (callback: (error: string) => void) => void
/** The main process mutated the cassettes table; reload + republish. */
onCassettesChanged: (callback: () => void) => void
/** Bolt Card reader: a tapped card's lnurlw voucher. */
onNfcCardTapped: (callback: (lnurlw: string) => void) => void
/** Bolt Card reader status (ready / reading / error / unavailable). */
onNfcStatus: (
callback: (status: { state: string; reader?: string; message?: string }) => void
) => void
onWatchdogPing: (callback: () => void) => void onWatchdogPing: (callback: () => void) => void
watchdogPong: () => Promise<void> watchdogPong: () => Promise<void>
platform: NodeJS.Platform platform: NodeJS.Platform

View file

@ -34,6 +34,12 @@ export interface TransactionRecord {
}[] }[]
error?: string | null error?: string | null
remediatedBy?: string | null remediatedBy?: string | null
/**
* ADR-005 §2: the dispense outcome to queue for spirekeeper, written in
* the same SQLite transaction as the row so a crash between the two cannot
* lose it. Cash-out only. Shape is @bitSpire/lnbits DispenseReportBody.
*/
report?: import('@bitSpire/lnbits').DispenseReportBody
} }
export interface ATMAvailability { export interface ATMAvailability {

View file

@ -3,6 +3,7 @@ import { watch, computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAtmStore } from '@/stores/atm' import { useAtmStore } from '@/stores/atm'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import CardChip from '@/components/CardChip.vue'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import QRCode from '@/components/QRCode.vue' import QRCode from '@/components/QRCode.vue'
@ -48,6 +49,9 @@ const showCancelButton = computed(() => {
// Invoice input for manual payment // Invoice input for manual payment
const invoiceInput = ref('') const invoiceInput = ref('')
// Dev: paste an lnurlw to simulate a Bolt Card tap-to-receive
const mockLnurlw = ref('')
// Copy state for ndebit URI // Copy state for ndebit URI
const copied = ref(false) const copied = ref(false)
@ -242,7 +246,10 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
</p> </p>
<!-- Status --> <!-- Status -->
<p v-if="balanceLimitReached" class="text-sm lg:text-xl text-muted-foreground"> <p v-if="context?.billPending" class="text-sm lg:text-xl text-muted-foreground">
⏳ Processing bill…
</p>
<p v-else-if="balanceLimitReached" class="text-sm lg:text-xl text-muted-foreground">
Maximum amount reached — press Done to continue Maximum amount reached — press Done to continue
</p> </p>
<p v-else class="text-sm lg:text-xl text-muted-foreground"> <p v-else class="text-sm lg:text-xl text-muted-foreground">
@ -269,11 +276,16 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<!-- Done button --> <!-- Done button — also blocked while a bill is between the
stack command and the validator's stacked-confirmation
(the machine guard drops FINISH_INSERTING regardless;
this keeps the UI honest about it) -->
<Button <Button
class="w-full bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500" class="w-full bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"
size="kiosk-lg" size="kiosk-lg"
:disabled="!context || context.billsInserted.length === 0" :disabled="
!context || context.billsInserted.length === 0 || context.billPending !== null
"
@click="finishInserting" @click="finishInserting"
> >
Done Inserting Done Inserting
@ -325,10 +337,47 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
{{ atmStore.fiatSymbol }}{{ ((context?.fiatCents || 0) / 100).toFixed(2) }} {{ atmStore.fiatSymbol }}{{ ((context?.fiatCents || 0) / 100).toFixed(2) }}
</p> </p>
<!-- Waiting indicator --> <!-- Waiting indicator + Bolt Card tap-to-receive status -->
<div class="flex items-center gap-3 pt-2 lg:pt-4"> <div class="flex flex-col items-center gap-2 pt-2 lg:pt-4">
<PickaxeIcon :size="32" /> <div class="flex items-center gap-3">
<p class="text-sm lg:text-xl text-muted-foreground">Waiting for wallet scan...</p> <PickaxeIcon :size="32" />
<p class="text-sm lg:text-xl text-muted-foreground">
{{
atmStore.boltCardProcessing
? 'Processing card…'
: isElectron
? 'Tap your card or scan to receive'
: 'Waiting for wallet scan...'
}}
</p>
</div>
<p
v-if="atmStore.nfcStatus?.message"
class="text-sm lg:text-lg"
:class="
atmStore.nfcStatus.state === 'declined' || atmStore.nfcStatus.state === 'error'
? 'text-destructive'
: 'text-muted-foreground'
"
>
{{ atmStore.nfcStatus.message }}
</p>
</div>
<!-- Tap-to-enter: card already loaded → one-press Complete (no re-tap) -->
<div
v-if="atmStore.loadedBoltCard"
class="flex w-full max-w-md flex-col items-center gap-2 pt-2"
>
<CardChip class="w-full" />
<Button
class="w-full bg-success text-success-foreground"
size="kiosk-lg"
:disabled="atmStore.boltCardProcessing"
@click="atmStore.completeWithCard()"
>
{{ atmStore.boltCardProcessing ? 'Completing…' : 'Complete Purchase' }}
</Button>
</div> </div>
<!-- LNURL URI (web-ui only) --> <!-- LNURL URI (web-ui only) -->
@ -347,8 +396,8 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
</div> </div>
</div> </div>
<!-- Debug: Simulate payment button --> <!-- Debug: simulate payment / Bolt Card tap-to-receive -->
<div v-if="atmStore.debugMode" class="pt-2"> <div v-if="atmStore.debugMode" class="pt-2 flex flex-col items-center gap-2">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -357,6 +406,21 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
> >
Dev: Skip to Success Dev: Skip to Success
</Button> </Button>
<div class="flex items-center gap-2">
<input
v-model="mockLnurlw"
placeholder="lnurlw://… (paste to simulate a card tap)"
class="w-56 rounded border border-input bg-background px-2 py-1 text-xs"
/>
<Button
variant="outline"
size="sm"
:disabled="!mockLnurlw"
@click="atmStore.simulateBoltCardReceive(mockLnurlw)"
>
Tap
</Button>
</div>
</div> </div>
</div> </div>

View file

@ -3,6 +3,7 @@ import { watch, computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAtmStore } from '@/stores/atm' import { useAtmStore } from '@/stores/atm'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import CardChip from '@/components/CardChip.vue'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import QRCode from '@/components/QRCode.vue' import QRCode from '@/components/QRCode.vue'
@ -15,6 +16,10 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
const cashOutSteps = ['Select', 'Pay', 'Collect'] const cashOutSteps = ['Select', 'Pay', 'Collect']
// Dev-only: paste a real card's lnurlw to exercise the Bolt Card pull without
// the reader (single-use, so a live tap each time).
const mockLnurlw = ref('')
const currentStepIndex = computed(() => { const currentStepIndex = computed(() => {
switch (nestedState.value) { switch (nestedState.value) {
case 'fetchingRate': case 'fetchingRate':
@ -58,13 +63,19 @@ watch(
const nestedState = computed(() => atmStore.nestedState) const nestedState = computed(() => atmStore.nestedState)
const context = computed(() => atmStore.context) const context = computed(() => atmStore.context)
// Dispense error 30s countdown // Terminal-screen countdowns (ADR-005 §4). The machine owns the real timers
// (DISPENSE_FAULT_TIMEOUT 120 s, DISPENSE_ERROR_TIMEOUT 30 s); this mirrors
// them for display only.
const TERMINAL_SECONDS: Record<string, number> = { dispenseFault: 120, outOfCash: 30 }
const dispenseErrorCountdown = ref(30) const dispenseErrorCountdown = ref(30)
let countdownTimer: ReturnType<typeof setInterval> | null = null let countdownTimer: ReturnType<typeof setInterval> | null = null
watch(nestedState, (newState, oldState) => { watch(nestedState, (newState, oldState) => {
if (newState === 'dispenseError' && oldState !== 'dispenseError') { const entering = typeof newState === 'string' && newState in TERMINAL_SECONDS
dispenseErrorCountdown.value = 30 const leaving = typeof oldState === 'string' && oldState in TERMINAL_SECONDS
if (entering && newState !== oldState) {
if (countdownTimer) clearInterval(countdownTimer)
dispenseErrorCountdown.value = TERMINAL_SECONDS[newState as string] ?? 30
countdownTimer = setInterval(() => { countdownTimer = setInterval(() => {
dispenseErrorCountdown.value-- dispenseErrorCountdown.value--
if (dispenseErrorCountdown.value <= 0 && countdownTimer) { if (dispenseErrorCountdown.value <= 0 && countdownTimer) {
@ -72,12 +83,21 @@ watch(nestedState, (newState, oldState) => {
countdownTimer = null countdownTimer = null
} }
}, 1000) }, 1000)
} else if (oldState === 'dispenseError' && countdownTimer) { } else if (leaving && !entering && countdownTimer) {
clearInterval(countdownTimer) clearInterval(countdownTimer)
countdownTimer = null countdownTimer = null
} }
}) })
function acknowledgeFault() {
atmStore.acknowledgeFault()
}
const faultTime = computed(() => {
const t = context.value?.startedAt
return t ? new Date(t).toLocaleString() : ''
})
// Available denominations from inventory // Available denominations from inventory
const availableDenominations = computed(() => { const availableDenominations = computed(() => {
if (!context.value?.inventory) return [] if (!context.value?.inventory) return []
@ -173,6 +193,24 @@ function formatFiat(cents: number): string {
key="selectingAmount" key="selectingAmount"
class="flex flex-1 flex-col justify-center px-4 lg:px-[8vw] py-4 lg:py-6 gap-4 lg:gap-6" class="flex flex-1 flex-col justify-center px-4 lg:px-[8vw] py-4 lg:py-6 gap-4 lg:gap-6"
> >
<!-- A payment was taken and no cash came out. Say so plainly, with
the reference an operator needs; do not let the customer wander
back into the amount grid believing nothing happened. -->
<div
v-if="atmStore.settlementError"
class="rounded-lg border-2 border-destructive bg-destructive/10 px-4 py-3 text-center lg:px-6 lg:py-4"
>
<p class="text-base font-bold text-destructive lg:text-2xl">
{{ atmStore.settlementError.message }}
</p>
<p class="mt-1 text-xs text-muted-foreground lg:text-lg">
Please contact the operator<template v-if="atmStore.settlementError.txid">
and quote
<span class="font-mono-code">{{ atmStore.settlementError.txid }}</span> </template
>.
</p>
</div>
<!-- Denomination grid --> <!-- Denomination grid -->
<div class="grid grid-cols-2 gap-3 lg:gap-6"> <div class="grid grid-cols-2 gap-3 lg:gap-6">
<div <div
@ -284,7 +322,9 @@ function formatFiat(cents: number): string {
<div <div
class="flex w-full lg:w-[52%] flex-col items-center justify-center gap-3 lg:gap-5 px-4 lg:px-[4vw] py-4 lg:py-0" class="flex w-full lg:w-[52%] flex-col items-center justify-center gap-3 lg:gap-5 px-4 lg:px-[4vw] py-4 lg:py-0"
> >
<p class="text-lg lg:text-[2rem] font-semibold text-warning">Scan to Pay</p> <p class="text-lg lg:text-[2rem] font-semibold text-warning">
{{ isElectron ? 'Tap Card or Scan to Pay' : 'Scan to Pay' }}
</p>
<p class="text-3xl lg:text-[7vh] font-bold text-bitcoin leading-tight"> <p class="text-3xl lg:text-[7vh] font-bold text-bitcoin leading-tight">
{{ context ? formatSats(context.satsAmount) : 0 }} sats {{ context ? formatSats(context.satsAmount) : 0 }} sats
</p> </p>
@ -295,10 +335,60 @@ function formatFiat(cents: number): string {
</Badge> </Badge>
</p> </p>
<!-- Waiting indicator --> <!-- Waiting indicator + Bolt Card status -->
<div class="flex items-center gap-3 pt-2 lg:pt-4"> <div class="flex flex-col items-center gap-2 pt-2 lg:pt-4">
<PickaxeIcon :size="32" /> <div class="flex items-center gap-3">
<p class="text-sm lg:text-xl text-muted-foreground">Waiting for payment...</p> <PickaxeIcon :size="32" />
<p class="text-sm lg:text-xl text-muted-foreground">
{{
atmStore.boltCardProcessing
? 'Processing card…'
: isElectron
? 'Tap your card or scan the QR'
: 'Waiting for payment...'
}}
</p>
</div>
<p
v-if="atmStore.nfcStatus?.message"
class="text-sm lg:text-lg"
:class="
atmStore.nfcStatus.state === 'declined' || atmStore.nfcStatus.state === 'error'
? 'text-destructive'
: 'text-muted-foreground'
"
>
{{ atmStore.nfcStatus.message }}
</p>
</div>
<!-- Tap-to-enter: card already loaded → one-press Complete (no re-tap) -->
<div
v-if="atmStore.loadedBoltCard"
class="flex w-full max-w-md flex-col items-center gap-2 pt-2"
>
<CardChip class="w-full" />
<!-- The card server withheld this session's withdraw step (e.g.
the card's daily limit is spent). Nothing the customer does
here can lift it, so say so rather than offering a button
that always fails. The QR remains as the way to get paid. -->
<p
v-if="!atmStore.loadedBoltCard.withdraw"
class="w-full text-center text-sm font-medium text-destructive lg:text-lg"
>
{{
atmStore.loadedBoltCard.withdrawBlockedReason ?? 'This card cannot sell right now'
}}
</p>
<Button
v-else
class="w-full bg-success text-success-foreground"
size="kiosk-lg"
:disabled="atmStore.boltCardProcessing"
@click="atmStore.completeWithCard()"
>
{{ atmStore.boltCardProcessing ? 'Completing…' : 'Complete Sale' }}
</Button>
</div> </div>
<!-- Invoice info with copy button (web-ui only) --> <!-- Invoice info with copy button (web-ui only) -->
@ -322,6 +412,21 @@ function formatFiat(cents: number): string {
> >
Simulate Payment Simulate Payment
</Button> </Button>
<div class="mt-2 flex items-center gap-2">
<input
v-model="mockLnurlw"
placeholder="lnurlw://… (paste to simulate a card tap)"
class="w-56 rounded border border-input bg-background px-2 py-1 text-xs"
/>
<Button
variant="outline"
size="sm"
:disabled="!mockLnurlw"
@click="atmStore.simulateBoltCardTap(mockLnurlw)"
>
Tap
</Button>
</div>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</div> </div>
@ -445,63 +550,92 @@ function formatFiat(cents: number): string {
</div> </div>
</div> </div>
<!-- Dispense Error (timed, 30s → idle) --> <!-- Dispense fault / could-not-dispense (ADR-005 §4).
Both reach here AFTER payment: the customer has paid and received
less than they paid for. dispenseFault = the dispenser reported an
error (and may have latched cash-out off); outOfCash = it reported
none. Either way: evidence on screen, operator notified. The raw
dispenser code is deliberately NOT shown — it travels in the report. -->
<div <div
v-else-if="nestedState === 'dispenseError'" v-else-if="nestedState === 'dispenseFault' || nestedState === 'outOfCash'"
key="dispenseError" key="dispenseTerminal"
class="flex flex-1 flex-col lg:flex-row items-center justify-center gap-6 lg:gap-10 p-4 lg:p-8" class="flex flex-1 flex-col lg:flex-row items-center justify-center gap-6 lg:gap-10 p-4 lg:p-8"
style="background: color-mix(in srgb, var(--destructive) 8%, var(--background))" style="background: color-mix(in srgb, var(--destructive) 8%, var(--background))"
> >
<!-- Left side — error details --> <div class="flex flex-col items-center gap-3 lg:gap-5 max-w-xl">
<div class="flex flex-col items-center gap-3 lg:gap-5">
<div class="text-5xl lg:text-[8vh]">⚠️</div> <div class="text-5xl lg:text-[8vh]">⚠️</div>
<h3 class="text-2xl lg:text-[3rem] font-bold text-destructive">Dispense Error</h3> <h3 class="text-2xl lg:text-[3rem] font-bold text-destructive">
<p class="text-base lg:text-2xl text-muted-foreground"> {{ nestedState === 'dispenseFault' ? 'Dispenser fault' : 'Could not dispense' }}
{{ context?.error || 'Cash could not be dispensed' }} </h3>
<p class="text-base lg:text-2xl text-foreground text-center font-semibold">
Your payment went through. The cash below could not be dispensed.
</p> </p>
<!-- Partial dispense info --> <div class="w-full rounded-xl bg-background/60 px-4 py-4 lg:px-8 lg:py-6 space-y-2">
<div <div class="flex justify-between text-base lg:text-2xl">
v-if="context?.dispenseResult?.bills?.length" <span class="text-muted-foreground">You paid</span>
class="w-full max-w-md rounded-xl bg-background/60 px-4 py-4 lg:px-8 lg:py-6 space-y-2" <span class="font-semibold"
> >{{ atmStore.fiatSymbol }}{{ ((context?.fiatCents ?? 0) / 100).toFixed(2) }}
<span class="text-muted-foreground text-sm lg:text-lg"
>({{ (context?.satsAmount ?? 0).toLocaleString() }} sats)</span
></span
>
</div>
<div <div
v-for="bill in context.dispenseResult.bills" v-for="bill in context?.dispenseResult?.bills ?? []"
:key="bill.denomination" :key="bill.denomination"
class="flex justify-between text-base lg:text-2xl" class="flex justify-between text-base lg:text-2xl"
> >
<span class="text-muted-foreground" <span class="text-muted-foreground"
>{{ atmStore.fiatSymbol }}{{ bill.denomination }}</span >{{ atmStore.fiatSymbol }}{{ bill.denomination }} notes</span
> >
<span :class="bill.dispensed > 0 ? 'text-success' : 'text-destructive'"> <span :class="bill.dispensed > 0 ? 'text-success' : 'text-destructive'">
{{ bill.dispensed }} dispensed {{ bill.dispensed }} dispensed
<span v-if="bill.rejected > 0" class="text-destructive">
({{ bill.rejected }} rejected)
</span>
</span> </span>
</div> </div>
</div> </div>
<p class="text-sm lg:text-lg text-muted-foreground"> <p class="text-base lg:text-xl text-foreground text-center">
Please contact support with the transaction ID below. The operator has been notified and holds a record of this transaction.
<strong>Keep this reference</strong> — photograph it or write it down.
</p>
<p v-if="context?.error" class="text-xs lg:text-sm text-muted-foreground text-center">
Technical detail: {{ context.error }}
</p> </p>
<!-- Countdown --> <div class="flex flex-wrap items-center justify-center gap-3">
<Button
v-if="nestedState === 'dispenseFault'"
class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black"
size="kiosk"
@click="acknowledgeFault"
>
I've saved this
</Button>
<Button variant="outline" size="kiosk" @click="cancel"> Return to Start </Button>
</div>
<p class="text-sm lg:text-base text-muted-foreground"> <p class="text-sm lg:text-base text-muted-foreground">
Returning to start in {{ dispenseErrorCountdown }}s Returning to start in {{ dispenseErrorCountdown }}s
</p> </p>
<Button variant="outline" size="kiosk" @click="cancel"> Return to Start </Button>
</div> </div>
<!-- Right side — txid QR --> <div v-if="context?.txid" class="flex flex-col items-center gap-3">
<div v-if="context?.txid" class="flex flex-col items-center gap-4"> <QRCode :value="context.txid" :size="260" />
<QRCode :value="context.txid" :size="280" /> <p class="text-xs lg:text-sm text-muted-foreground">Transaction</p>
<p <p
class="font-mono-code text-sm text-muted-foreground max-w-[300px] text-center break-all" class="font-mono-code text-sm lg:text-base text-foreground max-w-[320px] text-center break-all"
> >
{{ context.txid }} {{ context.txid }}
</p> </p>
<template v-if="context?.paymentHash">
<p class="text-xs lg:text-sm text-muted-foreground">Payment hash</p>
<p
class="font-mono-code text-xs lg:text-sm text-foreground max-w-[320px] text-center break-all"
>
{{ context.paymentHash }}
</p>
</template>
<p v-if="faultTime" class="text-xs lg:text-sm text-muted-foreground">{{ faultTime }}</p>
</div> </div>
</div> </div>

View file

@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { nip19 } from 'nostr-tools'
import { useAtmStore } from '@/stores/atm' import { useAtmStore } from '@/stores/atm'
import { useBranding } from '@/composables/useBranding' import { useBranding } from '@/composables/useBranding'
import { initialContext } from '@bitSpire/state-machine' import { initialContext } from '@bitSpire/state-machine'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import CardChip from '@/components/CardChip.vue'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import BitcoinIcon from '@/components/BitcoinIcon.vue' import BitcoinIcon from '@/components/BitcoinIcon.vue'
import QRCode from '@/components/QRCode.vue' import QRCode from '@/components/QRCode.vue'
@ -15,18 +15,8 @@ const atmStore = useAtmStore()
const { logoUrl, title: brandTitle } = useBranding() const { logoUrl, title: brandTitle } = useBranding()
const lndconnectUrl = import.meta.env.VITE_LNDCONNECT_URL || '' const lndconnectUrl = import.meta.env.VITE_LNDCONNECT_URL || ''
const showZeusQR = ref(false) const showZeusQR = ref(false)
const showLpQR = ref(false)
const copied = ref(false) const copied = ref(false)
// Build nprofile for Lightning.Pub (pubkey + relay hint)
const lpNprofile = computed(() => {
const pubkey = import.meta.env.VITE_LIGHTNING_PUB_PUBKEY
if (!pubkey) return ''
const relayUrl = import.meta.env.VITE_RELAY_URL
const relays = relayUrl ? [relayUrl.replace('ws://', 'wss://')] : []
return nip19.nprofileEncode({ pubkey, relays })
})
async function copyToClipboard(value: string) { async function copyToClipboard(value: string) {
try { try {
await navigator.clipboard.writeText(value) await navigator.clipboard.writeText(value)
@ -84,16 +74,12 @@ function handleCashOut() {
>Just Bitcoin</Badge >Just Bitcoin</Badge
> >
</div> </div>
<!-- Balance + Commission rates --> <!-- Commission rates. The machine's own balance is NOT repeated here: App.vue
already shows it in the top-right status chip, and next to the holder's
card chip an "Available: N sats" line reads as *their* balance. -->
<div <div
class="mt-1 flex flex-wrap items-center justify-center gap-2 lg:gap-4 text-[11px] lg:text-[2vh] text-muted-foreground" class="mt-1 flex flex-wrap items-center justify-center gap-2 lg:gap-4 text-[11px] lg:text-[2vh] text-muted-foreground"
> >
<span v-if="atmStore.balanceSats !== null">
Available:
<span class="font-bold text-primary"
>{{ atmStore.balanceSats.toLocaleString() }} sats</span
>
</span>
<span> <span>
Buy: Buy:
<span class="font-bold text-bitcoin" <span class="font-bold text-bitcoin"
@ -115,6 +101,8 @@ function handleCashOut() {
> >
</span> </span>
</div> </div>
<!-- Tap-to-enter: the holder's card session, balance hidden until revealed -->
<CardChip v-if="atmStore.loadedBoltCard" class="mt-1 w-full max-w-md" />
</div> </div>
<!-- Touch Zones --> <!-- Touch Zones -->
@ -133,16 +121,32 @@ function handleCashOut() {
> >
</button> </button>
<!-- Sell Bitcoin --> <!-- Sell Bitcoin — disabled while cash-out is held after a terminal
dispenser fault (ADR-005 §5). The state machine refuses
SELECT_CASH_OUT regardless; this just tells the customer why. -->
<button <button
class="flex aspect-square w-40 lg:w-[36vh] flex-col items-center justify-center gap-1.5 lg:gap-4 rounded-full border-2 border-success/50 bg-success/10 transition-all active:scale-[0.97]" class="flex aspect-square w-40 lg:w-[36vh] flex-col items-center justify-center gap-1.5 lg:gap-4 rounded-full border-2 transition-all"
:class="
atmStore.context?.cashOutHeld
? 'border-muted-foreground/30 bg-muted/20 opacity-60 cursor-not-allowed'
: 'border-success/50 bg-success/10 active:scale-[0.97]'
"
:disabled="!!atmStore.context?.cashOutHeld"
@click="handleCashOut" @click="handleCashOut"
> >
<span class="text-4xl lg:text-[8vh] leading-none">💵</span> <span class="text-4xl lg:text-[8vh] leading-none">{{
<span class="text-base lg:text-[3.5vh] font-bold text-success">Sell Bitcoin</span> atmStore.context?.cashOutHeld ? '🔧' : '💵'
<span class="text-[10px] lg:text-[1.8vh] text-foreground/70" }}</span>
>Pay invoice, receive cash</span <span
class="text-base lg:text-[3.5vh] font-bold"
:class="atmStore.context?.cashOutHeld ? 'text-muted-foreground' : 'text-success'"
>Sell Bitcoin</span
> >
<span class="text-[10px] lg:text-[1.8vh] text-foreground/70 text-center px-3">{{
atmStore.context?.cashOutHeld
? 'Temporarily unavailable — operator notified'
: 'Pay invoice, receive cash'
}}</span>
</button> </button>
</div> </div>
@ -157,15 +161,6 @@ function handleCashOut() {
> >
Zeus QR (lnd-alice) Zeus QR (lnd-alice)
</Button> </Button>
<Button
v-if="lpNprofile"
variant="ghost"
size="sm"
class="text-xs text-muted-foreground"
@click="showLpQR = true"
>
Lightning.Pub nprofile
</Button>
</div> </div>
<!-- Zeus QR fullscreen overlay --> <!-- Zeus QR fullscreen overlay -->
@ -193,37 +188,38 @@ function handleCashOut() {
</div> </div>
</div> </div>
<!-- Lightning.Pub nprofile QR fullscreen overlay --> <!-- Top-left utility buttons: Help, plus an End-session "✕" while the access
<div gate is engaged. Kept in the left corner (not top-right) so they never
v-if="showLpQR" collide with the centered balance/commission chips, which wrap into the
class="fixed inset-0 z-[100] flex flex-col items-center justify-center gap-4 bg-black/90 p-4" top-right on narrower screens (e.g. sintra). A tap-in loads the holder's
@click.self="showLpQR = false" Bolt Card for the whole session, so the ✕ gives them an explicit way to
> re-lock the moment they're done rather than waiting out the idle timeout
<p class="text-sm text-white/70">Lightning.Pub nprofile</p> (which would leave the card usable by the next person meanwhile). -->
<div class="rounded-2xl"> <div class="absolute top-4 left-4 lg:top-8 lg:left-8 flex items-center gap-2 lg:gap-3">
<QRCode :value="lpNprofile" :size="400" /> <!-- End session first (leftmost): a solid `destructive` swatch so the exit
</div> reads as red in every theme (--destructive is theme-scoped). Shown
<code class="max-w-[90vw] truncate text-xs text-white/50">{{ lpNprofile }}</code> only while the access gate is engaged. -->
<div class="flex items-center gap-2"> <Button
<Button variant="outline" size="sm" class="text-white" @click="copyToClipboard(lpNprofile)"> v-if="atmStore.accessControl.enabled"
{{ copied ? 'Copied!' : 'Copy' }} variant="destructive"
</Button> size="icon"
<Button variant="outline" size="sm" class="text-white" @click="showLpQR = false"> class="h-14 w-14 lg:h-20 lg:w-20 rounded-full text-2xl lg:text-4xl font-bold"
Close aria-label="End session"
</Button> @click="atmStore.endSession()"
</div> >
✕
</Button>
<Button
variant="outline"
size="icon"
class="h-14 w-14 lg:h-20 lg:w-20 rounded-full border-2 border-muted-foreground/30 text-2xl lg:text-4xl text-muted-foreground"
aria-label="Help"
@click="$router.push('/support')"
>
?
</Button>
</div> </div>
<!-- Help button (top-left) -->
<Button
variant="outline"
size="icon"
class="absolute top-4 left-4 lg:top-8 lg:left-8 h-14 w-14 lg:h-20 lg:w-20 rounded-full border-2 border-muted-foreground/30 text-2xl lg:text-4xl text-muted-foreground"
@click="$router.push('/support')"
>
?
</Button>
<!-- Debug toggle (only visible when debug bar is hidden, never in production) --> <!-- Debug toggle (only visible when debug bar is hidden, never in production) -->
<Button <Button
v-if="!atmStore.debugMode && atmStore.allowMockFallback" v-if="!atmStore.debugMode && atmStore.allowMockFallback"

View file

@ -0,0 +1,109 @@
<script setup lang="ts">
/**
* Access gate — "tap your Bolt Card" screen (ADR-003, tap-to-enter).
*
* Shown when the machine is healthy but locked (App.vue's `isLocked` branch).
* Entry is a single Bolt Card tap: the card is read by the main-process NFC
* reader and routed to the store (`handleBoltCardEntry`) which authorizes it
* (open-enrollment) and, on grant, loads the card into the session so buy/sell
* only need "Complete". This view is presentation-only — it shows the prompt
* and live reader status; the store owns the tap handling and machine events.
*
* Card-only by design: no camera/npub-QR, no PIN. A dev paste-box (debug builds)
* and a dev-unlock button remain for testing without hardware.
*/
import { computed, ref } from 'vue'
import { useAtmStore } from '@/stores/atm'
import { useBranding } from '@/composables/useBranding'
import { Button } from '@/components/ui/button'
import ColorModeToggle from '@/components/ColorModeToggle.vue'
import { Nfc } from 'lucide-vue-next'
const atmStore = useAtmStore()
const { logoUrl, title } = useBranding()
const denyReason = computed(() => atmStore.snapshot?.context.accessDenyReason ?? null)
const showDevUnlock = computed(() => atmStore.accessControl.devUnlock)
const nfc = computed(() => atmStore.nfcStatus)
const reading = computed(() => atmStore.boltCardProcessing)
// Dev: paste an lnurlw to simulate a tap-to-enter without a card.
const mockLnurlw = ref('')
</script>
<template>
<div
class="relative flex flex-1 flex-col items-center justify-center gap-10 bg-background p-8 text-foreground"
>
<!-- Light/dark toggle — shared kiosk-sized component -->
<ColorModeToggle class="absolute right-4 top-4 z-10" />
<!-- Brand: logo + title only, colours from the active theme (branding.json) -->
<div class="flex flex-col items-center gap-4">
<img v-if="logoUrl" :src="logoUrl" alt="" class="h-[16vh] max-h-44 w-auto object-contain" />
<h1 class="text-3xl font-bold tracking-tight lg:text-5xl">{{ title }}</h1>
</div>
<!-- Tap target -->
<div class="flex flex-col items-center gap-6">
<div
class="flex items-center justify-center rounded-full border-4 border-primary bg-card shadow-xl"
:class="reading ? 'animate-pulse' : ''"
style="width: min(48vw, 15rem); aspect-ratio: 1 / 1"
>
<Nfc class="size-24 text-primary lg:size-28" />
</div>
<p class="text-2xl font-semibold text-foreground lg:text-4xl">
{{ reading ? 'Reading card…' : 'Tap your Bolt Card to begin' }}
</p>
<!-- Reader status / denial reason -->
<p v-if="denyReason" class="text-lg font-medium text-destructive lg:text-2xl">
{{ denyReason }}
</p>
<p
v-else-if="nfc?.message"
class="text-base lg:text-xl"
:class="
nfc.state === 'declined' || nfc.state === 'error'
? 'text-destructive'
: 'text-muted-foreground'
"
>
{{ nfc.message }}
</p>
<p v-else class="max-w-md text-center text-base text-muted-foreground lg:text-xl">
Hold your card flat against the reader
</p>
</div>
<!-- Dev affordances -->
<div class="mt-2 flex flex-col items-center gap-2">
<Button
v-if="showDevUnlock"
variant="ghost"
size="sm"
class="text-muted-foreground opacity-40 transition-opacity hover:opacity-100"
@click="atmStore.devUnlock()"
>
Dev unlock
</Button>
<div v-if="atmStore.debugMode" class="flex items-center gap-2">
<input
v-model="mockLnurlw"
placeholder="lnurlw://… (paste to simulate a tap)"
class="w-56 rounded border border-input bg-background px-2 py-1 text-xs"
/>
<Button
variant="outline"
size="sm"
:disabled="!mockLnurlw"
@click="atmStore.simulateBoltCardEntry(mockLnurlw)"
>
Tap
</Button>
</div>
</div>
</div>
</template>

View file

@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { nip19 } from 'nostr-tools'
import { marked } from 'marked' import { marked } from 'marked'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
@ -23,35 +22,6 @@ import { QrCode, ExternalLink } from 'lucide-vue-next'
const router = useRouter() const router = useRouter()
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
// Lightning.Pub config loaded at runtime from Electron main process
const lpPubkey = ref('')
const relayUrl = ref('')
onMounted(async () => {
if (isElectron && window.electronAPI) {
const config = await window.electronAPI.getConfig()
lpPubkey.value = config.lightningPubPubkey || ''
relayUrl.value = config.relayUrl || ''
} else {
// Dev fallback: use Vite env vars
lpPubkey.value = import.meta.env.VITE_LIGHTNING_PUB_PUBKEY || ''
relayUrl.value = import.meta.env.VITE_RELAY_URL || ''
}
})
// Build nprofile for Lightning.Pub (pubkey + relay hint)
const lpNprofile = computed(() => {
if (!lpPubkey.value) return ''
const relays = relayUrl.value ? [relayUrl.value.replace('ws://', 'wss://')] : []
return nip19.nprofileEncode({ pubkey: lpPubkey.value, relays })
})
// Deep link URL: opens ShockWallet with this ATM's Lightning.Pub pre-filled
const shockwalletDeepLink = computed(() => {
if (!lpNprofile.value) return ''
return `https://wallet.aiolabs.dev/sources/add?nprofile=${encodeURIComponent(lpNprofile.value)}`
})
interface SupportPage { interface SupportPage {
id: string id: string
title: string title: string
@ -74,17 +44,11 @@ const defaultPages: SupportPage[] = [
| Blink | No | Partial | Yes | Yes | https://www.blink.sv | | Blink | No | Partial | Yes | Yes | https://www.blink.sv |
| Zeus | Yes | Yes | Yes | Yes | https://zeusln.com | | Zeus | Yes | Yes | Yes | Yes | https://zeusln.com |
| Breez | Yes | Yes | Yes | Yes | https://breez.technology | | Breez | Yes | Yes | Yes | Yes | https://breez.technology |
| ShockWallet | No | Yes | Yes | Yes | [shockwallet-deep-link] | | ShockWallet | No | Yes | Yes | Yes | https://shockwallet.app |
Tap a QR icon to scan and download a wallet. Tap a QR icon to scan and download a wallet.
**Non-custodial** means you hold your own keys and have full control of your Bitcoin. **KYC-free** means no identity verification is required. Partial (~) means limits apply without verification. **Non-custodial** means you hold your own keys and have full control of your Bitcoin. **KYC-free** means no identity verification is required. Partial (~) means limits apply without verification.`,
## Using ShockWallet with this ATM
Scan the QR code below to add this ATM's Lightning node to your ShockWallet. This lets you send and receive sats directly through the ATM's payment system.
[lp-nprofile]`,
}, },
{ {
id: 'faq', id: 'faq',
@ -153,7 +117,6 @@ type Segment =
| { type: 'qr'; content: string } | { type: 'qr'; content: string }
| { type: 'table'; table: ParsedTable } | { type: 'table'; table: ParsedTable }
| { type: 'qr-placeholder' } | { type: 'qr-placeholder' }
| { type: 'lp-nprofile' }
/** Parse markdown table into structured data */ /** Parse markdown table into structured data */
function parseMarkdownTable(tableLines: string[]): ParsedTable | null { function parseMarkdownTable(tableLines: string[]): ParsedTable | null {
@ -176,17 +139,8 @@ function parseMarkdownTable(tableLines: string[]): ParsedTable | null {
return { headers, rows } return { headers, rows }
} }
/** Resolve dynamic placeholders in markdown content */
function resolvePlaceholders(md: string): string {
return md.replace(
'[shockwallet-deep-link]',
shockwalletDeepLink.value || 'https://wallet.aiolabs.dev'
)
}
/** Parse content into segments: html, qr, or table */ /** Parse content into segments: html, qr, or table */
function parseContent(md: string): Segment[] { function parseContent(md: string): Segment[] {
md = resolvePlaceholders(md)
const segments: Segment[] = [] const segments: Segment[] = []
const lines = md.split('\n') const lines = md.split('\n')
let htmlBlock = '' let htmlBlock = ''
@ -235,9 +189,6 @@ function parseContent(md: string): Segment[] {
} else if (trimmed === '[operator-qr-placeholder]') { } else if (trimmed === '[operator-qr-placeholder]') {
flushHtml() flushHtml()
segments.push({ type: 'qr-placeholder' }) segments.push({ type: 'qr-placeholder' })
} else if (trimmed === '[lp-nprofile]') {
flushHtml()
segments.push({ type: 'lp-nprofile' })
} else { } else {
htmlBlock += line + '\n' htmlBlock += line + '\n'
} }
@ -375,33 +326,6 @@ onUnmounted(() => {
</CardContent> </CardContent>
</Card> </Card>
<!-- Lightning.Pub nprofile QR (scannable by ShockWallet) -->
<Card v-else-if="seg.type === 'lp-nprofile'" class="my-6 mx-auto max-w-xs">
<CardContent class="flex flex-col items-center gap-3 p-6">
<template v-if="lpNprofile">
<div class="rounded-xl bg-white p-3">
<QrcodeVue
:value="lpNprofile"
:size="180"
level="L"
render-as="svg"
background="#ffffff"
foreground="#000000"
/>
</div>
<span class="text-xs text-muted-foreground text-center px-2">
Scan with ShockWallet to connect
</span>
</template>
<template v-else>
<QrCode class="h-16 w-16 text-muted-foreground/30" />
<span class="text-sm text-muted-foreground/50 text-center">
Lightning.Pub not configured
</span>
</template>
</CardContent>
</Card>
<!-- Table with inline QR codes --> <!-- Table with inline QR codes -->
<div v-else-if="seg.type === 'table'" class="mb-8"> <div v-else-if="seg.type === 'table'" class="mb-8">
<Table class="text-sm sm:text-base lg:text-xl w-full"> <Table class="text-sm sm:text-base lg:text-xl w-full">

View file

@ -19,7 +19,7 @@ deploy/nixos/
│ └── 99-bitspire-hardware.rules # additional udev rules (loaded via configuration.nix) │ └── 99-bitspire-hardware.rules # additional udev rules (loaded via configuration.nix)
├── provision-atm.sh # Push LNbits credentials to a deployed ATM via SSH ├── provision-atm.sh # Push LNbits credentials to a deployed ATM via SSH
├── atm-transactions.sh # Operator query tool — reads /var/lib/bitspire/state.db ├── atm-transactions.sh # Operator query tool — reads /var/lib/bitspire/state.db
├── flash-douro-usb.sh # Helper for flashing a douro live USB ├── flash-douro-usb.sh # Helper for flashing a douro LIVE ISO (not the -usb disk image)
└── build-iso.sh # Convenience wrapper for `nix build .#iso-<model>` └── build-iso.sh # Convenience wrapper for `nix build .#iso-<model>`
``` ```
@ -33,8 +33,36 @@ Each ATM model has two flake outputs:
| `nixosConfigurations.<model>-installed` | installed | full GPT + systemd-boot install, ext4 root, supports `nixos-rebuild switch` | | `nixosConfigurations.<model>-installed` | installed | full GPT + systemd-boot install, ext4 root, supports `nixos-rebuild switch` |
| `packages.x86_64-linux.iso-<model>` | ISO | ISO image of the live variant | | `packages.x86_64-linux.iso-<model>` | ISO | ISO image of the live variant |
| `packages.x86_64-linux.disk-image-<model>` | raw image | dd-able full disk image of the installed variant | | `packages.x86_64-linux.disk-image-<model>` | raw image | dd-able full disk image of the installed variant |
| `nixosConfigurations.<model>-usb` | installed, on a stick | `<model>-installed` hardened to run from a USB stick: `nixos-usb`/`ESP-USB` labels, `nofail` `/boot`, no partition growing, auto-upgrade off, `uas` blacklisted |
| `packages.x86_64-linux.disk-image-<model>-usb` | raw image | dd-able image of the `-usb` variant — flash, plug in, boot; no installer step |
Models: `douro`, `tejo`, `sintra`, `batm3`. Models: `douro`, `tejo`, `sintra`, `batm3`. All four have `-usb` outputs.
**Run-from-USB deployments** skip the Alpine + dd-to-internal-disk procedure
below entirely: the stick *is* the system. That is how `batm3` runs today, how
`douro` runs since the LNbits cutover, and how `tejo` is being brought up — its
internal storage still holds the factory Debian (`ubilinux4`) and is never
touched. Flash `disk-image-<model>-usb` with balenaEtcher (it verifies the
write — a truncated or bad copy fails stage-1 fsck on first boot) onto a stick
of 16 GB or more, plug it in, power on. Updates go in-place against the
`<model>-usb` config (`nix copy` the toplevel + `switch-to-configuration`),
which keeps pairing and `/var/lib/bitspire`.
### Two bootloader shapes, picked by firmware
The `-usb` images are not interchangeable between models, because the fleet's
firmware is not:
| Models | Partition table | Bootloader | Why |
|---|---|---|---|
| `batm3`, `douro` | `efi` (GPT + ESP) | systemd-boot | Their firmware UEFI-USB-boots fine via the ESP's removable `/EFI/BOOT/BOOTX64.EFI` fallback |
| `tejo`, `sintra` | `hybrid` (GPT + `bios_grub` + ESP) | GRUB, BIOS **and** UEFI | Aaeon UP Board firmware USB-boots in Legacy/BIOS mode — it boots the live ISO via isolinux, not the ESP. systemd-boot is UEFI-only, so a dd'd systemd-boot stick isn't recognised as bootable at all. The hybrid image boots either way |
Both shapes come out of `mkUsbDiskImage` in `flake.nix`; the GRUB override is
`usbGrubHybridModule`. A UP Board `-usb` config installs GRUB with
`devices = [ "nodev" ]` so an in-place `switch-to-configuration` on a live
stick only regenerates `grub.cfg` — the image build is the one place the BIOS
stage gets written to an MBR.
```bash ```bash
# Build a Sintra disk image # Build a Sintra disk image
@ -59,7 +87,7 @@ scp bitspire@<sintra-ip>:/var/lib/bitspire/.env ~/sintra-backup-$(date +%Y
scp bitspire@<sintra-ip>:/var/lib/bitspire/state.db ~/sintra-backup-$(date +%Y%m%d)/ scp bitspire@<sintra-ip>:/var/lib/bitspire/state.db ~/sintra-backup-$(date +%Y%m%d)/
``` ```
The `.env` is the load-bearing one — it contains `VITE_ATM_PRIVATE_KEY` plus the LNbits / relay URLs. `state.db` is transaction history (cheap to keep, fine to drop on dev units). Reuse these in step 7 instead of regenerating. The `.env` is the load-bearing one — it contains `VITE_SPIRE_SEED` (the NIP-46 bunker pairing seed; or the dev-only `VITE_ATM_PRIVATE_KEY` fallback) plus the LNbits / relay URLs. Note the persisted bunker binding (the ATM's transport key) lives in `state.db` once paired — so on a bunker-backed unit, keep `state.db` too or you'll need to re-pair. `state.db` also holds transaction history. Reuse these in step 7 instead of regenerating.
Also before powering off the Sintra: make sure any unpushed commits on `dev` have been pushed AND `./deploy/push-cache.sh sintra` has run. Otherwise the next 04:00 auto-upgrade on the freshly-flashed unit will fail to substitute the new closure (or silently downgrade to whatever `origin/dev` HEAD points at). Also before powering off the Sintra: make sure any unpushed commits on `dev` have been pushed AND `./deploy/push-cache.sh sintra` has run. Otherwise the next 04:00 auto-upgrade on the freshly-flashed unit will fail to substitute the new closure (or silently downgrade to whatever `origin/dev` HEAD points at).
@ -187,7 +215,7 @@ The `dev`-branch `flake.nix` pins the auto-upgrade source to `?ref=dev` so any A
```nix ```nix
system.autoUpgrade = { system.autoUpgrade = {
enable = true; enable = true;
flake = "git+ssh://forgejo@git.atitlan.io/aiolabs/lamassu-next.git?ref=dev#${machineModel}-installed"; flake = "git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=dev#${machineModel}-installed";
dates = "04:00"; dates = "04:00";
allowReboot = false; allowReboot = false;
}; };
@ -202,7 +230,7 @@ Production ATMs on `main` continue to read `main`'s flake (no `?ref=` pin → re
| Path | Owner | Purpose | | Path | Owner | Purpose |
|------|-------|---------| |------|-------|---------|
| `/var/lib/bitspire/` | bitspire:bitspire, 0750 | Service data directory | | `/var/lib/bitspire/` | bitspire:bitspire, 0750 | Service data directory |
| `/var/lib/bitspire/.env` | bitspire:bitspire, 0600 | Runtime config — `VITE_RELAY_URL`, `VITE_LNBITS_SERVER_PUBKEY`, `VITE_ATM_PRIVATE_KEY`, … | | `/var/lib/bitspire/.env` | bitspire:bitspire, 0600 | Runtime config — `VITE_RELAY_URL`, `VITE_LNBITS_SERVER_PUBKEY`, `VITE_SPIRE_SEED` (or dev `VITE_ATM_PRIVATE_KEY`), … |
| `/var/lib/bitspire/state.db` | bitspire:bitspire | SQLite — cassette inventory, cashbox state, transaction history | | `/var/lib/bitspire/state.db` | bitspire:bitspire | SQLite — cassette inventory, cashbox state, transaction history |
| `/var/lib/bitspire/logs/` | bitspire:bitspire, 0750 | Service logs (if app writes them) | | `/var/lib/bitspire/logs/` | bitspire:bitspire, 0750 | Service logs (if app writes them) |
| `/var/lib/bitspire/branding/` | bitspire:bitspire, 0755 | Operator branding override (logo.png + branding.json) — see issue #47 | | `/var/lib/bitspire/branding/` | bitspire:bitspire, 0755 | Operator branding override (logo.png + branding.json) — see issue #47 |
@ -262,8 +290,8 @@ ls -la /dev/serial/by-id/
{ {
services.bitspire = { services.bitspire = {
enable = true; enable = true;
relayUrl = "wss://relay.aiolabs.dev"; # ATM ↔ LNbits relay relayUrl = ""; # seed-provided (#70); set to PIN a relay
lnbitsServerPubkey = "<64-hex>"; # LNbits transport pubkey lnbitsServerPubkey = ""; # seed-provided (#70); set to PIN a pubkey
appDir = "/opt/bitspire"; # rarely overridden — defaults via flake appDir = "/opt/bitspire"; # rarely overridden — defaults via flake
dataDir = "/var/lib/bitspire"; # rarely overridden dataDir = "/var/lib/bitspire"; # rarely overridden
logLevel = "info"; # error | warn | info | debug logLevel = "info"; # error | warn | info | debug

View file

@ -0,0 +1,5 @@
{
"enabled": true,
"openEnrollment": true,
"devUnlock": false
}

172
deploy/nixos/atm-reconcile.sh Executable file
View file

@ -0,0 +1,172 @@
#!/usr/bin/env bash
# atm-reconcile — Check each cassette's ledger count against recorded history
#
# The cassettes table is a running total maintained by the machine: operator
# ops add to it (refill) or set it (recount / empty), and dispenses subtract
# from it. That means the count can be re-derived, and a derived value that
# disagrees with the stored one is evidence of something the ledger never saw.
#
# Reconciliation runs forward from each bay's last ABSOLUTE truth point — a
# `recount` (someone opened the bay and counted it) or an `empty` (set to
# zero). Refills are deltas and cannot serve as a baseline: a refill applied
# on top of a wrong number just carries the error forward, which is exactly
# how a bad count survives for weeks.
#
# expected = base + refills_since_base - dispensed_since_base
# gap = ledger - expected
#
# A non-zero gap means either notes moved without an op recording it, or a
# dispense moved notes the dispenser's counters did not report. The second is
# real: a note that leaves the bay and jams in the transport completes neither
# the `dispensed` nor the `rejected` counter, so the bay silently reads one
# high while the transaction row says nothing was dispensed (aiolabs/bitspire#122).
#
# A bay with NO baseline cannot be reconciled at all — its starting number
# came from somewhere unrecorded. Publish a recount for it; that is the only
# op that establishes ground truth (and the only one that clears the
# counts-uncertain flag).
#
# Usage:
# atm-reconcile # reconcile every bay
# atm-reconcile --csv # machine-readable
# atm-reconcile --quiet # exit status only, no output
#
# Exit status:
# 0 every bay reconciles
# 1 at least one bay has a non-zero gap or no baseline
# 2 database missing / unreadable
set -euo pipefail
DB="${ATM_STATE_DB:-/var/lib/bitspire/state.db}"
FORMAT="-column -header"
QUIET=false
while [[ $# -gt 0 ]]; do
case "$1" in
--csv) FORMAT="-csv -header"; shift ;;
--quiet) QUIET=true; shift ;;
-h|--help)
sed -n '2,36p' "$0" | sed 's/^# \{0,1\}//'
exit 0 ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
if [ ! -r "$DB" ]; then
echo "ERROR: state database not readable at $DB" >&2
exit 2
fi
# Per-bay baseline, then the deltas since it. Written as scalar subqueries
# rather than joins on purpose: joining transaction_bills to cassettes fans
# out across bays, and a LEFT JOIN whose rows are all filtered out by the
# baseline cutoff collapses to NULL and poisons the arithmetic downstream.
RECONCILE_SQL="
WITH bay AS (
SELECT
c.position AS position,
c.denomination AS denomination,
c.count AS ledger,
(SELECT o.op_at FROM cassette_ops o
WHERE o.position = c.position AND o.op_type IN ('recount','empty')
ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_at,
(SELECT CASE o.op_type WHEN 'empty' THEN 0 ELSE o.count END FROM cassette_ops o
WHERE o.position = c.position AND o.op_type IN ('recount','empty')
ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_count
FROM cassettes c
),
delta AS (
SELECT
bay.*,
(SELECT COALESCE(SUM(o.bills), 0) FROM cassette_ops o
WHERE o.position = bay.position AND o.op_type = 'refill'
AND o.op_at > COALESCE(bay.base_at, -1)) AS added,
(SELECT COALESCE(SUM(tb.count), 0)
FROM transaction_bills tb
JOIN transactions t ON t.txid = tb.txid
WHERE tb.denomination = bay.denomination
AND t.type IN ('cash_out','manual_dispense')
AND t.created_at / 1000 > COALESCE(bay.base_at, -1)) AS dispensed
FROM bay
)
SELECT
position AS 'Bay',
denomination AS 'Denom',
CASE WHEN base_at IS NULL THEN '(none)' ELSE datetime(base_at,'unixepoch') END AS 'Baseline',
CASE WHEN base_at IS NULL THEN NULL ELSE base_count END AS 'Base',
added AS 'Refilled',
dispensed AS 'Dispensed',
ledger AS 'Ledger',
CASE WHEN base_at IS NULL THEN NULL
ELSE base_count + added - dispensed END AS 'Expected',
CASE WHEN base_at IS NULL THEN 'NO BASELINE'
ELSE printf('%+d', ledger - (base_count + added - dispensed)) END AS 'Gap'
FROM delta
ORDER BY position;
"
# Bays sharing a denomination break per-bay attribution: transaction_bills
# records what denomination went out, never which bay it came from, so the
# dispensed figure lands on every matching bay.
AMBIGUOUS=$(sqlite3 "$DB" \
"SELECT group_concat(denomination) FROM (
SELECT denomination FROM cassettes GROUP BY denomination HAVING COUNT(*) > 1);")
PROBLEMS=$(sqlite3 "$DB" "
WITH bay AS (
SELECT c.position AS position, c.denomination AS denomination, c.count AS ledger,
(SELECT o.op_at FROM cassette_ops o
WHERE o.position = c.position AND o.op_type IN ('recount','empty')
ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_at,
(SELECT CASE o.op_type WHEN 'empty' THEN 0 ELSE o.count END FROM cassette_ops o
WHERE o.position = c.position AND o.op_type IN ('recount','empty')
ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_count
FROM cassettes c
)
SELECT COUNT(*) FROM bay WHERE base_at IS NULL OR ledger <> (
base_count
+ (SELECT COALESCE(SUM(o.bills),0) FROM cassette_ops o
WHERE o.position = bay.position AND o.op_type = 'refill' AND o.op_at > bay.base_at)
- (SELECT COALESCE(SUM(tb.count),0) FROM transaction_bills tb
JOIN transactions t ON t.txid = tb.txid
WHERE tb.denomination = bay.denomination
AND t.type IN ('cash_out','manual_dispense')
AND t.created_at / 1000 > bay.base_at));")
if ! $QUIET; then
# shellcheck disable=SC2086 # $FORMAT must word-split into two sqlite3 flags
sqlite3 $FORMAT "$DB" "$RECONCILE_SQL"
echo
UNCERTAIN=$(sqlite3 "$DB" \
"SELECT COALESCE(NULLIF(value,''),'') FROM meta WHERE key = 'countsUncertainSince';")
if [ -n "$UNCERTAIN" ]; then
echo "Counts flagged UNVERIFIED since $(date -u -d "@$UNCERTAIN" '+%F %T UTC' 2>/dev/null || echo "$UNCERTAIN")"
echo " A dispense ended without a trustworthy report. Only a recount clears this."
else
echo "Counts not flagged unverified."
fi
echo
echo "=== Unresolved dispense failures ==="
# shellcheck disable=SC2086 # $FORMAT must word-split into two sqlite3 flags
sqlite3 $FORMAT "$DB" "
SELECT txid AS 'TX ID', status AS 'Status',
printf('%.2f', fiat_cents / 100.0) AS 'Fiat', currency AS 'Cur',
COALESCE(error,'') AS 'Error',
datetime(created_at / 1000,'unixepoch') AS 'Time (UTC)'
FROM transactions
WHERE status IN ('dispense_error','partial')
ORDER BY created_at DESC;"
if [ -n "$AMBIGUOUS" ]; then
echo
echo "WARNING: denomination(s) $AMBIGUOUS are loaded in more than one bay."
echo " Dispenses are recorded by denomination, not by bay, so the Dispensed"
echo " column double-counts across those bays. Reconcile them as a group."
fi
fi
[ "${PROBLEMS:-0}" -eq 0 ] || exit 1

View file

@ -141,7 +141,7 @@ sql "SELECT
t.fiat_cents / 100 AS 'Fiat', t.fiat_cents / 100 AS 'Fiat',
t.sats AS 'Sats', t.sats AS 'Sats',
t.fee_sats AS 'Fee Sats', t.fee_sats AS 'Fee Sats',
printf('%.1f%%', t.fee_percent * 100) AS 'Fee %', printf('%.1f%%', t.fee_fraction * 100) AS 'Fee %',
CASE WHEN t.exchange_rate > 0 THEN printf('%.0f', t.exchange_rate) ELSE '-' END AS 'Rate', CASE WHEN t.exchange_rate > 0 THEN printf('%.0f', t.exchange_rate) ELSE '-' END AS 'Rate',
datetime(t.created_at / 1000, 'unixepoch') AS 'Time (UTC)', datetime(t.created_at / 1000, 'unixepoch') AS 'Time (UTC)',
group_concat('Q' || tb.denomination || 'x' || tb.count) AS 'Bills' group_concat('Q' || tb.denomination || 'x' || tb.count) AS 'Bills'

View file

@ -20,18 +20,17 @@ in
relayUrl = mkOption { relayUrl = mkOption {
type = types.str; type = types.str;
default = "wss://relay.aiolabs.dev"; default = "";
description = '' description = ''
Nostr relay URL the ATM and LNbits both subscribe to. Optional override for the Nostr relay the ATM uses. Empty by
default (aiolabs/bitspire#70): the relay comes from the pairing
On a fresh-boot disk image this value is seeded into SEED, not from provisioning — a fresh machine boots blank, scans a
`/var/lib/bitspire/.env` as `VITE_RELAY_URL=…` (see flake.nix spire-seed, and the seed's relay drives the connection. A non-empty
`bitspire-env` activation script). The operator can override value here is seeded into `/var/lib/bitspire/.env` as
the seeded value at runtime by editing `.env` directly or by `VITE_RELAY_URL=…` and WINS over the seed (env-first precedence), so
re-running `deploy/nixos/provision-atm.sh` with a different only set it to pin a machine to a specific relay. The renderer's
`RELAY_URL`. The renderer's resolution order is: resolution order is: `VITE_RELAY_URL` (this / .env) → the pairing
`/var/lib/bitspire/.env` → this NixOS default → renderer seed's relay → a dev-only `ws://localhost:7777` fallback.
hardcoded fallback (`ws://localhost:7777`).
''; '';
}; };
@ -39,10 +38,13 @@ in
type = types.str; type = types.str;
default = ""; default = "";
description = '' description = ''
LNbits nostr-transport server pubkey (hex, 64 chars). Published Optional override for the LNbits nostr-transport server pubkey
by the LNbits server on startup. Required for the ATM to talk (hex, 64 chars). Empty by default (aiolabs/bitspire#70): the
to its wallet. Provisioned by provision-atm.sh; can be left pubkey comes from the pairing SEED (the seed's `lnbits_npub`), so
empty on disk-image builds. a seed-paired machine needs nothing here. A non-empty value is
seeded into `.env` as `VITE_LNBITS_SERVER_PUBKEY=…` and WINS over
the seed (env-first precedence) — set it only to pin a machine to
a specific server. Mirrors `relayUrl`.
''; '';
}; };
@ -130,6 +132,28 @@ in
description = "Camera device"; description = "Camera device";
}; };
}; };
# Contactless (CCID) card reader for Bolt Card taps — ADR-003.
#
# Opt-in, and deliberately defaulted off: only some machines have a reader
# fitted, and on a machine without one the app must not so much as
# initialise nfc-pcsc, because pcsclite busy-spins Electron's main thread
# when pcscd is absent (the full story is in nfc-service.ts). Per-model
# truth lives in `nfcReaderForModel` in flake.nix, since sintra and tejo
# share hardware/upboard.nix but only one of them has a reader.
nfc = {
enable = mkOption {
type = types.bool;
default = false;
description = ''
Enable the Bolt Card reader. Starts pcscd, authorises the `bitspire`
user to talk to it and to the card via polkit, installs the
wedge-recovery unit, and tells the app to initialise NFC at all.
Leave false on machines with no reader fitted; cash-out over QR is
unaffected either way.
'';
};
};
}; };
config = mkIf cfg.enable { config = mkIf cfg.enable {
@ -141,11 +165,14 @@ in
"d ${cfg.dataDir}/branding 0755 bitspire bitspire -" "d ${cfg.dataDir}/branding 0755 bitspire bitspire -"
]; ];
# Environment file for ATM configuration # Descriptive-only ATM info at /etc/bitspire/config.env. NOTE: this is NOT
# the runtime environment — the systemd service's EnvironmentFile is
# mkForce'd to /var/lib/bitspire/.env, and the renderer reads only VITE_*
# vars. Relay + server pubkey are deliberately omitted here: they come from
# the pairing seed (aiolabs/bitspire#70), and duplicating them as non-VITE
# RELAY_URL/LNBITS_SERVER_PUBKEY only invited "looks authoritative" confusion.
environment.etc."bitspire/config.env".text = '' environment.etc."bitspire/config.env".text = ''
# bitSpire ATM Configuration # bitSpire ATM Configuration (descriptive; not the runtime env)
RELAY_URL=${cfg.relayUrl}
LNBITS_SERVER_PUBKEY=${cfg.lnbitsServerPubkey}
LOG_LEVEL=${cfg.logLevel} LOG_LEVEL=${cfg.logLevel}
DATA_DIR=${cfg.dataDir} DATA_DIR=${cfg.dataDir}
@ -166,6 +193,70 @@ in
ELECTRON_DISABLE_GPU=false ELECTRON_DISABLE_GPU=false
''; '';
# ── Bolt Card reader (services.bitspire.nfc.enable) ─────────────────
# Lifted out of hardware/batm3.nix and hardware/upboard.nix so that "is a
# reader fitted" is one per-machine flag rather than a block copied into
# each hardware file — upboard.nix is shared by sintra (OMNIKEY 5022) and
# tejo (no reader), so a hardware file cannot answer the question.
# pcscd binds the CCID driver to the reader; the app talks to pcscd's
# socket via nfc-pcsc rather than the USB device directly. Reader-agnostic
# (Feitian KP382 on batm3, HID Global OMNIKEY 5022 on sintra).
services.pcscd.enable = mkIf cfg.nfc.enable true;
# pcscd gates client access via polkit; without a rule the sandboxed
# `bitspire` service user is "Rejected unauthorized PC/SC client".
# Authorise it to talk to the daemon and the card, and to trigger the
# wedge-recovery unit below.
security.polkit.extraConfig = mkIf cfg.nfc.enable ''
polkit.addRule(function(action, subject) {
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
action.id == "org.debian.pcsc-lite.access_card") &&
subject.user == "bitspire") {
return polkit.Result.YES;
}
});
polkit.addRule(function(action, subject) {
if (action.id == "org.freedesktop.systemd1.manage-units" &&
action.lookup("unit") == "nfc-reader-reset.service" &&
subject.user == "bitspire") {
return polkit.Result.YES;
}
});
'';
# NFC reader wedge-recovery. A CCID reader (the Feitian R502-CL especially)
# 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. This oneshot re-binds the reader's USB device (a software
# replug); pcscd + nfc-pcsc then re-detect it on hotplug with no app
# restart (verified on-device). The app (unprivileged `bitspire`) starts it
# via the polkit rule above when it sees repeated read failures. Matches
# the USB CCID interface class (0x0B), so a future reader swap needs no
# config change.
systemd.services.nfc-reader-reset = mkIf cfg.nfc.enable {
description = "Power-cycle a wedged CCID NFC reader (USB re-bind)";
serviceConfig = {
Type = "oneshot";
ExecStart = pkgs.writeShellScript "reset-nfc-reader" ''
set -u
found=0
for iface in /sys/bus/usb/devices/*:*/bInterfaceClass; do
[ -f "$iface" ] || continue
[ "$(${pkgs.coreutils}/bin/cat "$iface" 2>/dev/null)" = "0b" ] || continue
ifname=$(${pkgs.coreutils}/bin/basename "$(${pkgs.coreutils}/bin/dirname "$iface")")
dev=''${ifname%%:*}
echo "reset-nfc-reader: power-cycling CCID reader USB device $dev" >&2
echo -n "$dev" > /sys/bus/usb/drivers/usb/unbind 2>/dev/null || true
${pkgs.coreutils}/bin/sleep 2
echo -n "$dev" > /sys/bus/usb/drivers/usb/bind 2>/dev/null || true
found=1
done
[ "$found" = 1 ] || { echo "reset-nfc-reader: no CCID reader found" >&2; exit 1; }
'';
};
};
# Main ATM service # Main ATM service
systemd.services.bitspire = { systemd.services.bitspire = {
description = "bitSpire ATM Application"; description = "bitSpire ATM Application";
@ -176,6 +267,11 @@ in
]; ];
wants = [ "network-online.target" ]; wants = [ "network-online.target" ];
# Read by electron/main.ts. Lives in the unit rather than the .env
# EnvironmentFile because .env is only written when absent, so a machine
# provisioned months ago would never pick a new value up.
environment.BITSPIRE_NFC_ENABLED = boolToString cfg.nfc.enable;
serviceConfig = { serviceConfig = {
Type = "simple"; Type = "simple";
User = "bitspire"; User = "bitspire";

View file

@ -33,7 +33,7 @@ esac
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
echo "=== Building Lamassu ATM Live USB ISO (model: $MODEL) ===" echo "=== Building bitSpire ATM Live USB ISO (model: $MODEL) ==="
echo "" echo ""
echo "This is a pure Nix build — no local pnpm required." echo "This is a pure Nix build — no local pnpm required."
echo "" echo ""

View file

@ -3,10 +3,256 @@
{ config, lib, pkgs, pkgs-unstable, ... }: { config, lib, pkgs, pkgs-unstable, ... }:
let
# ── Firmware pruning (bitspire#70 sizing) ────────────────────────────
# hardware.enableRedistributableFirmware installs the entire linux-firmware
# tree: 752MB compressed, 16% of the image and its single largest item. The
# fleet is four fixed Intel boards. The other ~640MB is firmware for
# Qualcomm, Mellanox, NVIDIA, Marvell, AMD and MediaTek parts that will
# never appear in one of these machines.
#
# Keep only what a bitSpire board can plausibly load. Entries are paths
# inside lib/firmware; nothing outside this list is copied.
firmwareKeep = [
# Intel GPU. Gen9 (Apollo Lake) loads DMC from here. Bay Trail and
# Haswell load nothing, but 9.6MB is cheap insurance against a board swap.
"i915"
# Intel WiFi, 89MB and the bulk of what survives, covering every Intel
# card since 2008. This is the conservative half of the trade: losing the
# network on a deployed ATM is not remotely recoverable. Narrow it to the
# specific generation once each machine's card is known, via
# `lspci -k | grep -A3 Network` on the box.
"intel/iwlwifi"
"rtl_nic" # Realtek GbE (r8169) — the UP Board's onboard NIC
"rtw88" # Realtek WiFi, the usual M.2 or USB retrofit
"rtw89"
"brcm" # Broadcom WiFi, the other usual retrofit
# Intel Smart Sound Technology DSP, 420KB. Cherry Trail boards (sintra,
# tejo) probe intel_sst_acpi at boot whether or not anything will use the
# audio, and without the blob every boot logs
# Direct firmware load for intel/fw_sst_22a8.bin failed with error -2
# Found by pruning, rebooting sintra and reading dmesg. The audio stack is
# gone so this changes no behaviour, but a recurring error in a payment
# terminal's boot log is worth 420KB to remove: an error people learn to
# ignore is one they will ignore when it matters.
"intel/fw_sst_0f28.bin"
"intel/fw_sst_0f28_ssp0.bin"
"intel/fw_sst_22a8.bin"
];
# Prune the tree rather than hand-pick files, so a firmware bump can't
# silently drop a blob we depend on. Left UNCOMPRESSED on purpose: NixOS
# compresses each hardware.firmware entry itself, zstd or xz depending on
# what the machine's kernel understands, and douro's 5.15 predates zstd
# firmware support. Pre-compressing here would hand douro a tree it cannot
# read.
bitspireFirmware = pkgs.runCommand "linux-firmware-bitspire"
{
inherit (pkgs.linux-firmware) version;
meta = pkgs.linux-firmware.meta // {
description = "linux-firmware pruned to the hardware bitSpire ships on";
};
}
''
src=${pkgs.linux-firmware}/lib/firmware
dst=$out/lib/firmware
mkdir -p "$dst"
for p in ${lib.escapeShellArgs firmwareKeep}; do
if [ ! -e "$src/$p" ]; then
echo "ERROR: firmwareKeep entry '$p' is not in linux-firmware" >&2
exit 1
fi
mkdir -p "$dst/$(dirname "$p")"
cp -a "$src/$p" "$dst/$p"
done
# A kept directory can contain symlinks pointing at blobs OUTSIDE it:
# brcm/brcmfmac*.bin are links into cypress/, for instance. Left dangling
# they fail nixpkgs' firmware compression step, and silently deleting
# them would quietly drop firmware a device needs. So pull the targets in
# instead. Looped because a resolved target can itself be a link.
for _pass in 1 2 3; do
_pulled=0
while IFS= read -r link; do
tgt=$(readlink -m "$link")
case "$tgt" in
"$dst"/*) rel=''${tgt#"$dst"/} ;;
*) continue ;;
esac
if [ ! -e "$dst/$rel" ] && [ -e "$src/$rel" ]; then
mkdir -p "$dst/$(dirname "$rel")"
cp -a "$src/$rel" "$dst/$rel"
_pulled=1
fi
done < <(find "$dst" -xtype l)
[ "$_pulled" -eq 0 ] && break
done
# Anything still dangling is not in linux-firmware at all. Fail loudly
# rather than ship a tree with holes in it.
if find "$dst" -xtype l | grep -q .; then
echo "ERROR: dangling firmware symlinks after resolution:" >&2
find "$dst" -xtype l >&2
exit 1
fi
# linux-firmware stores many blobs under a vendor directory and leaves a
# flat top-level symlink pointing at them, e.g.
# iwlwifi-cc-a0-77.ucode -> intel/iwlwifi/iwlwifi-cc-a0-77.ucode. The
# kernel requests the flat name, so a kept blob is useless without its
# link. Recreate every top-level link whose target survived the prune.
( cd "$src"
find . -maxdepth 1 -type l -printf '%f\t%l\n' \
| while IFS="$(printf '\t')" read -r link target; do
# if/then, not `[ ... ] && ln`: the latter makes the loop's exit
# status depend on whether the LAST candidate matched, and a
# non-match returns 1, which set -e turns into a build failure.
# Whether it fails is then a function of readdir order.
if [ -e "$dst/$target" ]; then
ln -s "$target" "$dst/$link"
fi
done
)
echo "firmware kept: $(find "$dst" -type f | wc -l) files, \
$(find "$dst" -type l | wc -l) links, $(du -sh "$dst" | cut -f1) uncompressed"
'';
in
{ {
# System basics # System basics
system.stateVersion = "24.05"; system.stateVersion = "24.05";
# ── Image slimming (bitspire#70 sizing) ──────────────────────────────
# This is a single-purpose Electron kiosk; strip the desktop/multimedia
# baggage NixOS pulls in by default so the disk image stays lean.
# - speechd: text-to-speech (speech-dispatcher → espeak-ng → mbrola, ~1GB).
# An ATM does not talk.
# - documentation: man/info/NixOS manual — no one reads them on a kiosk.
# - pipewire: the audio stack (+ WirePlumber, ALSA, the PulseAudio shim),
# ~353MB. The app has never played a sound — nothing under apps/machine or
# packages/ constructs an Audio element or ships an audio file.
#
# All three need mkForce, not just an absent/false assignment: enabling
# services.xserver pulls in NixOS's `graphical-desktop` module, which
# mkDefault-enables speechd AND pipewire (services/misc/graphical-desktop.nix).
# Dropping our own `enable = true` simply falls back to that default — the
# 353MB stayed until this was forced off. Re-enable pipewire (with alsa +
# pulse) and security.rtkit if transaction sounds are ever added.
services.speechd.enable = lib.mkForce false;
services.pipewire.enable = lib.mkForce false;
documentation.enable = false;
documentation.nixos.enable = false;
# Ship the pruned firmware tree instead of all of linux-firmware. mkForce
# because every hardware/*.nix sets enableRedistributableFirmware = true;
# overriding once here keeps the four machines in step. Turning that option
# off also drops the extras it bundles (sof-firmware, libreelec-dvb,
# alsa-firmware, intel2200BG, zd1211fw and friends), none of which applies to
# a soundless kiosk on a wired Intel board. The regulatory database is
# normally implied by the same option, so ask for it explicitly: without it
# WiFi is pinned to the most restrictive channel set.
hardware.enableRedistributableFirmware = lib.mkForce false;
hardware.wirelessRegulatoryDatabase = true;
hardware.firmware = [ bitspireFirmware ];
# Make the prune stick. Without this, a nixpkgs bump or a stray module
# setting enableRedistributableFirmware back to true silently re-adds 750MB
# and nobody notices until an eMMC runs out of room at 04:00. The regex
# matches the upstream package's versioned name (linux-firmware-20260519)
# and deliberately not ours (linux-firmware-bitspire), so the pruned tree
# passes and the full one fails the build with a readable error.
system.forbiddenDependenciesRegexes = [ "linux-firmware-[0-9]" ];
# Mesa without an LLVM-backed rasterizer.
#
# nixpkgs builds Mesa with 21 gallium drivers. Two of them, llvmpipe and
# radeonsi, link LLVM, and that RPATH pulls llvm-21-lib into the system
# closure: 540MB, a ninth of the image, on a kiosk with a soldered Intel GPU.
#
# The driver list has to span three Intel generations:
# crocus EVERY machine in the fleet. Surveyed, not assumed: sintra
# and tejo are Braswell [8086:22b0], batm3 is Haswell GT2
# [8086:0412], douro is Bay Trail. sintra and batm3 were read
# straight off their running X logs; both say crocus.
# i915 pre-Gen4, insurance against an older board turning up.
# softpipe the software rasterizer that does NOT use LLVM. Kept so a
# board whose KMS driver fails still brings up X, slowly,
# rather than dying headless in the field.
#
# ── IRIS IS DELIBERATELY ABSENT AND RE-ADDING IT COSTS 540MB ────────
# iris covers Gen8+ big-core Intel, which nothing here has. Its absence is
# what lets -Dllvm=disabled below work: mesa's meson puts
# with_gallium_iris in with_driver_using_cl and then
# with_llvm.enable_if(with_clc, 'CLC requires LLVM')
# so asking for iris drags in the OpenCL frontend and the whole of
# llvm-lib. Mesa's closure is 88MB without iris, 633MB with.
#
# A newer x86 board — a modern NUC, the "build it from these parts" kiosk
# — WILL need iris. Until one exists, such a board falls back to softpipe
# and renders in software: it boots, it displays, it looks fine, and it is
# very slow. Check `DRI driver:` in /var/log/X.0.log on any new hardware
# rather than assuming this list still covers it.
# i915 pre-Gen4, insurance against an older board turning up
# softpipe the software rasterizer that does NOT use LLVM. Kept so a
# board whose KMS driver fails still brings up X, slowly,
# rather than dying headless in the field. This is the role
# llvmpipe was playing, for 540MB.
#
# Vulkan is emptied because nothing here uses it, and its software ICD
# (lavapipe) is the other LLVM consumer. The VDPAU and VA state trackers
# have to go with it: meson refuses to build them unless one of the AMD or
# NVIDIA gallium drivers is present. Intel VA-API is unaffected, it comes
# from intel-media-driver in hardware/*.nix.
hardware.graphics.package =
(pkgs.mesa.override {
galliumDrivers = [ "crocus" "i915" "softpipe" ];
vulkanDrivers = [ ];
vulkanLayers = [ ];
}).overrideAttrs
(old: {
mesonFlags = old.mesonFlags ++ [
# Severs LLVM outright. Only possible because iris is out of the
# driver list above; with iris present meson refuses this flag.
# Verified with patchelf: libgallium.so ends up with no libLLVM in
# its DT_NEEDED, not merely absent from the closure listing.
# Dropping llvmpipe alone never achieved this.
(lib.mesonEnable "llvm" false)
(lib.mesonBool "gallium-rusticl" false)
# nixpkgs builds the asahi/panfrost cross tools and installs
# mesa-clc on native builds. Both reference prog_mesa_clc, which
# exists only when CLC is on, so they go with LLVM. An x86 kiosk
# has no use for either.
(lib.mesonOption "tools" "")
(lib.mesonBool "install-mesa-clc" false)
(lib.mesonBool "install-precomp-compiler" false)
(lib.mesonEnable "gallium-vdpau" false)
(lib.mesonEnable "gallium-va" false)
(lib.mesonEnable "intel-rt" false)
];
# Mesa declares spirv2dxil and cross_tools as outputs unconditionally,
# but they only receive files when the d3d12, asahi or panfrost gallium
# drivers are built, and none of those are in the list above. Nix fails
# a build that leaves a declared output unproduced, so create them
# empty. (Mesa sets __structuredAttrs, so $outputs is a bash array and
# a plain `for o in $outputs` loop silently does nothing here.)
postInstall = (old.postInstall or "") + ''
mkdir -p "$spirv2dxil" "$cross_tools" "$opencl"
'';
# With rusticl off there is no libRusticlOpenCL.so, and Mesa's
# postFixup patchelfs it unconditionally. Drop just that argument.
# The assert makes a nixpkgs bump that reshapes this line fail loudly
# here rather than silently stop removing LLVM.
postFixup =
let
marker = " $opencl/lib/libRusticlOpenCL.so";
in
assert lib.assertMsg (lib.hasInfix marker old.postFixup)
"mesa postFixup no longer patchelfs libRusticlOpenCL.so; revisit this override";
lib.replaceStrings [ marker ] [ "" ] old.postFixup;
});
# Networking # Networking
networking = { networking = {
hostName = "bitspire"; hostName = "bitspire";
@ -89,45 +335,48 @@
user = "bitspire"; user = "bitspire";
}; };
# Audio (for transaction sounds)
security.rtkit.enable = true;
services.pipewire = {
enable = true;
alsa.enable = true;
pulse.enable = true;
};
# System packages # System packages
#
# Kept deliberately thin — this is a kiosk, and every entry here is closure
# that ships to each ATM and eats eMMC headroom the nightly rebuild needs.
# Deliberately absent (see #70 sizing):
# git 70MB. nixos-rebuild fetches the flake with its OWN git-minimal,
# which stays in the closure via unit-nixos-upgrade.service, so
# auto-upgrade is unaffected.
# vim 43MB. Replaced by nano — an on-box editor is worth a few MB for
# field edits to /var/lib/bitspire/.env, vim's bulk is not.
# nodejs_22 94MB. Nothing runs it: the app is Electron (which embeds its
# own node) and fund-atm already pins pkgs-unstable.nodejs itself.
# wget curl covers it.
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
# System utilities # System utilities
htop htop
vim nano
git
curl curl
wget
# Hardware debugging # Hardware debugging
usbutils usbutils
pciutils pciutils
lsof lsof
# Serial port tools # Serial port tools (validator/dispenser live on ttyJ5/ttyJ7 — these are
# how a field fault gets diagnosed, and they cost ~2MB between them)
minicom minicom
screen screen
# For the Electron app # For the Electron app
pkgs-unstable.electron pkgs-unstable.electron
# Node.js for the application # Camera support. v4l-utils' default build drags in the whole Qt6 stack
pkgs-unstable.nodejs_22 # for its qv4l2 GUI (~0.5GB) — we only ever use the v4l2-ctl CLI, so drop
# the GUI.
# Camera support (v4l-utils.override { withGUI = false; })
v4l-utils
fswebcam fswebcam
# ATM operations # ATM operations
sqlite sqlite
(writeShellScriptBin "atm-transactions" (builtins.readFile ./atm-transactions.sh)) (writeShellScriptBin "atm-transactions" (builtins.readFile ./atm-transactions.sh))
(writeShellScriptBin "atm-reconcile" (builtins.readFile ./atm-reconcile.sh))
]; ];
# Enable SSH for remote administration # Enable SSH for remote administration
@ -147,6 +396,18 @@
# Auto-updates (optional - disabled by default for stability) # Auto-updates (optional - disabled by default for stability)
# system.autoUpgrade.enable = false; # system.autoUpgrade.enable = false;
# Trust the Forgejo host key up front. system.autoUpgrade fetches the flake
# over ssh AS ROOT, and a machine whose root has never connected by hand has
# no known_hosts entry, so every nightly run dies at
# "Host key verification failed" before it reaches authentication. batm3 did
# exactly that, silently, from its 2026-08-06 install until 09-22 (#98): it
# sat on its install generation for six weeks while reporting a failed unit
# nobody was watching. sintra only ever worked because a human had ssh'd as
# root once and accepted the key. Declaring it means a freshly flashed ATM
# can update from first boot with no manual step.
programs.ssh.knownHosts."git.atitlan.io".publicKey =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMlo3f05o4+bk0+8x2VG91o9GubshOb46HmBPvND9pJx";
# pragma: allowlist secret # pragma: allowlist secret
# Ensure WireGuard private key directory exists with correct permissions # Ensure WireGuard private key directory exists with correct permissions
system.activationScripts.wireguard-key = '' system.activationScripts.wireguard-key = ''
@ -157,6 +418,40 @@
fi fi
''; '';
# The tunnel is operator-provisioned: wg0.key is written per machine after
# flashing, and until it is, `wg set … private-key` exits 1 with
# "fopen: No such file or directory". One failed unit makes
# switch-to-configuration exit 4, which marks the entire nightly
# system.autoUpgrade run as failed — so an ATM that simply never had its
# tunnel provisioned reports a broken updater for the life of the machine
# (sintra, #98). Skip the unit when there is no key instead of failing
# activation over an interface that was never set up; a provisioned machine
# is unaffected. Guarded on wg0 still being declared so the live image,
# which mkForce's the interfaces away, doesn't get a unit with no ExecStart.
systemd.services = lib.mkIf (config.networking.wireguard.interfaces ? wg0) (
let
iface = config.networking.wireguard.interfaces.wg0;
guard = { unitConfig.ConditionPathExists = "/var/lib/wireguard/wg0.key"; };
# The module emits one unit per peer alongside the interface unit, and a
# skipped interface is NOT a failed dependency, so the peer units still
# run and die on "Unable to modify interface: No such device" — same
# exit 4, different unit. Guard them too. Names come from the module's
# own `peers.*.name` option (whose default is the escaped public key)
# rather than re-deriving the escaping here; the `-refresh` suffix
# follows nixpkgs' peerUnitServiceName, where a peer's null refresh
# interval falls back to the interface's.
refreshes = peer:
(if peer.dynamicEndpointRefreshSeconds != null then
peer.dynamicEndpointRefreshSeconds
else
iface.dynamicEndpointRefreshSeconds) != 0;
peerUnit = peer:
"wireguard-wg0-peer-${peer.name}" + lib.optionalString (refreshes peer) "-refresh";
in
{ wireguard-wg0 = guard; }
// lib.listToAttrs (map (peer: lib.nameValuePair (peerUnit peer) guard) iface.peers)
);
# In-place rename migration: lamassu user → bitspire user. # In-place rename migration: lamassu user → bitspire user.
# Runs after `users` activation so the bitspire user exists with its UID. # Runs after `users` activation so the bitspire user exists with its UID.
# Idempotent: re-running on an already-migrated system is a chown no-op. # Idempotent: re-running on an already-migrated system is a chown no-op.
@ -184,6 +479,26 @@
''; '';
}; };
# In-place rename migration: VITE_LAMASSU_* → VITE_BITSPIRE_* in the
# provisioned .env. The machine reads MACHINE_MODEL / FIAT_CODE / CASSETTES
# from this file on every boot; renaming the keys in code without renaming
# them here would boot a live machine on preset defaults (wrong bays, wrong
# fiat) at the next nightly pull. Idempotent: a migrated file has nothing
# left to match. Runs before bitspire.service starts.
system.activationScripts.bitspire-env-migration = {
deps = [ "users" ];
text = ''
# Activation scripts run with a minimal PATH (coreutils, not gnugrep /
# gnused) — the first run of this snippet died with "sed: command not
# found" after printing success, so reference both by store path.
if [ -f /var/lib/bitspire/.env ] \
&& ${pkgs.gnugrep}/bin/grep -q '^VITE_LAMASSU_' /var/lib/bitspire/.env; then
${pkgs.gnused}/bin/sed -i 's/^VITE_LAMASSU_/VITE_BITSPIRE_/' /var/lib/bitspire/.env
echo "bitspire: migrated VITE_LAMASSU_* keys in /var/lib/bitspire/.env"
fi
'';
};
# Journal configuration # Journal configuration
services.journald = { services.journald = {
extraConfig = '' extraConfig = ''

View file

@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Factory-reset a bitSpire ATM to a truly-fresh state — the deterministic way to
# reproduce a brand-new machine so tests aren't masked by leftover env/db values
# (aiolabs/bitspire#70 remnant hygiene).
#
# WIPES:
# - /var/lib/bitspire/state.db (bunker binding, fee config, cassettes, cashbox,
# transactions, operator commands, replay watermarks — recreated on next boot)
# - /var/lib/bitspire/.env (truncated to the minimal image-baked template:
# machine model + fiat + display; drops relay, server pubkey, operator pubkey
# and any stored spire seed)
#
# After this the ATM boots UNPAIRED into the pairing wizard, exactly like a fresh
# disk image — so a scanned seed is the sole source of truth.
#
# Usage:
# bash factory-reset-atm.sh # SSH to localhost:2222 (QEMU)
# bash factory-reset-atm.sh 192.168.1.50 # a real ATM on the LAN
# bash factory-reset-atm.sh 192.168.1.50 22 # custom SSH port
# FORCE=1 bash factory-reset-atm.sh … # skip the confirmation prompt
# ATM_USER=root bash factory-reset-atm.sh … # override SSH user (default: bitspire)
set -euo pipefail
ATM_HOST="${1:-localhost}"
ATM_SSH_PORT="${2:-2222}"
ATM_USER="${ATM_USER:-bitspire}"
echo "=== Factory-reset bitSpire ATM at $ATM_USER@$ATM_HOST:$ATM_SSH_PORT ==="
echo "This WIPES state.db and truncates .env to the minimal template (keeps only"
echo "machine model + fiat). ALL pairing, cash accounting, and transaction history"
echo "on the ATM will be lost."
if [ "${FORCE:-}" != "1" ]; then
read -r -p "Type 'yes' to proceed: " confirm
[ "$confirm" = "yes" ] || { echo "Aborted."; exit 1; }
fi
ssh -o StrictHostKeyChecking=no -p "$ATM_SSH_PORT" "$ATM_USER@$ATM_HOST" 'sudo bash -s' <<'REMOTE'
set -euo pipefail
ENV=/var/lib/bitspire/.env
DB=/var/lib/bitspire/state.db
# Preserve model + fiat from the existing .env (fall back to sintra/EUR).
model=$(grep -E '^VITE_BITSPIRE_MACHINE_MODEL=' "$ENV" 2>/dev/null | cut -d= -f2- || true)
fiat=$(grep -E '^VITE_BITSPIRE_FIAT_CODE=' "$ENV" 2>/dev/null | cut -d= -f2- || true)
model=${model:-sintra}
fiat=${fiat:-EUR}
systemctl stop bitspire 2>/dev/null || true
# Wipe persisted state (db + WAL/SHM sidecars).
rm -f "$DB" "$DB-wal" "$DB-shm"
# Truncate .env to the minimal image-baked template.
cat > "$ENV" <<EOF
VITE_BITSPIRE_MACHINE_MODEL=$model
VITE_BITSPIRE_FIAT_CODE=$fiat
VITE_SPIRE_SEED=
ELECTRON_FORCE_PROD=1
DISPLAY=:0
EOF
chmod 600 "$ENV"
chown bitspire:bitspire "$ENV" 2>/dev/null || true
systemctl start bitspire 2>/dev/null || true
echo "--- .env is now (values blanked) ---"
sed -E 's/=.*/=/' "$ENV"
echo "--- state.db removed (recreated fresh on next boot) ---"
REMOTE
echo ""
echo "=== ATM factory-reset. It boots UNPAIRED → the pairing wizard. ==="
echo "Watch: ssh -p $ATM_SSH_PORT $ATM_USER@$ATM_HOST 'sudo journalctl -u bitspire -f'"

View file

@ -12,13 +12,36 @@
timeout = 3; timeout = 3;
}; };
# Pin the 6.6 LTS kernel. The Dell 9030 AIO's eGalax SAW touch panel
# (0eef:0001) works with the usbtouchscreen driver on 6.6 (the known-good
# internal-SATA install runs 6.6.68). On 25.11's default 6.12 kernel this
# old controller regressed: hid-multitouch grabs it and mis-parses the HID
# report ("failed to fetch feature 7", axes read stuck), usbtouchscreen
# refuses it, and touch is unusable regardless of udev/X config. Matching
# douro.nix's per-hardware kernel pin. Re-test touch before bumping this.
kernelPackages = pkgs.linuxPackages_6_6;
initrd.availableKernelModules = [ initrd.availableKernelModules = [
"xhci_pci" "xhci_pci"
"ahci" "ahci"
"usbhid" "usbhid"
"sd_mod" "sd_mod"
# USB mass-storage: required to boot the dd'd image from a USB stick
# (stage-1 must bind the flash drive as a SCSI disk so
# /dev/disk/by-label/nixos appears). Harmless on the internal-SATA
# install, where ahci+sd_mod already cover the root device.
#
# NOTE: deliberately NO "uas" here. Many USB sticks/bridges advertise
# UAS but drop off the bus ("device offline error, dev sdb") under the
# sustained write load of first-boot growPartition/journal/swapfile.
# Blacklisting uas below forces the slower-but-reliable usb-storage
# (Bulk-Only Transport) path. SATA/eMMC installs don't use uas anyway.
"usb_storage"
]; ];
# Keep the USB flash drive off the flaky UAS driver (see note above).
blacklistedKernelModules = [ "uas" ];
kernelModules = [ kernelModules = [
"kvm-intel" "kvm-intel"
"usbtouchscreen" "usbtouchscreen"
@ -27,6 +50,9 @@
kernelParams = [ kernelParams = [
"quiet" "quiet"
"splash" "splash"
# Disable USB autosuspend so the boot medium (and kiosk peripherals)
# aren't power-suspended mid-I/O — another cause of "device offline".
"usbcore.autosuspend=-1"
]; ];
}; };
@ -59,6 +85,11 @@
cpuFreqGovernor = "performance"; cpuFreqGovernor = "performance";
}; };
# The Feitian KP382 contactless reader (096e:0608) is declared as a machine
# capability, not here: `nfcReaderForModel` in flake.nix drives
# services.bitspire.nfc.enable, which owns pcscd, the polkit rules and the
# wedge-recovery unit (deploy/nixos/bitspire-atm.nix).
# Disable suspend/hibernate for kiosk # Disable suspend/hibernate for kiosk
systemd.targets = { systemd.targets = {
sleep.enable = false; sleep.enable = false;
@ -105,10 +136,20 @@
''; '';
# eGalax touchscreen (Dell 9030 AIO built-in panel) # eGalax touchscreen (Dell 9030 AIO built-in panel)
# The eGalax HID descriptor confuses libinput (treats it as touchpad). # By default usbhid/hid-multitouch claim the eGalax and mis-parse its
# Fix: unbind from usbhid at boot, bind to usbtouchscreen kernel module, # HID report descriptor (X axis reads as stuck), so touch is unusable.
# then apply calibration matrix after X11 starts. # Fix: hand the device to the usbtouchscreen kernel driver, which parses
# Unbind eGalax from usbhid, bind to usbtouchscreen # the raw eGalax protocol into a clean single-touch ABS device that the
# X evdev driver + calibration matrix (below) map correctly. This mirrors
# the known-good internal-SATA install.
#
# The RUN command modprobes usbtouchscreen ITSELF before unbinding usbhid
# and handing over via new_id. usbtouchscreen is also in boot.kernelModules
# (systemd-modules-load), but on a USB boot systemd-udev-trigger fires this
# rule (~2s) BEFORE modules-load gets usbtouchscreen in (~12s) — so the
# new_id write hit a not-yet-loaded driver and the panel was left bound to
# nothing. Loading it inline here makes the handoff independent of that
# boot-ordering race (on internal-SATA boot the order happened to work).
services.udev.extraRules = lib.mkAfter '' services.udev.extraRules = lib.mkAfter ''
KERNEL=="ttyS[0-9]*", MODE="0666" KERNEL=="ttyS[0-9]*", MODE="0666"
KERNEL=="ttyUSB[0-9]*", MODE="0666" KERNEL=="ttyUSB[0-9]*", MODE="0666"
@ -116,7 +157,32 @@
SUBSYSTEM=="tty", ATTRS{serial}=="DDDLb103Y23", SYMLINK+="ttyF56", MODE="0666" SUBSYSTEM=="tty", ATTRS{serial}=="DDDLb103Y23", SYMLINK+="ttyF56", MODE="0666"
SUBSYSTEM=="tty", ATTRS{serial}=="A9YW78OC", SYMLINK+="ttyMEI", MODE="0666" SUBSYSTEM=="tty", ATTRS{serial}=="A9YW78OC", SYMLINK+="ttyMEI", MODE="0666"
SUBSYSTEM=="tty", ATTRS{serial}=="A9ZF8ELY", SYMLINK+="ttyNFC", MODE="0666" SUBSYSTEM=="tty", ATTRS{serial}=="A9ZF8ELY", SYMLINK+="ttyNFC", MODE="0666"
ACTION=="add", SUBSYSTEM=="usb", ATTRS{idVendor}=="0eef", ATTRS{idProduct}=="0001", RUN+="${pkgs.bash}/bin/bash -c 'echo ''$kernel:1.0 > /sys/bus/usb/drivers/usbhid/unbind 2>/dev/null; echo 0eef 0001 > /sys/bus/usb/drivers/usbtouchscreen/new_id 2>/dev/null'" ACTION=="add", SUBSYSTEM=="usb", ATTRS{idVendor}=="0eef", ATTRS{idProduct}=="0001", RUN+="${pkgs.bash}/bin/bash -c '${pkgs.kmod}/bin/modprobe usbtouchscreen 2>/dev/null; echo ''$kernel:1.0 > /sys/bus/usb/drivers/usbhid/unbind 2>/dev/null; echo 0eef 0001 > /sys/bus/usb/drivers/usbtouchscreen/new_id 2>/dev/null'"
# Belt-and-suspenders for touch calibration: on a slow USB boot the
# usbtouchscreen panel can bind AFTER egalax-calibrate's poll window, which
# leaves the panel uncalibrated and unresponsive ("dead"). (Re)start the
# calibration the instant the eGalax input node actually appears — this is
# device-driven, so it cannot lose a boot-timing race no matter how late the
# driver hands over. Pairs with egalax-calibrate's own (widened) poll loop.
ACTION=="add", SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="eGalax Inc. USB TouchController", TAG+="systemd", ENV{SYSTEMD_WANTS}+="egalax-calibrate.service"
'';
# Force the X evdev driver on the eGalax (not libinput). The usbtouchscreen
# node is a plain single-touch absolute device; evdev + the transformation
# matrix in egalax-calibrate below give correct orientation. Mirrors the
# working internal-SATA install's /etc/X11/xorg.conf.d/99-egalax.conf.
environment.etc."X11/xorg.conf.d/99-egalax.conf".text = ''
Section "InputClass"
Identifier "eGalax Touchscreen"
MatchVendor "0eef"
MatchProduct "0001"
MatchDevicePath "/dev/input/event*"
Driver "evdev"
Option "InvertY" "false"
Option "InvertX" "false"
Option "SwapAxes" "false"
Option "Calibration" ""
EndSection
''; '';
# Apply touchscreen calibration after X11 starts # Apply touchscreen calibration after X11 starts
@ -130,12 +196,32 @@
Type = "oneshot"; Type = "oneshot";
RemainAfterExit = true; RemainAfterExit = true;
User = "bitspire"; User = "bitspire";
Environment = "DISPLAY=:0"; # DISPLAY *and* XAUTHORITY — without the auth cookie xinput dies with
ExecStartPre = "${pkgs.coreutils}/bin/sleep 3"; # "Invalid MIT-MAGIC-COOKIE-1 key / Unable to connect to X server" and
ExecStart = "${pkgs.xorg.xinput}/bin/xinput set-prop 'eGalax Inc. USB TouchController' 'Coordinate Transformation Matrix' 0 -1.268 1.147 -1.224 0 1.118 0 0 1"; # the matrix is never applied, so touches register but land in the wrong
# place (the panel then feels dead). This was the actual boot-time bug.
Environment = [ "DISPLAY=:0" "XAUTHORITY=/home/bitspire/.Xauthority" ];
# Wait for the eGalax X device to appear (usbtouchscreen binds a little
# after display-manager on a USB boot) and retry, instead of a fixed
# sleep — more robust to boot timing. 120s window: on a slow USB boot the
# panel has bound as late as ~30-60s after display-manager, so a 30s cap
# gave up before the device appeared and left touch dead (this service is
# ALSO re-triggered by a udev rule when the input node shows up, so this
# loop is the fallback, not the only path). Matrix: swap X/Y + invert +
# scale to the active panel area (matches the known-good internal install).
ExecStart = pkgs.writeShellScript "egalax-calibrate" ''
for i in $(${pkgs.coreutils}/bin/seq 1 120); do
if ${pkgs.xorg.xinput}/bin/xinput list --name-only 2>/dev/null | ${pkgs.gnugrep}/bin/grep -qx 'eGalax Inc. USB TouchController'; then
exec ${pkgs.xorg.xinput}/bin/xinput set-prop 'eGalax Inc. USB TouchController' \
'Coordinate Transformation Matrix' 0 -1.268 1.147 -1.224 0 1.118 0 0 1
fi
${pkgs.coreutils}/bin/sleep 1
done
echo "egalax-calibrate: eGalax device not found after 120s" >&2
exit 1
'';
}; };
}; };
# WireGuard VPN address # WireGuard VPN address → wireguardIpForModel in flake.nix.
networking.wireguard.interfaces.wg0.ips = [ "10.0.0.5/24" ];
} }

View file

@ -20,12 +20,24 @@
initrd.availableKernelModules = [ initrd.availableKernelModules = [
"xhci_pci" "xhci_pci"
"ahci" "ahci"
# USB mass-storage: required to boot the dd'd image from a USB stick
# (stage-1 must bind the flash drive as a SCSI disk so
# /dev/disk/by-label/* appears). Harmless on the internal install.
#
# NOTE: deliberately NO "uas" here. Many USB sticks/bridges advertise
# UAS but drop off the bus ("device offline error, dev sdb") under
# sustained write load. Blacklisting uas below forces the slower-but-
# reliable usb-storage (Bulk-Only Transport) path. SATA/mSATA installs
# don't use uas anyway. (Same hardening as batm3.nix.)
"usb_storage" "usb_storage"
"sd_mod" "sd_mod"
"sdhci_pci" "sdhci_pci"
"i915" "i915"
]; ];
# Keep the USB flash drive off the flaky UAS driver (see note above).
blacklistedKernelModules = [ "uas" ];
kernelModules = [ kernelModules = [
"kvm-intel" "kvm-intel"
"i2c-dev" "i2c-dev"
@ -37,6 +49,9 @@
"vt.handoff=7" # Bay Trail: preserve BIOS display init "vt.handoff=7" # Bay Trail: preserve BIOS display init
"quiet" "quiet"
"splash" "splash"
# Disable USB autosuspend so the boot medium (and kiosk peripherals)
# aren't power-suspended mid-I/O — another cause of "device offline".
"usbcore.autosuspend=-1"
]; ];
}; };
@ -78,8 +93,7 @@
hybrid-sleep.enable = false; hybrid-sleep.enable = false;
}; };
# WireGuard VPN address # WireGuard VPN address → wireguardIpForModel in flake.nix.
networking.wireguard.interfaces.wg0.ips = [ "10.0.0.4/24" ];
# Serial port access for bill validator/dispenser # Serial port access for bill validator/dispenser
services.udev.extraRules = lib.mkAfter '' services.udev.extraRules = lib.mkAfter ''

View file

@ -0,0 +1,61 @@
# UP Board serial peripherals — the validator / dispenser / printer wiring
# shared by the INSTALLED configs (hardware/upboard.nix, used by both
# tejo-installed and sintra-installed) AND the sintra live ISO (live.nix).
# Single source of truth so the two artifacts can't drift — the earlier bug
# was exactly this drift (the sintra live ISO lacked ftdi_sio + the ttyJ7
# symlink, so the F56 dispenser failed while the installed image worked).
#
# Sintra IS a UP Board, so these are the UP Board rules; ttyS1/ttyS5 cover the
# older UP Board / UP4000 (Tejo) dispenser nodes and ttyS4 covers the Sintra
# (Apollo Lake) where the F56 is on the SoC MMIO UART. Only the device that
# actually exists at runtime gets the symlink, so all three coexist safely.
#
# Serial port mapping:
# ttyJ4 = Printer (Nippon NP-2511D-2)
# ttyJ5 = Validator (iVIZION, ID003)
# ttyJ7 = Dispenser (Fujitsu F53/F56)
{ lib, ... }:
{
boot.kernelModules = [
"usbserial" # USB-to-serial adapters
"ftdi_sio" # FTDI USB serial (the iVIZION validator bridge)
"cp210x" # CP210x USB serial (alternative adapter)
];
boot.kernelParams = [
# Do NOT route the kernel console through ttyS4 on Sintra. ttyS4 is the
# SoC's MMIO 16550A (the only real UART besides the legacy ttyS0 at I/O
# 0x3f8) and is wired to the Fujitsu F56 dispenser's RS-232 header. Holding
# it as console prevents userspace opening it at 9600 baud and HAL fails
# with "Input/output error setting custom baud rate of 9600". For serial
# debug, point console at ttyS0 instead.
"console=tty0"
];
services.udev.extraRules = lib.mkAfter ''
# Generic serial port permissions (so the non-root HAL user can open them)
KERNEL=="ttyS[0-9]*", MODE="0666"
KERNEL=="ttyUSB[0-9]*", MODE="0666"
KERNEL=="ttyACM[0-9]*", MODE="0666"
# Printer (ttyJ4)
KERNELS=="1-7.2:1.0", SYMLINK+="ttyJ4"
KERNEL=="ttyUSB0", SYMLINK+="ttyJ4"
# Validator (ttyJ5)
KERNELS=="1-7.3:1.0", SYMLINK+="ttyJ5"
KERNEL=="ttyUSB1", SYMLINK+="ttyJ5"
# Dispenser (ttyJ7). ttyS1/ttyS5 = older UP Board / UP4000; ttyS4 = Sintra.
KERNEL=="ttyS1", SYMLINK+="ttyJ7"
KERNEL=="ttyS4", SYMLINK+="ttyJ7"
KERNEL=="ttyS5", SYMLINK+="ttyJ7"
# Legacy ttyAMA0 alias
SUBSYSTEM=="tty", KERNEL=="ttyS1", SYMLINK+="ttyAMA0", GROUP="dialout"
# Disable USB autosuspend (prevents serial adapters from sleeping)
ACTION=="add", SUBSYSTEM=="usb", TEST=="power/control", ATTR{power/control}="on"
'';
}

View file

@ -11,6 +11,10 @@
{ config, lib, pkgs, ... }: { config, lib, pkgs, ... }:
{ {
# Serial peripherals (validator/dispenser/printer modules + udev symlinks +
# console=tty0) are shared with the live ISO via ./upboard-serial.nix.
imports = [ ./upboard-serial.nix ];
boot = { boot = {
loader = { loader = {
systemd-boot.enable = true; systemd-boot.enable = true;
@ -42,21 +46,12 @@
"kvm-intel" "kvm-intel"
"i2c-dev" "i2c-dev"
"spi-dev" "spi-dev"
"usbserial" # USB-to-serial adapters # Serial modules (usbserial/ftdi_sio/cp210x) → ./upboard-serial.nix.
"ftdi_sio" # FTDI USB serial
"cp210x" # CP210x USB serial
]; ];
kernelParams = [ kernelParams = [
"i915.enable_psr=0" "i915.enable_psr=0"
# NOTE: do NOT route the kernel console through ttyS4 on Sintra. # console=tty0 (keeps ttyS4 free for the F56) → ./upboard-serial.nix.
# ttyS4 is the SoC's MMIO 16550A (the only real UART besides the
# legacy ttyS0 at I/O 0x3f8) and is wired to the Fujitsu F56
# dispenser's RS-232 header on Sintra. Holding it as console
# prevents userspace from opening it at 9600 baud and HAL fails
# with "Input/output error setting custom baud rate of 9600".
# If you want serial debug, point console at ttyS0 instead.
"console=tty0"
"quiet" "quiet"
"splash" "splash"
]; ];
@ -96,6 +91,10 @@
cpuFreqGovernor = "performance"; cpuFreqGovernor = "performance";
}; };
# No pcscd here. This file is shared by sintra (HID Global OMNIKEY 5022
# fitted) and tejo (no reader), so the reader is declared per model via
# `nfcReaderForModel` in flake.nix → services.bitspire.nfc.enable.
# Disable suspend/hibernate for kiosk # Disable suspend/hibernate for kiosk
systemd.targets = { systemd.targets = {
sleep.enable = false; sleep.enable = false;
@ -104,37 +103,9 @@
hybrid-sleep.enable = false; hybrid-sleep.enable = false;
}; };
# Serial port permissions + tejo-specific symlinks # Camera + LED/SPI peripherals. The serial rules (validator/dispenser/printer
# symlinks + permissions) are shared with the live ISO in ./upboard-serial.nix.
services.udev.extraRules = lib.mkAfter '' services.udev.extraRules = lib.mkAfter ''
# Generic serial port permissions
KERNEL=="ttyS[0-9]*", MODE="0666"
KERNEL=="ttyUSB[0-9]*", MODE="0666"
KERNEL=="ttyACM[0-9]*", MODE="0666"
# ── Tejo serial port symlinks ──────────────────────────────────────
# Both UP Board and UP4000 rules included (match different kernel paths)
# Printer (ttyJ4)
KERNELS=="1-7.2:1.0", SYMLINK+="ttyJ4"
KERNEL=="ttyUSB0", SYMLINK+="ttyJ4"
# Validator (ttyJ5)
KERNELS=="1-7.3:1.0", SYMLINK+="ttyJ5"
KERNEL=="ttyUSB1", SYMLINK+="ttyJ5"
# Dispenser (ttyJ7).
# ttyS1 / ttyS5 cover earlier UP Board variants where the dispenser
# lands on those kernel-enumerated serial nodes; ttyS4 covers the
# Sintra (UP Board Atom/Apollo Lake) where the dispenser is wired
# to the SoC's MMIO UART. Whichever device actually exists at
# runtime gets the ttyJ7 symlink.
KERNEL=="ttyS1", SYMLINK+="ttyJ7"
KERNEL=="ttyS4", SYMLINK+="ttyJ7"
KERNEL=="ttyS5", SYMLINK+="ttyJ7"
# Legacy ttyAMA0 alias
SUBSYSTEM=="tty", KERNEL=="ttyS1", SYMLINK+="ttyAMA0", GROUP="dialout"
# ── Camera devices ───────────────────────────────────────────────── # ── Camera devices ─────────────────────────────────────────────────
SUBSYSTEM=="video4linux", ATTR{index}=="0", KERNELS=="1-5", ATTRS{idVendor}=="0ac8", ATTRS{idProduct}=="0345", SYMLINK+="video-scan" SUBSYSTEM=="video4linux", ATTR{index}=="0", KERNELS=="1-5", ATTRS{idVendor}=="0ac8", ATTRS{idProduct}=="0345", SYMLINK+="video-scan"
SUBSYSTEM=="video4linux", ATTR{index}=="0", KERNELS=="1-2", ATTRS{idVendor}=="0ac8", ATTRS{idProduct}=="0345", SYMLINK+="video-scan" SUBSYSTEM=="video4linux", ATTR{index}=="0", KERNELS=="1-2", ATTRS{idVendor}=="0ac8", ATTRS{idProduct}=="0345", SYMLINK+="video-scan"
@ -147,8 +118,5 @@
SUBSYSTEM=="spidev", GROUP="spi", MODE="0660" SUBSYSTEM=="spidev", GROUP="spi", MODE="0660"
SUBSYSTEM=="i2c-dev", GROUP="i2c", MODE="0660" SUBSYSTEM=="i2c-dev", GROUP="i2c", MODE="0660"
SUBSYSTEM=="leds", KERNEL=="upboard:*", ACTION=="add|change", RUN+="${pkgs.findutils}/bin/find /sys$devpath -type f -exec ${pkgs.coreutils}/bin/chmod g+u {} + -exec ${pkgs.coreutils}/bin/chown :leds {} +" SUBSYSTEM=="leds", KERNEL=="upboard:*", ACTION=="add|change", RUN+="${pkgs.findutils}/bin/find /sys$devpath -type f -exec ${pkgs.coreutils}/bin/chmod g+u {} + -exec ${pkgs.coreutils}/bin/chown :leds {} +"
# Disable USB autosuspend (prevents serial adapters from sleeping)
ACTION=="add", SUBSYSTEM=="usb", TEST=="power/control", ATTR{power/control}="on"
''; '';
} }

View file

@ -1,4 +1,4 @@
# Lamassu ATM Live USB Configuration # bitSpire ATM Live USB Configuration
# Bootable ISO for testing on physical hardware without installing to disk. # Bootable ISO for testing on physical hardware without installing to disk.
# #
# Parameterized by machineModel (passed via specialArgs from flake.nix): # Parameterized by machineModel (passed via specialArgs from flake.nix):
@ -10,7 +10,7 @@
# Does NOT import hardware/upboard.nix (its fileSystems conflict with live boot). # Does NOT import hardware/upboard.nix (its fileSystems conflict with live boot).
# Instead, duplicates only the hardware-relevant kernel modules and GPU config. # Instead, duplicates only the hardware-relevant kernel modules and GPU config.
{ config, lib, pkgs, pkgs-unstable, nixpkgs, machineModel ? "douro", atm-app, ... }: { config, lib, pkgs, pkgs-unstable, nixpkgs, machineModel ? "douro", atm-app, kioskLauncher, ... }:
let let
# Fiat code per machine model (for envTemplate display only) # Fiat code per machine model (for envTemplate display only)
@ -21,19 +21,17 @@ let
batm3 = "USD"; batm3 = "USD";
}.${machineModel} or "USD"; }.${machineModel} or "USD";
# .env template — runtime secrets are provisioned later via provision-atm.sh. # Minimal .env template (aiolabs/bitspire#70 remnant hygiene). Seed ONLY
# Only non-secret defaults and display vars go here. # image-baked, non-maskable values. Relay + server pubkey come from the pairing
# SEED, operator pubkey + fee config come from LNbits over the transport — so we
# deliberately do NOT pre-seed those keys (a present-but-empty VITE_RELAY_URL /
# VITE_LNBITS_SERVER_PUBKEY / VITE_OPERATOR_PUBKEYS would win over the seed and
# mask its source). VITE_SPIRE_SEED is written by the wizard / provision-atm.sh;
# the dev-only VITE_ATM_PRIVATE_KEY fallback is omitted on purpose.
envTemplate = pkgs.writeText "bitspire-env" '' envTemplate = pkgs.writeText "bitspire-env" ''
VITE_RELAY_URL= VITE_BITSPIRE_MACHINE_MODEL=${machineModel}
VITE_LIGHTNING_PUB_PUBKEY= VITE_BITSPIRE_FIAT_CODE=${fiatCodeForModel}
VITE_LIGHTNING_PUB_API_URL= VITE_SPIRE_SEED=
VITE_ADMIN_TOKEN=
VITE_ATM_PRIVATE_KEY=
VITE_EXTENSION_API_URL=
VITE_APP_ID=
VITE_LNDCONNECT_URL=
VITE_LAMASSU_MACHINE_MODEL=${machineModel}
VITE_LAMASSU_FIAT_CODE=${fiatCodeForModel}
ELECTRON_FORCE_PROD=1 ELECTRON_FORCE_PROD=1
DISPLAY=:0 DISPLAY=:0
''; '';
@ -49,13 +47,24 @@ in
# Reuse ATM systemd service module # Reuse ATM systemd service module
./bitspire-atm.nix ./bitspire-atm.nix
]; ]
# Sintra: share the UP Board serial hardware (validator/dispenser/printer
# modules + udev symlinks + console=tty0) with the installed image so the
# live ISO drives the same hardware. Safe to import here — unlike upboard.nix
# it declares no fileSystems, so there's no live-boot mount conflict.
++ lib.optionals (machineModel == "sintra") [ ./hardware/upboard-serial.nix ];
# ISO image settings # ISO image settings
image.fileName = "bitspire-${machineModel}-live.iso"; image.fileName = "bitspire-${machineModel}-live.iso";
isoImage = { isoImage = {
makeEfiBootable = true; makeEfiBootable = true;
makeBiosBootable = true; makeBiosBootable = true;
# Apply the isohybrid MBR + GPT/ESP so the image boots when dd'd to a USB
# stick — not just from optical media via El Torito. Without this the ISO
# has BIOS+UEFI El Torito boot catalogs but no partition table, and picky
# firmware (e.g. the Sintra's Aaeon UP Board) won't recognise the USB as
# bootable. Requires makeBiosBootable (isohdpfx.bin), set above.
makeUsbBootable = true;
squashfsCompression = "zstd -Xcompression-level 6"; squashfsCompression = "zstd -Xcompression-level 6";
}; };
@ -153,7 +162,7 @@ in
Environment = "LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib"; Environment = "LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib";
# Electron needs --no-sandbox in the live/testing environment # Electron needs --no-sandbox in the live/testing environment
# --enable-logging makes renderer console.log visible in journalctl # --enable-logging makes renderer console.log visible in journalctl
ExecStart = lib.mkForce "${pkgs-unstable.electron}/bin/electron --no-sandbox --disable-gpu-sandbox --disable-gpu --disable-software-rasterizer --enable-logging ${atm-app}"; ExecStart = lib.mkForce "${kioskLauncher}";
# Prevent Electron from consuming all RAM on memory-constrained ATMs # Prevent Electron from consuming all RAM on memory-constrained ATMs
MemoryMax = lib.mkForce "1G"; MemoryMax = lib.mkForce "1G";
# Disable all security hardening that conflicts with Electron # Disable all security hardening that conflicts with Electron
@ -167,19 +176,29 @@ in
}; };
}; };
# Install the .env on first boot # Install the .env on first boot. Attrset form with deps=["users"] so the
system.activationScripts.bitspire-env = '' # chown runs AFTER the bitspire user is created. Otherwise on a fresh live
mkdir -p /var/lib/bitspire # boot (where /var/lib/bitspire/.env doesn't exist yet) the chown runs in the
if [ ! -f /var/lib/bitspire/.env ]; then # default activation order — before `users` — and fails with
cp ${envTemplate} /var/lib/bitspire/.env # "chown: invalid user: 'bitspire:bitspire'". The installed system skips this
chmod 600 /var/lib/bitspire/.env # block because its .env already exists, which is why only live boots tripped.
chown bitspire:bitspire /var/lib/bitspire/.env system.activationScripts.bitspire-env = {
fi deps = [ "users" ];
''; text = ''
mkdir -p /var/lib/bitspire
if [ ! -f /var/lib/bitspire/.env ]; then
cp ${envTemplate} /var/lib/bitspire/.env
chmod 600 /var/lib/bitspire/.env
chown bitspire:bitspire /var/lib/bitspire/.env
fi
'';
};
# Reset display output after X starts (required for kexec boots where # Reset display output after X starts (required for kexec boots where
# the GPU wasn't reinitialized by BIOS firmware) # the GPU wasn't reinitialized by BIOS firmware). Only the eDP-panel models
systemd.services.display-reset = { # (Douro/Tejo) have an eDP-1 output; the Sintra drives HDMI-1, so the
# `xrandr --output eDP-1` here just errors out — skip it there.
systemd.services.display-reset = lib.mkIf (machineModel != "sintra") {
description = "Reset eDP display output"; description = "Reset eDP display output";
after = [ "display-manager.service" ]; after = [ "display-manager.service" ];
requires = [ "display-manager.service" ]; requires = [ "display-manager.service" ];
@ -193,12 +212,17 @@ in
}; };
}; };
# Swap file — Douro/Tejo have only 2GB RAM; without swap the system # Low-RAM models (Douro/Tejo, 2GB) need a swap cushion or they hard-freeze
# hard-freezes under memory pressure instead of gracefully OOM-killing. # under memory pressure. The live system is RAM-rooted, so a /var/swapfile
swapDevices = [{ # lives in tmpfs — pointless, and its init fails on a fresh boot. Use
device = "/var/swapfile"; # compressed RAM swap (zram) instead; no on-disk file required.
size = 1024; # MB zramSwap.enable = true;
}];
# The wg0 VPN tunnel (declared in configuration.nix) needs a provisioned key
# at /var/lib/wireguard/wg0.key, which a fresh live boot doesn't have — it
# fails and drags network-setup down with it. A live test image doesn't need
# the VPN, so drop the interface entirely.
networking.wireguard.interfaces = lib.mkForce { };
# Clean /tmp on boot to prevent stale Nix build artifacts from filling disk # Clean /tmp on boot to prevent stale Nix build artifacts from filling disk
boot.tmp.cleanOnBoot = true; boot.tmp.cleanOnBoot = true;

View file

@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Provision the access-control gate (ADR-003) to a deployed bitSpire ATM.
# Pushes an access.json to /var/lib/bitspire/ and restarts the service, so the
# gate can be toggled on a machine without an image rebuild (mirrors
# provision-branding.sh). Env defaults are overridden by whatever this file sets.
#
# Usage:
# bash provision-access.sh <access.json> # SSH to localhost:2222 (QEMU)
# bash provision-access.sh <access.json> 192.168.1.50 # a real ATM on the LAN
# bash provision-access.sh <access.json> 192.168.1.50 22 # custom SSH port
#
# access.json schema (all keys optional; omitted keys fall back to env/defaults):
# {
# "enabled": true, // master switch for the gate
# "openEnrollment": true, // admit any Bolt Card (gate is not a security boundary)
# "devUnlock": false, // on-screen dev/operator unlock (bypasses the gate; default off)
# "salt": "per-machine", // hashing salt (provision a real one for prod)
# "allowList": [ // authorized identities (hashed); empty in open mode
# { "idHash": "<hashId(external_id,salt)>", "role": "user", "pinHash": "<hashPin(pin,salt)>" }
# ]
# }
#
# To DISABLE the gate again: push a file with {"enabled": false} (or delete
# /var/lib/bitspire/access.json on the machine) and restart.
set -euo pipefail
ACCESS_FILE="${1:-}"
ATM_HOST="${2:-localhost}"
ATM_SSH_PORT="${3:-2222}"
ATM_USER="bitspire"
REMOTE_FILE="/var/lib/bitspire/access.json"
if [ -z "$ACCESS_FILE" ]; then
echo "Usage: $0 <access.json> [host] [port]" >&2
echo " $0 ./access.json (QEMU on localhost:2222)" >&2
echo " $0 ./access.json 192.168.1.50 (real ATM)" >&2
exit 1
fi
if [ ! -f "$ACCESS_FILE" ]; then
echo "ERROR: access file not found: $ACCESS_FILE" >&2
exit 1
fi
# Fail fast on malformed JSON before touching the machine.
if command -v jq >/dev/null 2>&1; then
jq empty "$ACCESS_FILE" || { echo "ERROR: $ACCESS_FILE is not valid JSON" >&2; exit 1; }
fi
echo "=== Provisioning access gate to $ATM_HOST:$ATM_SSH_PORT ==="
echo "Local file : $ACCESS_FILE"
echo "Remote file: $REMOTE_FILE"
cat "$ACCESS_FILE"
echo ""
# Copy over SSH. --rsync-path=sudo because /var/lib/bitspire is owned by the
# bitspire service user, not the SSH user.
rsync -avz \
--rsync-path="sudo rsync" \
-e "ssh -o StrictHostKeyChecking=no -p $ATM_SSH_PORT" \
"$ACCESS_FILE" \
"$ATM_USER@$ATM_HOST:$REMOTE_FILE"
# Restart so loadAccessControl() re-reads the file.
ssh -o StrictHostKeyChecking=no -p "$ATM_SSH_PORT" "$ATM_USER@$ATM_HOST" \
"sudo systemctl restart bitspire"
echo ""
echo "=== Access gate provisioned. Service restarted. ==="

View file

@ -4,16 +4,25 @@
# kind-21000 NIP-44 v2 events on a relay — there is no out-of-band token, # kind-21000 NIP-44 v2 events on a relay — there is no out-of-band token,
# the ATM's nostr private key IS the credential. # pragma: allowlist secret # the ATM's nostr private key IS the credential. # pragma: allowlist secret
# #
# Required environment variables (or edit defaults below): # The primary input is SPIRE_SEED — the pairing seed carries the relay, the
# LNBITS_SERVER_PUBKEY Hex pubkey published by the LNbits server at startup. # LNbits server pubkey AND the signing identity, so a seed-provisioned machine
# From the LNbits compose: # needs nothing else (aiolabs/bitspire#70).
# docker logs lnbits | grep 'nostr_transport pubkey' #
# LNBITS_HTTP_URL Origin LNbits is reachable at over HTTP, used only # Environment variables:
# to compose the LNURL-withdraw callback URL that # SPIRE_SEED RECOMMENDED. The spire pairing seed
# customer wallets dereference. Default: http://10.0.2.2:5000 # (`spire-seed:v1:<base64url>`) minted by spirekeeper.
# RELAY_URL Nostr relay LNbits subscribes on. Default uses host gateway. # Carries relay + LNbits server pubkey + the production
# ATM_PRIVATE_KEY 32-byte hex key, ATM's nostr identity. If unset, a # identity under the NIP-46 bunker (aiolabs/bitspire#52 / #70).
# fresh key is generated and saved in the .env. # RELAY_URL OPTIONAL override — pins VITE_RELAY_URL and WINS over the
# seed's relay (env-first precedence). Leave unset to let the
# seed drive it. Required only on the no-seed dev path
# (default there: ws://$HOST_IP:5001/nostrrelay/test).
# LNBITS_SERVER_PUBKEY OPTIONAL override (hex). Leave unset with a seed. On the
# no-seed dev path it's scraped from
# `docker logs lnbits | grep 'nostr_transport pubkey'`.
# ATM_PRIVATE_KEY DEV-ONLY 32-byte hex nsec fallback, used only when
# SPIRE_SEED is unset (no bunker). Generated if unset
# AND no SPIRE_SEED is provided.
# #
# Usage: # Usage:
# bash provision-atm.sh # defaults: SSH to localhost:2222 (QEMU) # bash provision-atm.sh # defaults: SSH to localhost:2222 (QEMU)
@ -55,33 +64,58 @@ else
echo "--- LAN ATM: using $HOST_IP as dev machine address ---" echo "--- LAN ATM: using $HOST_IP as dev machine address ---"
fi fi
# Step 2: Resolve the LNbits server pubkey. Prefer the env override; else # Steps 2-4: transport config (relay + LNbits server pubkey) + signing identity.
# fall back to scraping the local docker compose stack. #
if [ -z "${LNBITS_SERVER_PUBKEY:-}" ]; then # Under aiolabs/bitspire#70 the relay + server pubkey come from the pairing SEED,
# so a seed-provisioned machine needs NEITHER in .env. We only pin them when the
# operator EXPLICITLY passes RELAY_URL / LNBITS_SERVER_PUBKEY (a deliberate
# override that WINS over the seed via env-first precedence), or when there is no
# seed (the dev-nsec fallback has nothing else to supply them, so we scrape/default).
TRANSPORT_LINES=""
if [ -n "${SPIRE_SEED:-}" ]; then
case "$SPIRE_SEED" in
spire-seed:v1:*) : ;;
*) echo "ERROR: SPIRE_SEED must start with 'spire-seed:v1:'"; exit 1 ;;
esac
echo "" echo ""
echo "--- Step 1: Extracting LNbits nostr-transport pubkey from docker logs ---" echo "--- Spire pairing seed: relay + LNbits pubkey come from the seed ---"
LNBITS_SERVER_PUBKEY=$(docker logs lnbits 2>&1 \ if [ -n "${RELAY_URL:-}" ]; then
| grep -oP 'nostr_transport pubkey:?\s*\K[a-f0-9]{64}' \ echo " (pinning VITE_RELAY_URL=$RELAY_URL — overrides the seed's relay)"
| tail -1 || true) TRANSPORT_LINES="VITE_RELAY_URL=$RELAY_URL"
if [ -z "$LNBITS_SERVER_PUBKEY" ]; then
echo "ERROR: Could not extract LNbits pubkey. Set LNBITS_SERVER_PUBKEY explicitly"
echo "or start the LNbits stack first (docker compose -f docker/docker-compose.dev.yml up lnbits)."
exit 1
fi fi
fi if [ -n "${LNBITS_SERVER_PUBKEY:-}" ]; then
echo "LNbits server pubkey: ${LNBITS_SERVER_PUBKEY:0:16}..." TRANSPORT_LINES="${TRANSPORT_LINES:+$TRANSPORT_LINES
}VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY"
# Step 3: Pin LNbits HTTP origin. fi
LNBITS_HTTP_URL="${LNBITS_HTTP_URL:-http://$HOST_IP:5000}" IDENTITY_LINES="# Spire pairing seed — bunker-backed identity (aiolabs/bitspire#52)
VITE_SPIRE_SEED=$SPIRE_SEED"
# Step 4: Relay URL. else
RELAY_URL="${RELAY_URL:-ws://$HOST_IP:7777}" # No seed → DEV-ONLY nsec fallback. Nothing else supplies the relay + pubkey,
# so scrape/default them.
# Step 5: ATM identity. Generate if unset. if [ -z "${LNBITS_SERVER_PUBKEY:-}" ]; then
if [ -z "${ATM_PRIVATE_KEY:-}" ]; then echo ""
ATM_PRIVATE_KEY=$(openssl rand -hex 32) echo "--- No seed: extracting LNbits nostr-transport pubkey from docker logs ---"
echo "" LNBITS_SERVER_PUBKEY=$(docker logs lnbits 2>&1 \
echo "--- Generated fresh ATM_PRIVATE_KEY (save this if you want it persisted) ---" | grep -oP 'nostr_transport pubkey:?\s*\K[a-f0-9]{64}' \
| tail -1 || true)
if [ -z "$LNBITS_SERVER_PUBKEY" ]; then
echo "ERROR: no SPIRE_SEED, and could not extract the LNbits pubkey."
echo "Provide a SPIRE_SEED (recommended — the seed carries relay + pubkey),"
echo "or set LNBITS_SERVER_PUBKEY explicitly."
exit 1
fi
fi
RELAY_URL="${RELAY_URL:-ws://$HOST_IP:5001/nostrrelay/test}"
TRANSPORT_LINES="VITE_RELAY_URL=$RELAY_URL
VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY"
if [ -z "${ATM_PRIVATE_KEY:-}" ]; then
ATM_PRIVATE_KEY=$(openssl rand -hex 32)
echo ""
echo "--- No SPIRE_SEED; generated a DEV-ONLY ATM_PRIVATE_KEY (no bunker) ---"
fi
IDENTITY_LINES="# DEV-ONLY local nsec (no bunker pairing) # pragma: allowlist secret
VITE_ATM_PRIVATE_KEY=$ATM_PRIVATE_KEY"
fi fi
# Step 6: Write .env to the ATM via SSH. # Step 6: Write .env to the ATM via SSH.
@ -90,17 +124,16 @@ echo "--- Step 2: Writing .env to ATM ---"
ENV_CONTENT="# bitSpire Configuration ENV_CONTENT="# bitSpire Configuration
# Auto-generated by provision-atm.sh on $(date -Iseconds) # Auto-generated by provision-atm.sh on $(date -Iseconds)
# LNbits nostr-transport connection # LNbits nostr-transport. Relay + server pubkey come from the pairing seed
VITE_RELAY_URL=$RELAY_URL # (aiolabs/bitspire#70); present below only as an explicit override or the
VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY # no-seed dev fallback.
VITE_LNBITS_HTTP_URL=$LNBITS_HTTP_URL $TRANSPORT_LINES
# ATM identity (signing key IS the credential under nostr-transport) $IDENTITY_LINES
VITE_ATM_PRIVATE_KEY=$ATM_PRIVATE_KEY
# Machine configuration # Machine configuration
VITE_LAMASSU_MACHINE_MODEL=$MODEL VITE_BITSPIRE_MACHINE_MODEL=$MODEL
VITE_LAMASSU_FIAT_CODE=$FIAT_CODE VITE_BITSPIRE_FIAT_CODE=$FIAT_CODE
# Force production mode # Force production mode
ELECTRON_FORCE_PROD=1 ELECTRON_FORCE_PROD=1
@ -113,6 +146,6 @@ echo ""
echo "=== ATM provisioned successfully ===" echo "=== ATM provisioned successfully ==="
echo "" echo ""
echo "Credentials written to /var/lib/bitspire/.env" echo "Credentials written to /var/lib/bitspire/.env"
echo "ATM service restarted. It should connect to LNbits via relay $RELAY_URL." echo "ATM service restarted. Relay: ${RELAY_URL:-from the pairing seed}."
echo "" echo ""
echo "To check status: ssh -p $ATM_SSH_PORT $ATM_USER@$ATM_HOST 'sudo journalctl -u bitspire -f'" echo "To check status: ssh -p $ATM_SSH_PORT $ATM_USER@$ATM_HOST 'sudo journalctl -u bitspire -f'"

View file

@ -1,4 +1,4 @@
# Lamassu ATM Hardware udev Rules # bitSpire ATM Hardware udev Rules
# Place in /etc/udev/rules.d/ or use services.udev.extraRules in NixOS # Place in /etc/udev/rules.d/ or use services.udev.extraRules in NixOS
# ============================================ # ============================================

View file

@ -1,8 +1,7 @@
{ pkgs, lib, config, ... }: { pkgs, lib, config, ... }:
{ {
# Project metadata name = "bitspire";
name = "lamassu-next";
# ============================================ # ============================================
# Languages # Languages
@ -19,9 +18,11 @@
languages.typescript.enable = true; languages.typescript.enable = true;
# Use nixpkgs rust (simpler, no overlay needed)
packages = with pkgs; [ packages = with pkgs; [
# Rust toolchain from nixpkgs # Rust toolchain — only the orphaned Rust HAL under packages/hal/src
# uses it (not built; see CLAUDE.md → Hardware drivers). Kept so the
# rustfmt git hook and `cargo check` still work until that crate is
# removed outright.
rustc rustc
cargo cargo
clippy clippy
@ -33,42 +34,22 @@
openssl openssl
openssl.dev openssl.dev
# Electron dependencies (Linux) # Python for node-gyp (native module builds: serialport, better-sqlite3)
# electron # Use npm-installed electron instead
# Python for node-gyp (native module builds)
(python3.withPackages (ps: [ ps.setuptools ])) (python3.withPackages (ps: [ ps.setuptools ]))
# Hardware access # Hardware access
libusb1 libusb1
udev udev
# Serial port access # Serial port access (talk to a validator / dispenser by hand)
picocom picocom
# Development utilities # Development utilities
just # Task runner
jq # JSON processing jq # JSON processing
websocat # WebSocket client websocat # WebSocket client — relay-test below
qrencode # QR code generation for terminal qrencode # QR code generation for terminal
# Database tools
pgcli
# Container tools (for Lightning.Pub, strfry)
docker-compose
]; ];
# ============================================
# Services
# ============================================
services.postgres = {
enable = true;
initialDatabases = [{ name = "lamassu_dev"; }];
listen_addresses = "127.0.0.1";
};
# ============================================ # ============================================
# Git Hooks (formerly pre-commit) # Git Hooks (formerly pre-commit)
# ============================================ # ============================================
@ -97,21 +78,24 @@
env = { env = {
RUST_BACKTRACE = "1"; RUST_BACKTRACE = "1";
DATABASE_URL = "postgresql://localhost/lamassu_dev";
# Nostr development relay # The dev relay is LNbits's bundled nostrrelay extension on the native
NOSTR_RELAY_URL = "ws://localhost:7777"; # instance (see CLAUDE.md → Environment variables); there is no
# in-repo relay container any more. Override per shell as needed.
NOSTR_RELAY_URL = "ws://localhost:5001/nostrrelay/test";
# Lightning.Pub development
LIGHTNING_PUB_URL = "http://localhost:1776";
# For Tauri
PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig"; PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig";
}; };
# ============================================ # ============================================
# Scripts (available as commands in shell) # Scripts (available as commands in shell)
# ============================================ # ============================================
#
# The Lightning.Pub-era regtest stack (docker/ + the infra-*/lncli/btccli/
# mine-blocks/setup-channel/fund-atm/test-* commands that drove it) was
# removed 2026-10-09. Development runs against LNbits: FakeWallet needs
# nothing; bohm's native instance answers on :5001; the shared regtest
# stack lives at ~/dev/local/docker/regtest (not in this repo).
scripts = { scripts = {
dev.exec = "pnpm turbo dev"; dev.exec = "pnpm turbo dev";
@ -119,421 +103,16 @@
test.exec = "pnpm turbo test"; test.exec = "pnpm turbo test";
lint.exec = "pnpm turbo lint"; lint.exec = "pnpm turbo lint";
# Start infrastructure # Test the dev relay connection
infra-up.exec = ''
echo "Starting development infrastructure..."
docker compose -f docker/docker-compose.dev.yml up -d
echo ""
echo "Waiting for services to be healthy..."
echo "(This may take 30-60 seconds on first run)"
echo ""
# Wait for strfry
echo -n "strfry relay: "
until docker exec lamassu-relay nc -z localhost 7777 2>/dev/null; do
echo -n "."
sleep 2
done
echo " ready"
# Wait for bitcoind
echo -n "bitcoind: "
until docker exec lamassu-bitcoind bitcoin-cli -regtest -rpcuser=lamassu -rpcpassword=lamassu getblockchaininfo >/dev/null 2>&1; do
echo -n "."
sleep 2
done
echo " ready"
# Wait for LND
echo -n "LND: "
until docker exec lamassu-lnd lncli --network=regtest getinfo >/dev/null 2>&1; do
echo -n "."
sleep 3
done
echo " ready"
# Wait for Alice's LND
echo -n "LND (Alice): "
until docker exec lamassu-lnd-alice lncli --network=regtest getinfo >/dev/null 2>&1; do
echo -n "."
sleep 3
done
echo " ready"
echo ""
echo "Infrastructure ready!"
echo " - Nostr relay: ws://localhost:7777"
echo " - Bitcoin RPC: localhost:18443 (regtest)"
echo " - LND gRPC: localhost:10009"
echo " - LND Alice gRPC: localhost:10010"
echo " - Lightning.Pub: http://localhost:1776"
echo " - PostgreSQL: localhost:5432"
echo ""
echo "Next steps:"
echo " 1. mine-blocks 101 # Fund the wallet (if first run)"
echo " 2. setup-channel # Open channel between Alice and LND"
echo ""
echo "Use 'infra-logs' to view logs, 'infra-status' to check status"
'';
infra-down.exec = ''
echo "Stopping development infrastructure..."
docker compose -f docker/docker-compose.dev.yml down
echo "Infrastructure stopped"
'';
infra-logs.exec = ''
docker compose -f docker/docker-compose.dev.yml logs -f "$@"
'';
infra-status.exec = ''
echo "Infrastructure Status:"
echo ""
docker compose -f docker/docker-compose.dev.yml ps
'';
# Mine regtest blocks (useful for testing)
mine-blocks.exec = ''
BLOCKS=''${1:-1}
echo "Mining $BLOCKS regtest block(s)..."
docker exec lamassu-bitcoind bitcoin-cli -regtest -rpcuser=lamassu -rpcpassword=lamassu -generate "$BLOCKS"
'';
# Start auto-miner (mines 1 block every 30 seconds)
auto-mine.exec = ''
echo "Starting auto-miner (1 block every 30 seconds)..."
docker compose -f docker/docker-compose.dev.yml --profile mining up -d miner
echo ""
echo "Auto-miner started. This keeps LND in sync."
echo "Use 'auto-mine-stop' to stop it."
'';
# Stop auto-miner
auto-mine-stop.exec = ''
echo "Stopping auto-miner..."
docker stop lamassu-miner 2>/dev/null || true
docker rm lamassu-miner 2>/dev/null || true
echo "Auto-miner stopped."
'';
# Connect to LND CLI
lncli.exec = ''
docker exec -it lamassu-lnd lncli --network=regtest "$@"
'';
# Connect to Bitcoin CLI
btccli.exec = ''
docker exec -it lamassu-bitcoind bitcoin-cli -regtest -rpcuser=lamassu -rpcpassword=lamassu "$@"
'';
# Test relay connection
relay-test.exec = '' relay-test.exec = ''
echo "Testing Nostr relay connection..." echo "Testing Nostr relay connection to $NOSTR_RELAY_URL ..."
echo '["REQ", "test", {"kinds": [0], "limit": 1}]' | websocat ws://localhost:7777 echo '["REQ", "test", {"kinds": [0], "limit": 1}]' | websocat "$NOSTR_RELAY_URL"
'';
# Connect to Alice's LND CLI (second node for testing payments)
lncli-alice.exec = ''
docker exec -it lamassu-lnd-alice lncli --network=regtest "$@"
'';
# Setup Lightning channel between Alice and the main LND node
setup-channel.exec = ''
echo "Setting up Lightning channel between Alice and LND..."
echo ""
# Get LND's pubkey and address
LND_INFO=$(docker exec lamassu-lnd lncli --network=regtest getinfo 2>/dev/null)
LND_PUBKEY=$(echo "$LND_INFO" | jq -r '.identity_pubkey')
echo "LND pubkey: $LND_PUBKEY"
# Connect Alice to LND
echo "Connecting Alice to LND..."
docker exec lamassu-lnd-alice lncli --network=regtest connect "$LND_PUBKEY@lnd:9735" 2>/dev/null || true
# Check if Alice has enough funds
ALICE_BALANCE=$(docker exec lamassu-lnd-alice lncli --network=regtest walletbalance 2>/dev/null | jq -r '.confirmed_balance')
echo "Alice's on-chain balance: $ALICE_BALANCE sats"
if [ "$ALICE_BALANCE" -lt 1000000 ]; then
echo ""
echo "Alice needs funds. Getting new address..."
ALICE_ADDR=$(docker exec lamassu-lnd-alice lncli --network=regtest newaddress p2wkh | jq -r '.address')
echo "Alice's address: $ALICE_ADDR"
echo ""
echo "Sending 5 BTC to Alice..."
docker exec lamassu-bitcoind bitcoin-cli -regtest -rpcuser=lamassu -rpcpassword=lamassu sendtoaddress "$ALICE_ADDR" 5
echo "Mining 6 blocks for confirmation..."
docker exec lamassu-bitcoind bitcoin-cli -regtest -rpcuser=lamassu -rpcpassword=lamassu -generate 6 >/dev/null
sleep 2
ALICE_BALANCE=$(docker exec lamassu-lnd-alice lncli --network=regtest walletbalance 2>/dev/null | jq -r '.confirmed_balance')
echo "Alice's new balance: $ALICE_BALANCE sats"
fi
# Open channel from Alice to LND (1M sats)
echo ""
echo "Opening 1M sat channel from Alice to LND..."
docker exec lamassu-lnd-alice lncli --network=regtest openchannel --node_key="$LND_PUBKEY" --local_amt=1000000
echo ""
echo "Mining 6 blocks to confirm channel..."
docker exec lamassu-bitcoind bitcoin-cli -regtest -rpcuser=lamassu -rpcpassword=lamassu -generate 6 >/dev/null
sleep 3
echo ""
echo "Channel status:"
docker exec lamassu-lnd-alice lncli --network=regtest listchannels | jq '.channels[] | {remote_pubkey, capacity, local_balance, remote_balance, active}'
echo ""
echo "Channel setup complete! Alice can now pay invoices to Lightning.Pub."
'';
# Pay an invoice from Alice's node
alice-pay.exec = ''
if [ -z "$1" ]; then
echo "Usage: alice-pay <invoice>"
exit 1
fi
echo "Paying invoice from Alice's node..."
docker exec lamassu-lnd-alice lncli --network=regtest payinvoice --force "$1"
'';
# Create invoice on Alice's node (for testing ATM payouts)
alice-invoice.exec = ''
AMOUNT=''${1:-1000}
MEMO=''${2:-"Test invoice"}
docker exec lamassu-lnd-alice lncli --network=regtest addinvoice --amt="$AMOUNT" --memo="$MEMO" | jq -r '.payment_request'
'';
# Fund ATM account in Lightning.Pub
fund-atm.exec = ''
AMOUNT=''${1:-100000}
echo "Funding ATM account with $AMOUNT sats..."
echo ""
# Run the funding script from nostr-client package
cd packages/nostr-client
FUND_AMOUNT=$AMOUNT node fund-dev.mjs 2>&1 | tee /tmp/fund-atm-output.txt
# Extract the invoice
INVOICE=$(grep -o 'lnbcrt[a-zA-Z0-9]*' /tmp/fund-atm-output.txt | head -1)
if [ -z "$INVOICE" ]; then
echo "Failed to create invoice. Check the output above."
exit 1
fi
echo ""
echo "Invoice created. Paying from Alice..."
docker exec lamassu-lnd-alice lncli --network=regtest payinvoice --force "$INVOICE"
echo ""
echo "ATM account funded with $AMOUNT sats!"
'';
# Validate entire test setup
test-setup.exec = ''
echo ""
echo "═══════════════════════════════════════════════════════════"
echo " Lamassu Next - Test Setup Validation"
echo "═══════════════════════════════════════════════════════════"
echo ""
# Mine a block to wake up LND sync (regtest quirk: LND reports
# "not synced" when no blocks mined recently)
echo "Mining block to sync nodes..."
docker exec lamassu-bitcoind bitcoin-cli -regtest -rpcuser=lamassu -rpcpassword=lamassu -generate 1 >/dev/null 2>&1
sleep 1
echo ""
PASSED=0
FAILED=0
check() {
if [ $1 -eq 0 ]; then
echo " ✓ $2"
PASSED=$((PASSED + 1))
else
echo " ✗ $2"
FAILED=$((FAILED + 1))
fi
}
echo "1. Services"
echo "───────────────────────────────────────────────────────────"
# Check containers
docker ps --format '{{.Names}}' | grep -q lamassu-relay
check $? "strfry relay running"
docker ps --format '{{.Names}}' | grep -q lamassu-bitcoind
check $? "bitcoind running"
docker ps --format '{{.Names}}' | grep -q lamassu-lnd
check $? "LND running"
docker ps --format '{{.Names}}' | grep -q lamassu-lnd-alice
check $? "LND Alice running"
docker ps --format '{{.Names}}' | grep -q lamassu-lightning-pub
check $? "Lightning.Pub running"
echo ""
echo "2. Connectivity"
echo "───────────────────────────────────────────────────────────"
# Check relay port is open
timeout 2 bash -c 'echo > /dev/tcp/localhost/7777' 2>/dev/null
check $? "Nostr relay port open"
# Check bitcoind RPC
docker exec lamassu-bitcoind bitcoin-cli -regtest -rpcuser=lamassu -rpcpassword=lamassu getblockchaininfo >/dev/null 2>&1
check $? "Bitcoin RPC responding"
# Check LND
docker exec lamassu-lnd lncli --network=regtest getinfo >/dev/null 2>&1
check $? "LND RPC responding"
# Check Alice
docker exec lamassu-lnd-alice lncli --network=regtest getinfo >/dev/null 2>&1
check $? "LND Alice RPC responding"
echo ""
echo "3. Blockchain State"
echo "───────────────────────────────────────────────────────────"
BLOCKS=$(docker exec lamassu-bitcoind bitcoin-cli -regtest -rpcuser=lamassu -rpcpassword=lamassu getblockcount 2>/dev/null)
[ "$BLOCKS" -ge 100 ]
check $? "Block height >= 100 (current: $BLOCKS)"
# Check LND synced
LND_SYNCED=$(docker exec lamassu-lnd lncli --network=regtest getinfo 2>/dev/null | jq -r '.synced_to_chain')
[ "$LND_SYNCED" = "true" ]
check $? "LND synced to chain"
# Check Alice synced
ALICE_SYNCED=$(docker exec lamassu-lnd-alice lncli --network=regtest getinfo 2>/dev/null | jq -r '.synced_to_chain')
[ "$ALICE_SYNCED" = "true" ]
check $? "LND Alice synced to chain"
echo ""
echo "4. Lightning Channels"
echo "───────────────────────────────────────────────────────────"
# Check Alice has active channels
ALICE_CHANNELS=$(docker exec lamassu-lnd-alice lncli --network=regtest listchannels 2>/dev/null | jq '.channels | length')
[ "$ALICE_CHANNELS" -ge 1 ]
check $? "Alice has active channels (count: $ALICE_CHANNELS)"
# Check channel is active
ACTIVE_CHANNEL=$(docker exec lamassu-lnd-alice lncli --network=regtest listchannels 2>/dev/null | jq '.channels[0].active')
[ "$ACTIVE_CHANNEL" = "true" ]
check $? "Channel is active"
# Check Alice has outbound capacity
ALICE_LOCAL=$(docker exec lamassu-lnd-alice lncli --network=regtest listchannels 2>/dev/null | jq -r '.channels[0].local_balance // 0')
[ "$ALICE_LOCAL" -ge 10000 ]
check $? "Alice has outbound capacity ($ALICE_LOCAL sats)"
echo ""
echo "5. Payment Test"
echo "───────────────────────────────────────────────────────────"
# Create test invoice on LND and pay from Alice
TEST_INVOICE=$(docker exec lamassu-lnd lncli --network=regtest addinvoice --amt=100 --memo="test-setup validation" 2>/dev/null | jq -r '.payment_request')
if [ -n "$TEST_INVOICE" ]; then
check 0 "Created 100 sat test invoice"
# Pay it
PAY_RESULT=$(docker exec lamassu-lnd-alice lncli --network=regtest payinvoice --force "$TEST_INVOICE" 2>&1)
if echo "$PAY_RESULT" | grep -q "SUCCEEDED"; then
check 0 "Payment Alice → LND succeeded"
else
check 1 "Payment Alice → LND failed"
fi
else
check 1 "Failed to create test invoice"
check 1 "Payment test skipped"
fi
echo ""
echo "═══════════════════════════════════════════════════════════"
echo " Results: $PASSED passed, $FAILED failed"
echo "═══════════════════════════════════════════════════════════"
echo ""
if [ $FAILED -gt 0 ]; then
echo "Some checks failed. Run 'infra-up' and 'setup-channel' first."
exit 1
else
echo "All checks passed! Ready for testing."
exit 0
fi
'';
# Quick e2e payment test (ATM pays customer invoice)
test-payment.exec = ''
AMOUNT=''${1:-1000}
echo "Testing e2e payment flow ($AMOUNT sats)..."
echo ""
# Create invoice on Alice (simulating customer's wallet)
echo "1. Creating invoice on Alice's wallet..."
INVOICE=$(docker exec lamassu-lnd-alice lncli --network=regtest addinvoice --amt="$AMOUNT" --memo="E2E test" | jq -r '.payment_request')
echo " Invoice: ''${INVOICE:0:40}..."
# Use test-pay.mjs to pay via Lightning.Pub
echo ""
echo "2. Paying via Lightning.Pub (ATM flow)..."
cd packages/nostr-client
node test-pay.mjs "$INVOICE"
echo ""
echo "3. Verifying payment on Alice..."
sleep 2
PAID=$(docker exec lamassu-lnd-alice lncli --network=regtest listinvoices 2>/dev/null | jq '.invoices[-1].settled')
if [ "$PAID" = "true" ]; then
echo " ✓ Payment received!"
else
echo " ✗ Payment not received"
exit 1
fi
'';
# Show node info
node-info.exec = ''
echo ""
echo "Node Information"
echo "════════════════════════════════════════════════════════"
echo ""
echo "LND (Lightning.Pub's node):"
docker exec lamassu-lnd lncli --network=regtest getinfo 2>/dev/null | jq '{pubkey: .identity_pubkey, alias: .alias, channels: .num_active_channels, peers: .num_peers}'
echo ""
echo "LND Alice (Payment source):"
docker exec lamassu-lnd-alice lncli --network=regtest getinfo 2>/dev/null | jq '{pubkey: .identity_pubkey, alias: .alias, channels: .num_active_channels, peers: .num_peers}'
echo ""
echo "Channel Details:"
docker exec lamassu-lnd-alice lncli --network=regtest listchannels 2>/dev/null | jq '.channels[] | {peer: .remote_pubkey[0:16], capacity, local: .local_balance, remote: .remote_balance, active}'
echo ""
echo "Lightning.Pub Nostr pubkey:"
echo " f454c5eec4ec4474128e19b57b45e49c3d0851d01eea960b39ce391cdba76fc6"
echo ""
echo "ATM Dev Identity:"
echo " 4646ae5047316b4230d0086c8acec687f00b1cd9d1dc634f6cb358ac0a9a8fff"
''; '';
}; };
# ============================================
# Shell Hook
# ============================================
enterShell = '' enterShell = ''
echo "" echo ""
echo " ⚡ Lamassu Next - Nostr-Native Lightning ATM" echo " ⚡ bitSpire - Nostr-Native Lightning ATM"
echo " ─────────────────────────────────────────────" echo " ─────────────────────────────────────────────"
echo " Node.js: $(node --version)" echo " Node.js: $(node --version)"
echo " Rust: $(rustc --version | cut -d' ' -f2)" echo " Rust: $(rustc --version | cut -d' ' -f2)"
@ -543,30 +122,8 @@
echo " dev Start development servers" echo " dev Start development servers"
echo " build Build all packages" echo " build Build all packages"
echo " test Run tests" echo " test Run tests"
echo "" echo " lint Lint all packages"
echo " Infrastructure:" echo " relay-test Test the dev relay ($NOSTR_RELAY_URL)"
echo " infra-up Start Docker services (strfry, bitcoind, LND, Lightning.Pub)"
echo " infra-down Stop Docker services"
echo " infra-status Show service status"
echo " infra-logs Follow service logs"
echo ""
echo " Bitcoin/Lightning:"
echo " btccli Bitcoin CLI (regtest)"
echo " lncli LND CLI (Lightning.Pub's node)"
echo " lncli-alice LND CLI (Alice's node for testing payments)"
echo " mine-blocks Mine regtest blocks (default: 1)"
echo " auto-mine Start auto-miner (1 block/30s, keeps LND synced)"
echo " auto-mine-stop Stop auto-miner"
echo " setup-channel Setup channel between Alice and LND"
echo " alice-pay Pay invoice from Alice's node"
echo " relay-test Test Nostr relay connection"
echo ""
echo " Testing (E2E):"
echo " test-setup Validate test environment (services, channels, payments)"
echo " test-payment Quick e2e payment test (ATM → customer)"
echo " fund-atm Fund ATM account (default: 100k sats)"
echo " alice-invoice Create invoice on Alice's node"
echo " node-info Show node pubkeys and channel info"
echo "" echo ""
''; '';
} }

File diff suppressed because it is too large Load diff

View file

@ -1,226 +0,0 @@
# Lamassu Next Development Infrastructure
# Usage: docker compose -f docker-compose.dev.yml up -d
services:
# Private Nostr relay for ATM communication
strfry:
image: ghcr.io/hoytech/strfry:latest
container_name: lamassu-relay
ports:
- '7777:7777'
volumes:
- ./strfry.conf:/etc/strfry.conf:ro
- strfry-data:/app/strfry-db
ulimits:
nofile:
soft: 524288
hard: 524288
healthcheck:
test: ['CMD', 'nc', '-z', 'localhost', '7777']
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# Bitcoin Core in regtest mode
bitcoind:
image: lncm/bitcoind:v27.0
container_name: lamassu-bitcoind
volumes:
- bitcoind-data:/data/.bitcoin
environment:
BITCOIN_NETWORK: regtest
command:
- -regtest
- -server
- -rpcuser=lamassu
- -rpcpassword=lamassu
- -rpcallowip=0.0.0.0/0
- -rpcbind=0.0.0.0
- -zmqpubrawblock=tcp://0.0.0.0:28332
- -zmqpubrawtx=tcp://0.0.0.0:28333
- -fallbackfee=0.00001
- -txindex=1
ports:
- '18443:18443' # RPC
- '28332:28332' # ZMQ blocks
- '28333:28333' # ZMQ tx
healthcheck:
test:
[
'CMD',
'bitcoin-cli',
'-regtest',
'-rpcuser=lamassu',
'-rpcpassword=lamassu',
'getblockchaininfo',
]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
# LND Lightning node
lnd:
image: lightninglabs/lnd:v0.18.0-beta
container_name: lamassu-lnd
depends_on:
bitcoind:
condition: service_healthy
volumes:
- lnd-data:/root/.lnd
environment:
- NETWORK=regtest
command:
- --bitcoin.active
- --bitcoin.regtest
- --bitcoin.node=bitcoind
- --bitcoind.rpchost=bitcoind:18443
- --bitcoind.rpcuser=lamassu
- --bitcoind.rpcpass=lamassu
- --bitcoind.zmqpubrawblock=tcp://bitcoind:28332
- --bitcoind.zmqpubrawtx=tcp://bitcoind:28333
- --rpclisten=0.0.0.0:10009
- --restlisten=0.0.0.0:8080
- --tlsextradomain=lnd
- --tlsextraip=0.0.0.0
- --noseedbackup
- --accept-keysend
- --accept-amp
ports:
- '10009:10009' # gRPC
- '8080:8080' # REST
- '9735:9735' # P2P
healthcheck:
test: ['CMD', 'lncli', '--network=regtest', 'getinfo']
interval: 10s
timeout: 10s
retries: 30
start_period: 30s
restart: unless-stopped
# Lightning.Pub - Nostr-native Lightning account system
# Note: Lightning.Pub connects to LND for actual Lightning operations
# Using patched image with Kind 0 profile publishing support
lightning-pub:
image: lightning-pub-patched:latest
container_name: lamassu-lightning-pub
depends_on:
lnd:
condition: service_healthy
extra_hosts:
- 'host.docker.internal:host-gateway' # Enable host.docker.internal on Linux
ports:
- '1776:1776'
volumes:
- lightning-pub-data:/root/lightning_pub
- lnd-data:/root/.lnd:ro # Read-only access to LND data for macaroons/certs
environment:
- NETWORK=regtest
- LND_ADDRESS=lnd:10009
- LND_CERT_PATH=/root/.lnd/tls.cert
- LND_MACAROON_PATH=/root/.lnd/data/chain/bitcoin/regtest/admin.macaroon
# Relay URLs (space-separated). Docker-internal relay FIRST for publishing,
# then backup. Mock ATM rewrites ndebit relay for browser access.
- NOSTR_RELAYS=ws://strfry:7777
# Disable external liquidity provider for regtest
- DISABLE_LIQUIDITY_PROVIDER=true
# Admin token for HTTP API access (development only)
- ADMIN_TOKEN=lamassu-dev-admin-token
restart: unless-stopped
# Second LND node (Alice) for payment testing
# This node can pay invoices to Lightning.Pub's LND
lnd-alice:
image: lightninglabs/lnd:v0.18.0-beta
container_name: lamassu-lnd-alice
depends_on:
bitcoind:
condition: service_healthy
volumes:
- lnd-alice-data:/root/.lnd
environment:
- NETWORK=regtest
command:
- --bitcoin.active
- --bitcoin.regtest
- --bitcoin.node=bitcoind
- --bitcoind.rpchost=bitcoind:18443
- --bitcoind.rpcuser=lamassu
- --bitcoind.rpcpass=lamassu
- --bitcoind.zmqpubrawblock=tcp://bitcoind:28332
- --bitcoind.zmqpubrawtx=tcp://bitcoind:28333
- --rpclisten=0.0.0.0:10009
- --restlisten=0.0.0.0:8080
- --tlsextradomain=lnd-alice
- --tlsextraip=0.0.0.0
- --noseedbackup
- --accept-keysend
- --accept-amp
- --alias=alice
ports:
- '10010:10009' # gRPC (different host port)
- '8081:8080' # REST (different host port)
- '9736:9735' # P2P (different host port)
healthcheck:
test: ['CMD', 'lncli', '--network=regtest', 'getinfo']
interval: 10s
timeout: 10s
retries: 30
start_period: 30s
restart: unless-stopped
# Automatic block miner for regtest (keeps LND synced)
# Mines 1 block every 30 seconds
miner:
image: alpine:latest
container_name: lamassu-miner
depends_on:
bitcoind:
condition: service_healthy
entrypoint: /bin/sh
command:
- -c
- |
apk add --no-cache curl jq
echo "Starting auto-miner (1 block every 30 seconds)..."
while true; do
# Create wallet if not exists
curl -s --user lamassu:lamassu --data-binary '{"jsonrpc":"1.0","method":"createwallet","params":["miner"]}' http://bitcoind:18443/ > /dev/null 2>&1
# Mine a block
ADDR=$$(curl -s --user lamassu:lamassu --data-binary '{"jsonrpc":"1.0","method":"getnewaddress","params":[]}' http://bitcoind:18443/ | jq -r '.result // empty')
if [ -n "$$ADDR" ]; then
curl -s --user lamassu:lamassu --data-binary "{\"jsonrpc\":\"1.0\",\"method\":\"generatetoaddress\",\"params\":[1,\"$$ADDR\"]}" http://bitcoind:18443/ > /dev/null
fi
sleep 30
done
restart: unless-stopped
profiles:
- mining # Only starts with: docker compose --profile mining up -d
# PostgreSQL for optional server-side state
postgres:
image: postgres:16-alpine
container_name: lamassu-postgres
ports:
- '5432:5432'
environment:
POSTGRES_DB: lamassu_dev
POSTGRES_USER: lamassu
POSTGRES_PASSWORD: lamassu_dev_password
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U lamassu -d lamassu_dev']
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
strfry-data:
bitcoind-data:
lnd-data:
lnd-alice-data:
lightning-pub-data:
postgres-data:

View file

@ -1,166 +0,0 @@
# Lamassu Next - Regtest Integration
#
# This overlay connects lamassu-next services to the comprehensive regtest
# environment at ~/dev/local/docker/regtest
#
# Usage:
# 1. Start regtest (minimal — only what LP needs):
# cd ~/dev/local/docker/regtest && docker compose up -d bitcoind lnd-1 lnd-4
#
# 2. Start lamassu services:
# cd docker && docker compose -f docker-compose.regtest.yml up -d
#
# 3. Bootstrap (funds LND, opens channels, creates LP app):
# cd docker && ./regtest-bootstrap.sh
#
# 4. Configure apps/machine/.env:
# VITE_RELAY_URL=ws://localhost:7777
# VITE_EXTENSION_API_URL=http://localhost:1777
#
# Phone wallet testing (LNURL callbacks need LAN IP):
# HOST_IP=192.168.1.100 docker compose -f docker-compose.regtest.yml up -d
#
# Services:
# - strfry: Private Nostr relay (port 7777)
# - lightning-pub: Nostr-native Lightning account system (port 1776)
# - Uses lnd-4 from regtest as backend
# - Withdraw extension on port 1777
# - miner: Auto-mines blocks to keep Lightning channels active
#
services:
# Private Nostr relay for ATM communication
strfry:
image: ghcr.io/hoytech/strfry:latest
container_name: lamassu-relay
ports:
- '7777:7777'
volumes:
- ./strfry.conf:/etc/strfry.conf:ro
- strfry-data:/app/strfry-db
ulimits:
nofile:
soft: 524288
hard: 524288
healthcheck:
# Check port 7777 (0x1E61) is listening via procfs — BusyBox nc lacks -z flag
test: ['CMD-SHELL', 'grep -q ":1E61 " /proc/net/tcp']
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
networks:
- regtest
# Lightning.Pub - Nostr-native Lightning account system
# Connects to lnd-4 from the regtest environment
# Use LIGHTNING_PUB_IMAGE env var to specify image (default: lightning-pub-withdraw)
lightning-pub:
image: ${LIGHTNING_PUB_IMAGE:-lightning-pub-withdraw:latest}
container_name: lamassu-lightning-pub
extra_hosts:
- 'host.docker.internal:host-gateway'
ports:
- '1776:1776'
- '1777:1777' # Withdraw extension HTTP API
volumes:
- lightning-pub-data:/root/lightning_pub
# Override Dockerfile's anonymous /app/data volume with named volume
- lightning-pub-appdata:/app/data
# Mount lnd-4 data from regtest for macaroons/certs
- ${REGTEST_DATA_DIR:-/home/padreug/dev/local/docker/regtest/data}/lnd-4:/root/.lnd:ro
environment:
- NETWORK=regtest
# lnd-4 is accessible via Docker network
- LND_ADDRESS=lnd-4:10009
- LND_CERT_PATH=/root/.lnd/tls.cert
- LND_MACAROON_PATH=/root/.lnd/data/chain/bitcoin/regtest/admin.macaroon
# Use strfry from this compose
- NOSTR_RELAYS=ws://strfry:7777
# Disable external liquidity provider for regtest
- DISABLE_LIQUIDITY_PROVIDER=true
# Admin token for HTTP API access (development only)
- ADMIN_TOKEN=lamassu-dev-admin-token
# Extension HTTP API URL (for LNURL callbacks from external wallets)
# Use HOST_IP env var for your machine's LAN IP (required for phone wallets)
- EXTENSION_SERVICE_URL=http://${HOST_IP:-localhost}:1777
depends_on:
strfry:
condition: service_healthy
healthcheck:
test: ['CMD-SHELL', 'curl -so /dev/null -w "%{http_code}" http://localhost:1776 | grep -q .']
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
restart: unless-stopped
networks:
- regtest
# Auto-miner for regtest (mines 1 block every MINE_INTERVAL seconds)
# Keeps Lightning channels active during development
miner:
image: boltz/bitcoin-core:25.0
container_name: lamassu-miner
entrypoint: /bin/sh
command:
- -c
- |
echo "Auto-miner started (interval: $${MINE_INTERVAL}s)"
# Wait for bitcoind to be ready
while ! bitcoin-cli -regtest -rpcconnect=bitcoind getblockchaininfo > /dev/null 2>&1; do
echo "Waiting for bitcoind..."
sleep 5
done
# Ensure a wallet is loaded (-generate requires one)
bitcoin-cli -regtest -rpcconnect=bitcoind createwallet default 2>/dev/null \
|| bitcoin-cli -regtest -rpcconnect=bitcoind loadwallet default 2>/dev/null \
|| true
echo "bitcoind ready, starting mining loop"
while true; do
bitcoin-cli -regtest -rpcconnect=bitcoind -generate 1 > /dev/null 2>&1 && echo "Mined block $(bitcoin-cli -regtest -rpcconnect=bitcoind getblockcount)"
sleep $${MINE_INTERVAL}
done
environment:
- MINE_INTERVAL=${MINE_INTERVAL:-30}
volumes:
- bitcoin-data:/root/.bitcoin
restart: unless-stopped
networks:
- regtest
# PostgreSQL for optional server-side state
postgres:
image: postgres:16-alpine
container_name: lamassu-postgres
ports:
- '5432:5432'
environment:
POSTGRES_DB: lamassu_dev
POSTGRES_USER: lamassu
POSTGRES_PASSWORD: lamassu_dev_password
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U lamassu -d lamassu_dev']
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- regtest
volumes:
strfry-data:
lightning-pub-data:
lightning-pub-appdata:
postgres-data:
# Mount the bitcoin-data volume from the regtest environment
bitcoin-data:
external: true
name: regtest_bitcoin-data
networks:
regtest:
name: regtest_default
external: true

View file

@ -1,273 +0,0 @@
#!/usr/bin/env bash
# regtest-bootstrap.sh — Bootstrap a working LP+LND regtest environment
#
# Prerequisites:
# 1. Regtest running (minimal — only needs bitcoind + lnd-1 + lnd-4):
# cd ~/dev/local/docker/regtest && docker compose up -d bitcoind lnd-1 lnd-4
# 2. Lamassu services running:
# cd docker && docker compose -f docker-compose.regtest.yml up -d
#
# What this script does:
# 1. Waits for bitcoind and LND nodes to be synced
# 2. Funds LND-1 (1 BTC from bitcoind)
# 3. Opens a 5M sat channel from LND-1 → LND-4 (LP's backend)
# 4. Mines blocks to confirm and announce the channel
# 5. Waits for LP to be healthy
# 6. Creates an LP app and funds it (10k sats via invoice from LND-1)
# 7. Prints summary with pubkeys, app token, and channel status
set -euo pipefail
# -- Colors --
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
err() { echo -e "${RED}[ERROR]${NC} $*"; }
# -- Auto-detect container name prefix --
# The regtest compose uses project name "lnbits" (via start-regtest) or "regtest" (direct).
# Detect which prefix the RUNNING bitcoind container has (filter=running avoids stale containers).
if docker ps -f name=lnbits-bitcoind-1 -f status=running --format '{{.Names}}' | grep -q .; then
PREFIX="lnbits"
elif docker ps -f name=regtest-bitcoind-1 -f status=running --format '{{.Names}}' | grep -q .; then
PREFIX="regtest"
else
err "No running bitcoind container found (tried lnbits-bitcoind-1 and regtest-bitcoind-1)"
err "Start regtest first: cd ~/dev/local/docker/regtest && docker compose up -d bitcoind lnd-1 lnd-4"
exit 1
fi
info "Using container prefix: ${PREFIX}"
# -- Container helpers --
bitcoin_cli() {
docker exec "${PREFIX}-bitcoind-1" bitcoin-cli -regtest "$@"
}
lncli_1() {
docker exec "${PREFIX}-lnd-1-1" lncli --network regtest --rpcserver=lnd-1:10009 "$@"
}
lncli_4() {
docker exec "${PREFIX}-lnd-4-1" lncli --network regtest --rpcserver=lnd-4:10009 "$@"
}
LP_URL="http://localhost:1776"
LP_EXT_URL="http://localhost:1777"
ADMIN_TOKEN="lamassu-dev-admin-token"
CHANNEL_SIZE=5000000 # 5M sats
FUND_AMOUNT=10000 # 10k sats for LP app
# -- Step 1: Wait for bitcoind --
info "Waiting for bitcoind..."
for i in $(seq 1 60); do
if bitcoin_cli getblockchaininfo > /dev/null 2>&1; then
ok "bitcoind ready (block $(bitcoin_cli getblockcount))"
break
fi
if [ "$i" -eq 60 ]; then
err "bitcoind not ready after 60s — is regtest running?"
exit 1
fi
sleep 1
done
# Ensure a wallet exists
bitcoin_cli createwallet default 2>/dev/null \
|| bitcoin_cli loadwallet default 2>/dev/null \
|| true
# Mine initial blocks if needed (coinbase needs 100 confirmations to be spendable)
BLOCKS=$(bitcoin_cli getblockcount)
if [ "$BLOCKS" -lt 101 ]; then
NEEDED=$((101 - BLOCKS))
info "Mining ${NEEDED} initial blocks (coinbase needs 100 confirmations)..."
bitcoin_cli -generate "$NEEDED" > /dev/null
ok "Mined to block $(bitcoin_cli getblockcount)"
fi
# -- Step 2: Wait for LND-1 and LND-4 to sync --
wait_lnd_sync() {
local name=$1
local cli_fn=$2
info "Waiting for ${name} to sync..."
for i in $(seq 1 120); do
if [ "$($cli_fn getinfo 2>/dev/null | jq -r '.synced_to_chain' 2>/dev/null)" = "true" ]; then
ok "${name} synced"
return 0
fi
if [ "$i" -eq 120 ]; then
err "${name} not synced after 120s"
exit 1
fi
sleep 1
done
}
wait_lnd_sync "LND-1" lncli_1
wait_lnd_sync "LND-4" lncli_4
# -- Step 3: Fund LND-1 --
info "Funding LND-1..."
LND1_ADDR=$(lncli_1 newaddress p2wkh | jq -r '.address')
bitcoin_cli -named sendtoaddress address="$LND1_ADDR" amount=1 fee_rate=100 > /dev/null
info "Mining 6 blocks to confirm funding..."
bitcoin_cli -generate 6 > /dev/null
# Wait for LND-1 to see the confirmed balance
for i in $(seq 1 30); do
BALANCE=$(lncli_1 walletbalance | jq -r '.confirmed_balance')
if [ "$BALANCE" != "0" ] && [ -n "$BALANCE" ]; then
ok "LND-1 funded: ${BALANCE} sats"
break
fi
sleep 1
done
# -- Step 4: Connect LND-1 → LND-4 and open channel --
LND4_PUBKEY=$(lncli_4 getinfo | jq -r '.identity_pubkey')
info "Connecting LND-1 → LND-4 (${LND4_PUBKEY:0:16}...)..."
lncli_1 connect "${LND4_PUBKEY}@${PREFIX}-lnd-4-1:9735" > /dev/null 2>&1 || true
# Check if channel already exists
EXISTING=$(lncli_1 listchannels --peer "$LND4_PUBKEY" 2>/dev/null | jq '.channels | length')
if [ "${EXISTING:-0}" -gt 0 ]; then
warn "Channel to LND-4 already exists, skipping open"
else
info "Opening ${CHANNEL_SIZE} sat channel LND-1 → LND-4..."
lncli_1 openchannel "$LND4_PUBKEY" "$CHANNEL_SIZE" > /dev/null
info "Mining 6 blocks to confirm channel..."
bitcoin_cli -generate 6 > /dev/null
# Wait for channel to leave pending
for i in $(seq 1 60); do
PENDING=$(lncli_1 pendingchannels | jq '.pending_open_channels | length')
if [ "$PENDING" = "0" ]; then
break
fi
sleep 1
done
fi
# -- Step 5: Verify channel is active and in graph --
info "Waiting for channel to become active..."
for i in $(seq 1 60); do
ACTIVE=$(lncli_1 listchannels --peer "$LND4_PUBKEY" 2>/dev/null | jq '[.channels[] | select(.active == true)] | length')
if [ "${ACTIVE:-0}" -gt 0 ]; then
ok "Channel active"
break
fi
if [ "$i" -eq 60 ]; then
warn "Channel not active after 60s — may need more blocks"
fi
sleep 1
done
# Mine a few more blocks to ensure graph announcement propagates
bitcoin_cli -generate 3 > /dev/null
info "Checking graph..."
for i in $(seq 1 30); do
GRAPH_NODES=$(lncli_1 describegraph 2>/dev/null | jq '.nodes | length')
GRAPH_EDGES=$(lncli_1 describegraph 2>/dev/null | jq '.edges | length')
if [ "${GRAPH_EDGES:-0}" -gt 0 ]; then
ok "Graph: ${GRAPH_NODES} nodes, ${GRAPH_EDGES} edges"
break
fi
if [ "$i" -eq 30 ]; then
warn "Graph has no edges yet — channel may not be announced"
fi
sleep 2
done
# -- Step 6: Wait for Lightning.Pub to be healthy --
info "Waiting for Lightning.Pub..."
for i in $(seq 1 120); do
if curl -so /dev/null "${LP_URL}" 2>/dev/null; then
ok "Lightning.Pub ready"
break
fi
if [ "$i" -eq 120 ]; then
err "Lightning.Pub not ready after 120s"
err "Check: docker logs lamassu-lightning-pub"
exit 1
fi
sleep 1
done
# Small delay to ensure LP has fully initialized its LND connection
sleep 5
# -- Step 7: Create LP app --
info "Creating LP app 'regtest-atm'..."
APP_RESPONSE=$(curl -sf "${LP_URL}/api/admin/app/add" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"name": "regtest-atm", "allow_user_creation": true}')
if [ -z "$APP_RESPONSE" ]; then
err "Failed to create LP app — check LP logs"
exit 1
fi
APP_ID=$(echo "$APP_RESPONSE" | jq -r '.app.id')
APP_NPUB=$(echo "$APP_RESPONSE" | jq -r '.app.npub')
APP_TOKEN=$(echo "$APP_RESPONSE" | jq -r '.auth_token')
if [ "$APP_ID" = "null" ] || [ -z "$APP_ID" ]; then
err "App creation returned unexpected response:"
echo "$APP_RESPONSE" | jq .
exit 1
fi
ok "App created: id=${APP_ID}"
# -- Step 8: Fund the app (create invoice via LP, pay from LND-1) --
info "Funding app with ${FUND_AMOUNT} sats..."
INVOICE_RESPONSE=$(curl -sf "${LP_URL}/api/app/add/invoice" \
-H "Authorization: Bearer ${APP_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"payer_identifier\": \"bootstrap\", \"http_callback_url\": \"\", \"invoice_req\": {\"amountSats\": ${FUND_AMOUNT}, \"memo\": \"regtest bootstrap\"}}")
INVOICE=$(echo "$INVOICE_RESPONSE" | jq -r '.invoice')
if [ "$INVOICE" = "null" ] || [ -z "$INVOICE" ]; then
warn "Failed to create invoice — app has 0 balance"
warn "Response: $INVOICE_RESPONSE"
else
# Pay from LND-1
PAY_RESULT=$(lncli_1 payinvoice --force "$INVOICE" 2>&1) || true
PAY_STATUS=$(echo "$PAY_RESULT" | jq -r '.status' 2>/dev/null || echo "")
if [ "$PAY_STATUS" = "SUCCEEDED" ] || echo "$PAY_RESULT" | grep -q "SUCCEEDED"; then
ok "App funded with ${FUND_AMOUNT} sats"
else
warn "Payment may have failed — check manually"
warn "Result: $(echo "$PAY_RESULT" | tail -3)"
fi
fi
# -- Summary --
# LP uses LND-4 as its backend, so LP pubkey = LND-4 pubkey
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} Regtest Bootstrap Complete${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo -e " ${CYAN}LND-1 pubkey:${NC} $(lncli_1 getinfo | jq -r '.identity_pubkey')"
echo -e " ${CYAN}LP (LND-4):${NC} ${LND4_PUBKEY}"
echo ""
echo -e " ${CYAN}LP app ID:${NC} ${APP_ID}"
echo -e " ${CYAN}LP app token:${NC} ${APP_TOKEN}"
echo ""
echo -e " ${CYAN}LP URL:${NC} ${LP_URL}"
echo -e " ${CYAN}Extension URL:${NC} ${LP_EXT_URL}"
echo -e " ${CYAN}Relay URL:${NC} ws://localhost:7777"
echo ""
CHAN_INFO=$(lncli_1 listchannels --peer "$LND4_PUBKEY" 2>/dev/null | jq -r '.channels[0] | "\(.local_balance) / \(.capacity) sats (active: \(.active))"' 2>/dev/null || echo "unknown")
echo -e " ${CYAN}Channel:${NC} LND-1 → LND-4: ${CHAN_INFO}"
echo ""

View file

@ -1,142 +0,0 @@
#!/usr/bin/env bash
# regtest.sh — Manage the LP+LND regtest development environment
#
# Usage:
# ./regtest.sh up Start everything + bootstrap
# ./regtest.sh down Stop everything
# ./regtest.sh reset Full teardown (volumes + data) and fresh start
# ./regtest.sh status Show container status
# ./regtest.sh logs [svc] Tail logs (lp, relay, miner, lnd1, lnd4, bitcoind)
# ./regtest.sh lncli N Run lncli on LND-N (e.g. ./regtest.sh lncli 1 getinfo)
# ./regtest.sh bitcoin Run bitcoin-cli (e.g. ./regtest.sh bitcoin getblockcount)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REGTEST_DIR="${REGTEST_DIR:-$HOME/dev/local/docker/regtest}"
COMPOSE_FILE="${SCRIPT_DIR}/docker-compose.regtest.yml"
RED='\033[0;31m'
GREEN='\033[0;32m'
CYAN='\033[0;36m'
NC='\033[0m'
# -- Detect container prefix (regtest- or lnbits-) --
detect_prefix() {
if docker ps -f name=regtest-bitcoind-1 -f status=running --format '{{.Names}}' | grep -q .; then
echo "regtest"
elif docker ps -f name=lnbits-bitcoind-1 -f status=running --format '{{.Names}}' | grep -q .; then
echo "lnbits"
else
echo ""
fi
}
cmd_up() {
echo -e "${CYAN}Starting regtest (bitcoind + lnd-1 + lnd-4)...${NC}"
cd "$REGTEST_DIR" && docker compose up -d bitcoind lnd-1 lnd-4
echo -e "${CYAN}Starting lamassu services...${NC}"
docker compose -f "$COMPOSE_FILE" up -d
echo -e "${CYAN}Running bootstrap...${NC}"
"$SCRIPT_DIR/regtest-bootstrap.sh"
}
cmd_down() {
echo -e "${CYAN}Stopping lamassu services...${NC}"
docker compose -f "$COMPOSE_FILE" down 2>/dev/null || true
echo -e "${CYAN}Stopping regtest...${NC}"
cd "$REGTEST_DIR" && docker compose down 2>/dev/null || true
echo -e "${GREEN}All stopped.${NC}"
}
cmd_reset() {
echo -e "${CYAN}Full reset: tearing down everything + wiping data...${NC}"
docker compose -f "$COMPOSE_FILE" down -v 2>/dev/null || true
cd "$REGTEST_DIR" && docker compose down -v 2>/dev/null || true
# Clean LND data (root-owned from containers)
docker run --rm -v "$REGTEST_DIR/data:/data" alpine sh -c 'rm -rf /data/lnd-1/* /data/lnd-4/*' 2>/dev/null || true
echo -e "${GREEN}Clean. Starting fresh...${NC}"
cmd_up
}
cmd_status() {
echo -e "${CYAN}Containers:${NC}"
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | grep -E 'regtest|lamassu|NAMES' | sort
PREFIX=$(detect_prefix)
if [ -n "$PREFIX" ]; then
echo ""
BLOCK=$(docker exec "${PREFIX}-bitcoind-1" bitcoin-cli -regtest getblockcount 2>/dev/null || echo "?")
echo -e " ${CYAN}Block height:${NC} ${BLOCK}"
for N in 1 4; do
SYNCED=$(docker exec "${PREFIX}-lnd-${N}-1" lncli --network regtest --rpcserver="lnd-${N}:10009" getinfo 2>/dev/null | jq -r '.synced_to_chain' 2>/dev/null || echo "?")
echo -e " ${CYAN}LND-${N} synced:${NC} ${SYNCED}"
done
LP_STATUS=$(curl -so /dev/null -w "%{http_code}" http://localhost:1776 2>/dev/null || echo "down")
echo -e " ${CYAN}LP status:${NC} ${LP_STATUS}"
EXT_STATUS=$(curl -so /dev/null -w "%{http_code}" http://localhost:1777 2>/dev/null || echo "down")
echo -e " ${CYAN}Extension:${NC} ${EXT_STATUS}"
fi
}
cmd_logs() {
local svc="${1:-lp}"
case "$svc" in
lp|lightning-pub) docker logs -f --tail 50 lamassu-lightning-pub ;;
relay|strfry) docker logs -f --tail 50 lamassu-relay ;;
miner) docker logs -f --tail 50 lamassu-miner ;;
lnd1|lnd-1) PREFIX=$(detect_prefix); docker logs -f --tail 50 "${PREFIX}-lnd-1-1" ;;
lnd4|lnd-4) PREFIX=$(detect_prefix); docker logs -f --tail 50 "${PREFIX}-lnd-4-1" ;;
bitcoind) PREFIX=$(detect_prefix); docker logs -f --tail 50 "${PREFIX}-bitcoind-1" ;;
*) echo "Unknown service: $svc (try: lp, relay, miner, lnd1, lnd4, bitcoind)"; exit 1 ;;
esac
}
cmd_lncli() {
local N="$1"; shift
PREFIX=$(detect_prefix)
if [ -z "$PREFIX" ]; then
echo -e "${RED}No running regtest found${NC}"; exit 1
fi
docker exec "${PREFIX}-lnd-${N}-1" lncli --network regtest --rpcserver="lnd-${N}:10009" "$@"
}
cmd_bitcoin() {
PREFIX=$(detect_prefix)
if [ -z "$PREFIX" ]; then
echo -e "${RED}No running regtest found${NC}"; exit 1
fi
docker exec "${PREFIX}-bitcoind-1" bitcoin-cli -regtest "$@"
}
# -- Main --
case "${1:-help}" in
up) cmd_up ;;
down) cmd_down ;;
reset) cmd_reset ;;
status) cmd_status ;;
logs) shift; cmd_logs "${1:-lp}" ;;
lncli) shift; cmd_lncli "$@" ;;
bitcoin) shift; cmd_bitcoin "$@" ;;
*)
echo "Usage: $0 {up|down|reset|status|logs|lncli|bitcoin}"
echo ""
echo " up Start everything + bootstrap"
echo " down Stop everything"
echo " reset Full teardown + fresh start"
echo " status Show container & service status"
echo " logs [s] Tail logs (lp, relay, miner, lnd1, lnd4, bitcoind)"
echo " lncli N Run lncli on LND-N (e.g. $0 lncli 1 getinfo)"
echo " bitcoin Run bitcoin-cli (e.g. $0 bitcoin getblockcount)"
;;
esac

View file

@ -1,335 +0,0 @@
#!/bin/bash
#
# Start lamassu-next development environment with comprehensive regtest
#
# This script:
# 1. Connects to the regtest environment at ~/dev/local/docker/regtest
# 2. Starts Lightning.Pub with the specified image/worktree
# 3. Creates and funds an ATM account
# 4. Displays connection info for Zeus wallet and Lightning.Pub nprofile
#
# Prerequisites:
# ~/dev/local/docker/regtest must be running:
# cd ~/dev/local/docker/regtest && ./start-regtest
#
# Usage:
# ./start-with-regtest.sh # Start with default image
# ./start-with-regtest.sh --image myimage # Use specific Docker image
# ./start-with-regtest.sh --worktree ~/path # Build from worktree
# ./start-with-regtest.sh down # Stop services
# ./start-with-regtest.sh logs # Follow logs
# ./start-with-regtest.sh status # Show connection info
#
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REGTEST_DIR="${REGTEST_DIR:-$HOME/dev/local/docker/regtest}"
DEFAULT_IMAGE="lightning-pub-withdraw:latest"
ATM_FUNDING_SATS=100000
# Colors
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
log() { echo -e "${GREEN}[lamassu]${NC} $1"; }
warn() { echo -e "${YELLOW}[lamassu]${NC} $1"; }
error() { echo -e "${RED}[lamassu]${NC} $1"; }
info() { echo -e "${CYAN}[lamassu]${NC} $1"; }
# Get local IP for external wallet access
get_local_ip() {
ip route get 1 2>/dev/null | awk '{print $7; exit}' || hostname -I | awk '{print $1}'
}
# Check if regtest is running
check_regtest() {
if ! docker network inspect lnbits_default &>/dev/null; then
error "Regtest network not found!"
echo ""
echo "Start the regtest environment first:"
echo " cd $REGTEST_DIR && ./start-regtest"
exit 1
fi
# Check if lnd-4 is running (Lightning.Pub's backend)
if ! docker exec lnbits-lnd-4-1 lncli --network=regtest getinfo &>/dev/null 2>&1; then
warn "lnd-4 not responding. Waiting..."
sleep 5
fi
}
# Build Lightning.Pub from worktree
build_from_worktree() {
local worktree="$1"
local image_name="lightning-pub-custom:latest"
if [[ ! -d "$worktree" ]]; then
error "Worktree not found: $worktree"
exit 1
fi
log "Building Lightning.Pub from $worktree..."
docker build -t "$image_name" "$worktree"
echo "$image_name"
}
# Wait for Lightning.Pub to be ready and get nprofile
wait_for_lightning_pub() {
log "Waiting for Lightning.Pub to start..."
local max_attempts=30
local attempt=0
while [[ $attempt -lt $max_attempts ]]; do
if docker logs lamassu-lightning-pub 2>&1 | grep -q "LightningPub listening"; then
sleep 2 # Extra time for Nostr middleware
return 0
fi
attempt=$((attempt + 1))
sleep 2
done
error "Lightning.Pub failed to start within 60 seconds"
docker logs lamassu-lightning-pub 2>&1 | tail -20
return 1
}
# Get Lightning.Pub nprofile from logs
get_nprofile() {
docker logs lamassu-lightning-pub 2>&1 | grep -oP 'nprofile:\s*\K\S+' | tail -1
}
# Get Lightning.Pub pubkey from logs
get_pubkey() {
docker logs lamassu-lightning-pub 2>&1 | grep -oP 'pubkey:\s*\K[a-f0-9]+' | tail -1
}
# Generate lndconnect URL for Zeus (using lnd-3 which has REST exposed)
generate_lndconnect() {
local lnd_container="lnbits-lnd-3-1"
local lnd_data="$REGTEST_DIR/data/lnd-3"
local local_ip=$(get_local_ip)
local rest_port=8082 # lnd-3's REST port
# Get cert and macaroon
local cert_path="$lnd_data/tls.cert"
local mac_path="$lnd_data/data/chain/bitcoin/regtest/admin.macaroon"
if [[ ! -f "$cert_path" ]] || [[ ! -f "$mac_path" ]]; then
warn "lnd-3 credentials not found. Zeus connection string unavailable."
return 1
fi
# Base64url encode (replace + with -, / with _, remove =)
local cert_b64=$(base64 -w0 "$cert_path" | tr '+/' '-_' | tr -d '=')
local mac_b64=$(base64 -w0 "$mac_path" | tr '+/' '-_' | tr -d '=')
echo "lndconnect://${local_ip}:${rest_port}?cert=${cert_b64}&macaroon=${mac_b64}"
}
# Create and fund ATM account
setup_atm_account() {
log "Setting up ATM account..."
# Create app
local app_response=$(curl -s -X POST http://localhost:1776/api/admin/app/add \
-H "Content-Type: application/json" \
-H "Authorization: Bearer lamassu-dev-admin-token" \
-d '{"name":"atm-app","allow_user_creation":true}' 2>/dev/null)
if echo "$app_response" | grep -q '"status":"OK"'; then
local app_id=$(echo "$app_response" | grep -oP '"id":"\K[^"]+')
local app_token=$(echo "$app_response" | grep -oP '"auth_token":"\K[^"]+')
log "Created ATM app: $app_id"
# Store app token for later use
echo "$app_token" > "$SCRIPT_DIR/.atm-app-token"
echo "$app_id" > "$SCRIPT_DIR/.atm-app-id"
# TODO: Fund the app by creating an invoice and paying from lnd-3
# This requires the app to support invoice creation via HTTP API
# For now, the app starts with 0 balance
return 0
else
warn "Failed to create ATM app: $app_response"
return 1
fi
}
# Display connection information
show_connection_info() {
local nprofile=$(get_nprofile)
local pubkey=$(get_pubkey)
local local_ip=$(get_local_ip)
local lndconnect=$(generate_lndconnect 2>/dev/null || echo "")
echo ""
echo -e "${BOLD}═══════════════════════════════════════════════════════════════${NC}"
echo -e "${BOLD} LAMASSU REGTEST ENVIRONMENT ${NC}"
echo -e "${BOLD}═══════════════════════════════════════════════════════════════${NC}"
echo ""
echo -e "${CYAN}Lightning.Pub:${NC}"
echo -e " Pubkey: ${GREEN}$pubkey${NC}"
echo -e " nprofile: ${GREEN}$nprofile${NC}"
echo ""
echo -e "${CYAN}Service URLs:${NC}"
echo " Nostr Relay: ws://localhost:7777"
echo " Lightning.Pub: http://localhost:1776"
echo " Withdraw API: http://localhost:1777"
echo " PostgreSQL: localhost:5432"
echo ""
echo -e "${CYAN}External Access (from phone/other devices):${NC}"
echo " Nostr Relay: ws://${local_ip}:7777"
echo " Withdraw API: http://${local_ip}:1777"
echo ""
if [[ -n "$lndconnect" ]]; then
echo -e "${CYAN}Zeus Wallet Connection (lnd-3):${NC}"
echo -e " ${GREEN}$lndconnect${NC}"
echo ""
echo " Scan this with Zeus to connect to lnd-3 for testing payments."
echo ""
fi
echo -e "${CYAN}ATM Configuration (apps/machine/.env):${NC}"
echo " VITE_RELAY_URL=ws://localhost:7777"
echo " VITE_LIGHTNING_PUB_PUBKEY=$pubkey"
echo " VITE_EXTENSION_API_URL=http://localhost:1777"
echo ""
echo -e "${CYAN}CLI Helpers:${NC}"
echo " source $REGTEST_DIR/docker-scripts.sh"
echo " bitcoin-cli-sim -generate 1 # Mine blocks"
echo " lncli-sim 4 getinfo # lnd-4 (Lightning.Pub)"
echo " lncli-sim 3 getinfo # lnd-3 (Zeus wallet)"
echo ""
echo -e "${BOLD}═══════════════════════════════════════════════════════════════${NC}"
}
# Start services
start() {
local image="$DEFAULT_IMAGE"
local worktree=""
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--image)
image="$2"
shift 2
;;
--worktree)
worktree="$2"
shift 2
;;
*)
shift
;;
esac
done
log "Checking prerequisites..."
check_regtest
# Build from worktree if specified
if [[ -n "$worktree" ]]; then
image=$(build_from_worktree "$worktree")
fi
# Check if image exists
if ! docker image inspect "$image" &>/dev/null; then
error "Docker image not found: $image"
echo ""
echo "Either:"
echo " 1. Build the image: docker build -t $image <path>"
echo " 2. Use --worktree: ./start-with-regtest.sh --worktree ~/path/to/lightning-pub"
exit 1
fi
log "Using Lightning.Pub image: $image"
# Export environment variables
export REGTEST_DATA_DIR="$REGTEST_DIR/data"
export LIGHTNING_PUB_IMAGE="$image"
export HOST_IP=$(get_local_ip)
log "Starting lamassu services..."
docker compose -f "$SCRIPT_DIR/docker-compose.regtest.yml" up -d
# Wait for Lightning.Pub and setup
if wait_for_lightning_pub; then
setup_atm_account || true
show_connection_info
else
error "Failed to start Lightning.Pub. Check logs with: $0 logs lightning-pub"
exit 1
fi
}
# Stop services
stop() {
log "Stopping lamassu services..."
docker compose -f "$SCRIPT_DIR/docker-compose.regtest.yml" down
rm -f "$SCRIPT_DIR/.atm-app-token" "$SCRIPT_DIR/.atm-app-id"
log "Services stopped."
}
# Show logs
logs() {
docker compose -f "$SCRIPT_DIR/docker-compose.regtest.yml" logs -f "$@"
}
# Show status/connection info
status() {
if ! docker ps --format '{{.Names}}' | grep -q lamassu-lightning-pub; then
error "Services not running. Start with: $0 start"
exit 1
fi
show_connection_info
}
# Main
case "${1:-start}" in
start)
shift || true
start "$@"
;;
stop|down)
stop
;;
logs)
shift
logs "$@"
;;
status|info)
status
;;
*)
echo "Usage: $0 [command] [options]"
echo ""
echo "Commands:"
echo " start Start services (default)"
echo " stop, down Stop services"
echo " logs [service] Follow logs"
echo " status, info Show connection info"
echo ""
echo "Options for 'start':"
echo " --image <name> Use specific Docker image"
echo " --worktree <path> Build from Lightning.Pub worktree"
echo ""
echo "Examples:"
echo " $0 # Start with default image"
echo " $0 --worktree ~/dev/lightning-pub/withdraw # Build and use worktree"
echo " $0 --image lightning-pub-custom:v1 # Use specific image"
exit 1
;;
esac

View file

@ -1,65 +0,0 @@
##
## strfry configuration for Lamassu ATM development
##
relay {
bind = "0.0.0.0"
port = 7777
# Set to 0 to skip configuring nofiles limit (avoids container ulimit issues)
nofiles = 0
info {
name = "Lamassu Dev Relay"
description = "Private Nostr relay for ATM development and testing"
pubkey = ""
contact = ""
}
# Maximum message size in bytes
maxWebsocketPayloadSize = 131072
# Connection limits
maxWebsockets = 100
maxConnections = 1000
}
# Event policies
events {
# Maximum event size
maxEventSize = 65536
# Rate limiting
rejectEventsNewerThanSeconds = 900
rejectEventsOlderThanSeconds = 94608000
# Event kinds we care about
# 14: Private DMs (NIP-17)
# 21000: Lightning.Pub RPC (generic request/response)
# 21001-21003: CLINK events (Offer, Debit, Manage)
# 22242: NIP-42 auth
# 30078: Service Beacon (replaceable, service discovery)
# 30079: Transaction records (replaceable)
# TODO: Review if these ephemeral event settings actually do anything useful.
# CLINK events (21000-21003) are in the ephemeral range but need to persist
# long enough for request/response flows. These settings may not be recognized
# by strfry - verify against strfry documentation.
# Allow ephemeral events up to 5 minutes old to be accepted
rejectEphemeralEventsOlderThanSeconds = 300
# Keep ephemeral events for 5 minutes before deletion
ephemeralEventsLifetimeSeconds = 300
}
# Negentropy sync (for relay federation)
negentropy {
enabled = true
syncOnConnect = false
}
# Plugins (for NIP-42 auth in production)
# plugins {
# authRequired = true
# writePolicy = "accept"
# readPolicy = "accept"
# }

View file

@ -0,0 +1,144 @@
# ADR-002: Remote Access & Fleet Management — Three Planes, Operator-Owned Access via NetBird
**Status:** Accepted
**Date:** 2026-06-14
**Context:** Multi-operator bitSpire fleet — separating the payment, control, and recovery planes by who owns them.
## Decision
1. **Separate three planes by trust owner**, and never conflate them:
- **Payment plane** — ATM ↔ LNbits over the nostr-native-transport. Owned by the **SaaS operator**. Implies *no* machine access.
- **Fleet control plane** — routine ops/telemetry/enrollment over Nostr (see [#42](https://git.atitlan.io/aiolabs/bitspire/issues/42)). Authorized by the **machine operator's** key.
- **Access / recovery plane** — SSH for the unanticipated and the broken. Owned by the **machine operator**.
2. **The machine operator's own recovery access is provisioned at install and is app-independent.** Their SSH key and their VPN/NetBird enrollment are established when the machine is set up, so they can always reach a box even when the bitSpire app or OS is broken. Access for *anyone else* is runtime-granted, scoped, and revocable — never the owner's own path.
3. **Adopt NetBird as the standard access/recovery plane**, chosen for fleet scale and a **self-hostable, fully FOSS control plane**. The platform may provide a default NetBird setup as a convenience; a machine operator who does not wish to trust whoever runs that control plane **disables it and provisions their own access plane** (self-hosted NetBird, or their own WireGuard hub).
4. **We will NOT build a "revoke SaaS-operator access" toggle in the operator dashboard.** It is a false promise of security: the SaaS operator runs LNbits (and, in the default deployment, the NetBird control plane), so a toggle they ultimately control cannot protect a machine operator against them. The honest boundary is **exclusion-by-ownership, not exclusion-by-toggle** — an operator who wants to exclude the SaaS operator takes ownership of the access plane.
## Context
### The players
A deployed bitSpire machine sits between two distinct principals:
- **SaaS operator** — runs the LNbits instance and provides the Lightning backend as a service.
- **Machine operator** — owns the physical ATM(s) and is identified by a Nostr key (the operator pubkey in the [#42](https://git.atitlan.io/aiolabs/bitspire/issues/42) allow-list).
These are different parties with different interests. A machine operator will want to SSH to their own machine for support and recovery, and **may or may not want to grant the SaaS operator that same access.**
### Why the SaaS operator needs zero box access by design
The whole nostr-native architecture (no admin tokens on the kiosk, no inbound network surface, payment over Nostr) means the SaaS operator can deliver the full service **without ever touching the machine**. So "the machine operator may refuse the SaaS operator access" is not a constraint to engineer around — it is the **default that costs nothing**. SaaS-operator box access is a *support convenience*, never a service requirement. The natural posture is therefore **default-deny for the SaaS operator**.
### Why SSH can't be replaced by the Nostr control plane
The Nostr control plane (#42) is a fixed menu of structured, capability-scoped commands dispatched by a handler *inside the app*. It is excellent for routine, auditable, fleet-wide ops on **healthy** machines, and strictly better than SSH for those (signed, scoped, logged, fan-out). But:
- It can only do what a handler was written for; incidents are by definition unanticipated.
- The listener lives in the app, so it dies exactly when the app dies — the case you most need recovery for.
SSH (arbitrary, interactive, app-independent) is therefore irreducible as the **recovery plane**. The two are complements, not substitutes.
### Why the recovery path must be app-independent
The whole point of a recovery path is to survive the failure of the thing it recovers. So it must not be gated by the bitSpire app, nor by a Nostr command the app dispatches. The kernel/agent that carries the tunnel and `sshd` must come up at boot independent of the app. (`allowedTCPPorts = []` already means `sshd` is unreachable except across the tunnel — the VPN handshake is the outer lock, the SSH key the inner one.)
### Why the single shared hub had to change
The pre-existing design used one WireGuard hub (`170.75.161.21`) run by platform infra. Whoever runs that hub has a standing network path to every enrolled box — i.e. the SaaS operator having access to machines they don't own. Multi-tenancy requires the access plane to be **per-operator or policy-isolated**, rooted in the machine operator, not the platform.
## Options Considered
### Access-plane mechanism
#### Option A: Always-up minimal WireGuard hub
**Pros:** After boot, zero userspace dependency — the kernel holds the tunnel, nothing can crash it short of a kernel/networking fault; smallest, most battle-tested trusted-code surface; simplest possible recovery floor.
**Cons:** Manual peer management; no policy/ACL/enrollment ergonomics; a single shared hub re-creates the multi-tenant trust problem (must be run per-operator to avoid it); does not scale operationally to many operators × many machines.
#### Option B: NetBird (Selected)
**Pros:** Policy/ACL-based, revocable, per-peer access control; enrollment + audit out of the box; **self-hostable, fully FOSS control plane** — we retain the ability to run and modify every layer; scales to the many-operators × many-machines world #42 anticipates.
**Cons:** The NetBird agent is a userspace daemon, so the recovery path depends on that daemon being up (less bulletproof than kernel-level always-up WG) — mitigated by it being independent of the bitSpire app, mature, and systemd-restarted; running a control plane is operational weight (acceptable: the platform provides a default; sovereignty-seeking operators self-host).
#### Option C: Tailscale
**Pros:** Best-in-class ergonomics and NAT traversal.
**Cons:** **Control plane is closed source with no FOSS alternative** (headscale only reimplements the coordination server, chasing an upstream we don't control). Fails the hard requirement that we can always self-host and modify any software we depend on. Rejected on that basis alone.
#### Option D: On-demand tunnel toggled by the Nostr control plane
**Pros:** No standing reachability; every access window is a signed, audited, time-boxed event.
**Cons:** If the toggle is handled by the app, it fails in the exact recovery scenario (listener died with the app). If handled by a separate daemon, it reintroduces a privileged userspace listener into the recovery path and grows, rather than shrinks, the trusted-code surface. Acceptable only as an **audited convenience layer on top of** an always-available floor (and designed to fail open), never as the load-bearing gate. Not adopted as the primary mechanism.
### Trust model for excluding the SaaS operator
#### Option 1: Dashboard toggle to revoke SaaS-operator access (Rejected)
The SaaS operator controls LNbits (the machine's wallet/account is an LNbits user they can administer) and, in the default deployment, the NetBird control plane. A toggle whose enforcement they ultimately control gives the machine operator no real protection against them — it is security theater. **Rejected as a false promise.**
#### Option 2: Exclusion by ownership (Selected)
The only honest way for a machine operator to exclude the SaaS operator is to **own the access plane**: disable the default (platform-provided) NetBird enrollment and stand up their own — self-hosted NetBird, or their own WireGuard hub. The default deployment trusts whoever runs the control plane *and says so plainly*; operators who won't extend that trust take ownership. Control = ownership; we do not pretend otherwise.
## Consequences
### Positive
- Honest trust boundaries: the SaaS operator has no standing box access by default, and the limits of platform-provided convenience are stated rather than faked.
- Scales to many operators × many machines via NetBird policy/enrollment, while preserving a self-hosting escape hatch for sovereignty.
- The recovery plane survives app and OS failure because it is provisioned at install and independent of the runtime.
- Every dependency remains FOSS and self-hostable — no closed control plane anywhere in the stack.
### Negative
- The NetBird agent is a standing userspace daemon; a box where *both* the app and the agent are down falls to the physical/LAN floor (same floor as any remote scheme — only pure kernel-WG narrows it, at the cost of NetBird's ergonomics). Operators who weight reliability over ergonomics can choose self-hosted plain WireGuard.
- Sovereignty for a distrusting operator costs them operational work (running their own access plane). This is inherent to "control = ownership," not incidental.
- Two enrollment surfaces at provisioning: app/payment identity (#42 seed URL) and system/access identity (this plane). They must be kept conceptually distinct.
### Future Considerations
- An **audited convenience layer** (Nostr `OpenAccess`/`CloseAccess` that opens a time-boxed SSH window and logs it as a signed event) may be added *on top of* the always-available floor, designed to fail open, for the routine "let me in" case. It is explicitly not the recovery gate.
- The machine operator's Nostr key can become the single root of trust across all three planes — SSH `authorized_keys` + VPN enrollment at install, `AddOperator`/`RevokeOperator` (#42) for delegation — so granting/revoking any party (including the SaaS operator) is one scoped, revocable capability model.
- `sshd` posture should be tightened to key-only for deployed boxes (password auth is currently forced on for installed configs for first-boot provisioning; scope it to the LAN/first-boot window). Tracks with [#51](https://git.atitlan.io/aiolabs/bitspire/issues/51).
## Amendment (2026-08-04): the access/recovery plane is not the *only* recovery
**Status:** Accepted · **Context:** the ATM app had no way to recover its own
connectivity — a machine that booted with no internet (or whose init otherwise
failed) sat on "ATM Unavailable" until a manual `systemctl restart bitspire`,
even after the network came back.
This ADR's SSH/NetBird recovery plane stands — it is the operator's
**app-and-OS-independent** path for the unanticipated and the broken, and may
carry recovery *procedures* (restart the service, inspect logs, re-provision).
But it is explicitly **not the first-line and not the only recovery method.**
Recovery is layered, cheapest-first:
1. **App auto-recovery (first-line, no human).** The ATM app recovers its own
relay/Lightning connectivity when possible: the nostr client already
reconnects with backoff, and the app now re-initializes when connectivity
returns (a fresh renderer reload — HAL is preserved in the main process),
so "internet came back" self-heals without anyone touching the machine.
2. **On-screen manual retry (operator at the machine).** The maintenance
("ATM Unavailable") screen carries a **Retry** button so a person standing
at the kiosk can force an immediate recovery attempt without shell access.
3. **SSH/NetBird (operator remote, last resort).** This plane — for when the
app *can't* self-heal or the box is genuinely broken. Unchanged by this
amendment beyond the reframing: it is the floor, not the front line.
Rationale: the common failure (transient network / boot-before-network) must
not require remote shell access to a public kiosk. Reserve the heavyweight
recovery plane for genuine app/OS failure. Implemented on branch
`feat/connection-recovery`.
## References
- [#41](https://git.atitlan.io/aiolabs/bitspire/issues/41) — Multi-location deployment: runtime site config (the access plane's per-machine identity is provisioned here, not baked into the closure).
- [#42](https://git.atitlan.io/aiolabs/bitspire/issues/42) — Fleet management: Nostr-native remote control & telemetry (the control plane this ADR sits beside).
- [#51](https://git.atitlan.io/aiolabs/bitspire/issues/51) — NixOS systemd hardening (sshd posture tightening).
- [#52](https://git.atitlan.io/aiolabs/bitspire/issues/52) — Sidecar bunker for the ATM key (related key-handling direction).
- `deploy/nixos/configuration.nix` — current WireGuard hub + `sshd` config (to be reworked per this decision).
- NetBird — <https://github.com/netbirdio/netbird> (self-hostable, FOSS control plane).

View file

@ -0,0 +1,216 @@
# ADR-003: NFC Access-Control Layer (badge-to-enter) + Developer Bypass
**Status:** Accepted — amended 2026-09-20 (see [Amendment](#amendment-2026-09-20-what-shipped) below; the original text follows it unchanged)
**Date:** 2026-07-29
**Context:** batm3 gaining a physical access layer — an NFC card must be presented to unlock the machine before anyone can transact. Reader hardware is not yet on hand; this ADR defines the direction and a non-breaking skeleton that is fully testable without it.
## Amendment (2026-09-20): what shipped
The gate landed in aiolabs/bitspire#86 as **Bolt Card tap-to-enter**, not the npub-QR → UID → serial-reader path planned below. The decisions still stand (opt-in `locked` state, operator-owned authorization, hashed identities, fail-closed, audited); the mechanism differs:
- **Reader.** The batm3 / upboard reader is a USB CCID contactless reader (Feitian KP382, OMNIKEY 5022) driven by `pcscd` + `nfc-pcsc` in the **main process** (`electron/nfc-service.ts`, from #83), not the serial `/dev/ttyNFC` device or Web NFC. Taps reach the renderer over the existing `nfc:card-tapped` IPC and the store routes them by state (`locked` → enter; the cash screens → pay / receive). The renderer-side `AccessReader` abstraction, the camera npub-QR reader and the mock reader were never wired to anything and have been removed; `services/access/` now holds only `authorize`, the Bolt Card parser and the credential types.
- **Credential.** Identity is the card's boltcards `external_id`, parsed **locally** from the tapped `lnurlw` (`AccessScan` kind `boltcard`), not the NFC UID. Only `hashId(external_id, salt)` is compared or logged. The `npub` variant (with its PIN second factor) stays in `authorize()` and its tests for a future non-card credential; `challenge` remains the v2 seam; the planned `uid` variant is gone.
- **Verified entry via `/session`** (superseding #86's soft entry). A tap yields a single-use SUN `p`/`c`, and verifying it at entry would have spent the voucher Complete needed, so #86 made no server call at entry. The fork now exposes `/session/<id>?p=&c=` (`docs/boltcard-session.md`): it spends the SUN **once**, proves a genuine non-replayed card, returns the card wallet's balance + fiat, and hands back the hit-keyed LUD-03 / LUD-06 second steps so Complete still needs no second tap. The session is single-shot — after the first Complete attempt, accepted or declined, the store drops it and the customer re-taps. The balance is shown on the idle menu and cash screens **hidden by default** behind an eye toggle (`CardChip.vue`), priced the way the LNbits wallet page prices it (wallet currency, else instance default, else the ATM's fiat at its own rate).
- **Open enrollment is still not a security boundary.** `/session` proves the card is genuine *to the server the card names*: the session URL is derived from the tapped `lnurlw`'s host, so with `openEnrollment` on a forged NDEF tag pointing at a server that answers `authenticated: true` still unlocks the terminal. Money is unaffected (cash-out never dispenses without `PAYMENT_RECEIVED`; cash-in pays where the holder pointed). Closing the gate means pinning the accepted card-server host(s) and/or a provisioned allow-list — tracked in aiolabs/bitspire#91.
- **Session semantics.** One tap = one session; every transaction terminal state returns to `locked`. Inactivity (60 s on the idle menu) and the absolute cap (10 min) are measured at the DOM layer (`useSessionSecurity`) because an XState `after` cannot observe touches. Both send `END_SESSION`, which the machine accepts **from `idle` only**, so a timer can never abandon stacked bills or an in-flight dispense. An explicit End Session button re-locks immediately.
- **Config.** `ACCESS_CONTROL_ENABLED` / `ACCESS_OPEN_ENROLLMENT` / `ACCESS_DEV_UNLOCK` / `ACCESS_SALT` from env, overridden by `/var/lib/bitspire/access.json` (pushed with `deploy/nixos/provision-access.sh`, no rebuild). `devUnlock` defaults **off**.
- **Audit** is still a console stub; the state.db write promised in decision 7 is tracked in aiolabs/bitspire#90.
- **Phased plan superseded.** PR3 (Web NFC) and PR4 (serial HAL) will not happen — the pcscd reader covers real hardware and there is no laptop dev path beyond the debug paste-box on `LockedView`. PR5's challenge-response idea survives as the `challenge` seam.
---
## Decision
1. **Add a top-level `locked` state to the ATM state machine, and make it the initial state.** It sits *below* the existing initialization gates (`unpaired` / `awaiting-fees` / `maintenance` / `signer-unreachable`, which live in `App.vue`). A healthy, paired machine boots into `locked` and only reveals `idle` (Buy/Sell) after an access grant.
2. **Access control is opt-in via runtime config (`accessControl.enabled`, default `false`).** When disabled, the machine behaves exactly as today (boots straight to `idle`). This makes the whole feature non-breaking for the current test unit and for production ATMs, and lets a half-built access layer never brick a working box. This is the single most important constraint on the design.
3. **The reader lives behind an `AccessReader` abstraction that mirrors the existing `PairingSource` seam.** First implementation is a `MockAccessReader` (dev button / hotkey), so the locked→idle→transaction path is exercisable today with zero hardware. Web NFC and serial-NFC implementations follow.
4. **Credential model is a discriminated union with an explicit upgrade path.** v1 = card UID matched against a hashed allow-list. v2 = challenge-response (card-held key signs a machine nonce), verified against an operator-authorized set. Ship v1; design the types so v2 is additive.
5. **Three-tier developer bypass**, following existing conventions: a build flag (`VITE_SKIP_ACCESS_GATE`), the config disable (`accessControl.enabled=false`), and a runtime operator/dev unlock gesture that dispatches a synthetic grant.
6. **Authorization is owned by the machine operator, not the SaaS operator** — consistent with [ADR-002](./002-remote-access-and-fleet-management.md). The card allow-list is authorized by the operator key (the [#42](https://git.atitlan.io/aiolabs/bitspire/issues/42) allow-list mechanism), local first, operator-synced later. When access control is *enabled* and the reader is absent/broken, the machine **fails closed** (with the operator/dev unlock as the escape hatch); when *disabled*, reader state is irrelevant.
7. **Every grant/deny is audited** to `state.db` (hashed credential + timestamp + role + outcome), with optional later publication as a Nostr event. No PII, consistent with the KYC-free principle.
## Context
### What this is (and what it is not)
This is the **end-user physical access plane**: a person must badge in to use the machine. It is distinct from the three planes in [ADR-002](./002-remote-access-and-fleet-management.md) — it is *not* the operator's SSH/NetBird recovery plane, and *not* the SaaS payment plane. It shares one idea with ADR-002: **the machine operator owns who is authorized**, expressed through the operator key / #42 allow-list.
### Why the codebase is well-shaped for this
Three seams already exist; we extend them rather than invent:
- **The idle→transaction transition is unguarded.** `packages/state-machine/src/machine.ts` starts at `initial: 'idle'` (~L431) and `idle` moves to `cashIn`/`cashOut` via plain `SELECT_CASH_IN` / `SELECT_CASH_OUT` transitions with no guards (~L457-464). Inserting a `locked` predecessor state is a localized change.
- **`PairingSource` is a reader abstraction designed to grow.** Its doc (`apps/machine/src/services/pairing/types.ts`) explicitly anticipates *"an NFC reader or a HAL barcode scanner… a HAL-scanner source can be added the same way without touching the wizard."* `AccessReader` mirrors it: `qr-source.ts` / `nfc-source.ts` → `mock-reader.ts` / `web-nfc-reader.ts` / `serial-reader.ts`.
- **Dev-flag and config conventions are established.** `import.meta.env.VITE_* === 'true'` (e.g. `VITE_MAINTENANCE_MODE`, `VITE_FORCE_MOCK`), plus Electron `get-config` fields that the renderer reads (`electron/main.ts` L280–312: `maintenanceMode`, `branding`). A new `accessControl` config field and a `VITE_SKIP_ACCESS_GATE` flag follow the same shape.
### Hardware reality check (important)
The batm3 already exposes an NFC device, but it is **serial**: `deploy/nixos/hardware/batm3.nix` L154 maps udev serial `A9ZF8ELY` → `/dev/ttyNFC`. The *existing* `pairing/nfc-source.ts` uses **Web NFC** (`NDEFReader`), which drives a phone/laptop NFC radio, **not** a serial reader. So the real batm3 access reader needs a **main-process serial driver** (HAL-style, per [ADR-001](./001-hal-architecture.md)) exposing card events to the renderer over IPC — the Web NFC path is only useful for laptop/phone dev. This ADR keeps that driver as a clearly-scoped later PR so the skeleton doesn't pretend the scaffold "just works" on the panel.
## Architecture
### Boot / render layering
```
Electron get-config ─┐
▼
App.vue init gates (unchanged):
unpaired? → PairingWizard
initError (maintenance / awaiting-fees / signer-unreachable)? → maintenance screen
else ▼
State machine (paired + healthy):
┌───────────────────────────────────────────────┐
│ locked ──ACCESS_GRANTED──▶ idle │ ← NEW initial state
│ ▲ │ SELECT_CASH_* │
│ │ re-lock (session end / ▼ │
│ │ inactivity / complete) cashIn / cashOut │
│ └──────────────────────────┘ │
└───────────────────────────────────────────────┘
(when accessControl.enabled === false,
`locked` immediately `always`-bypasses to `idle`)
```
The access gate is strictly below App.vue's init gates: a machine that is unpaired or in maintenance never reaches `locked`.
### 1. State machine (`packages/state-machine`)
- New top-level state `locked`, `initial: 'locked'`.
- New events on the machine's event union: `ACCESS_GRANTED` (carries an authorized `CardCredential` + resolved role), `ACCESS_DENIED` (carries a reason), `DEV_UNLOCK`.
- `locked` transitions:
- `always: [{ guard: 'accessBypass', target: 'idle' }]` — instant pass-through when disabled/bypassed (no UI flicker; the view is gated on the same predicate).
- `on: { ACCESS_GRANTED: { target: 'idle', actions: ['startSession', 'recordAccessGrant'] }, ACCESS_DENIED: { actions: 'recordAccessDeny' }, DEV_UNLOCK: { guard: 'devUnlockAllowed', target: 'idle', actions: 'startDevSession' } }`.
- Re-lock: the existing `complete` auto-return (currently 60s → `idle`) and the inactivity timeouts (`INACTIVITY_TIMEOUT`/`TIMEOUT_MS`, ~L422-429) target `locked` instead of `idle`. Because `locked` `always`-bypasses when disabled, this is one code path for both modes.
- Purity: the state-machine package must not read Vite env. `accessControl.enabled` and the bypass boolean are passed in as **actor input → context** (`context.accessControlEnabled`, `context.accessBypass`); guard `accessBypass` reads context only. New context fields: `accessControlEnabled`, `accessBypass`, `session` (`{ role, grantedAt, credentialIdHash } | null`).
- Guards: `accessBypass`, `devUnlockAllowed`. Actions: `startSession`, `startDevSession`, `recordAccessGrant`, `recordAccessDeny`, and `resetContext` extended to clear `session`.
### 2. Config plumbing
- `apps/machine/src/types/electron.d.ts` — extend `RuntimeConfig` (L5) with:
```ts
accessControl: {
enabled: boolean // default false
devUnlock: boolean // allow the runtime operator/dev unlock gesture
// v2: allowListSource, challengeRequired, …
}
```
- `apps/machine/electron/main.ts` — the `get-config` handler (L280) returns `accessControl`, sourced from env for now (`ACCESS_CONTROL_ENABLED === 'true'`, `VITE_SKIP_ACCESS_GATE` → forces `enabled:false`), later from a provisioned file under `/var/lib/bitspire/` alongside branding.
- `apps/machine/.env.example` — document `VITE_SKIP_ACCESS_GATE=true` (browser/dev straight to idle) and `ACCESS_CONTROL_ENABLED`.
### 3. Reader abstraction (`apps/machine/src/services/access/`)
Mirrors `services/pairing/`:
```
services/access/
types.ts # AccessReader, CardCredential (union), AccessRole, StopCapture
mock-reader.ts # PR1 — fires a card event on demand (dev button / hotkey)
web-nfc-reader.ts # PR3 — NDEFReader, dev on laptop/phone
serial-reader.ts # PR4 — /dev/ttyNFC via main-process HAL + IPC
authorize.ts # allow-list check + role resolution (hashed UID v1)
index.ts # availableAccessReaders(): AccessReader[]
__tests__/
```
```ts
export type AccessRole = 'user' | 'operator'
export type CardCredential =
| { kind: 'uid'; uidHash: string } // v1
| { kind: 'challenge'; pubkey: string; nonce: string; sig: string } // v2 (seam)
export interface AccessReader {
readonly kind: 'mock' | 'nfc-web' | 'nfc-serial'
readonly label: string
isAvailable(): Promise<boolean>
start(opts: {
onCard: (cred: CardCredential) => void
onError?: (e: unknown) => void
}): Promise<StopCapture>
}
```
### 4. Renderer wiring
- `apps/machine/src/views/LockedView.vue` (new) — the badge-in screen. Shows brand/logo + "Present your card", a live reader status, and (when `accessControl.devUnlock`) a discreet operator/dev unlock affordance (hidden long-press corner, or a button on the existing debug bar).
- `apps/machine/src/App.vue` — add a `locked` render branch mirroring the `PairingWizard` branch (L179) and the `initError` branch (L183): `<LockedView v-else-if="atmStore.isLocked" />`, then the existing `<router-view>` only when unlocked. Keeps rendering state-driven and matches the current shape.
- `apps/machine/src/stores/atm.ts` —
- `createActor(machine, { input: { accessControlEnabled, accessBypass } })` at the existing `createActor(machine)` site (L452), seeded from `RuntimeConfig`.
- `isLocked` computed off the snapshot (peer of `isIdle`, ~L422).
- `grantAccess(cred, role)` / `denyAccess(reason)` / `devUnlock()` that `send({ type: 'ACCESS_GRANTED' | 'ACCESS_DENIED' | 'DEV_UNLOCK', … })` (peers of `selectCashIn` at L1382, using the existing `send` at L1378).
- On init, when `accessControl.enabled`, subscribe to `availableAccessReaders()[0]`; on `onCard`, run `authorize()` → `grantAccess`/`denyAccess`. When disabled, do nothing (machine `always`-bypasses).
### 5. Audit
- Add `recordAccessEvent({ credentialIdHash, role, outcome, at })` alongside the existing state.db handlers (`state:record-transaction` etc. in `electron/main.ts`, exposed via `preload.ts`). v1 writes locally; a later PR can mirror to a replaceable Nostr event.
### Session semantics (decided)
**One badge = one transaction-scoped session.** A grant unlocks `idle`, the user runs a single transaction (Buy or Sell), and the machine re-locks on `complete`, on inactivity, or on an explicit "Done". The `session` context field is deliberately shaped as a general access session (`{ role, grantedAt, credentialIdHash }`), not a transaction handle, because this terminal may later handle **non-transaction functions** — so "unlock the terminal" and "authorize a transaction" stay separate concepts.
**Step-up authorization (future seam, not in PR1).** The badge tap grants *terminal access*; a specific sensitive action can independently *request re-authorization* — e.g. "tap your phone" or "enter a PIN" — without conflating the two. This is why the credential model is a union and the machine carries a `session` rather than a boolean "unlocked": a later `REQUIRE_STEPUP` event can gate an individual action against a fresh credential/PIN while the terminal session stays open. PR1 ships only the entry gate; step-up is a documented extension.
## Alternatives considered
- **Gate between `idle` and the transaction** (idle visible, tap requires a card). Rejected: the requirement is "gain access to the exchange" — the whole machine should be locked, not just the transact button. A `locked` predecessor matches the mental model and gives a clean re-lock boundary.
- **Web NFC only** (reuse `nfc-source.ts` as-is). Rejected: the batm3 reader is serial (`ttyNFC`); Web NFC can't drive it. Web NFC stays a dev-only convenience.
- **Fail-open by default** (no card → allow). Rejected for an access-control feature; but note the *disabled* default sidesteps this — access is simply off until an operator turns it on, at which point it fails **closed**.
- **OS/kiosk-level lock** (lock the desktop, not the app). Rejected: too coarse, no per-transaction audit, no role model, and it fights the existing state-driven UI.
- **UID allow-list as the permanent model.** Rejected as an endpoint (UIDs clone trivially) but accepted as v1 behind a union type, so challenge-response is additive.
## Security considerations
- **UID cloning** — card UIDs are not secret and are cloneable; v1 is "better than nothing" and is explicitly labeled upgradeable. The v2 challenge-response path (card signs a machine nonce) is the real security boundary; design the credential union and the `authorize()` seam for it now.
- **Data at rest** — store only a salted hash of the credential id; never raw UIDs or any PII (KYC-free). Never log a card secret or nsec (repo security priority #1).
- **Fail-closed when enabled** — reader absent/broken + `enabled` ⇒ locked, escape hatch = operator/dev unlock. Reader problems on a *disabled* machine are inert.
- **Operator ownership** — authorization derives from the operator key / #42 allow-list, not the SaaS operator (ADR-002 boundary). Local allow-list first; operator-published (NIP-51-style) sync later.
- **Dev bypass blast radius** — `VITE_SKIP_ACCESS_GATE` is build-time and never set in a production image; `devUnlock` is gated by `accessControl.devUnlock` (off in a locked-down deployment) and every dev unlock is audited with role `operator`/`dev`.
## Resolved decisions (2026-07-29)
1. **Session model** — ✅ one badge = one **transaction-scoped session**, with the `session` context modeled generally (terminal access, not a transaction handle) to allow non-transaction functions and per-action **step-up auth** (tap phone / PIN) later. See *Session semantics* above.
2. **v1 credential** — ✅ **UID allow-list first** (hashed), behind a `CardCredential` union so challenge-response is additive (PR5).
3. **Allow-list home** — ✅ **local first** (`state.db` / provisioned `access.json`, peer of `branding/`); operator-Nostr sync is a later PR.
Still open (cosmetic, decide during PR2):
4. **Dev unlock affordance** — hidden long-press corner vs a labeled button on the existing debug bar.
---
## Implementation plan (phased PRs)
Each PR is independently mergeable. **PR1 changes nothing observable while `accessControl.enabled=false` (the default).**
### PR1 — Non-breaking skeleton (state + config + mock reader + dev bypass + audit stub)
**Goal:** the locked→idle→transaction path is exercisable on the batm3 today, and the flag-off machine is byte-for-byte behavior-identical.
- `packages/state-machine`: add `locked` state (`initial`), `ACCESS_GRANTED`/`ACCESS_DENIED`/`DEV_UNLOCK` events, `accessBypass`/`devUnlockAllowed` guards, `startSession`/`recordAccess*` actions, context fields + actor `input`. Re-point `complete`/inactivity re-lock targets to `locked`.
- `apps/machine/src/types/electron.d.ts`: `RuntimeConfig.accessControl`.
- `apps/machine/electron/main.ts`: `get-config` returns `accessControl` (env-sourced); `.env.example` documents `VITE_SKIP_ACCESS_GATE` + `ACCESS_CONTROL_ENABLED`.
- `apps/machine/src/services/access/`: `types.ts`, `mock-reader.ts`, `authorize.ts` (UID allow-list, hashed), `index.ts`.
- `apps/machine/src/stores/atm.ts`: actor `input`, `isLocked`, `grantAccess`/`denyAccess`/`devUnlock`, reader subscription (only when enabled).
- `apps/machine/src/views/LockedView.vue` + `App.vue` `locked` render branch.
- Audit stub: `recordAccessEvent` handler + preload exposure (local write only).
- **Tests:** state-machine `locked → idle` on grant; `always`-bypass when disabled; re-lock from `complete`; `authorize()` allow/deny; `devUnlock` gated by config.
- **Flag state on merge:** `enabled=false`. CI green = no behavior change.
### PR2 — Locked UX polish
- Reader status/animation, brand-aware LockedView, denied-flash + reason, inactivity copy, operator/dev unlock affordance per open-question #4. Pure renderer.
### PR3 — Web NFC reader (dev)
- `web-nfc-reader.ts` (`NDEFReader`), registered in `availableAccessReaders()` behind availability check. Lets a laptop/phone drive the gate for demos/dev. No hardware dependency.
### PR4 — Serial NFC HAL driver (real batm3 hardware)
- Main-process serial driver for `/dev/ttyNFC` (HAL-style per ADR-001), IPC channel `access:watch-card` + `preload.ts` exposure; `serial-reader.ts` renderer client. Requires the physical reader to validate. Document the reader's protocol/baud in `docs/device-configuration.md`.
### PR5 — Challenge-response credential + operator allow-list sync
- Extend `CardCredential` with the `challenge` variant; `authorize.ts` verifies a signature over a machine nonce against the operator-authorized set; allow-list synced from an operator-published event (#42 mechanism). This is the real security upgrade; v1 UID path stays as a fallback/dev mode.
### Cross-cutting
- **Docs:** update `docs/machine-installation.md` (enabling access control, enrolling cards) and `deploy/nixos/README.md` (the `accessControl` config + `/dev/ttyNFC`) as PR4/PR5 land.
- **Provisioning:** a later change can add an `access.json` under `/var/lib/bitspire/` (peer of `branding/`) with the allow-list + `enabled`, plus a `provision-access.sh` mirroring `provision-branding.sh`.

View file

@ -0,0 +1,178 @@
# ADR-004: Cassette-State Synchronization
**Status:** Accepted
**Date:** 2026-09-22
**Context:** Cassette counts exist on two machines that both write them, over a transport
that cannot report a losing write. This has been load-bearing since #56 shipped, and until
now its only specification was a closed issue and a chat log — which is how four separate
divergence bugs went unnoticed.
## The problem
The ATM holds per-bay rows in `state.db` (`position` → `denomination`, `count`). spirekeeper
holds its own `cassette_configs` view for the operator dashboard. They are kept in step over
Nostr kind-30078, one addressable document per direction:
| d-tag | Direction | Author |
| --------------------------------------- | --------------------- | -------- |
| `bitspire-cassettes-state:<atm_pubkey>` | ATM reports counts up | ATM |
| `bitspire-cassettes:<atm_pubkey>` | operator pushes down | operator |
Counts drive cash dispensing and the public availability beacon, so a wrong number either
strands a customer at a machine that will not pay out or advertises cash that is not there.
The transport shapes everything else. Per NIP-01, an addressable event is identified by
`kind:pubkey:d` and ordered by `created_at` at **second granularity**, ties broken by lowest
event id. Relays MAY discard the loser, and a relay returns `OK` for an event it then
discards — so **acceptance is not persistence, and a losing writer is never told**. That
single fact rules out the obvious design.
## Decisions
### 1. The ATM owns `count`. The operator publishes operations, not counts.
A value with one writer cannot be clobbered. Compare-and-swap was considered and rejected:
CAS works because the writer learns it failed and retries, and every standard implementation
of it — HTTP `412`, Kubernetes `409`, a zero rowcount, `CMPXCHG` returning false — delivers
that signal. A kind-30078 publish cannot. Bolting a version onto the current design would let
the ATM refuse a stale push but leave the operator believing they set a count they did not,
trading a wrong number for a phantom edit.
So the operator publishes `refill`, `empty`, `recount` and `set_denomination` operations. The
vocabulary mirrors lamassu-server's `cash_unit_operation_type`, which is the same shape the
ancestor of this HAL arrived at. Absolute writes survive only as `recount`, which is what an
operator opening a bay and counting actually does.
`denomination` stays operator-authoritative: the machine cannot know what was physically
loaded into a bay.
### 2. Idempotency is explicit, because deltas are not idempotent.
Addressable events are re-delivered on reconnect, so a naive delta would be applied twice.
Every operation carries an operator-minted `id`; the ATM records applied ids and ignores
duplicates. This is lamassu-server's `pullNewBills` pattern — a client-minted UUID per unit of
work, making resend free and ordering irrelevant — rather than a sequence number.
### 3. The operator publishes a window of recent operations, not one.
An event the ATM missed self-heals on the next publish, because the next event still carries
the earlier operations. This is the same trick as Lightning.Pub piggybacking `latest_balance`
on every incremental message so a client that missed events corrects itself.
### 4. The ATM echoes applied ids back, which is the acknowledgement.
The state document carries `applied_ops`, so the dashboard can render each published operation
as applied or pending. This supplies the feedback leg a replaceable event cannot, without
needing the transport to report failures.
### 4a. Superseded. Before decisions 1 to 4 shipped, the overwrite was warned about.
The dashboard's publish dialog stated the failure plainly — that the publish would overwrite
the ATM's tracked counts, that decrements since the last baseline would be lost, and that it
should follow a physical refill rather than a mid-day tweak.
Kept here rather than deleted, because it is the calibration for how much a warning is worth.
It was a known, deliberately accepted risk carrying a human-factors mitigation, not an
oversight, and the product had already reached the same conclusion these decisions formalise.
It was also the weakest control available: it depended on an operator reading a dialog at the
end of a refill round, and it could not help at all when the stale value was the one already
in the form. Confirmed live on 2026-09-22 — a dispense moved a bay from 54 to 53 while a form
loaded at 54 stayed open, and nothing but that dialog stood between the operator and
discarding the decrement.
The dialog and the endpoint behind it are both gone. The operator dashboard no longer has a
field that accepts a count, which is a stronger guarantee than any wording could be.
### 5. Ordering is decided by `created_at`, never by arrival order, on both sides.
The ATM forces each stamp strictly above its last published one, so a same-second publish or a
clock stepping backwards cannot silently discard a report. spirekeeper applies an event only
when strictly newer than the **oldest** stamp on file for that machine.
Oldest, not newest, because LNbits' `Connection.execute` commits per call: a multi-row apply
cannot be made atomic through that data layer, so a crash mid-apply leaves some rows advanced.
Gating on the oldest means a partial apply is re-applied rather than mistaken for a complete
one, and the ATM's heartbeat makes it converge.
### 6. The machine's bay set is authoritative for layout.
Bay count is hardware-determined. spirekeeper deletes positions absent from a report rather
than leaving them; the operator cannot add or remove bays.
### 7. Unverified counts are declared, not guessed.
When a dispense ends with no per-bay report — a driver throw, or the dispense timeout — bills
may have reached the customer with nothing knowing how many. The ATM flags
`counts_uncertain_since` and carries it in the state document rather than letting a number
known to read high stand as measurement. An operator `recount` clears it.
### 8. State is published on every change and on a heartbeat.
A publish is one fire-and-forget event with no retry. The heartbeat is what makes the channel
self-healing after a relay outage, and the only way an out-of-band edit to the table ever
reaches the operator.
## What this replaces
The original design published a single hello-event gated on a one-shot flag, deduplicated on
one remembered event id, and never compared `created_at` at all. In practice that produced:
| Failure | Issue |
| --------------------------------------------------------------- | -------------- |
| Layout changes after first boot never published | bitspire#94 |
| Remediation dispenses debited HAL but not the rows | bitspire#76 |
| Absent positions never deleted; publishes then rejected forever | spirekeeper#43 |
| A stale dashboard publish overwriting a newer report | spirekeeper#43 |
| A drained machine advertising bills it had already dispensed | found in audit |
| A re-delivered A, B, A applied three times | found in audit |
The last of these is the only one decisions 5 to 8 do not close, because it is not a defect
in the mechanism: the operator is permitted to write the count, so a stale write is
indistinguishable from an intended one. Only decisions 1 to 4 remove it, by removing the
operator's ability to write counts at all.
## Status of implementation
Decisions 5 through 8 shipped in bitspire#104 and spirekeeper#44, on the existing wire format,
and were verified against the deployed code on sintra on 2026-09-22: a zeroed machine reported
drained rather than freezing its beacon, a re-delivered operator config was dropped as stale on
eight consecutive restarts, three heartbeat republishes carried strictly increasing stamps read
back off the relay, and the machine, the relay and the operator dashboard agreed on the counts
with timestamps correlated to the second.
Decisions 1 through 4 are the v2 operations wire and shipped in spirekeeper#46 and
bitspire#106.
On the operator side there is no longer any endpoint that accepts a count: the absolute
publish, its CRUD write and its request model were removed rather than deprecated. The
dashboard records operations and renders each as applied or pending from the machine's
`applied_ops` echo. On the machine side, schema v13 adds a `cassette_ops` dedup ledger, the
`created_at` watermark on this path is retired in favour of per-op ids, and the state document
carries `schema_version`, `seq` and `applied_ops`.
The wire shapes are those given under decisions 1 and 4 above.
Cutover for v2 is strict, no compatibility code: spirekeeper deploys first, machines follow on
their nightly pull. During that window a not-yet-updated ATM ignores an ops payload, so an
operator refill does not land until it updates — which fails safe, since the machine
under-counts and will not dispense bills it believes it lacks. In the other direction an
updated machine drops a v1 absolute-count payload on the missing `ops` array, which is the
same safe direction: the machine keeps the counts it is now the only writer of.
One gap stays open deliberately. An operation recorded while the relay is unreachable waits
for the operator's next action to be published, because only an operator action triggers a
publish. The window makes that self-healing once anything is published, but nothing on the
operator side republishes on its own. An operator-side heartbeat is the fix; it is not built.
## Alternatives considered
- **Compare-and-swap on absolute writes.** Rejected: see decision 1. Viable only with a
feedback leg the transport cannot provide, and decision 4 gets the same benefit without
pretending the transport is something it is not.
- **NIP-77 negentropy for reconciliation.** Rejected: it reconciles sets of event ids and
still requires a separate fetch. For a single mutable document it costs more than
re-reading it.
- **One envelope carrying all operator config.** Rejected earlier and still right: a fee edit
that republished a stale cassette inventory is a real failure mode. One d-tag per lifecycle.
- **Publishing the operation log as kind-78.** Deferred. The operator authors the operations
and the ATM records what it applied, so both sides already hold an audit trail.

View file

@ -0,0 +1,521 @@
# ADR-005: Cash-Out Dispense Outcome and Settlement Capture
**Status:** Accepted (2026-10-10)
**Date:** 2026-10-09
**Context:** On 2026-10-09 a customer paid a 40 EUR cash-out on sintra, a note jammed at the
cassette exit, and the operator dashboard showed the settlement as `processed` with no sign
anything was wrong (aiolabs/bitspire#122). The machine had recorded the failure correctly and
in detail. Nothing it knew ever reached anyone. This ADR specifies how a dispense outcome
becomes a first-class fact on both sides of the wire, and fixes the structural reason the
existing remediation tool could not have helped.
## The problem
A cash-out moves value in two steps that today are not connected:
1. **Payment.** The customer pays the ATM's BOLT11 invoice. LNbits lands it in the machine
wallet, `spirekeeper._handle_payment` verifies attribution, inserts a `dca_settlements` row,
and — in the same breath — spawns `process_settlement` as a background task.
2. **Dispense.** The machine, which learns of the payment through `watchInvoice`, commands the
dispenser. The hardware reports per-bay `dispensed` / `rejected` counts and, on failure, an
error code.
Step 1 does not wait for step 2. `process_settlement` pays the super fee, the operator's
commission splits, and the DCA legs the moment the payment lands, which on an LNbits-internal
transfer is sub-second. The dispense begins afterwards. So by the time the F56 reported
`78 42` at T+2 s, the settlement's legs were already `completed` and its status was
`processed` — which is what the dashboard faithfully displayed. `processed` means *all
distribution legs paid*. It has never meant *cash reached a hand*, because the server has no
input that could tell it.
The tool built for this situation, `apply_partial_dispense_and_redistribute`, carries a hard
guard: it refuses once any leg has completed, because a Lightning payment cannot be clawed
back. Under the current ordering that guard is reached on every real failure. The remediation
is structurally unreachable for the exact case it was written for, except by winning a race
against a sub-second transfer.
Downstream of that, four smaller gaps compound it (all in #122):
- The machine's `dispenseError` state is terminal and local. No report, no notification. The
only record that a customer is owed money lives in `state.db` on the ATM.
- The dispenser's counters do not see a note that leaves the bay and stops in the transport —
it is neither `dispensed` nor `rejected`. The machine trusts the resulting `dispensed: 0`,
leaves the bay count untouched, and republishes it as fact. The `countsUncertainSince`
safety net fires only when the report is *absent*, not when it is present and wrong.
- Nothing reads dispenser health. The availability beacon derives `cash_out` from
`totalBills > 0` alone and kept advertising a jammed machine as available. (Nothing
consumes that beacon today, so it could not have been the enforcement point in any case.)
- The customer sees a 30-second countdown and a txid QR, then the idle screen. There is no
claim reference, no statement that they have paid, and nothing distinguishes a mechanical
fault from an out-of-cash condition.
## Prior art
lamassu-machine / lamassu-server ran this exact hardware in production for a decade. Their
model, which this ADR adopts where it fits and deviates from where it is wrong:
- **Three fields on the transaction:** `error` (human message), `error_code` (the error's
*name*, machine-readable), `dispense_confirmed` (boolean). `dispense_confirmed` is computed on
**value** — `tx.fiat.eq(Σ denomination × dispensed)` — not taken from the driver.
- **An append-only action log, `cash_out_actions`,** one row per dispense attempt with
per-bay `provisioned_N` / `denomination_N` / `dispensed_N` / `rejected_N`, written by
`logDispense` as `action: 'dispense'` or `'dispenseError'` purely on whether `error` is set.
- **The operator is notified in the same atomic block that logs the dispense**
(`notifyOperator`, cash-out-atomic.js). Push, not a worklist.
- **A mechanical fault is not "out of cash."** Their 2026-09-29 fix routes a
dispenser-reported error to the screen that asks the customer to photograph their receipt,
*"the right prompt when they have paid and are owed money,"* and reserves `outOfCash` for a
shortfall with no error. The same commit removed a borrowed `statusCode 570` from the F56
driver because 570 meant "insufficient funds" to the server — a jammed BDU was being
reported as a hot-wallet problem.
- **What they did not have:** any notion of disabling a machine on a dispenser fault.
`getMachineStatuses` is inferential — ping age, stuck-screen age — and `cashOut` is an
operator config toggle. A jammed dispenser on a responsive machine reads *Fully
functional*. That is sintra's beacon exactly, so the latch below is new work, not a port.
- **What they got wrong and we will not copy:** `dispenseOccurred(bills)` returns true if the
bill entries merely *have* `dispensed` and `rejected` keys, and `updateCassettes` then
decrements by those numbers. A jam reporting `dispensed: 0` passes and decrements by zero.
Their cassette counts drift the same way sintra's did.
## Decisions
### 1. Payment is authorization. Dispense confirmation is capture. Distribution waits for capture.
A `cash_out` settlement lands as `pending` exactly as now, but `_handle_payment` no longer
spawns `process_settlement` for it. The row moves to a new status, `awaiting_dispense`, and
stays there until the machine reports.
| Machine reports | Settlement becomes | Then |
| ----------------------------------------- | ------------------ | --------------------------------------- |
| `dispense_confirmed: true` | `pending` | claim + distribute → `processed` |
| partial (some notes out, value short) | `partial_pending` | nothing moves until the shortfall is resolved (below) |
| `dispense_confirmed: false`, nothing out | `cash_owed` | legs never run; funds stay in wallet |
| no report within `DISPENSE_REPORT_TTL` | `dispense_unreported` | worklist; operator investigates |
**A partial dispense distributes once, when the outcome is final.** Some notes reached the
customer and some did not, so the sale's true amount is not yet known: it is the full amount
if the shortfall is remediated (an on-machine `manual_dispense` against the `txid`, or an
off-machine payout recorded with `settle_cash_owed`), and the scaled amount if the shortfall
is vouchered or written off. `partial_pending` therefore holds *everything* — including the
operator's and LPs' share of the notes that did dispense — until the operator records which
of those it was. Then one distribution runs, at that amount, using the existing
`apply_partial_dispense_and_redistribute` arithmetic for the scaled case (linear scale; the
fee split by the ratio locked at landing; operator absorbs rounding). A vouchered remainder
stays undistributed until redemption or expiry, per the voucher rules under *Future
directions*.
The alternative — distribute the scaled part immediately and the remainder on resolution —
pays the LPs the same afternoon but needs a second, additive distribution pass keyed to the
same settlement, which the repo does not have and which the completed-legs guard in the
current tool would fight. It is deferred, not rejected: a partial is almost always a
terminal-class fault that has also latched cash-out off, so the operator is coming to the
machine anyway and resolution is hours, not weeks. Revisit if prompt LP payout ever matters
more than one-distribution-per-settlement. (Decided 2026-10-10.)
This is the card-processing shape — authorize, then capture — and it is the same ordering
lamassu-server enforces between `dispense_confirmed` and `updateCassettes`. The cost is that
operator and DCA legs land seconds later than they do today, which is the dispense time.
The benefit is that `apply_partial_dispense_and_redistribute` is always reachable, because
no leg has run yet, and `cash_owed` is a state the money has not left.
`cash_in` settlements are unaffected: there is no dispense to wait for, and
`_pay_dca_distributions` already branches on `tx_type` for exactly this kind of asymmetry.
**Rejected:** keeping immediate distribution and adding a compensating reversal. Internal
legs *are* reversible — they are LNbits-internal invoices, so a compensating internal
payment is mechanically possible and the guard's "Lightning can't be clawed back" is only
true of the `autoforward` leg. But undoing money movement is strictly harder than not
moving it yet, and the autoforward leg stays irreversible either way. Compensation is kept
as a secondary tool for settlements that distributed before this ADR landed.
### 2. The machine reports every cash-out outcome over a `report_dispense` RPC.
Not over the kind-30078 state document. ADR-004 established why: an addressable event gives
its publisher no failure signal, and a losing writer is never told. A per-transaction
outcome is an append-only fact that must be acknowledged, which is a request/reply.
The RPC follows `create_withdraw` and `get_machine_config`: `register_rpc` at
`AUTH_ACCOUNT`, identity taken from the **verified** `sender_pubkey`, never from the body.
It is sent on **success as well as failure** — a success report is what captures (Decision
1). Payload, adopting the lamassu field names:
```jsonc
{
"txid": "tx_mv0madw6_wdhtea1v",
"payment_hash": "6f216df32c36…",
"tx_type": "cash_out",
"dispense_confirmed": false, // value equality, Decision 3
"error": "Dispensing, code: 78 42", // human, null on success
"error_code": "F56DispenseError", // the error's NAME, null on success
"raw_code": "78 42", // driver-native, for the decode table
"error_class": "terminal", // "terminal" | "recoverable" | null, Decision 5
"fiat_cents": 4000,
"bills": [{ "denomination": 20, "requested": 2, "dispensed": 0, "rejected": 0 }],
"cassettes": [ // the machine's cassette_bills rows, verbatim
{ "position": 2, "denomination": 20, "provisioned": 2, "dispensed": 0, "rejected": 0 }
],
"counts_uncertain": true, // Decision 3
"at": 1791529353
}
```
**Delivery is at-least-once with a durable outbox.** The machine writes the report to
`state.db` in the same transaction as the `transactions` row (`dispense_reports`:
`txid PRIMARY KEY, payload, created_at, acked_at`), then sends. It resends on boot, on relay
reconnect, and on a timer until an `OK` reply sets `acked_at`. The server upserts on `txid`,
so a resend is a no-op. This is the cassette-ops idempotency pattern applied to the other
direction.
The server stores every report in an append-only `dispense_reports` table (one row per
attempt, lamassu's `cash_out_actions` shape, keyed to the settlement by `bitspire_txid`,
which is already populated from `extra.txid`), copies the per-bay detail into
`dca_settlements.bills_json` / `cassettes_json` (columns that exist today and are never
written), and sets `dispense_confirmed`, `error`, `error_code` on the settlement.
### 3. `dispense_confirmed` is computed on value, separately from `error`, and a zero report with an error is unverified.
On the machine, after the HAL returns:
```
confirmed = requestedFiatCents === Σ(denomination × dispensed) × 100
```
`error` is carried independently. The state machine's `dispensingCash.onDone` guard moves
from `output.dispensed === true` to `output.dispenseConfirmed`, and `DispenseCashResult`
gains `dispenseConfirmed`, `errorCode`, `rawCode`, `errorClass`.
**The deviation from both bitSpire-today and lamassu:** when a report arrives with `error`
set and `dispensed === 0` on every bay, the machine does *not* treat that zero as a count. A
note in the transport path completes neither counter. The machine sets `countsUncertainSince`
exactly as it already does for an absent report, carries `counts_uncertain: true` in the RPC,
and the bay stays flagged until a `recount` op clears it. `atm-reconcile` then shows the gap
instead of a clean ledger.
### 4. A dispenser fault is its own customer screen, with evidence, and it is not "out of cash."
Two distinct terminal states replace the single `dispenseError`:
- **`outOfCash`** — the request could not be met and the dispenser reported **no error**
(an inventory refusal, or simply short). In the cash-out flow this state is reached *after*
payment, so the customer **has paid** and is owed the shortfall exactly as below; the
difference is the cause — no hardware fault, so the machine stays in service and nothing
latches. (An earlier draft said "nothing was charged beyond what was dispensed"; that is
only true of the inventory check *before* payment, which already prevents the sale.)
- **`dispenseFault`** — the dispenser reported an error. The customer **has paid** and is
owed the shortfall, and a `terminal` class also latches cash-out off (Decision 5).
Both screens therefore show the same evidence; the heading and the latch differ.
`dispenseFault` shows: the amount paid, the amount dispensed (per denomination, as now), the
txid as QR (as now) **and as text**, the first 12 characters of the payment hash, the time,
and the sentence *"You have paid. The operator has been notified and holds your transaction
record. Keep this reference."* The raw error code is **not** shown to the customer; it is in
the report. The 30-second auto-return is extended to 120 s and the screen offers "I've saved
this" rather than only "Return to Start." This is lamassu's `fiatTransactionError` prompt
without the receipt camera.
### 5. Terminal dispenser faults latch cash-out off. A recount or an explicit operator op clears it.
The HAL classifies each error as `terminal` or `recoverable` (#27's split: jam, motor stop,
diverter and sensor faults, dispense timeout are terminal; pickup error and bill-end are
recoverable). The machine persists `cashOutHeld: { reason, errorCode, since }` in `meta`
when a terminal fault lands, and `SELECT_CASH_OUT` is guarded on it. The idle screen shows
cash-out unavailable with the reason.
The hold is **not** cleared by re-initialising the dispenser. `dispenseCash` already re-inits
on the next attempt, and re-initialising does not move a note that is stuck. It is cleared
by:
- a `recount` operator op on any bay — the same "operator opened the machine" gesture that
clears `countsUncertainSince`, so one physical act resolves both; or
- a new `resume_cash_out` operator op, idempotent-id'd like the cassette ops, for the case
where the operator cleared the jam without touching a bay count.
The machine mirrors the hold into its cassettes-state document (`cash_out_held_since`,
`cash_out_held_reason`) and spirekeeper writes it onto `dca_machines` beside
`counts_uncertain_since`. The availability beacon reports `cash_out: false` while held.
Separately, the operator gets a manual switch: `cash_out_enabled` on `dca_machines`,
published as an operator op, defaulting true. This is lamassu's `cashOutConfig.active`
shape. Cash-out is offered only when the machine is not held **and** the switch is on.
`dca_machines.is_active` is **not** used for either — it is the roster filter in
`get_machine_by_wallet`, and flipping it makes the machine unknown to the RPC handlers
rather than pausing it.
### 6. Owed cash is a first-class state on both sides, with an off-machine settle path.
Server: `cash_owed` and `dispense_unreported` are two new buckets on
`StuckSettlementsResponse`. They are the only buckets whose meaning is *a customer is owed
money*, and they render first. Arrival in `cash_owed` or `partial_pending` sends the operator
a **NIP-17 gift-wrapped DM** (kind 14 → 13 → 1059) to their own LNbits-account pubkey, or to
`super_config.alerts_pubkey` when set — a note to self any NIP-46 client renders. It is signed
through the operator's signer (no key at rest), is best-effort (a failed publish is logged and
the report is still acked — the worklist is the durable record), and sets
`operator_notified_at` so a report resend never re-alerts. Not email, not NIP-04.
*(Implemented: spirekeeper `notify.py`, slice 2.)*
Resolution closes **both** ledgers:
- **On-machine remediation.** `manual_dispense` with `ref_txid` already flips the machine row
to `remediated` via `remediateTransaction`. The machine sends a `report_dispense` for the
remediation with `remediates_txid`, and the server moves the settlement from `cash_owed` to
`pending` and distributes.
- **Off-machine settlement.** The operator paid the customer by hand.
`POST /settlements/{id}/settle-cash-owed` records provenance (free text, author, time) on the
settlement, moves it to `pending` and distributes **at the full amount** (the customer is
whole), and publishes a machine-wide `settle_transaction { id, at, txid, note }` operator op;
the machine applies it through `remediateTransaction(txid, "settled-off-machine:<op id>:<note>")`,
which only touches rows still in an error state, so re-delivery is harmless. Before slice 2
there was no way to record this at all, and the machine's ledger asserted the debt forever.
`PartialDispenseData` is pre-filled from the report's `bills` so the operator confirms a
number the hardware produced rather than typing one.
### 7. Raw codes get a decode table in the driver, built empirically.
`packages/hal` owns a `rawCode → { errorCode, errorClass, human }` table per dispenser. It is
seeded with what has been observed — `78 42` on an F56 is a note stopped at the cassette
exit (sintra, 2026-10-09) — and grows as codes occur; an unknown code reports as
`F56DispenseError` / `terminal` / `"unrecognised dispenser error <code>"`, failing safe. No
code is borrowed from another layer's vocabulary (the lamassu 570 lesson).
## Consequences
- One cash-out now produces one `report_dispense`; the server's `dispense_reports` table is
the audit trail, and `atm-reconcile`'s natural sibling is a settlement↔transaction
reconciliation that joins on `txid` and flags any settlement without a report.
- Distribution for `cash_out` is delayed by the dispense (seconds). Operators watching the
dashboard will see `awaiting_dispense` briefly on every sale.
- `apply_partial_dispense_and_redistribute`'s hard guard still exists but is reached only for
settlements that pre-date this ADR; its message should say which leg type blocked it.
- The state machine gains `dispenseFault`, `outOfCash` and a `cashOutHeld` guard; tests in
`packages/state-machine` cover all three (cash-out is the critical path).
- A machine on an old build keeps working: the server treats a `cash_out` settlement with no
report after `DISPENSE_REPORT_TTL` as `dispense_unreported`, not as failed, and the operator
can capture manually. That is also the upgrade path.
## Rollout
1. **bitspire:** `DispenseCashResult` gains the new fields; HAL computes `dispenseConfirmed`,
classifies, decodes; `dispensingCash` guards on it; `dispenseFault` / `outOfCash` screens;
`dispense_reports` outbox + `report_dispense` client; `cashOutHeld` latch and guard;
`counts_uncertain` on zero-with-error. Ships first — with no server handler the RPC
returns an error and the outbox simply retries, so the machine is never blocked on it.
2. **spirekeeper:** `report_dispense` handler + `dispense_reports` table; `awaiting_dispense` /
`cash_owed` / `partial_pending` / `dispense_unreported` statuses; gate in `_handle_payment`;
worklist buckets + notification; `settle_cash_owed`; `cash_out_enabled` and the two new
operator ops; `dca_machines.cash_out_held_*`.
3. **Both:** `resume_cash_out` / `settle_transaction` ops on the machine; beacon reflects
held; docs: `docs/nostr-patterns` entry for the outbox-RPC pattern, this ADR → Accepted.
## Future directions
Recorded 2026-10-09 so the decisions above are made with the destination in view. None of
these are decided; several would change what "owed cash" even means.
### Hold invoices — capture at the Lightning layer instead of the application layer
Decision 1 implements authorize/capture in spirekeeper because a plain BOLT11 payment is
final the moment it lands. A **hold (HODL) invoice** moves that boundary into the protocol:
the payer's HTLC is accepted but not settled until the receiver reveals the preimage, and
can be cancelled instead, returning the funds with no second payment. The ATM would mint the
preimage, create the hold invoice, dispense, and then `settle` on `dispense_confirmed` or
`cancel` on a fault. A cancelled hold means nobody is owed anything — the customer's funds
were never taken.
What is already there: LNbits core has `create_hold_invoice`, `settle_hold_invoice(preimage)`
and `cancel_hold_invoice` (`lnbits/core/services/payments.py`), tagging `extra.hold_invoice`.
It is implemented for the **lndrest and lndgrpc** funding sources only; other backends raise
*"Hold invoices are not supported by the funding source."* None of the three is exposed over
the nostr-transport yet, so three RPCs are needed before the machine can use them. Spark's
`createLightningHodlInvoice({ amountSats, paymentHash, … })` and RoboSats' escrow bonds are
the reference shapes — the payer-visible behaviour (a pending payment that later settles or
cancels) is identical.
Two properties bound what this buys:
- **Settlement is all-or-nothing per HTLC.** A hold invoice cannot be partially settled, so
the machine's rule is **`dispensed > 0 → settle; dispensed == 0 → cancel`**. A fault with
nothing presented cancels cleanly — and that includes an exit jam like sintra's 2026-10-09,
where the note stopped in the transport and the counter read zero: the customer is charged
nothing and the note is the operator's to recover, with `countsUncertainSince` covering the
inventory side. A **partial** — notes actually in the customer's hand — cannot cancel without
refunding someone holding cash, so it settles the full amount and from that instant is
identical to a plain-BOLT11 partial: `partial_pending`, one distribution when the shortfall
is resolved (Decision 1). Hold invoices eliminate owed-cash for full faults and exit jams,
not for true partials.
- **The hold window locks the payer's funds and route liquidity**, and some wallets surface
a long-pending payment as a failure. The window should equal the dispense window — seconds,
capped at a minute or two — with an automatic `cancel` on timeout, never an open-ended hold.
Where it lands in this ADR: a settlement created from a held payment reaches
`_handle_payment` only on **settle** (`payment.success` is false while held), so for hold-paid
transactions the `awaiting_dispense` state in Decision 1 is unnecessary — the Lightning layer
is already waiting. Decision 1 stays for plain BOLT11 and for any CLINK path that resolves to
an ordinary invoice. The two coexist; `report_dispense` (Decision 2) is what triggers the
settle/cancel either way.
### Vouchers — a fiat-denominated claim instead of a debt
For the shortfall a hold invoice cannot refund, and for any dispense failure on a plain
invoice, the customer could be issued a **voucher**: a claim on the operator for *X fiat value
of notes*, redeemable at a machine at a later date **at the original exchange rate**,
independent of the BTC price at redemption and requiring no further Lightning payment. The
machine prints or displays it; redemption is a cash-out whose "payment" is the voucher.
Vouchers could also be a general product — buy X fiat value now, collect later — but the
first use is fault recovery. Accounting constraints that must hold whichever form ships:
- A voucher is a **liability** row on the server, linked to its origin `txid` / settlement
when it has one (nullable for the general case), with the fiat amount, the locked rate, the
issuing machine and an expiry.
- The sats the origin settlement received for the undispensed portion must **not** be
distributed while the voucher is open: redemption or expiry is what releases them, so the
operator never pays out commission on cash that has not left a machine. This is the same
rule as Decision 1 applied over a longer window.
- Redemption records a `cash_out` with `tx_type = 'voucher_redeem'`, `wire_sats = 0`, and a
reference to the voucher; the machine's own ledger treats it as a dispense like any other
(bays decrement, `cassette_bills` written, `report_dispense` sent).
- A voucher is a bearer instrument unless bound to a pubkey. Both are possible; the choice
decides whether a lost voucher is lost money.
Taken together, hold invoices plus vouchers could remove the *owed cash* state entirely:
full fault → cancel, nobody pays; partial fault → settle, voucher for the difference; and
`cash_owed` in Decision 6 becomes the fallback for a plain-invoice path, not the norm.
### CLINK — the protocol this machine is moving toward
bitSpire is a Nostr machine and the long-term direction is to implement, and eventually
favour, Shocknet's **CLINK** (Common Lightning Interface for Nostr Keys) for customer-facing
payment flows: kind 21001 Offers (`noffer`), 21002 Debits (`ndebit`), 21003 Manage, 21004
Enroll, and the **CLINK Beacon** — a kind-30078 service heartbeat carrying liveness, persona
and fee disclosure. Reference tree: `~/dev/refs/repos/shocknet/shocknet/{CLINK,ClinkSDK,
clink-demo,Lightning.Pub}`. The `@bitSpire/clink` package is in tree and dormant.
Two consequences for this ADR. First, BOLT11 stays alongside CLINK rather than being
replaced, so Decisions 1–2 remain load-bearing for the invoice path. Second, review finding 4
below — the availability beacon has no readers and overlaps the cassettes-state document —
should be resolved by aligning the beacon with the **CLINK Beacon** spec rather than by
inventing a third shape: a machine advertising itself to Nostr clients should do so in the
form those clients will read.
### Operator notification is Nostr-native
Decision 6 calls for the operator to be notified when a settlement reaches `cash_owed`.
That notification is **a Nostr event to the operator's pubkey**, not email or SMS (the
lamassu `notifyOperator` channel). The pieces exist:
- The operator's pubkey is already on their LNbits account — `get_machine_config` refuses to
run without it ("operator has no Nostr pubkey on file").
- The sender signs through the bunker (`sign_as_operator` / `resolve_operator_signer` in
`nostr_publish.py`), so no key is at rest on the server. A dedicated server identity for
alerts is preferable to the operator messaging themself.
- The operator does **not** need their private key to read it. nsecbunkerd supports
`nip44_encrypt` / `nip44_decrypt` for its users (see its ACL tests), so any NIP-46 client —
our webapp, Amber, nsec.app — decrypts the message through the bunker. nsecbunkerd does hold
a `decryptNsec` path, but it is the bunker *admin's*, gated on the passphrase; exposing it to
operators would reverse the "no nsec outside the bunker" principle this stack is built on.
- Operators may still want alerts on a phone identity that is not their LNbits pubkey. An
optional per-operator `alerts_pubkey` covers that without changing the default.
NIP-17 (kind 14 in a kind-1059 gift wrap) is the right wire for metadata privacy; NIP-04 is an
acceptable interim if the receiving clients are ours. Pick one in the notification issue.
### An operator-facing error glossary
Every `error_code` surfaced by Decisions 2 and 7 should link to a glossary entry: what the
code means, what the operator will find when they open the machine, what clears it, and
whether it is `terminal` or `recoverable`. The authoritative source for the F56 is the Fujitsu
Frontech **F56-BDU Error Code List** (K3KD03234–K3KD03236-0001, edition E02), per the Lamassu
port backlog; it is not held locally yet and should be obtained. Seed entries, from the
backlog and from this incident:
| raw | meaning (F56-BDU) | class | observed |
| ------- | --------------------------------- | ----------- | ------------------------- |
| `78 42` | note stopped at the cassette exit | terminal | sintra, 2026-10-09 |
| `82 00` | bill length — long | recoverable | Tejo GTQ, 2026-09-26 |
| `83 00` | bill length — short | recoverable | |
| `84 00` | bill thickness | recoverable | |
| `85 0n` | pick from another safe | recoverable | |
| `86 00` | bill spacing | recoverable | |
| `B5 ..` | reject box overflow | terminal | |
The glossary lives in `docs/` and is served by spirekeeper so the dashboard can deep-link
from a report. Neither lamassu codebase ever decoded these bytes.
### The Lamassu port backlog
A provenance-gated analysis of what bitSpire and spirekeeper can take from lamassu-machine
and lamassu-server exists as *Lamassu Port Backlog* (27 Sep 2026, claude.ai artifact
`61af38f6`). Items that bear directly on this ADR: **structured fault reporting**
(`routes/diagnosticsRoutes.js` → `machine-loader.updateDiagnostics` — a fault record carrying
the driver error, the raw device frame and cassette state at failure, which is `report_dispense`
by another name); the **interactive hardware test harness** (`lib/hardware-testing/`, an F56
dispense-and-count case is the obvious first addition); the **denomination solver**
(`lib/coin-change.js`, portable from its upstream `git.sr.ht/~siiky/coin-change`); and the
ID003 startup and hang fixes. The backlog also records that bitSpire carried lamassu's narrow
GTQ note-length window byte for byte — fixed on `dev` alongside this ADR, mirroring
lamassu-machine `b1cc3622`.
### Bay layout, machine identity, and the operator docs that do not exist yet
How many bays a machine has is **machine-authoritative**: on first boot the layout comes from
`VITE_LAMASSU_CASSETTES` in the machine's `.env` (documented in `docs/device-configuration.md`
— an installer document, with a stale `LAMASSU` prefix), after which `state.db` owns it and
the operator adjusts counts and denominations by publishing ops. spirekeeper adopts whatever
the machine reports: `apply_reported_state` treats the payload as the full bay set and
*deletes* positions the machine no longer reports ("the bay count is hardware-determined").
There is **no operator-facing document** describing any of this — spirekeeper's README still
describes the satmachineadmin era and has no setup section — so an operator with a two-bay
Tejo and one with a four-bay Tejo have no page telling them where the difference is set.
That gap is one face of a larger one: every fleet target in `flake.nix` is a **specific
machine** (fiat, upgrade timer, card reader, address are keyed by hostname), so a second Tejo
cannot join the fleet by pointing at the same flake. Generalising the install means splitting
**model** (the hardware preset: `sintra`, `tejo`, `douro`, `batm3`) from **identity** (the
per-install provisioning: seed, cassettes, fiat, network), with the latter arriving through
pairing and operator ops rather than through Nix. The operator docs should be written against
that split, not the current one.
## Review findings outside this ADR
Found while tracing the money path end to end for this document. Not decided here; each is
a candidate issue.
1. **The settlement push did not deliver — `[ATM Service] Invoice paid (poll)!`** The
`subscribe_payments` stream missed the payment and the polling fallback caught it. Worth
knowing before relying on push latency anywhere; related to #78.
2. **`waitingForCashTaken` auto-advances to `complete` after 30 s "assume taken."** A
transaction can be recorded complete with notes still in the slot. The HAL already blocks
in `waitForBillsRemoved` inside `dispenseCash`, so the state is doing a second, weaker
version of the same job.
3. **The machine and server keep two ledgers with no reconciliation.** `transactions` and
`dca_settlements` join on `txid` today and nothing ever joins them. Decision 2 gives the
server everything a reconciliation needs.
4. **The availability beacon has no consumers and overlaps the cassettes-state document.**
Two machine-authored state documents with overlapping fields is drift waiting to happen.
Either fold availability into the cassettes-state doc or give the beacon a reader.
5. **`is_active` reads as a service gate and is a roster flag.** Rename to something like
`enrolled`, or document at the column.
6. **`generateInvoice` carries a comment deferring `bills`/`cassettes` onto the invoice
`extra`.** With Decision 2 that would be the wrong place — provisioned is not dispensed —
and the comment invites a future contributor to wire it there. Remove it.
7. **The HAL's `dispensed` boolean is count-based (`totalRequested === totalDispensed`),
not value-based.** Equivalent only while each bay dispenses its own denomination.
Decision 3 replaces it.
8. **`_handle_payment` processes cash-in and cash-out through one path and only `tx_type`
tells them apart.** Decision 1 adds a second direction-specific branch. If a third
arrives, split the handler.
9. **The partial-dispense guard's message is wrong for internal legs.** "Lightning payments
can't be clawed back" is true of `autoforward` and false of the LNbits-internal legs,
which are compensatable. Make the guard leg-aware or correct the message.
10. **`packages/hal` has no tests.** `pnpm test` there exits 1 with "No test files found."
The F56 note-length table that produced a production fault on a GTQ Tejo was carried
byte for byte from lamassu with nothing over it; the fix landed the same way. A table test
asserting every currency's window is centred on its note length is a few lines, and
`dispenseConfirmed` (Decision 3) needs the harness to exist before it can be tested.
11. **Review scope.** This document traced the cash-out path: state machine → HAL → ledger →
transport → settlement → distribution → dashboard. The cash-in path shares the settlement
pipeline and has its own money-at-risk shape in `create_withdraw` (server-side amounts,
`max_cash_in_sats`); it has not been reviewed to the same depth and is the obvious next
slice. The HAL drivers, access layer and deploy module were not in scope.

View file

@ -0,0 +1,116 @@
# Bolt Card tap-to-receive — LNbits resolver endpoint
Spec for the small **custom endpoint** the ATM needs on the LNbits `boltcards`
extension to support **tap-to-receive** (the cash-in / buy flow). The ATM side
(`apps/machine/electron/lnurl-pay.ts`) is already built against this contract;
this document is what to implement in the `omni-private` LNbits fork.
## Why a new endpoint
A Bolt Card only ever emits its `lnurlw://…/scan/<external_id>?p=&c=` voucher —
a **withdraw** (spend) credential. You cannot push sats _into_ the card with it.
To deposit to the card's wallet, the ATM uses the same tap as an **authenticated
identity** (the `external_id` + the SUN `p`/`c`, verified exactly as `/scan`
does) and needs the wallet's **pay** target back. Stock `boltcards` is
withdraw-only, so we add a `pay` sibling of `scan`.
The card is **not re-written** — same NDEF, same keys, same `external_id`. Only
the server learns a new way to answer the same tap.
## Endpoint
```
GET /boltcards/api/v1/pay/{external_id}?p={p}&c={c}
```
- Same URL shape as `GET /boltcards/api/v1/scan/{external_id}?p=&c=`, with the
path segment `scan` → `pay`. The ATM derives it by string-substitution on the
tapped `lnurlw` (`scanUrlToResolver()` in `lnurl-pay.ts`).
- **Verify `p`/`c` exactly like `/scan`**: decrypt the PICC (`p`) with the
card's `k1`, recompute the CMAC (`c`) with `k2`, check the read counter is
fresh (monotonic). Reject replays. Reuse the boltcards SUN verification path —
do not fork it. A valid `p`/`c` is the authorization: it proves card
possession and prevents a cloned UID from misdirecting a deposit.
- No auth key/header — like `/scan`, this is a public LNURL-style endpoint
gated solely by the SUN.
## Response
Return **one** of the following JSON shapes (the ATM accepts all three). Since
each card wallet has a Lightning Address, either of the first two is simplest.
### (a) Lightning Address (recommended)
```json
{ "lightningAddress": "cardname@l484.com" }
```
The ATM resolves it via LUD-16 (`/.well-known/lnurlp/cardname`) → LUD-06 pay.
### (b) LUD-06 payRequest, inline
```json
{
"tag": "payRequest",
"callback": "https://lnbits.l484.com/lnurlp/api/v1/lnurl/<id>",
"minSendable": 1000,
"maxSendable": 100000000,
"metadata": "[[\"text/plain\",\"bolt card top-up\"]]"
}
```
Hand back the card wallet's existing `lnurlp` payRequest directly (no extra
round-trip for the ATM).
### (c) lnurlp pointer
```json
{ "lnurlp": "https://lnbits.l484.com/lnurlp/<id>" }
```
An `https://` (or `lnurl://`) URL the ATM will fetch to get the payRequest.
### Error
```json
{ "status": "ERROR", "reason": "invalid card" }
```
Use for a failed SUN check, a disabled/unknown card, or a wallet with no pay
target. `reason` is surfaced verbatim on the ATM screen, so keep it terse and
non-sensitive.
## Flow, end to end
```
customer inserts cash → ATM owes N sats → customer taps Bolt Card
→ ATM reads lnurlw (external_id + fresh p/c)
→ GET /boltcards/api/v1/pay/<external_id>?p=&c= ← THIS ENDPOINT
→ { lightningAddress | payRequest | lnurlp }
→ ATM: LUD-16/LUD-06 → GET callback?amount=<N*1000 msat> → BOLT11
→ ATM pays the BOLT11 over its own nostr transport → card wallet credited
→ PAYMENT_RECEIVED → cash-in completes
```
Amounts are in **millisatoshis** on the LUD-06 callback (`amount=<msat>`), per
spec. Make sure each card wallet's `minSendable`/`maxSendable` span the ATM's
payout range or the tap will be declined with "amount is above/below the card
wallet …".
## Notes
- **Tap-to-enter uses `/session` instead.** When the access gate is on, the
card was already verified at entry and the ATM holds the LUD-06 second step
from `/session` (see `boltcard-session.md`), so Complete calls `pay.callback`
directly and never touches `/pay`. `/pay` remains the path for a card tapped
directly on the cash-in screen (gate off, or a second card).
- **Double-payout:** the cash-in screen still shows the LNURL-withdraw QR as a
fallback (customer _pulls_). A tap _pays_ instead. The ATM gates re-entry
while a tap is in flight and leaves `displayingQR` on success; the withdraw
link is `uses:1`. A customer would have to both tap and pull near-simultaneously
to double-collect — acceptable for now, revisit if it bites.
- **Future nostr transport:** `resolveCardPayTarget` (the `/pay` GET) is the one
HTTPS-today / nostr-tomorrow seam. A nostr-native boltcard would answer the
same `external_id + SUN` identity over the ATM's existing nostr connection,
dropping the clearnet HTTPS call. The rest (standard LNURL-pay) is unchanged.

119
docs/boltcard-session.md Normal file
View file

@ -0,0 +1,119 @@
# Bolt Card session — one verified tap for a terminal visit
Wire contract for the `/session` endpoint the ATM's access gate (ADR-003
tap-to-enter) uses on the LNbits `boltcards` extension. Implemented in the
aiolabs fork (`git.atitlan.io/aiolabs/boltcards`, `v1.1.1-aio.3`+); consumed by
`apps/machine/electron/boltcard-session.ts`.
## Why a session
A Bolt Card tap yields a single-use SUN `p`/`c`: the card server verifies it
and advances the card's read counter, so any endpoint that checks it — `/scan`,
`/pay`, `/verify` — spends it. The gate wants two things from one tap:
1. **Verify at entry** — a genuine, non-replayed card unlocks the terminal and
we can show the holder their balance.
2. **Complete without a second tap** — the buy or sell later in the visit
moves sats with the same card.
`/session` does the verification once and hands back the _second steps_ of
both LNURL flows, keyed by a single-use server-side `hit` — the same bearer
`/scan` (as `k1`) and `/pay` already issue. The terminal holds no `p`/`c`
afterwards.
## Endpoint
```
GET /boltcards/api/v1/session/{external_id}?p={p}&c={c}
```
Same URL shape as `/scan/{external_id}?p=&c=` with `scan` → `session`; the ATM
derives it by string substitution on the tapped `lnurlw`
(`scanUrlToSessionUrl()`). SUN verification is byte-for-byte `/scan`'s (shared
helper in the fork): unknown / disabled card, UID mismatch, bad CMAC, replayed
counter all reject with `/scan`'s reasons. On success the counter advances and
one `hit` is recorded.
## Response
```json
{
"authenticated": true,
"external_id": "abc123",
"card_name": "Alice",
"balance_msat": 123456000,
"currency": "USD",
"fiat": 98.76,
"withdraw": {
"callback": "https://lnbits.l484.com/boltcards/api/v1/lnurl/cb/<hit>",
"k1": "<hit>",
"minWithdrawable": 1000,
"maxWithdrawable": 50000000
},
"withdraw_blocked_reason": null,
"pay": {
"callback": "https://lnbits.l484.com/boltcards/api/v1/pay/cb/<hit>",
"minSendable": 1000,
"maxSendable": 50000000,
"metadata": "[[\"text/plain\",\"Bolt Card top-up\"]]"
}
}
```
- `balance_msat` — the card wallet's balance. Display only.
- `currency` / `fiat` — the balance priced the way the LNbits wallet page does
it: the wallet's own currency (per-wallet setting) first, else the instance's
default accounting currency. `fiat` is filled **only from the server's
already-warm rate cache** — this response gates the unlock, and a cold rate
lookup queries external exchanges (~1 s). On a cache miss it is `null` and
the ATM prices the sats itself: in `currency` from its own rate source, else
in its own fiat at its display rate. No rate lookup ever blocks the session.
- `withdraw` — the LUD-03 second step. The ATM calls
`callback?k1=<hit>&pr=<bolt11>` at cash-out Complete. `null` with
`withdraw_blocked_reason` set when `/scan` would have refused (daily limit
spent); cash-in stays possible.
- `pay` — the LUD-06 second step. The ATM calls `callback?amount=<msat>` at
cash-in Complete and pays the returned BOLT11 over its own nostr transport.
- Limits are the card's `tx_limit`, as on `/scan` and `/pay`.
Rejection:
```json
{ "authenticated": false, "reason": "This link is already used." }
```
`reason` is surfaced verbatim on the locked screen — terse, non-sensitive.
## Semantics of the hit
- The first withdraw that uses the hit spends it (`spent = true`), exactly as
after a `/scan`; a second withdraw is refused with "Payment already claimed."
- A top-up does not mark the hit spent (as `/pay` today).
- The ATM treats the whole session as single-shot regardless: after the first
Complete attempt, accepted or declined, it drops the session and asks for a
re-tap (`completeWithCard` in `stores/atm.ts`).
- Hits do not expire server-side. The ATM's session security (60 s idle,
10 min cap, End Session) bounds how long one is held.
## Flow
```
locked ── tap ─▶ GET /session/<id>?p=&c= (spends the SUN)
├─ authenticated:false → stay locked, show reason
└─ authenticated:true → authorize(external_id) → idle, session held
CardChip: "Alice · ••c123 •••••• [eye]"
sell: pick amount → Complete Sale → withdraw.callback?k1&pr → PAYMENT_RECEIVED → dispense
buy: insert cash → Complete Purchase → pay.callback?amount → BOLT11 → ATM pays → complete
… re-lock (End Session / idle / complete) drops the session
```
## Trust boundary (read this)
The ATM derives the session URL from the **card's own `lnurlw` host**. With
`openEnrollment` on, a forged NDEF tag pointing at an attacker's server that
answers `{"authenticated": true, …}` still unlocks the terminal. Money is not
at risk — a fake server can only make the ATM pay an invoice the holder chose
(cash-in) or accept a pull it never honours (cash-out never dispenses without
`PAYMENT_RECEIVED`) — but the _gate_ is only as trustworthy as the host it was
told to ask. Closing that means pinning the card-server host(s) the gate
accepts; tracked in aiolabs/bitspire#91.

View file

@ -68,7 +68,7 @@ noffer1<bech32-encoded-data>
The ATM wants to receive payment from a customer: The ATM wants to receive payment from a customer:
```typescript ```typescript
import { encodeNoffer } from '@lamassu/clink' import { encodeNoffer } from '@bitSpire/clink'
// ATM creates a noffer for receiving payment // ATM creates a noffer for receiving payment
const noffer = encodeNoffer({ const noffer = encodeNoffer({
@ -134,7 +134,7 @@ ndebit1<bech32-encoded-data>
The ATM wants to pay the customer (customer inserted cash, wants Bitcoin): The ATM wants to pay the customer (customer inserted cash, wants Bitcoin):
```typescript ```typescript
import { encodeNdebit, formatNdebitUri } from '@lamassu/clink' import { encodeNdebit, formatNdebitUri } from '@bitSpire/clink'
// ATM creates an ndebit for the customer to authorize withdrawal // ATM creates an ndebit for the customer to authorize withdrawal
const ndebit = encodeNdebit({ const ndebit = encodeNdebit({
@ -438,14 +438,14 @@ import {
CLINKClient, CLINKClient,
createOfferSuccess, createOfferSuccess,
createOfferError, createOfferError,
} from '@lamassu/clink' } from '@bitSpire/clink'
``` ```
### Reference Implementation ### Reference Implementation
- [CLINK Protocol Spec](https://github.com/shocknet/clink) - [CLINK Protocol Spec](https://github.com/shocknet/clink)
- [Lightning.Pub](https://github.com/shocknet/Lightning.Pub) - [Lightning.Pub](https://github.com/shocknet/Lightning.Pub)
- [@lamassu/clink](../packages/clink/) - TypeScript implementation - [@bitSpire/clink](../packages/clink/) - TypeScript implementation
## Related NIPs ## Related NIPs

View file

@ -48,24 +48,24 @@ All configuration can be overridden via environment variables. For Vite/Electron
| Variable | Description | Default | | Variable | Description | Default |
| ------------------------------- | ---------------------------- | ------------- | | ------------------------------- | ---------------------------- | ------------- |
| `VITE_LAMASSU_MACHINE_MODEL` | Machine preset | `sintra` | | `VITE_BITSPIRE_MACHINE_MODEL` | Machine preset | `sintra` |
| `VITE_LAMASSU_FIAT_CODE` | Fiat currency (ISO 4217) | `USD` | | `VITE_BITSPIRE_FIAT_CODE` | Fiat currency (ISO 4217) | `USD` |
| `VITE_LAMASSU_VALIDATOR_DEVICE` | Validator serial device path | (from preset) | | `VITE_BITSPIRE_VALIDATOR_DEVICE` | Validator serial device path | (from preset) |
| `VITE_LAMASSU_DISPENSER_DEVICE` | Dispenser serial device path | (from preset) | | `VITE_BITSPIRE_DISPENSER_DEVICE` | Dispenser serial device path | (from preset) |
| `VITE_LAMASSU_CASSETTES` | JSON array of cassettes | (from preset) | | `VITE_BITSPIRE_CASSETTES` | JSON array of cassettes | (from preset) |
### Example: Custom Cassette Configuration ### Example: Custom Cassette Configuration
```bash ```bash
# Two cassettes: $20 bills (100 count) and $50 bills (50 count) # Two cassettes: $20 bills (100 count) and $50 bills (50 count)
export VITE_LAMASSU_CASSETTES='[{"denomination":20,"count":100},{"denomination":50,"count":50}]' export VITE_BITSPIRE_CASSETTES='[{"denomination":20,"count":100},{"denomination":50,"count":50}]'
``` ```
### Example: Custom Device Paths ### Example: Custom Device Paths
```bash ```bash
export VITE_LAMASSU_VALIDATOR_DEVICE="/dev/ttyUSB0" export VITE_BITSPIRE_VALIDATOR_DEVICE="/dev/ttyUSB0"
export VITE_LAMASSU_DISPENSER_DEVICE="/dev/ttyUSB1" export VITE_BITSPIRE_DISPENSER_DEVICE="/dev/ttyUSB1"
``` ```
## Cassette Configuration ## Cassette Configuration

View file

@ -52,7 +52,7 @@ Conceptually:
| Layer | Source | Purpose | | Layer | Source | Purpose |
|---|---|---| |---|---|---|
| **Kernel + initrd** | `nixpkgs` 24.11 + `upboard.nix` initrd modules | Boot the Sintra hardware (eMMC via `sdhci-acpi`, validator/dispenser at `ttyJ5`/`ttyJ7`) | | **Kernel + initrd** | `nixpkgs` 24.11 + `upboard.nix` initrd modules | Boot the Sintra hardware (eMMC via `sdhci-acpi`, validator/dispenser at `ttyJ5`/`ttyJ7`) |
| **NixOS base** | `nixpkgs` 24.11 | systemd, Xorg, openbox, the `lamassu` user, sshd for provisioning | | **NixOS base** | `nixpkgs` 24.11 | systemd, Xorg, openbox, the `bitspire` user, sshd for provisioning |
| **bitspire.service** | `deploy/nixos/bitspire-atm.nix` | systemd unit that launches the Electron kiosk | | **bitspire.service** | `deploy/nixos/bitspire-atm.nix` | systemd unit that launches the Electron kiosk |
| **The Electron app** | `apps/machine` built into a nix derivation | The actual ATM UI + state machine + Lightning client | | **The Electron app** | `apps/machine` built into a nix derivation | The actual ATM UI + state machine + Lightning client |
| **Hardware-specific config** | `deploy/nixos/hardware/upboard.nix` (or `douro.nix`, `batm3.nix`) | udev rules, kernel modules, panel calibration | | **Hardware-specific config** | `deploy/nixos/hardware/upboard.nix` (or `douro.nix`, `batm3.nix`) | udev rules, kernel modules, panel calibration |
@ -82,11 +82,11 @@ Before flashing a Sintra you'll want:
Once the kiosk is up, useful things to know: Once the kiosk is up, useful things to know:
- **Service status:** `ssh lamassu@<atm> 'sudo systemctl status bitspire'` - **Service status:** `ssh bitspire@<atm> 'sudo systemctl status bitspire'`
- **Live log tail:** `ssh lamassu@<atm> 'sudo journalctl -u bitspire -f'` - **Live log tail:** `ssh bitspire@<atm> 'sudo journalctl -u bitspire -f'`
- **Re-provision (e.g., wrong relay URL):** rerun `provision-atm.sh` from the dev box with the new env vars - **Re-provision (e.g., wrong relay URL):** rerun `provision-atm.sh` from the dev box with the new env vars
- **Push a code change without reflashing:** `nixos-rebuild switch --flake .#sintra-installed --target-host lamassu@<atm> --use-remote-sudo` - **Push a code change without reflashing:** `nixos-rebuild switch --flake .#sintra-installed --target-host bitspire@<atm> --use-remote-sudo`
- **Inspect transaction history:** `ssh lamassu@<atm> 'sudo bash /etc/nixos/atm-transactions.sh'` (queries `/var/lib/bitspire/state.db`) - **Inspect transaction history:** `ssh bitspire@<atm> 'sudo bash /etc/nixos/atm-transactions.sh'` (queries `/var/lib/bitspire/state.db`)
## Related documentation ## Related documentation

View file

@ -9,6 +9,11 @@
> debit-approval listener and all kind-21002 handling were removed in > debit-approval listener and all kind-21002 handling were removed in
> commit `3c14eea` (the 3b.4 cleanup of LP-paired infrastructure). > commit `3c14eea` (the 3b.4 cleanup of LP-paired infrastructure).
> >
> The Lightning.Pub regtest tooling this doc leans on — the `docker/`
> compose stack, the `fund-atm` / `lncli` devenv commands, and the
> `packages/nostr-client/dev/` agent scripts — was removed on 2026-10-09.
> Commands quoted below no longer exist in this repo.
>
> This doc is retained because it explains *why* the previous flow > This doc is retained because it explains *why* the previous flow
> existed and what the ndebit/CLINK protocol surface looks like — useful > existed and what the ndebit/CLINK protocol surface looks like — useful
> if the project ever wants to reintroduce nostr-native cash-in that > if the project ever wants to reintroduce nostr-native cash-in that
@ -139,7 +144,7 @@ export const NDEBIT_REGEX = new RegExp(
```typescript ```typescript
// In lib/types/parse.ts // In lib/types/parse.ts
import type { DebitPointer } from '@lamassu/clink' import type { DebitPointer } from '@bitSpire/clink'
import type { Satoshi } from './units' import type { Satoshi } from './units'
export enum InputClassification { export enum InputClassification {
@ -159,7 +164,7 @@ export interface ParsedNdebitInput {
```typescript ```typescript
// In lib/parse.ts // In lib/parse.ts
import { decodeNdebit } from '@lamassu/clink' import { decodeNdebit } from '@bitSpire/clink'
// Add to VALIDATORS array: // Add to VALIDATORS array:
{ {
@ -209,7 +214,7 @@ case InputClassification.NDEBIT: {
```typescript ```typescript
// In State/scoped/backups/sources/history/claimNdebitThunk.ts // In State/scoped/backups/sources/history/claimNdebitThunk.ts
import { getNostrClient } from '@/Api/nostr' import { getNostrClient } from '@/Api/nostr'
// Note: SendNdebitRequest is wallet-side code, not part of @lamassu/clink // Note: SendNdebitRequest is wallet-side code, not part of @bitSpire/clink
// This example shows the wallet's implementation pattern // This example shows the wallet's implementation pattern
import { finalizeEvent } from 'nostr-tools' import { finalizeEvent } from 'nostr-tools'
import { SimplePool } from 'nostr-tools' import { SimplePool } from 'nostr-tools'
@ -849,7 +854,7 @@ See "Single-Use Protection" section above for implementation details.
```json ```json
{ {
"@lamassu/clink": "workspace:*", "@bitSpire/clink": "workspace:*",
"nostr-tools": "^2.x.x", "nostr-tools": "^2.x.x",
"@noble/hashes": "^1.x.x", "@noble/hashes": "^1.x.x",
"qrcode": "^1.x.x" "qrcode": "^1.x.x"

439
flake.nix
View file

@ -1,5 +1,5 @@
{ {
description = "Lamassu Next - Nostr-Native Lightning ATM"; description = "bitSpire - Nostr-Native Lightning ATM";
inputs = { inputs = {
# Stable NixOS for the ATM OS base # Stable NixOS for the ATM OS base
@ -53,6 +53,55 @@
overlays = [ (import rust-overlay) ]; overlays = [ (import rust-overlay) ];
}; };
# Kiosk launcher. The GPU-related Electron flags sit in a shell variable
# rather than being baked into ExecStart, so they can be changed on a
# running machine by editing /var/lib/bitspire/.env and restarting the
# unit. No rebuild, no reboot, and a bad value is one edit away from
# being undone — which matters on a box whose screen nobody can see.
#
# THE DEFAULT IS NOW HARDWARE ACCELERATION.
#
# From the first ISO commit (19d43c2) until today the kiosk launched with
# --disable-gpu AND --disable-software-rasterizer, which turns off GPU
# compositing and the SwiftShader fallback together and leaves Chromium
# rasterising every pixel on the CPU. Nothing in git ever justified the
# pair: no comment, no issue, no commit message. Meanwhile the
# descriptive config at /etc/bitspire/config.env claimed
# ELECTRON_DISABLE_GPU=false, contradicting the actual command line.
#
# Tested on sintra 2026-09-24. With the flags removed the GPU process is
# stable (zero crashes, zero service restarts) and genuinely on hardware
# — /proc/<gpu-pid>/maps shows libgallium, libGLX_mesa and dri_gbm, with
# no swrast and no SwiftShader — rendering through crocus on Braswell.
# Confirmed by eye on the panel.
#
# DOURO IS EXEMPT. It keeps the old flags. Bay Trail carries three
# separate display workarounds already — a 5.15 kernel pin for an i915
# eDP regression, i915.enable_psr=0, and vt.handoff=7 to preserve the
# BIOS display init — so it is the most plausible machine for the
# original flags to have been a real fix rather than scaffolding. It is
# also down pending a reflash, so it cannot be tested. Drop this
# exemption once douro is back and accelerates cleanly.
#
# To override per machine, in /var/lib/bitspire/.env:
# BITSPIRE_ELECTRON_GPU_FLAGS= acceleration
# BITSPIRE_ELECTRON_GPU_FLAGS=--disable-gpu no GPU
# BITSPIRE_ELECTRON_GPU_FLAGS=--use-gl=egl force EGL
# (line absent) model default
#
# Note `-` and not `:-`: an explicitly EMPTY value means "no GPU flags at
# all", and must not fall back to the default. Unquoted on purpose so the
# value word-splits into argv.
mkKioskLauncher = machineModel: atm-app: pkgs.writeShellScript "bitspire-kiosk" ''
default_gpu_flags="${
if machineModel == "douro" then "--disable-gpu --disable-software-rasterizer" else ""
}"
exec ${pkgs-unstable.electron}/bin/electron \
--no-sandbox --disable-gpu-sandbox --enable-logging \
''${BITSPIRE_ELECTRON_GPU_FLAGS-$default_gpu_flags} \
${atm-app}
'';
# Pure ATM app builder (no --impure needed) # Pure ATM app builder (no --impure needed)
mkAtmApp = import ./nix/mkAtmApp.nix { mkAtmApp = import ./nix/mkAtmApp.nix {
inherit pkgs pkgs-unstable; inherit pkgs pkgs-unstable;
@ -67,6 +116,79 @@
batm3 = "USD"; batm3 = "USD";
}; };
# Nightly auto-upgrade window per machine model, as a systemd calendar
# spec. An upgrade restarts the app, and the Fujitsu dispenser runs an
# audible init routine when it does, so this wants to land in the middle
# of the machine's own night rather than its business hours.
#
# The timezone suffix (systemd 252+) is what makes that work WITHOUT
# setting the system clock: the timer follows the named zone and its DST,
# while time.timeZone stays a fleet-wide default nobody has to maintain
# per host. Verified on sintra with systemd-analyze — `04:00
# Europe/Paris` resolves to 02:00 UTC in summer, `04:00
# America/Guatemala` to 10:00 UTC.
#
# Do NOT check a spec like this under `nix-shell -p systemd`. The sandbox
# cannot resolve named zones and silently computes EVERY one of them as
# UTC, while still echoing the zone back in its "Normalized form" line.
# It looks accepted and is wrong. Test on a real system.
#
# Keyed on model like fiatCodeForModel above, and inheriting the same
# limitation: model is a hardware model, and it only doubles as host
# identity while there is one machine of each. A second sintra in another
# country needs this keyed on host instead, along with the fiat code and
# the app build that bakes it in.
#
# Unlisted models get 04:00 in whatever time.timeZone says, unchanged.
upgradeWindowForModel = {
sintra = "04:00 Europe/Paris";
};
# Which models have a contactless (CCID) card reader fitted, for Bolt
# Card taps. Same keying caveat as the two tables above.
#
# This can't live in a hardware file: hardware/upboard.nix is shared by
# sintra (HID Global OMNIKEY 5022) and tejo (nothing fitted), so before
# this table the tejo inherited pcscd it had no use for while the douro,
# with its own hardware file, got none and wedged on boot — nfc-pcsc
# busy-spins Electron's main thread when pcscd is absent (see
# apps/machine/electron/nfc-service.ts). Flip a model to true when a
# reader is actually fitted; douro and tejo are planned.
nfcReaderForModel = {
batm3 = true; # Feitian KP382
sintra = true; # HID Global OMNIKEY 5022
};
# WireGuard address on the 10.0.0.0/24 management tunnel to the VPS
# (peer + listenPort live in configuration.nix; only the address is
# per-machine). Same keying caveat as the three tables above.
#
# This cannot live in a hardware file for the UP Board models, and the
# reason it now lives here for ALL of them is tejo: hardware/upboard.nix
# is shared by tejo and sintra, so an address set there would be claimed
# by both machines on the same /24. tejo had no address at all as a
# result — `wg0.ips = [ ]` brings the interface up with no IP and the
# tunnel is dead, which is a silent way to lose remote access to a
# machine that has no other route in. Keeping douro's and batm3's
# addresses here too means there is one list to read when allocating the
# next one, rather than three files plus the VPS peer config.
#
# An unlisted model gets no address and no tunnel. That is deliberate for
# sintra, which is reachable on the LAN (192.168.0.252) and has never had
# a tunnel address.
#
# NOTE: the address is only half of it. The VPS maps peer PUBLIC KEY to
# pragma: allowlist secret
# tunnel IP, so a machine also needs its private key at
# /var/lib/wireguard/wg0.key — carried over from the machine's previous
# install, or newly generated with its pubkey added to the VPS peer list.
# The key is operator-provisioned and deliberately not in the image.
wireguardIpForModel = {
tejo = "10.0.0.3/24";
douro = "10.0.0.4/24";
batm3 = "10.0.0.5/24";
};
lib = nixpkgs.lib; lib = nixpkgs.lib;
# Helper to create a live USB NixOS config for a specific machine model # Helper to create a live USB NixOS config for a specific machine model
@ -81,6 +203,7 @@
inherit system; inherit system;
specialArgs = { specialArgs = {
inherit pkgs-unstable nixpkgs machineModel atm-app; inherit pkgs-unstable nixpkgs machineModel atm-app;
kioskLauncher = mkKioskLauncher machineModel atm-app;
}; };
modules = [ modules = [
./deploy/nixos/live.nix ./deploy/nixos/live.nix
@ -122,8 +245,14 @@
services.bitspire = { services.bitspire = {
enable = true; enable = true;
appDir = "${atm-app}"; appDir = "${atm-app}";
nfc.enable = nfcReaderForModel.${machineModel} or false;
}; };
# Management-tunnel address; see wireguardIpForModel.
networking.wireguard.interfaces.wg0.ips =
lib.optional (wireguardIpForModel ? ${machineModel})
wireguardIpForModel.${machineModel};
# Operator TUI and CLI tools # Operator TUI and CLI tools
environment.systemPackages = [ environment.systemPackages = [
atm-tui.packages.${system}.default atm-tui.packages.${system}.default
@ -169,45 +298,60 @@
}; };
# Auto-upgrade: pulls latest flake and runs nixos-rebuild switch. # Auto-upgrade: pulls latest flake and runs nixos-rebuild switch.
# NOTE: this branch (dev) pins the upgrade source to ?ref=dev so # NOTE: bitSpire machines pull from the `aiolabs/bitspire` repo —
# any ATM flashed from `dev` stays on `dev`. Without the explicit # the post-migration home of this code. This branch (dev) pins the
# ref, nix would resolve to the repo's default branch (main), # upgrade source to ?ref=dev so any ATM flashed from `dev` stays on
# which would silently regress a dev-deployed Sintra back to the # `dev`. Without the explicit ?ref=dev, nix would resolve the repo's
# lamassu-next production code at 04:00. The `main` branch's # default branch and could silently change a dev-deployed Sintra at
# flake.nix continues to omit ?ref= so production ATMs (batm3, # 04:00. (Every live machine now pulls from this repo; the
# douro) keep pulling main HEAD as before. # pre-rename `aiolabs/lamassu-next` repo no longer feeds anything.)
# To update manually: sudo nixos-rebuild switch --flake git+ssh://forgejo@git.atitlan.io/aiolabs/lamassu-next.git?ref=dev#<model>-installed # To update manually: sudo nixos-rebuild switch --flake git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=dev#<model>-installed
system.autoUpgrade = { system.autoUpgrade = {
enable = true; enable = true;
flake = "git+ssh://forgejo@git.atitlan.io/aiolabs/lamassu-next.git?ref=dev#${machineModel}-installed"; flake = "git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=dev#${machineModel}-installed";
flags = [ "--refresh" ]; flags = [ "--refresh" ];
dates = "04:00"; # daily at 4am # Daily at 4am in the machine's own zone; see
# upgradeWindowForModel above.
dates = upgradeWindowForModel.${machineModel} or "04:00";
allowReboot = false; allowReboot = false;
}; };
# Env template — runtime secrets provisioned via provision-atm.sh. # Minimal env template (aiolabs/bitspire#70 remnant hygiene).
# Identity fields are intentionally empty so a fresh disk image # Seed ONLY image-baked, non-maskable values. Everything else the
# boots cleanly into the "needs provisioning" state; provision- # ATM needs comes from the pairing SEED (relay, lnbits_npub, bunker)
# atm.sh SSHes in and overwrites with real values. # or from LNbits over the transport (operator pubkey, fee config) —
# so we must NOT pre-seed those keys. A present-but-empty
# VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY / VITE_OPERATOR_PUBKEYS
# is a masking hazard: env WINS over the seed, and this activation
# only writes when .env is ABSENT, so any value written at first
# boot is frozen for the life of the disk. Leaving the keys out
# entirely lets the seed/transport be the sole source.
# #
# VITE_RELAY_URL seeds from `config.services.bitspire.relayUrl` # VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY are emitted ONLY when
# so the NixOS module's `relayUrl` option becomes the default # the operator deliberately pins them via the Nix options (non-empty
# without losing the operator's ability to override via .env # default ""), which is an explicit override that wins over the seed.
# (edit the file or re-run provision-atm.sh).
system.activationScripts.bitspire-env = '' system.activationScripts.bitspire-env = ''
mkdir -p /var/lib/bitspire mkdir -p /var/lib/bitspire
if [ ! -f /var/lib/bitspire/.env ]; then if [ ! -f /var/lib/bitspire/.env ]; then
cp ${pkgs.writeText "bitspire-env-default" '' cp ${pkgs.writeText "bitspire-env-default" (''
VITE_RELAY_URL=${config.services.bitspire.relayUrl} VITE_BITSPIRE_MACHINE_MODEL=${machineModel}
VITE_LNBITS_SERVER_PUBKEY= VITE_BITSPIRE_FIAT_CODE=${fiatCode}
VITE_ATM_PRIVATE_KEY= VITE_SPIRE_SEED=
VITE_APP_ID=
VITE_OPERATOR_PUBKEYS=
VITE_LAMASSU_MACHINE_MODEL=${machineModel}
VITE_LAMASSU_FIAT_CODE=${fiatCode}
ELECTRON_FORCE_PROD=1 ELECTRON_FORCE_PROD=1
DISPLAY=:0 DISPLAY=:0
''} /var/lib/bitspire/.env # Uncomment to change Electron's GPU flags without a
# rebuild, then `systemctl restart bitspire`. An empty
# value means full GPU acceleration; the line being absent
# means the shipped default (GPU and software rasterizer
# both off). Commented rather than set, because a present
# -but-empty value here would silently enable the GPU on
# every machine that regenerates its .env.
# BITSPIRE_ELECTRON_GPU_FLAGS=
'' + pkgs.lib.optionalString (config.services.bitspire.relayUrl != "") ''
VITE_RELAY_URL=${config.services.bitspire.relayUrl}
'' + pkgs.lib.optionalString (config.services.bitspire.lnbitsServerPubkey != "") ''
VITE_LNBITS_SERVER_PUBKEY=${config.services.bitspire.lnbitsServerPubkey}
'')} /var/lib/bitspire/.env
chmod 600 /var/lib/bitspire/.env chmod 600 /var/lib/bitspire/.env
chown bitspire:bitspire /var/lib/bitspire/.env chown bitspire:bitspire /var/lib/bitspire/.env
fi fi
@ -218,7 +362,7 @@
serviceConfig = { serviceConfig = {
EnvironmentFile = lib.mkForce "/var/lib/bitspire/.env"; EnvironmentFile = lib.mkForce "/var/lib/bitspire/.env";
Environment = "LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib"; Environment = "LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib";
ExecStart = lib.mkForce "${pkgs-unstable.electron}/bin/electron --no-sandbox --disable-gpu-sandbox --disable-gpu --disable-software-rasterizer --enable-logging ${atm-app}"; ExecStart = lib.mkForce "${mkKioskLauncher machineModel atm-app}";
MemoryMax = lib.mkForce "1G"; MemoryMax = lib.mkForce "1G";
NoNewPrivileges = lib.mkForce false; NoNewPrivileges = lib.mkForce false;
ProtectSystem = lib.mkForce false; ProtectSystem = lib.mkForce false;
@ -255,6 +399,134 @@
}) })
]; ];
}; };
# Module that turns an <model>-installed config into the one a dd'd USB
# stick actually runs. Shared by every *-usb variant so the USB-boot
# hazards are solved once:
# - distinct fs labels (nixos-usb / ESP-USB) so stage-1 can't latch an
# internal drive that already holds a generic nixos/ESP-labelled install;
# - nofail /boot: the firmware already loaded the bootloader before Linux;
# without nofail a slow/late ESP-USB enumeration (BOT is slower than UAS)
# blows past systemd's 90s device-timeout into emergency mode with root
# locked — a dead end. nofail + short timeout lets the already-mounted
# root carry the boot; /boot mounts if/when it shows;
# - NO growPartition/autoResize: 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 D-state and
# ESP-USB vanishes with the device). Persistent state is a few MB and
# the image ships ~2GB free. The internal-disk images keep it;
# - autoUpgrade off: no scheduled nix-store churn or bootloader writes on
# the stick. Updates go in-place via `nix copy` + switch-to-configuration
# against the named <model>-usb config (preserves pairing + /var/lib).
usbBootModule = { lib, ... }: {
fileSystems."/".device = lib.mkForce "/dev/disk/by-label/nixos-usb";
fileSystems."/boot".device = lib.mkForce "/dev/disk/by-label/ESP-USB";
fileSystems."/boot".options = [ "nofail" "x-systemd.device-timeout=10s" ];
system.autoUpgrade.enable = lib.mkForce false;
};
# Bus hardening that makes a USB stick a reliable boot medium: keep the
# flash drive off the flaky UAS driver (many bridges advertise UAS then
# drop off the bus under sustained write load — "device offline error,
# dev sdb"), and stop USB autosuspend cutting power mid-I/O. douro.nix
# and batm3.nix carry this in their hardware files because those machines
# boot from USB exclusively; hardware/upboard.nix is SHARED with sintra's
# eMMC install, so for the UP Board models it is scoped to the -usb
# config here rather than changing a production machine's cmdline.
usbBusHardening = {
boot.blacklistedKernelModules = [ "uas" ];
boot.kernelParams = [ "usbcore.autosuspend=-1" ];
};
# Aaeon UP Board firmware (tejo, sintra) USB-boots in Legacy/BIOS mode —
# it boots the live ISO via its isolinux (BIOS) El Torito image, not the
# UEFI ESP. systemd-boot is UEFI-only, so a dd'd systemd-boot image is not
# recognised as bootable at all. Switch the UP Board USB configs to GRUB
# with BOTH BIOS (MBR + bios_grub partition, via mkUsbDiskImage's
# partitionTableType = "hybrid") and UEFI (removable
# /EFI/BOOT/BOOTX64.EFI) — mirroring the live ISO's dual boot — so the
# stick boots on Legacy and UEFI alike. Scoped to the USB configs; the
# eMMC installs keep systemd-boot.
#
# devices = [ "nodev" ] here, NOT the image's disk. This config is also
# what in-place updates (`nix copy` + switch-to-configuration) run against
# on a LIVE stick, where the build VM's /dev/vda does not exist and a BIOS
# grub-install against it would fail the switch. "nodev" regenerates
# grub.cfg and skips the MBR write, which is the correct behaviour for an
# update: GRUB's embedded core.img reads grub.cfg off the partition, so
# the MBR stage never needs rewriting per generation. mkUsbDiskImage's
# grubBiosDevice overrides this for the image build, where the BIOS stage
# genuinely has to be written.
usbGrubHybridModule = { lib, ... }: {
boot.loader.systemd-boot.enable = lib.mkForce false;
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
boot.loader.grub = {
enable = lib.mkForce true;
efiSupport = true;
efiInstallAsRemovable = true;
# Plain definition, NOT mkForce: grubBiosDevice overrides it with
# mkForce, and two mkForce list definitions would merge (both
# priority 50) into [ "/dev/vda" "nodev" ] instead of replacing.
# Nothing else in the module stack defines grub.devices.
devices = [ "nodev" ];
};
};
# dd-able USB image of a <model>-usb config. make-disk-image gives the
# ext4 root the nixos-usb label directly (-L) but hardcodes the ESP FAT
# label to "ESP", so the volume is relabelled to ESP-USB afterwards —
# volume label only; bootloader files are untouched and UEFI loads
# /EFI/BOOT/BOOTX64.EFI regardless.
#
# partitionTableType: "efi" (GPT + ESP, systemd-boot) for batm3 and douro,
# whose firmware UEFI-USB-boots fine via that removable fallback;
# "hybrid" (GPT + bios_grub + ESP) for the UP Board models, paired with
# usbGrubHybridModule. In BOTH layouts the ESP is partition 1 — the hybrid
# table creates the ESP first and the bios_grub partition second — so the
# parted/mlabel relabel below is layout-independent.
#
# grubBiosDevice: the build VM's disk, for hybrid images only. GRUB must
# write its BIOS stage to that disk's MBR at image-build time, while the
# config itself says "nodev" so in-place updates on a live stick work;
# see usbGrubHybridModule.
mkUsbDiskImage =
{ machineModel
, usbConfig
, partitionTableType ? "efi"
, grubBiosDevice ? null
}:
let
imageConfig =
if grubBiosDevice == null then
usbConfig
else
usbConfig.extendModules {
modules = [
({ lib, ... }: {
boot.loader.grub.devices = lib.mkForce [ grubBiosDevice ];
})
];
};
baseImage = import (nixpkgs + "/nixos/lib/make-disk-image.nix") {
inherit pkgs lib partitionTableType;
config = imageConfig.config;
format = "raw";
diskSize = "auto";
label = "nixos-usb"; # ext4 root label (make-disk-image -L)
};
in
pkgs.runCommand "nixos-disk-image-${machineModel}-usb"
{ nativeBuildInputs = [ pkgs.parted pkgs.mtools ]; }
''
mkdir -p $out
cp --sparse=always ${baseImage}/nixos.img $out/nixos.img
chmod +w $out/nixos.img
espStart=$(parted -sm "$out/nixos.img" unit B print | awk -F: '$1==1 {gsub("B","",$2); print $2}')
echo "ESP partition starts at byte $espStart — relabelling to ESP-USB"
export MTOOLS_SKIP_CHECK=1
mlabel -i "$out/nixos.img@@$espStart" ::ESP-USB
printf 'verify ESP label: '; mlabel -i "$out/nixos.img@@$espStart" -s :: || true
'';
in in
{ {
# ── NixOS Configurations (top-level, not per-system) ────────── # ── NixOS Configurations (top-level, not per-system) ──────────
@ -266,18 +538,12 @@
sintra = mkLiveConfig "sintra"; sintra = mkLiveConfig "sintra";
batm3 = mkLiveConfig "batm3"; batm3 = mkLiveConfig "batm3";
# Backwards-compat aliases. Renamed lamassu-live-* → bitSpire-live-* # Branded aliases for the live configs above. The lamassu-live-* names
# for the brand transition; both styles available until callers # were dropped 2026-10-09; nothing referenced them.
# (CI / scripts / docs) catch up. Drop the lamassu-* names once
# nothing references them.
bitSpire-live-douro = mkLiveConfig "douro"; bitSpire-live-douro = mkLiveConfig "douro";
bitSpire-live-tejo = mkLiveConfig "tejo"; bitSpire-live-tejo = mkLiveConfig "tejo";
bitSpire-live-sintra = mkLiveConfig "sintra"; bitSpire-live-sintra = mkLiveConfig "sintra";
bitSpire-live = mkLiveConfig "douro"; bitSpire-live = mkLiveConfig "douro";
lamassu-live-douro = mkLiveConfig "douro";
lamassu-live-tejo = mkLiveConfig "tejo";
lamassu-live-sintra = mkLiveConfig "sintra";
lamassu-live = mkLiveConfig "douro";
# Installed-to-disk configs (proper GPT + systemd-boot, supports nixos-rebuild) # Installed-to-disk configs (proper GPT + systemd-boot, supports nixos-rebuild)
douro-installed = mkInstalledConfig "douro" ./deploy/nixos/hardware/douro.nix; douro-installed = mkInstalledConfig "douro" ./deploy/nixos/hardware/douro.nix;
@ -286,6 +552,42 @@
# at ttyJ5, dispenser at ttyJ7 layout) — reuse the same hw module. # at ttyJ5, dispenser at ttyJ7 layout) — reuse the same hw module.
sintra-installed = mkInstalledConfig "sintra" ./deploy/nixos/hardware/upboard.nix; sintra-installed = mkInstalledConfig "sintra" ./deploy/nixos/hardware/upboard.nix;
batm3-installed = mkInstalledConfig "batm3" ./deploy/nixos/hardware/batm3.nix; batm3-installed = mkInstalledConfig "batm3" ./deploy/nixos/hardware/batm3.nix;
# USB-bootable variants of <model>-installed (see usbBootModule for
# what changes). These are the configs a flashed stick actually runs.
# Exposed as named configs (not just inline in the disk-image targets)
# so their system closures can be built here and deployed in-place with
# `nix copy` + `switch-to-configuration` — updating the app on a running
# stick WITHOUT reflashing (preserves pairing + /var/lib state).
# disk-image-<model>-usb builds its filesystem image from the same config.
batm3-usb = self.nixosConfigurations.batm3-installed.extendModules {
modules = [ usbBootModule ];
};
# douro: the production unit's internal drive is not NixOS, so the
# label disambiguation is moot today, but the nofail /boot and the
# uas/autosuspend hardening in douro.nix are what make a stick a
# reliable boot medium on the Bay Trail box. Same in-place update flow.
douro-usb = self.nixosConfigurations.douro-installed.extendModules {
modules = [ usbBootModule ];
};
# UP Board models get the same run-from-USB shape plus two things the
# Bay Trail / OptiPlex boxes don't need: GRUB on a hybrid table, because
# the Aaeon firmware USB-boots in Legacy/BIOS mode (usbGrubHybridModule),
# and the uas/autosuspend hardening from here instead of
# hardware/upboard.nix, which sintra's eMMC install also reads.
#
# tejo: the unit still runs its factory Debian (ubilinux4) on internal
# storage, so the stick has to BE the system, same as douro. Its
# internal install is not NixOS and carries no nixos/ESP labels, which
# makes the label disambiguation moot there today — but the hardened
# /boot and the bus settings are what make a stick a reliable boot
# medium, so it takes the identical module set as sintra.
tejo-usb = self.nixosConfigurations.tejo-installed.extendModules {
modules = [ usbBootModule usbBusHardening usbGrubHybridModule ];
};
sintra-usb = self.nixosConfigurations.sintra-installed.extendModules {
modules = [ usbBootModule usbBusHardening usbGrubHybridModule ];
};
}; };
# ── Standalone NixOS module ─────────────────────────────────── # ── Standalone NixOS module ───────────────────────────────────
@ -325,6 +627,69 @@
diskSize = "auto"; diskSize = "auto";
}; };
# BATM3 (OptiPlex 9030 AIO board-swap) installed image, dd-able to
# its SATA drive. Unlike the douro/sintra images, this one grows
# itself: growPartition expands the root partition to fill whatever
# drive it lands on (16GB today) at first boot and autoResize
# stretches the ext4 to match — no manual parted/resize2fs step
# after flashing, and all the drive's headroom is available to the
# nix store from day one (cf. #55). Image-only override: once
# grown, subsequent nixos-rebuilds against plain batm3-installed
# are unaffected.
disk-image-batm3 =
let
cfg = self.nixosConfigurations.batm3-installed.extendModules {
modules = [
{
boot.growPartition = true;
fileSystems."/".autoResize = true;
}
];
};
in
import (nixpkgs + "/nixos/lib/make-disk-image.nix") {
inherit pkgs lib;
config = cfg.config;
format = "raw";
partitionTableType = "efi";
diskSize = "auto";
};
# USB-bootable images (see usbBootModule / mkUsbDiskImage in the let
# block). Flash with dd or balenaEtcher, boot the stick, done — no
# installer step. The plain disk-image-<model> reuses the generic
# nixos/ESP labels, so a stick carrying it, booted on a machine whose
# internal drive ALREADY holds a nixos/ESP-labelled install, makes
# stage-1's by-label/nixos resolve to the internal drive instead of the
# stick — the stage-2 init path baked into the USB's boot entry isn't on
# that root, so stage 1 aborts. These variants can't hit that.
disk-image-batm3-usb = mkUsbDiskImage {
machineModel = "batm3";
usbConfig = self.nixosConfigurations.batm3-usb;
};
disk-image-douro-usb = mkUsbDiskImage {
machineModel = "douro";
usbConfig = self.nixosConfigurations.douro-usb;
};
# UP Board models: hybrid table + GRUB, so one stick boots on the Aaeon
# firmware's Legacy/BIOS USB path as well as UEFI. Distinct labels also
# matter more here than on douro — a sintra's eMMC already holds a
# nixos/ESP-labelled install, and stage-1 would otherwise race the two
# roots and likely mount the eMMC.
disk-image-tejo-usb = mkUsbDiskImage {
machineModel = "tejo";
usbConfig = self.nixosConfigurations.tejo-usb;
partitionTableType = "hybrid";
grubBiosDevice = "/dev/vda";
};
disk-image-sintra-usb = mkUsbDiskImage {
machineModel = "sintra";
usbConfig = self.nixosConfigurations.sintra-usb;
partitionTableType = "hybrid";
grubBiosDevice = "/dev/vda";
};
# Backwards compat # Backwards compat
iso = self.nixosConfigurations.douro.config.system.build.isoImage; iso = self.nixosConfigurations.douro.config.system.build.isoImage;
}; };

View file

@ -1,4 +1,4 @@
# Pure Nix derivation for the Lamassu ATM Electron app. # Pure Nix derivation for the bitSpire ATM Electron app.
# #
# Uses fetchPnpmDeps + pnpmConfigHook to build entirely inside the Nix sandbox, # Uses fetchPnpmDeps + pnpmConfigHook to build entirely inside the Nix sandbox,
# eliminating the need for --impure or a local pnpm install. # eliminating the need for --impure or a local pnpm install.
@ -38,7 +38,7 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
inherit (finalAttrs) pname version src pnpmWorkspaces; inherit (finalAttrs) pname version src pnpmWorkspaces;
inherit pnpm; inherit pnpm;
fetcherVersion = 3; fetcherVersion = 3;
hash = "sha256-Jv5p62E40DtCSZvN/+LTzkhRYJBTyyUOVSBlQyxzcEw="; hash = "sha256-HIMjQx0REauknZkIU50M4KMU0lyNQ41UG6AUM/3Hn54=";
}; };
nativeBuildInputs = [ nativeBuildInputs = [
@ -57,13 +57,18 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
pkgs.sqlite.dev # better-sqlite3 pkgs.sqlite.dev # better-sqlite3
pkgs.libudev-zero # serialport pkgs.libudev-zero # serialport
pkgs.stdenv.cc.cc.lib # libstdc++ pkgs.stdenv.cc.cc.lib # libstdc++
# @pokusew/pcsclite (nfc-pcsc): the `lib` output carries libpcsclite.so so
# autoPatchelf wires it into the .node RPATH at runtime. Compile/link paths
# are injected via CPATH/LIBRARY_PATH in buildPhase (its binding.gyp
# hardcodes Debian /usr paths instead of using pkg-config).
pkgs.pcsclite.lib
]; ];
env = { env = {
ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
# Vite compile-time variables (baked into the frontend bundle) # Vite compile-time variables (baked into the frontend bundle)
VITE_LAMASSU_MACHINE_MODEL = model; VITE_BITSPIRE_MACHINE_MODEL = model;
VITE_LAMASSU_FIAT_CODE = fiatCode; VITE_BITSPIRE_FIAT_CODE = fiatCode;
}; };
buildPhase = '' buildPhase = ''
@ -83,6 +88,19 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
--arch=x64 --arch=x64
popd popd
# @pokusew/pcsclite (nfc-pcsc's native addon) — also V8 C++ API, so it too
# must be rebuilt against Electron's headers. Its binding.gyp hardcodes
# /usr/include/PCSC + /usr/lib, so point the compiler/linker at nixpkgs'
# pcsclite explicitly (winscard.h lives under include/PCSC).
echo "=== Rebuilding @pokusew/pcsclite against Electron ${electron.version} headers ==="
pushd node_modules/.pnpm/@pokusew+pcsclite@*/node_modules/@pokusew/pcsclite
CPATH="${pkgs.pcsclite.dev}/include/PCSC''${CPATH:+:$CPATH}" \
LIBRARY_PATH="${pkgs.pcsclite.lib}/lib''${LIBRARY_PATH:+:$LIBRARY_PATH}" \
HOME=$TMPDIR ${nodejs}/bin/npx --yes node-gyp rebuild \
--nodedir="$electron_nodedir" \
--arch=x64
popd
# Build the Electron app (turbo builds all workspace deps + app) # Build the Electron app (turbo builds all workspace deps + app)
pnpm --filter="@bitSpire/machine..." build pnpm --filter="@bitSpire/machine..." build
@ -95,7 +113,7 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
installPhase = '' installPhase = ''
runHook preInstall runHook preInstall
mkdir -p $out/node_modules/{@lamassu,@serialport} mkdir -p $out/node_modules/{@lamassu,@serialport,@pokusew}
# Helper: find a package dir inside the pnpm virtual store. # Helper: find a package dir inside the pnpm virtual store.
# pnpm store dirs look like: node_modules/.pnpm/<name>@<ver>[_<peer-suffix>]/node_modules/<name> # pnpm store dirs look like: node_modules/.pnpm/<name>@<ver>[_<peer-suffix>]/node_modules/<name>
@ -132,6 +150,12 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
copy_pnpm_pkg bindings $out/node_modules/bindings copy_pnpm_pkg bindings $out/node_modules/bindings
copy_pnpm_pkg file-uri-to-path $out/node_modules/file-uri-to-path copy_pnpm_pkg file-uri-to-path $out/node_modules/file-uri-to-path
# nfc-pcsc + @pokusew/pcsclite (Bolt Card reader). The compiled
# pcsclite.node (from the rebuild above) rides along in the package dir and
# loads via `bindings` (already copied). autoPatchelf wires libpcsclite.
copy_pnpm_pkg nfc-pcsc $out/node_modules/nfc-pcsc
copy_pnpm_pkg @pokusew/pcsclite $out/node_modules/@pokusew/pcsclite
# @bitSpire/hal (workspace package, dynamically imported for hardware access) # @bitSpire/hal (workspace package, dynamically imported for hardware access)
mkdir -p $out/node_modules/@bitSpire/hal/dist mkdir -p $out/node_modules/@bitSpire/hal/dist
cp -rL packages/hal/dist/* $out/node_modules/@bitSpire/hal/dist/ cp -rL packages/hal/dist/* $out/node_modules/@bitSpire/hal/dist/
@ -166,6 +190,33 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
copy_pnpm_pkg "@serialport/$parser" "$out/node_modules/@serialport/$parser" copy_pnpm_pkg "@serialport/$parser" "$out/node_modules/@serialport/$parser"
done done
# ── Strip node-gyp build detritus ──────────────────────────────────
# node-gyp leaves its scaffolding beside the compiled addons, and several
# of those files embed absolute /nix/store paths to the BUILD toolchain:
# build/node_gyp_bins/python3 an ELF copy of python3 with an RPATH
# build/config.gypi python3 + nodejs + npm paths
# build/Release/.deps/**.o.d pcsclite.dev include paths
# Nix scans $out for store hashes, so each becomes a RUNTIME reference and
# drags python311 + nodejs + npm + pcsclite.dev (~212MB of closure) onto
# every ATM. Nothing reads them at runtime — only build/Release/*.node is
# loaded, via `bindings` / `node-gyp-build`. Keep the addons, drop the
# scaffolding. obj.target/*.node is node-gyp's pre-copy of the same addon;
# the loaded one at build/Release/*.node is untouched.
find $out/node_modules -type d \
\( -name node_gyp_bins -o -name .deps -o -name obj.target -o -name obj \) \
-prune -exec rm -rf {} +
find $out/node_modules -path '*/build/*' -type f \
\( -name config.gypi -o -name '*.mk' -o -name Makefile \
-o -name binding.Makefile -o -name '*.a' -o -name '*.o' \) -delete
# pnpm/node-gyp rewrote these CLI helpers' shebangs to the build nodejs,
# which alone retains the full nodejs (not the slim one Electron needs).
# They are build-time utilities — the runtime entry of each package
# (index.js) carries no shebang — so point them at PATH instead of
# deleting files a package might still require.
find $out/node_modules -type f -name '*.js' \
-exec sed -i '1s|^#!/nix/store/[^ ]*/bin/node$|#!/usr/bin/env node|' {} +
runHook postInstall runHook postInstall
''; '';

Some files were not shown because too many files have changed in this diff Show more