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.
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>
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
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
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.
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.
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>
Fixes gap-3 from coord log 2026-06-01T18:30Z: the operator-fees
subscriber wasn't running during the 'awaiting-fees' maintenance state,
so the maintenance state had no path to clear. Every restart found
empty state.db, entered maintenance, never subscribed, never wrote.
Forever stuck.
Root cause: `initializeForProduction` bailed via early `return` when
the persisted fee config was null. The subscriber starts inside
`initializeWithHalIpc`, which was never reached.
Fix has three pieces:
1. Remove the early return. HAL + Lightning + operator-fees subscriber
all init even when `initError = 'awaiting-fees'` is set. The
maintenance card UI still blocks user interaction (no router-view
renders), and the state machine starts with zero fractions until
the first event lands.
2. New `UPDATE_FEE_CONFIG` event on the state machine, handled at the
root level — assigns `cashInFeeFraction` / `cashOutFeeFraction` onto
context so subsequent cashIn/cashOut entries pick them up via
setCashInFee / setCashOutFee actions. No actor restart needed.
3. `applyFeeConfig` (the operator-fees subscriber's onApply callback)
now dispatches UPDATE_FEE_CONFIG into the running actor AND clears
`initError` when it was 'awaiting-fees'. Operator publishes the
first event → ATM auto-unblocks → UI flips from maintenance card
to IdleView showing the new fee%. No `systemctl restart bitspire`
needed.
Adds three tests covering the new UPDATE_FEE_CONFIG handler:
- updates context fractions
- does not leave idle state
- propagates to context.feeFraction on next cashIn entry
(the load-bearing chain: subscriber → context → setCashInFee → fee
math is correct for the next transaction)
Total state-machine tests: 21 (was 18); apps/machine tests unchanged
at 24. All 12 workspace packages typecheck.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`initialContext.cashInFeeFraction` / `cashOutFeeFraction` drop from
0.0333 / 0.0777 → 0. The state-machine no longer carries a fee
opinion; callers (the renderer's atm-store) are responsible for
supplying explicit fractions via `createATMMachine(..., options)`.
Why now: aiolabs/lamassu-next#57 makes the operator's Nostr-pushed
fee config the source of truth on the ATM. Keeping non-zero defaults
in the state machine would mean a misconfigured caller could silently
fall back to a 7.77% cash-out fee instead of failing closed into the
"awaiting fee configuration" maintenance screen.
Extracts `ATMMachineOptions` as a named interface (was inline). No
functional change to the option spread.
Updates the `should calculate sats amount from fiat with fee` test
to pass `cashOutFeeFraction: 0.0777` explicitly so it still exercises
the cash-out fee math; the post-refactor zero default would otherwise
land 50,000 sats instead of 53,885.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Aligns lamassu-next with the canonical sat-amount vocabulary agreed
across lnbits/bitspire/satmachineadmin (satmachineadmin@d717a6e,
coordination log 2026-05-26T17:10Z):
- `feePercent` / `cashInFeePercent` / `cashOutFeePercent`
→ `feeFraction` / `cashInFeeFraction` / `cashOutFeeFraction`
(canonical: unit fraction in [0, 1], NEVER a percentage)
- `cashInFeeRate` / `cashOutFeeRate` (config option names)
→ `cashInFeeFraction` / `cashOutFeeFraction`
- `fee_percent` (wire field on Payment.extra + state.db column)
→ `fee_fraction`
Bug fix bundled with the rename:
`lightning.ts:780` previously stamped `Payment.extra.fee_percent =
context.feePercent * 100` (0.05 → 5.0). state.db stored the unit
fraction (0.05) but Payment.extra carried the percent (5.0) — 100×
divergence that any consumer reading Payment.extra computed fees
wrong by exactly 100×. Now stamps `fee_fraction` directly as unit
fraction. Display layers (atm-tui, view components) multiply by 100
themselves.
Defensive invariants added:
- `computeFeeSats` (atm store) throws if `feeFraction` outside [0, 1]
or if cash-in `feeSats > principalSats` (would mean negative payout)
- `recordTransaction` (state-store) throws on the same range
- state-machine + electron + Vue views propagate the rename
state.db migration v6 → v7: `ALTER TABLE transactions RENAME COLUMN
fee_percent TO fee_fraction`. Historical migrations preserved
verbatim (they wrote `fee_percent`, future installs see the same
sequence followed by the v7 rename).
12/12 typecheck + 18/18 state-machine tests green. Coordinated with
~/dev/bitspire/atm-tui (separate commit) reading `fee_fraction`
from the new column.
refs: log:2026-05-26T17:10Z, log:2026-05-26T18:50Z,
satmachineadmin@d717a6e
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cash-out invoices created via `lnbits.createInvoice()` now carry the
principal / commission / exchange-rate metadata satmachineadmin needs
to drive DCA distribution without back-deriving from a stored rate.
Closes the wire-format side of `aiolabs/lamassu-next#44`.
Wire payload (matches the canonical names agreed in #44 comments
#598/#599/#600 — `principal_sats` not `net_sats`, `fee_percent` not
`fee_pct`):
extra: {
source: 'bitspire',
type: 'cash_out',
txid: context.txid,
principal_sats: floor((fiatCents / 100) * exchangeRate),
fee_sats: max(0, satsAmount - principal_sats),
fee_percent: feePercent * 100,
exchange_rate: context.exchangeRate, // raw market rate, sats/fiat
currency: context.currency, // customer-paid currency
}
`bills` / `cassettes` deferred — they're meaningful for cash-in and
partial-dispense reconciliation, neither of which is wired on the
satmachineadmin side yet (#22, #3).
Plumbing:
- `ATMServices.generateInvoice` signature changes from
`(amountMsat: number) => Promise<string>` to
`(context: ATMContext) => Promise<string>`. The on-wire BOLT11
amount is derived inside the service as `satsAmount * 1000` msats;
the rest of the context drives the extra payload.
- State-machine `generatingInvoice` actor passes the full context
instead of just msats.
- Dev mock in `apps/machine/src/stores/atm.ts` updated to match.
All 18 state-machine tests pass. Typecheck clean across the app.
Two `// pragma: allowlist secret` markers added to lightning.ts on
existing doc-comment lines that mention "private key" — the dev-env
pre-commit secret scanner flagged them as false positives (every
prior commit touching this file had bypassed via --no-verify).
Cash-in (`generateLnurlWithdraw`) intentionally left alone for now —
satmachineadmin's listener doesn't handle the outbound LNURL-withdraw
flow yet (`aiolabs/satmachineadmin#22`), so stamping metadata it
won't read would be premature. Will land alongside that issue.
"Gross" was operator-vs-customer ambiguous (cash-out: customer's gross
payment = principal + commission, not the variable's value). atm-tui
already settled on "principal" for the same quantity (bitspire/atm-tui
src/db.zig:166-171, src/main.zig:98,716), and #44's Payment.extra
proposal will surface it as `principal_sats` on the kind-21000 wire.
Aligning the internal name removes one translation step across DB →
TUI → state machine → wire envelope.
Pure mechanical rename — no behavioral change. Also rewrites the
computeFeeSats JSDoc to drop the "gross"/"net" framing and document
the principalSats / on-wire satsAmount relationship explicitly.
Refs aiolabs/lamassu-next#44
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Accepts percentage (5.55) or decimal (0.0555) — auto-detected by
whether the value is >= 1. Defaults to 3.33% cash-in, 7.77% cash-out.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously, if getAvailableBalance returned 0 or exchange rate was
missing, all bills were accepted — risking cash-in exceeding the
ATM's sats balance. Now rejects bills in that case to protect
customers from losing cash.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
insertingBills and selectingAmount now auto-idle after 3 minutes of
inactivity. displayingInvoice returns to amount selection after 5
minutes if the customer never pays.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
If a customer walks away from the abandon confirmation screen,
the machine now returns to idle after 60 seconds instead of
hanging indefinitely. Cash stays in the cashbox as operator funds.
Closes#31
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Failed dispenses (sats debited, cash not dispensed) were invisible —
transactions only recorded on 'complete'. Now records on 'dispenseError'
with status ('dispense_error'|'partial'|'complete'), error message, and
per-cassette detail.
Also fixes a bug in both HAL services where dispense results were mapped
by amounts-array index instead of cassette position, causing swapped
denomination counts when cassette order differs from request order.
Schema v3→v4: adds status/error columns to transactions, new
cassette_bills table for per-cassette provisioned/dispensed/rejected.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The IPC HAL path (production) never set halServices.value, so the
auto-advance from waitingForCashTaken → complete never fired. The
machine hung on "Cash Ready!" indefinitely — no transaction persisted.
Three fixes:
- Auto-advance now checks `isElectron` (covers IPC path)
- waitingForCashTaken has a 30s after-timeout as safety net
- Fix unscoped lightningPub refs in LNURL session helpers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The field was always stored in cents but the name was ambiguous.
Rename to fiatCents across state machine, store, and views.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
If the dispenseCash promise hangs (hardware jam, serial port freeze,
waitForBillsRemoved stuck), the machine was trapped in dispensingCash
forever with no way to recover. Now it transitions to dispenseError
after 2 minutes, which then auto-returns to idle after 30 seconds.
This ensures the ATM always recovers to a usable state, even when
hardware fails mid-dispense.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The hal:dispense IPC handler in main.ts did not return the result of
dispenseCash(), causing the state machine guard to crash on undefined
output. This left the UI stuck on "Dispensing cash..." after successful
dispense.
- hal-service.ts: return DispenseResult instead of void/throwing
- main.ts: add missing return in IPC handler
- machine.ts: defensive guard (?. instead of .) as safety net
Bug found with the aid of Seoyoung at Trece Cielos.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dispenseCash now always resolves with a DispenseCashResult (per-bill
dispensed/rejected counts, overall success flag, optional error) instead
of throwing. dispenseError is a 30s timed state that auto-returns to
idle, matching brain.js _timedState('outOfCash'). The dead-end retry
loop (which the UI never exposed) is removed.
The Vue dispenseError screen now shows partial dispense info, the
transaction ID as a QR code, and a 30s countdown.
Closes#30
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add cashInFeePercent (3.33%) and cashOutFeePercent (7.77%) to context.
Set feePercent from the per-flow value on SELECT_CASH_IN/SELECT_CASH_OUT
transitions. Preserve both rates across resetContext.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Cancel button is now always visible during the cash-in flow. The state
machine routes CANCEL to confirmAbandon when bills are present, so the
user always has an exit path with appropriate warnings. Also adds a
5-minute auto-timeout on displayingQR and allows cancel during
generatingNdebit.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The cancel button was still accessible during insertingBills after a bill
had been stacked (physically irreversible). Now CANCEL in insertingBills
is guarded: no bills → idle, bills present → confirmAbandon warning.
The UI also hides the cancel button once bills are detected.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Once bills are stacked in the cash box they cannot be returned.
Cancel in displayingQR now goes to confirmAbandon warning state.
Error state retries to generatingNdebit instead of idle when bills
are present. CANCEL from error only goes to idle if no bills inserted.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix cassette denominations: Douro uses Q100/Q200, not Q20
- Add hal:get-inventory IPC so renderer can read HAL cassette inventory
- Add balance fetch/display to HAL+IPC init path and idle screen
- Enable/disable bill validator via watch on nested state transitions
- Pass fiatCode to state machine context (was hardcoded to USD)
- Preserve currency across state machine resetContext
- Add CANCEL handler to dispenseError state (was stuck)
- Fix remaining hardcoded $ symbols in CashInView
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add crash-safe persistence for cassette inventory, cashbox state, and
transaction history using better-sqlite3 in the Electron main process.
The state machine now loads inventory from the database at runtime
instead of using hardcoded values, and transactions are automatically
persisted on completion.
Remove the unnecessary npub linking code — Lightning.Pub auto-creates
and associates Nostr users when appId is included in RPC requests,
making the HTTP-based user creation and token linking redundant.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add billWithinBalance guard to state machine that rejects bills
exceeding the ATM's available sats. UI disables denomination buttons
and shows informational message when limit is reached.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add getRateAndBalance actor that fetches exchange rate and available
balance in parallel during cash-in flow. Add generateLnurlWithdraw
service type for LNURL-withdraw support.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add dev.sh script for managing regtest development environment
- Implement cmd_fund to fund ATM app owner via Lightning.Pub API
- Add --fund flag to cmd_up for automatic funding on startup
- Update setup_atm_app to write VITE_APP_ID to machine .env
- Fix Electron IPC to pass appId and extensionApiUrl to renderer
- Restructure repo from nested lamassu-next/ to root
The dev.sh script now supports:
- ./dev.sh up --fund # Start regtest and auto-fund ATM
- ./dev.sh fund # Fund existing ATM app
- ./dev.sh status # Show environment status
- ./dev.sh reset # Clean restart
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>