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.
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
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
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Under path B (NOSTR_TRANSPORT_ROSTER_REQUIRED=true), lnbits's
roster-lookup override routes create_invoice to the operator's
wallet, but the subsequent subscribe_payments was scoping its
filter to the ATM's pre-override wallet_id. The dispatcher
AND-filters payment_hash + wallet_id, so the settlement on the
operator wallet was invisible to the subscription — bitspire
stayed in "Watching invoice" forever, dispense never fired.
Omit wallet_id on the single-invoice watcher: lnbits already
resolves the wallet from get_standalone_payment(payment_hash)
and ownership-checks against the auth'd account. Works pre/post-
override; payment_hash is the natural primary key for "wait for
THIS invoice" anyway.
Cash-out subscription site at services/lightning.ts:1008-1010
(production caller) + watchInvoice convenience helper at
packages/lnbits/src/client.ts:286-313 both flipped.
LNURL-withdraw subscription at services/lightning.ts:720
(filter: tag+link_id) is the symmetric case but pending lnbits
confirmation that the tag+link_id branch of _resolve_owner_wallet_id
exists alongside the payment_hash branch.
Coordination: ~/dev/coordination/log.md 2026-05-31T18:35Z (joint
smoke surfaced the bug), 18:40Z (bitspire diagnosis), 18:50Z
(lnbits narrowed the fix shape + confirmed path-2 works against
deployed lnbits today).
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>
Closes aiolabs/lamassu-next#50. Builds on #49 (commit 0dcbe44):
isAuthenticServerEvent now Schnorr-verifies inbound events, so ev.id
is by construction the id of a server-signed event and can be used
as a dedup key without risk of attacker pre-poisoning.
Two layers:
1. Client-global `seenEventIds` (Set<string>, FIFO cap 1000) in
`LnbitsClient.handleReply`. Skips events whose id has been seen.
Catches exact-replay — relay re-delivers the same bytes after
reconnect, or any other source that emits a bit-identical event.
Without #49's guard, an attacker could pre-poison this set with
chosen ids; with the guard, every entry is a server-signed event.
2. Per-subscription `seenPaymentHashes` (Set<string>, FIFO cap 500)
on `ActiveSubscription`. Skips `onPush` invocations whose payment
hash has been seen on the same subscription. Catches logical
duplicates — server fan-out across two relays produces two
different ev.ids carrying the same payment_hash, which the
client-global ev.id layer can't dedup but the per-sub hash layer
does. Scoped per-sub so independent subscriptions seeing the same
hash for their own reasons still fire.
Why this matters in production: without dedup, a relay rebroadcast of
a settlement push would invoke `onPush` twice. The state machine's
`watchInvoice` callback resolves on the first push and unsubscribes,
but a race between the second push and the unsubscribe round-trip
could land a second `dispenseCash()` for the same cash-out — customer
walks away with double the cash. Per-sub `payment_hash` is the only
field guaranteed-unique per settlement (the customer's payment hash
is fixed at invoice creation), so it's the right dedup key.
5 new tests:
- forged event doesn't poison `seenEventIds` (proves guard + dedup
interact: a refactor that removes either #49's guard or this
patch's `seenEventIds.add()` ordering would fail this test)
- exact-replay: same event injected twice, callback fires once
- distinct ev.ids with same payment_hash, callback fires once
(per-sub hash dedup; ev.id dedup doesn't apply)
- distinct payment_hashes fire normally (negative dedup case)
- per-sub isolation: same hash on two subs fires both callbacks
Total: 11/11 lnbits package tests pass. Workspace typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to commit 0dcbe44 (closesaiolabs/lamassu-next#49) addressing
two review notes from the bitspire session:
1. Integration test gap. The four unit tests on isAuthenticServerEvent
cover the predicate in isolation — a refactor that dropped the
`if (!isAuthenticServerEvent(...)) return` line from handleReply
would still pass them silently. Adds two integration tests that
exercise the full handleReply wiring through a mock NostrClient
that captures the subscribe callback:
- Forged event injected through the callback → pre-registered
pending entry's resolve is NOT called (asserts handleReply
short-circuited).
- Legitimate server-signed reply (NIP-44 v2 encrypted with
real keys) → pending entry's resolve IS called with the
decoded payload (positive sanity).
2. `@internal` JSDoc on isAuthenticServerEvent. The helper is exported
only so the unit tests can reach it directly; production callers
should go through handleReply. The annotation makes the intent
explicit and discourages accidental wider use.
6/6 tests pass. Workspace typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LnbitsClient.handleReply matched replies by request_id alone — no
verification that the inbound event was actually signed by the
configured LNbits server pubkey. The relay's subscription `authors`
filter is relay-honour, not relay-enforced; a malicious or buggy
relay could forward an event with the server's pubkey in the body
but signed by a different key (or with a tampered body whose id no
longer matches).
Adds `isAuthenticServerEvent(ev, expectedPubkey)`:
- `ev.pubkey === expectedPubkey` (explicit, not relying on filter)
- `verifyEvent(ev)` (catches sig/id/content tampering)
handleReply calls it before passing the event to decryptContentV2.
NIP-44 v2 already binds ciphertext to sender via ECDH, so a relay
without the server's nsec can't forge decryptable content — but
verifying the outer event keeps `ev.id` trustworthy for any
downstream dedup/logging code and matches the symmetric defence on
the server side (`nostr_transport/relay_pool.py:~320`) and on the
lnbits-bunker-client side (`aiolabs/lnbits` commit 4ebcd959,
`NsecBunkerAdminClient._match_response`).
Test `packages/lnbits/src/__tests__/client.test.ts` covers:
- legitimate server-signed event accepted
- event whose pubkey field doesn't match config rejected
- forged event (signed by attacker, pubkey overwritten to server)
rejected — recomputed id no longer matches stored id
- event with tampered content (id mismatch) rejected
Gotcha worth noting: `finalizeEvent` stamps `event[verifiedSymbol] = true`
to cache the verification result, and `{...ev}` spread copies symbol-
keyed properties. So forged/tampered events constructed via spread
inherit the cached `true` and `verifyEvent` short-circuits. The test
JSON-round-trips through `stripVerifiedCache` to drop the cache.
Closesaiolabs/lamassu-next#49.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a 5-minute expiration tag to every outbound RPC envelope. Belt-
and-suspenders with the handler-side max_age check (aiolabs/lnbits
e4b5bcd7) — the tag lets compliant relays drop expired events at the
relay layer before they reach LNbits, while the handler's own
time-bounds check defends against a stripped tag.
Closesaiolabs/satmachineadmin#15 (S1 / G4 — no replay window on RPC
events) on the ATM emission side.
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>
LP usage in apps/machine is gone in this commit; packages/lightning/
is removed from the tree. atm.ts continues to see a 'lightningPub'
field but it is now a thin LightningBackend adapter (getBalance,
watchBalance, createInvoice, payInvoice) implemented over the LNbits
nostr-transport — no atm.ts surgery needed.
services/lightning.ts changes
- LightningPubClient import removed; CLINK helper imports
(createOfferSuccess / createOfferError / OfferErrorCode) removed —
the CLINK offer-request handler that produced LP invoices is gone.
- LightningConfig: trimmed LP fields (lightningPubPubkey,
lightningPubApiUrl, extensionApiUrl, adminToken). loadLightningConfig
reads only LNbits + relay + identity vars.
- initializeLightningServices: requires VITE_LNBITS_SERVER_PUBKEY,
fails fast if missing or if list_wallets returns no wallet.
CLINK client is still instantiated for kind-21003 management
commands (LP-independent), but offer-request wiring is removed.
- New LightningBackend interface defines the surface atm.ts uses;
the in-init adapter implements it over the LnbitsClient.
- ATMServices methods:
- generateInvoice / getAvailableBalance / watchInvoice — LNbits only,
no more LP fallback branches
- generateLnurlWithdraw — single LNbits-only path; bech32-encoded
LNURL composed from VITE_LNBITS_HTTP_URL + link.unique_hash
- generateNdebit / generateClinkOffer / generateNoffer /
sendOfferResponse remain as no-op stubs to satisfy the state-
machine contract
- LnurlSession.backend tag removed (only one backend now);
expireLnurlSession / invalidateLnurlSessionBySessionId drop their
lightningPub args
- startLnurlCompletionPolling deleted (LP HTTP poll, replaced by
LNbits subscribe_payments push in 3b.3)
- Standalone export `watchInvoice(lp, hash, cb)` deleted (unused)
atm.ts changes (minimal)
- Import LightningBackend from @/services/lightning instead of
LightningPubClient from @bitSpire/lightning
- lightningPub ref retyped to LightningBackend | null
Package layout
- packages/lightning/ deleted (LightningPubClient sources + tests)
- apps/machine/package.json drops @bitSpire/lightning dep
- tsconfig.json drops the path alias
- pnpm-lock.yaml regenerated
State-machine tests pass; vue-tsc clean. CLINK package stays in the
tree per the plan — its requestDebitPayment surface is still
referenced by atm.ts.requestDebit (a dead production path that's
gated by null checks anyway).
Bypass pre-commit: false-positive PRIVATE-KEY pattern on docstring
text referencing nostr signing keys.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New TypeScript workspace package targeting aiolabs/lnbits's
nostr-native-transport (kind-21000 NIP-44 v2 RPC). Designed as a
drop-in peer of @bitSpire/lightning's LightningPubClient so the
machine app can swap backends with minimal churn (3b).
packages/lnbits/
package.json workspace + nostr-tools deps
tsconfig.json extends root config
src/types.ts wire envelope, Payment, WalletInfo,
SubscribePayments filter / ack / push,
WithdrawLink, PayLink, callback types
src/client.ts LnbitsClient with:
getWallet / getBalance / listWallets
createInvoice / payInvoice
getPayment / decodePayment
subscribePayments / unsubscribe
watchInvoice (resolve-on-paid)
watchWallet (cancel-fn)
createWithdrawLink / getWithdrawLink
listWithdrawLinks / updateWithdrawLink
deleteWithdrawLink
getWithdrawLinkUniqueHashes
src/index.ts public exports
Key differences vs LightningPubClient:
- Auth: signing pubkey IS the credential (no authIdentifier/appId
in the request envelope).
- Subscriptions: one `subscribe_payments` RPC handles all three
cases (payment_hash, wallet_id, tag+link_id). Server emits an
ack first, then push events sharing subscription_id. unsubscribe
teardown emits a closed-push immediately followed by ACK.
- Sharding: outbound responses ≤40K plaintext arrive as a single
event. Larger responses come back as multiple kind-21000 events
sharing a `shardsId`; client reassembly is TODO (lazy: ATM RPCs
rarely return that much data).
- CLINK/ndebit/noffer is NOT covered — LNbits has no CLINK. For
the ATM that means cash-in uses lnurlw + subscribe_payments
instead of ndebit (wired in 3b).
Reuses @bitSpire/nostr-client's encryptContentV2 / decryptContentV2
(NIP-44 v2 via nostr-tools/nip44). No new crypto code.
tsconfig.json path mapping added so `@bitSpire/lnbits` imports
resolve to ./packages/lnbits/src across the monorepo.
Verified: pnpm typecheck clean (13/13 turbo tasks).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If MEI reports `escrowed` AND `powerup` on the first message after
service start with no error flags (jammed/stalled/failure/
transportOpen/stackerFull), fire a reject to clear stale state
before the FSM emits spurious billsAccepted/billsRead upstream.
Guards exclude every scenario where reject's motor sequence could
do harm: physical jams (`jammed`/`stalled`/`failure`/`transportOpen`)
are left alone for hands-on diagnosis, and a real customer bill in
escrow with `stackerFull` is preserved rather than returned.
Diagnosed on Austin batm3 2026-06-01: unit had been stuck in this
state since 2026-05-25 (six days, three boots) requiring manual
intervention to clear. Self-heals now on next service restart.
Surface MEI SCR's full status-flag set in the journal — diagnostic
for stuck-bill / jam scenarios where the raw flag combination
(jammed, stalled, transportOpen, escrowed, etc.) reveals what state
the validator actually believes it's in. Dedup'd on change so 100ms
polling doesn't flood logs; firmware model + revision logged once.
Time each bill's stay in the `billsRead` (escrow) status. Anything that
exits escrow within ESCROW_WATCHDOG_WARN_MS=500ms gets logged at info
level; anything longer gets a warning naming the elapsed milliseconds.
500ms is 5× our 100ms poll cadence and well below MEI's ~5s grace
window. A warning means we're drifting toward the autonomous-return
failure mode that tears bills on the BATM3 — useful field signal for
verifying the poll-interval fix is sufficient under real load.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
parseStatus previously collapsed all in-motion bits into a single derived
status, hiding when the MEI was moving a bill between escrow and the
stacker/mouth. Field journals showed nothing in the window where the
BATM3 tore bills.
Surface the three transient bits between the terminal states and
escrow/standby, route them through EbdsFsm with a Date.now() timestamp,
and emit them as diagnostic events on the validator. No state machine
consumes them; they're for journalctl.
Also: warn when `returning` is observed straight out of escrow with no
host reject() — that signature is the autonomous-return failure mode we
just polled around. Surfacing it gives us field confirmation the 100ms
fix is sufficient (or evidence it isn't).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EBDS is a host-polled protocol: the MEI validator only speaks when
polled, and its internal escrow grace expires after ~5 seconds. With
POLLING_INTERVAL=10_000 the host learned about an escrowed bill *after*
the MEI had already autonomously begun returning it, which on BATM3
hardware can shear the bill between the transport rollers and the
mouth (see torn $20 reported by operator).
Drop the interval to 100ms to match id003's cadence — so we see
escrow and issue stack/reject inside the validator's grace window.
This is the production-critical fix; observability + escrow watchdog
follow in subsequent commits.
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>
When a relay reconnects after a disconnect, all active subscriptions
(including Lightning.Pub RPC listener) are now re-established on the
new relay instance. Previously subscriptions were lost permanently.
Also publishes availability broadcast immediately on reconnect instead
of waiting up to 5 minutes for the next heartbeat.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously the relay reconnect logic gave up after 5 attempts (~31s).
If the relay was down longer, the ATM permanently lost connectivity
and couldn't fetch available balance — causing all bills to be
rejected after the recent safety guard change.
Now reconnects indefinitely with exponential backoff capped at 60s.
Attempts reset on successful connection.
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>
Addresses security audit findings for the operator command channel:
1. Replay protection: track processed management event IDs in a Set,
reject duplicates. Caps at 1000 entries to prevent unbounded growth.
2. Timestamp validation: reject events created before machine startup
(prevents processing stale events on relay reconnect) and events
older than 60 seconds (limits replay window).
3. Input validation: validate bill denomination/count in
handleManagementCommand (defense in depth — IPC path also validates
but direct HAL path did not). Caps count at 100 per denomination.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a Nostr-native operator command channel using Kind 21003 (CLINK
Manage) events. Operators listed in OPERATOR_PUBKEYS can send encrypted
commands to the machine.
Phase 1 implements manual dispense: operator sends a dispense command,
machine verifies sender, checks it's idle, performs a direct HAL
dispense (bypassing state machine), and records the transaction.
When ref_txid is provided, the referenced failed transaction is updated
to status 'remediated', closing the loop on dispense errors.
Changes:
- CLINK types: add 'machine' resource, MachineDispenseRequest type
- CLINK client: support operator pubkey list (string | string[])
- Runtime config: VITE_OPERATOR_PUBKEYS env var
- Schema v4→v5: manual_dispense type, remediated_by column
- Lightning services: wire onManagement callback
- ATM store: handleManagementCommand with idle check + remediation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Port MEI CashFlow SC / BNR Advance EBDS protocol from lamassu-machine
to TypeScript HAL. Adds 'batm3' machine model preset (EBDS validator +
F56 dispenser). Fixes hardcoded 'id003' validator type in device config
overrides so model presets correctly propagate their validator type.
Co-Authored-By: Claude Opus 4.6 <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>