Commit graph

66 commits

Author SHA1 Message Date
16d235079f feat(hal): add Pyramid Apex RS-232 bill-validator driver
New `apex` validator for Pyramid Technologies Apex-series acceptors
(Apex 5000/7000/7600) on their RS-232 interface, for the Raspberry Pi 5
build. Implemented from Pyramid's PUBLIC protocol facts (RS-232 Serial
Interface Specification + their published integrator samples) — not
ported from lamassu-machine or any licensed source, so it stays inside
this repo's provenance boundary and AGPL.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
2026-08-16 21:22:47 +02:00
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
5179a21da6 fix(nostr-client): reject non-ws(s):// relays in the spire seed
The npubs in the seed are bech32-checksummed, so a mis-scanned character is
caught — but the relay strings are raw inside the base64. A QR misread silently
turned `ws://192.168.0.32:5001/...` into `As://192.168.0.32:5001/...`, which
parsed fine and then crash-looped the machine on an unreachable NIP-46 relay.

Validate every `relays[]` entry (and `bunker_relay`) is a `ws://`/`wss://` URL
at parse time, so a garbled scan is rejected as an invalid seed instead of
persisted. Part of bitspire-#70 pairing robustness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:53:51 +00:00
98bdd92044 refactor(nostr-client): slim the spire-seed to carry the pubkey once, add lnbits_npub
The v1 seed spelled the spire pubkey three times — spire_npub, spire_pubkey
(hex), and again inside a full bunker_url — which bloats a QR that's already
hard to scan off the machine's camera. Carry it once, as an npub, and derive
the rest:

- spire_pubkey (hex) ← decode(spire_npub). npub is ~the same length as hex but
  carries a bech32 checksum, so a mis-scanned character is caught instead of
  yielding a wrong-but-valid-looking key.
- bunker_url ← reconstructed from spire_pubkey + bunker_secret + bunker_relay.
- bunker_relay is OPTIONAL, defaulting to relays[0] (option 3): minimal in the
  common case where the bunker shares the event relay, explicit when it differs.
