Commit graph

186 commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
Patrick Mulligan
64582e7fe6 feat(machine): Bolt Card (NFC) tap-to-receive on cash-in
Tap-to-receive for the buy flow, the receive counterpart to #83's
cash-out tap-to-pay. A Bolt Card only emits an lnurlw withdraw voucher
(wrong direction to deposit into it), so the tap is used as an
authenticated identity (external_id + SUN p/c) to resolve the card
wallet's lnurlp/Lightning Address; the ATM then pays an invoice for the
payout over its existing nostr transport.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

lnbits + machine typecheck clean; lnbits suite 29 pass.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:53:51 +00:00
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
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