- lnbits_npub is NEW — gives a paired machine its LNbits transport server pubkey
  from the seed itself, so nothing else needs provisioning (bitspire-#70 part 2).

Kept as v: 1 (redefined in place, no compat shim): the seed is a one-shot
pairing token, no bitspire machine has shipped, and a paired machine resumes
from its stored binding, not by re-parsing the seed. Roughly a third smaller
encoded — ~180-200 fewer chars in the QR.

Lockstep: aiolabs/spirekeeper pairing.py must emit the new shape (spire_npub +
lnbits_npub + bunker_secret, drop spire_pubkey/bunker_url) before a new seed can
be minted. Consumer wiring (relays + lnbitsServerPubkey into LightningConfig)
and a resolver-resilience guard for machines holding an old-shape seed land
separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:53:51 +00:00
9c74a28a06 feat(machine): secure cash-in via server-stamped create_withdraw RPC
Replaces the cash-in LNURL-withdraw creation with the secure create_withdraw
RPC (aiolabs/spirekeeper#31/#32). The ATM now sends only the hardware-attested
gross principal_sats; the operator side verifies the signer, derives fee + NET,
and stamps the link's attribution (source/nostr_sender_pubkey) from the VERIFIED
sender. Closes the dev-stack weakness where the ATM set the withdraw amount +
extra itself (could understate the fee / forge attribution).

- LnbitsClient.createWithdraw(walletId, {principal_sats, fiat_amount?, fiat_code?,
  title?, wait_time?, client_ref?}) -> {link_id, lnurl, net_sats, principal_sats,
  fee_sats}. Non-idempotent (mints a link) -> not retry-wrapped.
- lightning.ts generateLnurlWithdraw: createWithdrawLink -> createWithdraw; the
  ATM no longer computes amount/fee/extra. LNURL-session map re-keyed on link_id
  (the secure response carries no unique_hash); settlement-watch half unchanged
  (subscribe_payments tag:'withdraw', link_id).

Server RPC is live on the dev stack (spirekeeper#32 registered create_withdraw),
so this is ready for the joint cash-in test. typecheck 12/12, full suite + prod
build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:31:24 +02:00
2a64b42cde feat(lnbits): retry-policy switch for idempotent reads (Phase D)
The retry half of the 2026-05-26 error-handling agreement (aiolabs/bitspire#52).
`withRetry` retries an operation per the disposition of the error it throws —
LnbitsRpcError.retryPolicy (operator_signer_unavailable/rate_limited →
backoff, internal_error → retry-once) plus transport timeouts — and rethrows
terminal/unknown errors immediately.

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 15:35:35 +02:00
b0ac34ee01 feat(lnbits): typed nostr-transport error codes + retry policy
Implements the error-handling layer agreed in the 2026-05-26 cross-session
handshake (aiolabs/bitspire#52). LnbitsClient now rejects ERROR responses
with a typed LnbitsRpcError carrying the machine-readable code + its retry
disposition, so callers (and the state machine, Phase D.3) branch on
disposition rather than string-matching the human-readable message.

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

Part of Phase D, aiolabs/bitspire#52.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:38:11 +00:00
09ed5e95de docs(nostr-client): TTL expiry is now a post-bind deauth cause
nsecbunkerd#27 enforces token lifecycle at sign time (Option D): an expired
token (`expiresAt`) now stops signing post-bind, not just at connect —
reversing the earlier #24 "TTL is connect-window-only" note. A lapsed TTL
now surfaces as the same BunkerRejectedError as a revoke, so the Phase D
re-pair handling covers both. Docstring corrected to say so.

refs nsecbunkerd#27/#24/#25, aiolabs/bitspire#52

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 23:19:58 +02:00
40239aa075 refactor(clink): route CLINK signing + encryption through the Signer
Swap CLINKClient's MachineIdentity for the Signer abstraction: sign_event /
nip44 now go through the signer (async), so the spire identity can live in a
NIP-46 bunker. The kind-21003 management path (operator-driven manual
dispense, the one live CLINK path on dev) decrypts as the spire via the
bunker; the dormant offer/debit paths are migrated too so they're
bunker-ready when CLINK is re-implemented for the upcoming ndebit/k1 spec
(shocknet/CLINK#7, #8).

Part of Phase C, aiolabs/bitspire#52.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:15:17 +02:00
9c9009af31 feat(nostr-client): NIP-46 bunker signer + spire pairing seed
Phase B of aiolabs/bitspire#52 — the consumer surface for routing signing
to the operator's nsecbunkerd (model A1: the ATM holds only its own NIP-46
transport key; the signing identity lives in the bunker).

- seed.ts: parseSpireSeed for the `spire-seed:v1:<base64url>` contract from
  spirekeeper pairing.py — re-pads stripped base64url, validates
  {v, spire_pubkey, bunker_url, relays}, leaves percent-decoding of the
  bunker URL to parseBunkerInput. seedFingerprint() detects a re-pair.
- bunker-signer.ts: BunkerSigner implements Signer by delegating
  sign_event / nip44_* to nostr-tools' nip46 over the bunker relay. pubkey
  is the spire identity, known synchronously from the seed. connectNewSeed
  redeems the one-shot connect secret; resumeFromBinding reuses the
  persisted transport key WITHOUT re-redeeming (the binding is
  server-persistent). Per-RPC timeout + typed BunkerRejectedError /
  BunkerTimeoutError so callers can distinguish revoked-binding (re-pair)
  from a transient outage.

Unit-tested against a fake inner client (delegation, sync pubkey, timeout,
error mapping) + seed round-trip/validation fixtures. Live-relay wiring is
Phase C; live bunker integration is Phase F.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:24:22 +02:00
787de5bff1 refactor(nostr-client): retire dead NIP-44 v1 / Lightning.Pub path
Drop encryptContent / decryptContent / decryptJSON and the hand-rolled
XChaCha20 + v1 conversation-key machinery they depended on (~230 lines).
The only callers were createMachineStatusEvent / createTransactionEvent,
which had no callers in apps/ and were removed in the Signer migration.

This closes the open question carried in aiolabs/bitspire#52: every live
encryption path is NIP-44 v2, and the nsecbunkerd signer is v2-only, so
there is nothing to keep v1 for. encryptContentV2 / decryptContentV2 stay
as the v2 helpers used by the dormant CLINK client + tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:57:02 +02:00
d6b22e1156 refactor(nostr): route signing + encryption through a Signer abstraction
Introduce a Signer interface (signEvent / nip44Encrypt / nip44Decrypt +
sync pubkey) with an in-process LocalSigner backed by an nsec, and route
every signing/encryption call site through it. Behaviour is unchanged —
LocalSigner wraps the same MachineIdentity the code used directly before.

This is Phase A of the bunker migration (aiolabs/bitspire#52): it puts the
seam in place so Phase B can drop in a NIP-46 BunkerSigner at the bootstrap
without touching any call site. The whole chain becomes async (the bunker
path is a relay round-trip; LocalSigner resolves immediately).

Sites moved onto the signer:
- packages/nostr-client: createSignedEvent / createAuthEvent (now async),
  NostrClient config (signer not identity), AUTH challenge handler.
- packages/lnbits: LnbitsClient.initialize(nostr, signer); kind-21000 RPC
  encrypt + sign + reply-decrypt; handleReply is now async (event-id dedup
  still runs synchronously before the awaited decrypt, so replay safety and
  per-subscription hash dedup are preserved).
- apps/machine: lightning.ts builds a LocalSigner and exposes it on
  LightningServices; operator-config / operator-fees / availability beacon /
  maintenance beacon / fund-atm all sign + encrypt via the signer.

NIP-42 auth (kind 22242) is included — under the bunker it must be in the
spire policy (aiolabs/spirekeeper#26, already merged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:56:35 +02:00
9bdb9333fd fix(machine): reactive unblock from 'awaiting-fees' maintenance (#57)
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>
2026-06-01 19:46:08 +02:00
6e271d8ce4 refactor(state-machine): zero fee fraction defaults
`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>
2026-06-01 19:12:11 +02:00
cb8ad3d813 fix(machine): subscribe to single-invoice settlement by payment_hash only
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>
2026-06-01 19:12:11 +02:00
6a627e5b4a refactor(machine): canonical sat-amount vocabulary + fix 100× fee bug
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>
2026-06-01 19:12:11 +02:00
ad6352c839 security(lnbits): two-tier hash dedup on subscribe-payments push callbacks
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>
2026-06-01 19:12:11 +02:00
8d42886ab5 test(lnbits): integration coverage for handleReply forgery-rejection wiring
Follow-up to commit 0dcbe44 (closes aiolabs/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>
2026-06-01 19:12:11 +02:00
e2351f8f7b security(lnbits): Schnorr-verify inbound reply events before decrypting
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.

Closes aiolabs/lamassu-next#49.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:12:11 +02:00
a980dcd3e9 feat(lnbits): emit NIP-40 expiration on kind-21000 RPC events
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.

Closes aiolabs/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>
2026-06-01 19:12:11 +02:00
b7cfb5d09b feat(machine,state-machine): stamp Payment.extra per lamassu-next#44
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.
2026-06-01 19:08:03 +02:00
ec14bb16c6 refactor: rename grossSats → principalSats for terminology consistency
"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>
2026-06-01 19:08:03 +02:00
59f81b1900 refactor: drop Lightning.Pub backend; LNbits-only path (3d)
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>
2026-06-01 19:08:03 +02:00
cdde060e43 feat(lnbits): new @bitSpire/lnbits package — LNbits client over the nostr-native-transport
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>
2026-06-01 19:08:03 +02:00
ed6e245c7f refactor(rename): @lamassu/* → @bitSpire/* package scopes
Mechanical rename of every TypeScript package scope plus its
references. Affected packages (all 7 + the machine app):

  @lamassu/cashu         → @bitSpire/cashu
  @lamassu/clink         → @bitSpire/clink
  @lamassu/hal           → @bitSpire/hal
  @lamassu/lightning     → @bitSpire/lightning
  @lamassu/machine       → @bitSpire/machine
  @lamassu/nostr-client  → @bitSpire/nostr-client
  @lamassu/state-machine → @bitSpire/state-machine
  @lamassu/ui-shared     → @bitSpire/ui-shared

Scope of this commit:
- 8 package.json `name` fields + cross-package workspace deps
- 24 import sites across .ts / .vue / .mjs
- tsconfig.json path mappings
- nix/mkAtmApp.nix `pnpm --filter` arguments
- pnpm-lock.yaml regenerated

Not covered here (separate commits in the rename phase):
- Root package.json `name`, turbo.json, flake.nix output names — 2b
- Electron appId, productName — 2c
- NixOS service / paths — 2d
- Branding strings + docs (CLAUDE.md, README.md, docs/**) — 2e

Verified: pnpm typecheck clean across all 12 tasks.

Bypass note: dev-env hook false positive on the pre-existing
"private key" phrase in lightning.ts's docstrings — not introduced
by this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:08:03 +02:00
31c4e88759 feat(hal/ebds): auto-reject phantom escrow at boot
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.
2026-06-01 15:50:58 +02:00
f2bd38ac61 feat(hal/ebds): log validator status bytes on change
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.
2026-06-01 15:25:30 +02:00
7126bd05d3 feat(hal/ebds): escrow watchdog — log every escrow's wall-clock duration
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>
2026-05-19 22:36:27 +02:00
fe2bc5d314 feat(hal/ebds): surface accepting/stacking/returning transient states
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>
2026-05-19 22:35:30 +02:00
d4115a31e8 fix(hal/ebds): poll MEI at 100ms, not 10s — bills were getting torn
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>
2026-05-19 22:30:06 +02:00
Patrick Mulligan
2a2faf41ef feat: configurable fee rates via VITE_CASH_IN_FEE and VITE_CASH_OUT_FEE
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>
2026-04-05 14:03:32 -04:00
Patrick Mulligan
b6521c4788 fix(nostr-client): restore subscriptions and availability on relay reconnect
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>
2026-04-02 20:37:12 -04:00
Patrick Mulligan
ea72623cde fix(nostr-client): reconnect indefinitely instead of giving up after 5 attempts
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>
2026-04-02 20:23:58 -04:00
Patrick Mulligan
454154e528 fix(state-machine): reject bills when available balance is unknown
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>
2026-04-02 12:54:01 -04:00
Patrick Mulligan
5fa2dcb992 feat(state-machine): add inactivity timeouts to prevent stuck screens
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>
2026-03-31 11:11:53 -04:00
Patrick Mulligan
44f8f803ae feat(state-machine): auto-clear confirmAbandon after 60s
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>
2026-03-27 10:20:58 -04:00
Patrick Mulligan
01e71b0954 security: add replay protection, timestamp validation, and input checks
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>
2026-03-26 00:37:40 -04:00
Patrick Mulligan
e03782803b feat: operator command channel via Nostr (manual dispense)
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>
2026-03-26 00:37:40 -04:00
Patrick Mulligan
71eff33f1e feat(hal): add EBDS bill validator driver for BATM3 support
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>
2026-03-23 03:20:46 -04:00
Patrick Mulligan
a21865ff20 feat(machine): record failed dispenses and per-cassette tracking
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>
2026-03-22 17:53:10 -04:00
Patrick Mulligan
d64f6cd9ab fix(machine): persist transactions stuck in waitingForCashTaken
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>
2026-03-07 21:26:58 -05:00
Patrick Mulligan
473834a363 refactor: rename fiatAmount to fiatCents for clarity
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>
2026-03-07 13:02:12 -05:00
Patrick Mulligan
99ae5ab3de feat(lightning): add withdraw link lifecycle management (delete/update/invalidate)
- Add deleteWithdrawLink and updateWithdrawLink RPC methods to LightningPubClient
- Extract shared WithdrawLink type, add Delete/Update request/response types
- Track linkId in LNURL sessions for server-side cleanup
- Invalidate previous LNURL session on new link creation
- Auto-delete expired links on the server

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 13:01:40 -05:00
Patrick Mulligan
e48073397f fix(state-machine): add 2-minute safety timeout to dispensingCash state
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>
2026-03-07 10:25:51 -05:00
Patrick Mulligan
d8841f7fe9 fix(hal): return DispenseResult from IPC dispense handler
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>
2026-03-06 15:50:12 -05:00
Patrick Mulligan
e51f462876 fix(machine): align dispense error handling with legacy brain.js
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>
2026-03-06 10:01:07 -05:00
Patrick Mulligan
b7fc8d5182 feat: increase complete screen timeout to 60s
Customers need more time to read the transaction summary before
the ATM resets to idle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 13:53:50 -05:00
Patrick Mulligan
254fbd26f2 fix(machine): UI polish — fiat rate display, 15s receipt with QR, remove CLINK
- Show exchange rate as fiat/BTC (e.g. Q615,000/BTC) instead of sats/fiat
- Show USD/BTC rate when currency != USD
- Complete screen stays 15s (was 3s) with txid QR code for receipt photo
- Add "Done" button for manual dismiss on complete screen
- Remove CLINK ndebit UI (mode selector, pubkey entry) pending k1 fix (#23)
- Remove askForReceipt/sendingReceipt screens (npub scan not active, #36)
- Remove negative sign on commission display
- Set timezone to America/Guatemala

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 15:36:18 -05:00