diff --git a/CLAUDE.md b/CLAUDE.md index 1b2e8a0..2cc2db7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Guidance for Claude Code when working in this repo. Read this before touching co ## Project Overview -**bitSpire** is a Nostr-native Lightning ATM. Production ATMs (`batm3`, `douro`) currently run from `main` against Lightning.Pub; the `dev` branch — which is what this file describes — has been migrated to **LNbits over the nostr-native-transport**. +**bitSpire** is a Nostr-native Lightning ATM running **LNbits over the nostr-native-transport**. The `dev` branch, which this file describes, is what the machines run. Core principles: @@ -25,8 +25,53 @@ bitSpire is an independent project under AGPL-3.0 and is not affiliated with Lam ## Branch model -- `main` — production. Lightning.Pub backend. The two production ATMs auto-pull from here daily at 04:00 (`flake.nix:152-160`). **DO NOT** push to `main` casually — a wrong commit gets baked into prod ATMs the next morning. -- `dev` — staging. LNbits backend. The Sintra dev unit auto-pulls from here (`?ref=dev` pin on this branch's `flake.nix`). Push freely; tag `pre-bitspire-cutover` is the rollback target if the migration ever needs to be reverted on prod. +- `dev` — **what every live machine runs.** Not a staging branch any more. Verified + 2026-09-24 on batm3, whose `nixos-upgrade` unit pulls + `git+ssh://…/bitspire.git?ref=dev#batm3-installed` daily at 04:00. "Push freely + to dev" is no longer safe advice: a bad commit reaches production hardware the + next morning, unattended. +- `main` — Lightning.Pub era, historical. Tag `pre-bitspire-cutover` is the + rollback target if the migration ever has to be reverted. + +> This section previously said the production ATMs ran `main` against +> Lightning.Pub and that only Sintra was on `dev`. That was stale and it was +> repeatedly taken at face value. Check the machine, not this file, before +> relying on which stack a given box runs: `systemctl cat nixos-upgrade` gives +> the branch, `/var/lib/bitspire` vs `/var/lib/lamassu-atm` gives the era. + +### Fleet state (surveyed 2026-09-24) + +| Machine | Reachable | Stack | GPU | Notes | +|---|---|---|---|---| +| `sintra` | LAN `192.168.0.252` | dev / LNbits | Braswell `8086:22b0` → crocus | dev unit; ethernet `r8169` | +| `batm3` | wg `10.0.0.5` | dev / LNbits | Haswell GT2 `8086:0412` → crocus | **networks over WiFi**, `iwlwifi` 7260; ethernet down | +| `douro` | **down** | — | Bay Trail (Gen7) | needs reflashing with the current image and reconnecting to WireGuard | +| `tejo` | wg `10.0.0.3` | **Debian** (`ubilinux4`, kernel 4.9) | Braswell `8086:22b0` | never had bitspire installed; a flake target, not a deployment | + +Two consequences worth holding onto. Every GPU in the fleet binds **crocus**, not +iris — sintra's Braswell does so despite being Gen8. And batm3's only working +network path is Intel WiFi, so `intel/iwlwifi` firmware is load-bearing there; +trimming it would strand the machine with no way back in. + +### batm3's nightly upgrade is currently FAILING + +Confirmed 2026-09-24. The run dies at: + +``` +04:03:26 building '…-bitspire-atm-app-0.1.0.drv'... +04:04:28 error: timed out after 60 seconds +``` + +The ATM app is built in-house and is **not in `aiolabs.cachix.org` or +`cache.nixos.org`**, so batm3 has to build it locally, and `nix.settings.timeout += 60` in `flake.nix` kills it. The comment there assumes heavy derivations are +"effectively cache-only … upstream-cached", which is true of nixpkgs and false of +our own app. + +So the machine is pinned to whatever generation last succeeded, and nothing +merged to `dev` reaches it. This is the same class of silent-updater failure as +#98, in a new form. The fix is pushing `atm-app-*` to the aiolabs cachix as part +of releasing, not raising the timeout — a 60s ceiling on ATM hardware is correct. ## Architecture @@ -219,7 +264,8 @@ UP Board enumerates its eMMC controller via ACPI, not PCI. `upboard.nix` force-l ## Useful invariants when debugging - The renderer logs prefix every line with a tag: `[Lightning]`, `[ATM]`, `[ATM Service]`, `[LNURL Session]`, `[CLINK]`, `[StateStore]`. `journalctl -u bitspire | grep '\['` is your friend. -- `bitspire.service` runs as the `lamassu` user; `/var/lib/bitspire` is its `dataDir` (ReadWritePaths). DB lives at `/var/lib/bitspire/state.db` (we previously had `/var/lib/lamassu-atm` — that path is gone on dev, see commit `9c455d6`). +- **Never pass an object as a console argument in the renderer.** Electron's console bridge stringifies each argument, so `console.log('msg:', { a, b })` reaches the journal as `msg: [object Object]` and every field is lost. Interpolate instead. Cost a debugging session on 2026-09-23, when a cassette publish that had worked looked like it had done nothing. +- `bitspire.service` runs as the `bitspire` user (verified on sintra 2026-09-24; this line used to say `lamassu`, left over from the rename in `46e52f6`); `/var/lib/bitspire` is its `dataDir` (ReadWritePaths). DB lives at `/var/lib/bitspire/state.db` (we previously had `/var/lib/lamassu-atm` — that path is gone on dev, see commit `9c455d6`). - The `lightning.lightningPub` field on `LightningServices` is a `LightningBackend` *adapter*, not a `LightningPubClient`. Don't try to call LP-only methods on it. ## Related documentation diff --git a/apps/machine/.env.example b/apps/machine/.env.example index ec66f81..153066b 100644 --- a/apps/machine/.env.example +++ b/apps/machine/.env.example @@ -93,3 +93,31 @@ VITE_SPIRE_SEED= # Set to 'true' for development/demo environments only # When false (production default), initialization failures show a maintenance screen # VITE_ALLOW_MOCK_FALLBACK=true + +# ============================================================================= +# Access Control (ADR-003) +# ============================================================================= + +# Tap-to-enter gate. When disabled (default), the machine boots straight to +# idle exactly as before. When enabled, it boots into a locked screen and a +# Bolt Card tap (read by the main-process NFC service over pcscd) unlocks it +# and loads the card for the session, so buy/sell finish with one Complete. +# ACCESS_CONTROL_ENABLED=true + +# Admit ANY Bolt Card when the allow-list has no match. With this on the gate +# only keeps casual users off the menu — any NDEF tag with a /scan/ URL +# unlocks it; money still moves only on a valid SUN at Complete. Turn OFF once +# a real allow-list (/var/lib/bitspire/access.json) is provisioned. +# ACCESS_OPEN_ENROLLMENT=true + +# Show the on-screen runtime dev/operator unlock button on the locked screen. +# Default OFF — it bypasses the gate, so enable only on a bench/dev machine. +# ACCESS_DEV_UNLOCK=true + +# Per-machine salt for hashing credentials/PINs. Provision a real value in +# production (or in access.json); a fixed default is used if unset. +# ACCESS_SALT=change-me-per-machine + +# Build/dev bypass — forces the gate OPEN even when enabled (browser dev / CI). +# Renderer-side (Vite) flag, never set in a production image. +# VITE_SKIP_ACCESS_GATE=true diff --git a/apps/machine/electron/__tests__/state-store-transactions.test.ts b/apps/machine/electron/__tests__/state-store-transactions.test.ts index fbb9a87..02dd87e 100644 --- a/apps/machine/electron/__tests__/state-store-transactions.test.ts +++ b/apps/machine/electron/__tests__/state-store-transactions.test.ts @@ -12,10 +12,16 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { + applyOperatorCassetteOps, closeDatabase, + getAppliedOpIds, + getCassetteStateSeq, getCashbox, + getCountsUncertainSince, + getInventory, initDatabase, loadCassettes, + markCountsUncertain, recordTransaction, setCassettes, } from '../state-store.js' @@ -168,3 +174,322 @@ describe('state-store: recordTransaction cash_in cashbox', () => { expect(countsByPosition()).toEqual({ 1: 50, 2: 50, 3: 30 }) }) }) + +describe('state-store: recordTransaction manual_dispense inventory (#76)', () => { + it('decrements the bays an operator remediation actually emptied', () => { + recordTransaction({ + ...TX_BASE, + txid: 'tx-manual', + type: 'manual_dispense', + status: 'complete', + bills: [{ denomination: 20, count: 2 }], + cassettes: [ + { + name: 'cassette1', + position: 1, + denomination: 20, + provisioned: 2, + dispensed: 2, + rejected: 0, + }, + ], + }) + // Bills physically left bay 1; before #76 this row was untouched and the + // inflated count became truth on the next boot. + expect(countsByPosition()).toEqual({ 1: 48, 2: 50, 3: 30 }) + }) + + it('decrements again when remediating a partly-dispensed cash-out', () => { + // Original cash-out managed 1 of the 2 notes it provisioned. + recordTransaction({ + ...TX_BASE, + txid: 'tx-partial', + type: 'cash_out', + status: 'partial', + bills: [{ denomination: 50, count: 1 }], + cassettes: [ + { + name: 'cassette3', + position: 3, + denomination: 50, + provisioned: 2, + dispensed: 1, + rejected: 0, + }, + ], + }) + expect(countsByPosition()[3]).toBe(29) + + // The operator dispenses the missing note by hand. That is a second lot of + // bills leaving the bay, so it debits again — the original only ever + // debited what physically left. + recordTransaction({ + ...TX_BASE, + txid: 'tx-remediate', + type: 'manual_dispense', + status: 'complete', + bills: [{ denomination: 50, count: 1 }], + cassettes: [ + { + name: 'cassette3', + position: 3, + denomination: 50, + provisioned: 1, + dispensed: 1, + rejected: 0, + }, + ], + }) + expect(countsByPosition()[3]).toBe(28) + }) + + it('leaves the cashbox alone (bills leave, they do not arrive)', () => { + const before = getCashbox() + recordTransaction({ + ...TX_BASE, + txid: 'tx-manual-cashbox', + type: 'manual_dispense', + status: 'complete', + bills: [{ denomination: 20, count: 1 }], + cassettes: [ + { + name: 'cassette2', + position: 2, + denomination: 20, + provisioned: 1, + dispensed: 1, + rejected: 0, + }, + ], + }) + expect(getCashbox()).toEqual(before) + expect(countsByPosition()[2]).toBe(49) + }) +}) + +describe('state-store: getInventory represents a drained machine', () => { + it('keeps configured bays at zero rather than dropping them', () => { + recordTransaction({ + ...TX_BASE, + txid: 'tx-drain-50s', + type: 'cash_out', + status: 'complete', + bills: [{ denomination: 50, count: 30 }], + cassettes: [ + { + name: 'cassette3', + position: 3, + denomination: 50, + provisioned: 30, + dispensed: 30, + rejected: 0, + }, + ], + }) + // The $50 bay is empty but still configured. Dropping the key made this + // look like "no inventory known", and callers then fell back to a stale + // snapshot or to HAL. + expect(getInventory()).toEqual({ 20: 100, 50: 0 }) + }) + + it('reports every bay at zero when the machine is fully drained', () => { + for (const [txid, position, denomination, count] of [ + ['d1', 1, 20, 50], + ['d2', 2, 20, 50], + ['d3', 3, 50, 30], + ] as const) { + recordTransaction({ + ...TX_BASE, + txid, + type: 'cash_out', + status: 'complete', + bills: [{ denomination, count }], + cassettes: [ + { + name: `cassette${position}`, + position, + denomination, + provisioned: count, + dispensed: count, + rejected: 0, + }, + ], + }) + } + expect(getInventory()).toEqual({ 20: 0, 50: 0 }) + }) + + it('returns an empty map only when no cassettes are configured', () => { + // A fresh DB with no bays at all — the one case that should read as + // "nothing known", so callers may legitimately defer to the hardware. + closeDatabase() + initDatabase(':memory:') + expect(getInventory()).toEqual({}) + }) +}) + +describe('state-store: unverified counts after a silent dispense', () => { + it('starts clear, latches the first time, and keeps the earliest time', () => { + expect(getCountsUncertainSince()).toBeNull() + markCountsUncertain(1000) + expect(getCountsUncertainSince()).toBe(1000) + // A second failure does not move the clock forward — the question is how + // long the numbers have been untrustworthy, not when we last noticed. + markCountsUncertain(2000) + expect(getCountsUncertainSince()).toBe(1000) + }) + + it('clears on a recount, because that is what a recount is', () => { + markCountsUncertain(1000) + const result = applyOperatorCassetteOps([ + { id: 'op-1', at: 1_700_000_000, type: 'recount', position: 1, count: 40 }, + ]) + expect(result.applied).toEqual(['op-1']) + expect(getCountsUncertainSince()).toBeNull() + }) + + it('does not clear on a refill', () => { + // A refill adds to a number still known to be wrong. Only someone + // opening the bay and counting it resolves that. + markCountsUncertain(1000) + const result = applyOperatorCassetteOps([ + { id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 10 }, + ]) + expect(result.applied).toEqual(['op-1']) + expect(getCountsUncertainSince()).toBe(1000) + }) + + it('leaves the flag alone when the op is rejected', () => { + markCountsUncertain(1000) + // Bay 9 does not exist — the layout is hardware-determined. + const result = applyOperatorCassetteOps([ + { id: 'op-1', at: 1_700_000_000, type: 'recount', position: 9, count: 40 }, + ]) + expect(result.applied).toEqual([]) + expect(result.rejected).toHaveLength(1) + expect(getCountsUncertainSince()).toBe(1000) + }) +}) + +describe('state-store: operator cassette operations (ADR-004)', () => { + beforeEach(() => { + seedDuplicateDenomBays() + }) + + it('applies a refill as a delta, not a total', () => { + applyOperatorCassetteOps([ + { id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 30 }, + ]) + expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(80) + }) + + it('is a no-op on a re-delivered operation', () => { + // Addressable events are re-delivered on every relay reconnect and the + // operator republishes a WINDOW, so the same op arrives many times. A + // delta applied twice is simply wrong, which is why every op carries an + // id and this table records the ones already applied. + const op = { + id: 'op-1', + at: 1_700_000_000, + type: 'refill' as const, + position: 1, + bills: 30, + } + expect(applyOperatorCassetteOps([op]).applied).toEqual(['op-1']) + expect(applyOperatorCassetteOps([op]).applied).toEqual([]) + expect(applyOperatorCassetteOps([op, op]).applied).toEqual([]) + expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(80) + }) + + it('applies only the unseen ops from a window that mixes both', () => { + applyOperatorCassetteOps([ + { id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 10 }, + ]) + const result = applyOperatorCassetteOps([ + { id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 10 }, + { id: 'op-2', at: 1_700_000_001, type: 'refill', position: 1, bills: 5 }, + ]) + expect(result.applied).toEqual(['op-2']) + expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(65) + }) + + it('applies a window oldest-first regardless of arrival order', () => { + // A recount then a refill is not the same as the reverse, so ordering is + // load-bearing and cannot be left to however the array arrived. + applyOperatorCassetteOps([ + { id: 'op-b', at: 1_700_000_002, type: 'refill', position: 1, bills: 7 }, + { id: 'op-a', at: 1_700_000_001, type: 'recount', position: 1, count: 3 }, + ]) + expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(10) + }) + + it('empties a bay and sets a denomination', () => { + applyOperatorCassetteOps([ + { id: 'op-1', at: 1_700_000_000, type: 'empty', position: 2 }, + { id: 'op-2', at: 1_700_000_001, type: 'set_denomination', position: 2, denomination: 10 }, + ]) + const bay = loadCassettes().find((c) => c.position === 2)! + expect(bay.count).toBe(0) + expect(bay.denomination).toBe(10) + }) + + it('rejects a malformed op without applying or recording it', () => { + // Unrecorded on purpose: it stays pending on the operator's dashboard, + // which is the honest outcome. Recording it as applied would silence the + // noise by telling the operator their refill landed. + const result = applyOperatorCassetteOps([ + { id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: -5 }, + ]) + expect(result.applied).toEqual([]) + expect(result.rejected[0]!.id).toBe('op-1') + expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(50) + expect(getAppliedOpIds()).not.toContain('op-1') + }) + + it('echoes applied ids back, newest first', () => { + applyOperatorCassetteOps([ + { id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 1 }, + { id: 'op-2', at: 1_700_000_001, type: 'refill', position: 1, bills: 1 }, + ]) + expect(getAppliedOpIds()).toContain('op-1') + expect(getAppliedOpIds()).toContain('op-2') + }) + + it('advances the sequence on an applied op but not on a duplicate', () => { + const op = { + id: 'op-1', + at: 1_700_000_000, + type: 'refill' as const, + position: 1, + bills: 1, + } + const before = getCassetteStateSeq() + applyOperatorCassetteOps([op]) + const after = getCassetteStateSeq() + expect(after).toBeGreaterThan(before) + applyOperatorCassetteOps([op]) + expect(getCassetteStateSeq()).toBe(after) + }) + + it('advances the sequence on a dispense', () => { + const before = getCassetteStateSeq() + recordTransaction({ + ...TX_BASE, + txid: 'tx-seq', + type: 'cash_out', + status: 'complete', + bills: [{ denomination: 20, count: 1 }], + cassettes: [ + { + name: 'cassette1', + position: 1, + denomination: 20, + provisioned: 1, + dispensed: 1, + rejected: 0, + }, + ], + }) + expect(getCassetteStateSeq()).toBeGreaterThan(before) + }) +}) diff --git a/apps/machine/electron/boltcard-session.test.ts b/apps/machine/electron/boltcard-session.test.ts new file mode 100644 index 0000000..58e30f4 --- /dev/null +++ b/apps/machine/electron/boltcard-session.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi } from 'vitest' +import { openCardSession, scanUrlToSessionUrl } from './boltcard-session' + +const LNURLW = + 'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788' + +/** Mock fetch returning the given JSON bodies per call, in order (status 200). */ +function mockFetch(bodies: unknown[], status = 200) { + const calls: string[] = [] + const impl = vi.fn(async (url: string | URL) => { + calls.push(url.toString()) + const body = bodies[calls.length - 1] + return { status, json: async () => body } as Response + }) + return { impl: impl as unknown as typeof fetch, calls } +} + +const SESSION = { + authenticated: true, + external_id: 'abc123', + card_name: 'Alice', + balance_msat: 123_456_789, + currency: 'usd', + fiat: 98.76, + withdraw: { + callback: 'https://lnbits.l484.com/boltcards/api/v1/lnurl/cb/hit1', + k1: 'hit1', + minWithdrawable: 1000, + maxWithdrawable: 50_000_000, + }, + withdraw_blocked_reason: null, + pay: { + callback: 'https://lnbits.l484.com/boltcards/api/v1/pay/cb/hit1', + minSendable: 1000, + maxSendable: 50_000_000, + metadata: '[["text/plain","Bolt Card top-up"]]', + }, +} + +describe('scanUrlToSessionUrl', () => { + it('rewrites /scan/ to /session/ and preserves p + c', () => { + const u = scanUrlToSessionUrl(LNURLW) + expect(u).toContain('https://lnbits.l484.com/boltcards/api/v1/session/abc123') + expect(u).toContain('p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF') + expect(u).toContain('c=1122334455667788') + }) + it('returns null for a non-scan URL', () => { + expect(scanUrlToSessionUrl('lnurlw://host/somethingelse?p=1&c=2')).toBeNull() + expect(scanUrlToSessionUrl('http://host/boltcards/api/v1/scan/x')).toBeNull() + }) +}) + +describe('openCardSession', () => { + it('opens a session: balance in sats, upper-cased currency, both steps', async () => { + const f = mockFetch([SESSION]) + const out = await openCardSession(LNURLW, { fetchImpl: f.impl }) + expect(f.calls).toHaveLength(1) + expect(f.calls[0]).toContain('/session/abc123') + expect(out).toEqual({ + ok: true, + session: { + externalId: 'abc123', + cardName: 'Alice', + balanceSats: 123_456, + currency: 'USD', + fiat: 98.76, + withdraw: SESSION.withdraw, + withdrawBlockedReason: null, + pay: SESSION.pay, + }, + }) + }) + + it('carries a withheld withdraw step with its reason', async () => { + const f = mockFetch([ + { ...SESSION, withdraw: null, withdraw_blocked_reason: 'Max daily limit spent.' }, + ]) + const out = await openCardSession(LNURLW, { fetchImpl: f.impl }) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(out.session.withdraw).toBeNull() + expect(out.session.withdrawBlockedReason).toBe('Max daily limit spent.') + expect(out.session.pay.callback).toBe(SESSION.pay.callback) + }) + + it('has no fiat when the server sent no currency', async () => { + const f = mockFetch([{ ...SESSION, currency: null, fiat: null }]) + const out = await openCardSession(LNURLW, { fetchImpl: f.impl }) + expect(out.ok && out.session.currency).toBeNull() + expect(out.ok && out.session.fiat).toBeNull() + }) + + it('surfaces the server reason on a rejected tap', async () => { + const f = mockFetch([{ authenticated: false, reason: 'This link is already used.' }]) + const out = await openCardSession(LNURLW, { fetchImpl: f.impl }) + expect(out).toEqual({ ok: false, reason: 'This link is already used.' }) + }) + + it('rejects an incomplete session (no pay step)', async () => { + const f = mockFetch([{ ...SESSION, pay: undefined }]) + const out = await openCardSession(LNURLW, { fetchImpl: f.impl }) + expect(out).toEqual({ ok: false, reason: 'card server returned an incomplete session' }) + }) + + it('names an old card server that has no /session', async () => { + const f = mockFetch([{ detail: 'Not Found' }], 404) + const out = await openCardSession(LNURLW, { fetchImpl: f.impl }) + expect(out).toEqual({ ok: false, reason: 'card server does not support sessions' }) + }) + + it('rejects a non-card tag without a network call', async () => { + const f = mockFetch([]) + const out = await openCardSession('https://host/not/a/card', { fetchImpl: f.impl }) + expect(out.ok).toBe(false) + expect(f.calls).toHaveLength(0) + }) + + it('reports an unreachable card server', async () => { + const impl = vi.fn(async () => { + throw new TypeError('fetch failed') + }) as unknown as typeof fetch + const out = await openCardSession(LNURLW, { fetchImpl: impl }) + expect(out).toEqual({ ok: false, reason: 'could not reach the card: fetch failed' }) + }) +}) diff --git a/apps/machine/electron/boltcard-session.ts b/apps/machine/electron/boltcard-session.ts new file mode 100644 index 0000000..a799178 --- /dev/null +++ b/apps/machine/electron/boltcard-session.ts @@ -0,0 +1,167 @@ +/** + * Bolt Card session (tap-to-enter) — one tap, one verified visit. + * + * A Bolt Card tap yields a single-use SUN `p`/`c`; anything that verifies it + * spends it. The access gate (ADR-003) wants to verify the card at entry AND + * let the holder finish a buy or sell later without tapping again, so the + * aiolabs `boltcards` fork exposes `/session/?p=&c=` — a sibling + * of `/scan` and `/pay` that verifies once, records one hit, and returns: + * - the card wallet's balance and fiat equivalent (display only), + * - the LUD-03 second step (withdraw callback + k1 = hit) for cash-out, + * - the LUD-06 second step (pay callback) for cash-in. + * Both callbacks are keyed by the hit — the same single-use bearer `/scan` + * and `/pay` hand out — so the ATM holds no p/c for the rest of the visit. + * The withdraw step is withheld (with a reason) once the card's daily limit is + * spent, exactly as `/scan` would refuse. + * + * Runs in the MAIN process (Node fetch) to avoid renderer CORS, like the other + * LNURL modules. See docs/boltcard-session.md for the wire contract. + */ + +import { lnurlwToHttps, type WithdrawStep } from './lnurl-withdraw.js' +import type { PayStep } from './lnurl-pay.js' + +export interface CardSession { + externalId: string + cardName: string + balanceSats: number + /** ISO currency the card server priced the balance in; null → no fiat. */ + currency: string | null + /** Balance in `currency` at the card server's rate; null when unknown. */ + fiat: number | null + /** LUD-03 second step, or null when the card server withheld it. */ + withdraw: WithdrawStep | null + /** Why `withdraw` is null (e.g. daily limit spent); safe to show on-screen. */ + withdrawBlockedReason: string | null + /** LUD-06 second step for topping the card wallet up. */ + pay: PayStep +} + +export type OpenCardSessionResult = + | { ok: true; session: CardSession } + | { ok: false; reason: string } + +type FetchLike = typeof fetch + +export interface OpenCardSessionOptions { + /** Injected for tests; defaults to global fetch. */ + fetchImpl?: FetchLike + /** Per-request timeout (default 15s). */ + timeoutMs?: number +} + +/** + * Derive the session URL from a tapped card's `lnurlw`: the card presents + * `…/boltcards/api/v1/scan/?p=&c=`; the session endpoint is its sibling + * `…/boltcards/api/v1/session/?p=&c=` with the same SUN. + */ +export function scanUrlToSessionUrl(lnurlw: string): string | null { + const https = lnurlwToHttps(lnurlw) + if (!https) return null + const u = new URL(https) + if (!u.pathname.includes('/scan/')) return null + u.pathname = u.pathname.replace('/scan/', '/session/') + return u.toString() +} + +/** Wire shape of a `/session` reply (any of the fields may be missing/odd). */ +interface SessionWire { + authenticated?: unknown + reason?: unknown + external_id?: unknown + card_name?: unknown + balance_msat?: unknown + currency?: unknown + fiat?: unknown + withdraw?: unknown + withdraw_blocked_reason?: unknown + pay?: unknown +} + +const isObj = (v: unknown): v is Record => typeof v === 'object' && v !== null +const optNum = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined) +const optStr = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined) + +function parseWithdraw(v: unknown): WithdrawStep | null { + if (!isObj(v)) return null + const callback = optStr(v.callback) + const k1 = optStr(v.k1) + if (!callback || !k1) return null + return { + callback, + k1, + minWithdrawable: optNum(v.minWithdrawable), + maxWithdrawable: optNum(v.maxWithdrawable), + } +} + +function parsePay(v: unknown): PayStep | null { + if (!isObj(v)) return null + const callback = optStr(v.callback) + if (!callback) return null + return { + callback, + minSendable: optNum(v.minSendable), + maxSendable: optNum(v.maxSendable), + metadata: optStr(v.metadata), + } +} + +function errMsg(e: unknown): string { + if (e instanceof Error) + return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message + return String(e) +} + +/** + * Open a session for a tapped card. Spends the tap's SUN. Never throws — + * every failure returns `{ ok: false, reason }` (reasons come from the card + * server verbatim and are safe to show). + */ +export async function openCardSession( + lnurlw: string, + opts: OpenCardSessionOptions = {} +): Promise { + const doFetch = opts.fetchImpl ?? fetch + const timeoutMs = opts.timeoutMs ?? 15_000 + + const url = scanUrlToSessionUrl(lnurlw) + if (!url) return { ok: false, reason: 'not a valid Bolt Card (lnurlw) tag' } + + let wire: SessionWire + try { + const res = await doFetch(url, { signal: AbortSignal.timeout(timeoutMs) }) + if (res.status === 404) { + // Older fork without /session — say so rather than "card rejected". + return { ok: false, reason: 'card server does not support sessions' } + } + wire = (await res.json()) as SessionWire + } catch (e) { + return { ok: false, reason: `could not reach the card: ${errMsg(e)}` } + } + + if (wire.authenticated !== true) { + return { ok: false, reason: optStr(wire.reason) || 'card rejected the tap' } + } + const externalId = optStr(wire.external_id) + const pay = parsePay(wire.pay) + if (!externalId || !pay) { + return { ok: false, reason: 'card server returned an incomplete session' } + } + const balanceMsat = optNum(wire.balance_msat) ?? 0 + const currency = optStr(wire.currency)?.toUpperCase() ?? null + const fiat = optNum(wire.fiat) + return { + ok: true, + session: { + externalId, + cardName: optStr(wire.card_name) ?? '', + balanceSats: Math.floor(balanceMsat / 1000), + currency, + fiat: currency && fiat !== undefined ? fiat : null, + withdraw: parseWithdraw(wire.withdraw), + withdrawBlockedReason: optStr(wire.withdraw_blocked_reason) ?? null, + pay, + }, + } +} diff --git a/apps/machine/electron/lnurl-pay.test.ts b/apps/machine/electron/lnurl-pay.test.ts index 165688f..4dbe150 100644 --- a/apps/machine/electron/lnurl-pay.test.ts +++ b/apps/machine/electron/lnurl-pay.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi } from 'vitest' -import { resolveCardInvoice, scanUrlToResolver, lnAddressToLnurlp } from './lnurl-pay' +import { + resolveCardInvoice, + resolveInvoiceFromPayStep, + scanUrlToResolver, + lnAddressToLnurlp, +} from './lnurl-pay' const LNURLW = 'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788' @@ -118,3 +123,43 @@ describe('resolveCardInvoice', () => { expect(res.reason).toMatch(/could not reach the card/i) }) }) + +describe('resolveInvoiceFromPayStep (session second step, no tap)', () => { + const step = { + callback: 'https://lnbits.l484.com/boltcards/api/v1/pay/cb/hit1', + minSendable: 1000, + maxSendable: 50_000_000, + metadata: '[["text/plain","Bolt Card top-up"]]', + } + + it('fetches an invoice for the amount from the callback', async () => { + const f = mockFetch([{ pr: BOLT11 }]) + const out = await resolveInvoiceFromPayStep(step, 25_000, { fetchImpl: f.impl }) + expect(out).toEqual({ ok: true, bolt11: BOLT11 }) + expect(f.calls).toHaveLength(1) + expect(f.calls[0]).toContain('amount=25000') + }) + + it('enforces the step bounds without calling out', async () => { + const f = mockFetch([]) + expect(await resolveInvoiceFromPayStep(step, 500, { fetchImpl: f.impl })).toEqual({ + ok: false, + reason: 'amount is below the card wallet minimum', + }) + expect(await resolveInvoiceFromPayStep(step, 60_000_000, { fetchImpl: f.impl })).toEqual({ + ok: false, + reason: 'amount is above the card wallet maximum', + }) + expect(await resolveInvoiceFromPayStep(step, 0, { fetchImpl: f.impl })).toEqual({ + ok: false, + reason: 'no amount to send', + }) + expect(f.calls).toHaveLength(0) + }) + + it('surfaces a callback decline', async () => { + const f = mockFetch([{ status: 'ERROR', reason: 'Card is disabled.' }]) + const out = await resolveInvoiceFromPayStep(step, 25_000, { fetchImpl: f.impl }) + expect(out).toEqual({ ok: false, reason: 'Card is disabled.' }) + }) +}) diff --git a/apps/machine/electron/lnurl-pay.ts b/apps/machine/electron/lnurl-pay.ts index 91da90c..cb408e6 100644 --- a/apps/machine/electron/lnurl-pay.ts +++ b/apps/machine/electron/lnurl-pay.ts @@ -59,6 +59,17 @@ interface CardPayTarget { lnurl?: string } +/** + * The LUD-06 second step on its own: what a `payRequest` (or a Bolt Card + * session, see boltcard-session.ts) hands us to fetch an invoice. + */ +export interface PayStep { + callback: string + minSendable?: number + maxSendable?: number + metadata?: string +} + /** LUD-06 payRequest (subset) + error shape. */ interface PayRequest { tag?: string @@ -163,17 +174,18 @@ async function toPayRequest( } async function requestInvoice( - pr: PayRequest, + pr: PayStep, amountMsat: number, ctx: Ctx ): Promise { + if (!(amountMsat > 0)) return { ok: false, reason: 'no amount to send' } if (typeof pr.minSendable === 'number' && amountMsat < pr.minSendable) { return { ok: false, reason: 'amount is below the card wallet minimum' } } if (typeof pr.maxSendable === 'number' && amountMsat > pr.maxSendable) { return { ok: false, reason: 'amount is above the card wallet maximum' } } - const cbUrl = appendQuery(pr.callback!, { amount: String(amountMsat) }) + const cbUrl = appendQuery(pr.callback, { amount: String(amountMsat) }) let vals: PayValues try { const res = await ctx.doFetch(cbUrl, { signal: AbortSignal.timeout(ctx.timeoutMs) }) @@ -222,5 +234,19 @@ export async function resolveCardInvoice( if (!pr.ok) return pr // 3) Ask for an invoice for the payout amount. - return requestInvoice(pr.payRequest, amountMsat, ctx) + return requestInvoice({ ...pr.payRequest, callback: pr.payRequest.callback! }, amountMsat, ctx) +} + +/** + * The LUD-06 second step alone: fetch a BOLT11 for `amountMsat` from an + * already-obtained pay step (from a Bolt Card session opened at tap-to-enter). + * Never throws — every failure returns `{ ok: false, reason }`. + */ +export async function resolveInvoiceFromPayStep( + step: PayStep, + amountMsat: number, + opts: ResolveCardInvoiceOptions = {} +): Promise { + const ctx: Ctx = { doFetch: opts.fetchImpl ?? fetch, timeoutMs: opts.timeoutMs ?? 15_000 } + return requestInvoice(step, amountMsat, ctx) } diff --git a/apps/machine/electron/lnurl-withdraw.test.ts b/apps/machine/electron/lnurl-withdraw.test.ts index 4d14611..cceda64 100644 --- a/apps/machine/electron/lnurl-withdraw.test.ts +++ b/apps/machine/electron/lnurl-withdraw.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest' -import { executeLnurlWithdraw, lnurlwToHttps } from './lnurl-withdraw' +import { executeLnurlWithdraw, executeWithdrawCallback, lnurlwToHttps } from './lnurl-withdraw' const BOLT11 = 'lnbc10u1p3xyz...' const LNURLW = @@ -101,3 +101,44 @@ describe('executeLnurlWithdraw', () => { expect(res.reason).toMatch(/could not reach the card/i) }) }) + +describe('executeWithdrawCallback (session second step, no tap)', () => { + const step = { + callback: 'https://lnbits.l484.com/boltcards/api/v1/lnurl/cb/hit1', + k1: 'hit1', + maxWithdrawable: 5_000_000, + } + + it('hands the invoice straight to the callback with k1', async () => { + const f = mockFetch([{ status: 'OK' }]) + const out = await executeWithdrawCallback(step, BOLT11, { fetchImpl: f.impl }) + expect(out).toEqual({ ok: true }) + expect(f.calls).toHaveLength(1) + expect(f.calls[0]).toContain('k1=hit1') + expect(f.calls[0]).toContain('pr=' + BOLT11) + }) + + it('refuses an amount above the step limit without calling out', async () => { + const f = mockFetch([]) + const out = await executeWithdrawCallback(step, BOLT11, { + fetchImpl: f.impl, + amountMsat: 6_000_000, + }) + expect(out).toEqual({ ok: false, reason: 'card limit is below this amount' }) + expect(f.calls).toHaveLength(0) + }) + + it('surfaces a callback decline', async () => { + const f = mockFetch([{ status: 'ERROR', reason: 'Payment already claimed.' }]) + const out = await executeWithdrawCallback(step, BOLT11, { fetchImpl: f.impl }) + expect(out).toEqual({ ok: false, reason: 'Payment already claimed.' }) + }) + + it('rejects a missing invoice', async () => { + const f = mockFetch([]) + expect(await executeWithdrawCallback(step, '', { fetchImpl: f.impl })).toEqual({ + ok: false, + reason: 'no invoice to charge', + }) + }) +}) diff --git a/apps/machine/electron/lnurl-withdraw.ts b/apps/machine/electron/lnurl-withdraw.ts index eb93c08..35429ce 100644 --- a/apps/machine/electron/lnurl-withdraw.ts +++ b/apps/machine/electron/lnurl-withdraw.ts @@ -36,6 +36,17 @@ interface WithdrawRequest { type FetchLike = typeof fetch +/** + * The LUD-03 second step on its own: what a `withdrawRequest` (or a Bolt Card + * session, see boltcard-session.ts) hands us to actually pull a payment. + */ +export interface WithdrawStep { + callback: string + k1: string + minWithdrawable?: number + maxWithdrawable?: number +} + export interface ExecuteLnurlWithdrawOptions { /** Injected for tests; defaults to global fetch. */ fetchImpl?: FetchLike @@ -73,7 +84,8 @@ function appendQuery(url: string, params: Record): string { } function errMsg(e: unknown): string { - if (e instanceof Error) return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message + if (e instanceof Error) + return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message return String(e) } @@ -105,16 +117,46 @@ export async function executeLnurlWithdraw( if (params.tag !== 'withdrawRequest' || !params.callback || !params.k1) { return { ok: false, reason: 'card did not return a withdraw voucher' } } + + // 2) Hand our invoice to the callback — the card's wallet pays it. + return executeWithdrawCallback( + { + callback: params.callback, + k1: params.k1, + minWithdrawable: params.minWithdrawable, + maxWithdrawable: params.maxWithdrawable, + }, + bolt11, + opts + ) +} + +/** + * The LUD-03 second step alone: hand our invoice to an already-obtained + * withdraw step (from a `/scan` withdrawRequest, or from a Bolt Card session + * opened at tap-to-enter) — the card's wallet pays it. `{ ok: true }` means the + * card accepted the pull; settlement is observed by the invoice watcher. + */ +export async function executeWithdrawCallback( + step: WithdrawStep, + bolt11: string, + opts: ExecuteLnurlWithdrawOptions = {} +): Promise { + const doFetch = opts.fetchImpl ?? fetch + const timeoutMs = opts.timeoutMs ?? 15_000 + + if (!bolt11 || !/^ln[a-z0-9]/i.test(bolt11.trim())) { + return { ok: false, reason: 'no invoice to charge' } + } if ( opts.amountMsat != null && - typeof params.maxWithdrawable === 'number' && - opts.amountMsat > params.maxWithdrawable + typeof step.maxWithdrawable === 'number' && + opts.amountMsat > step.maxWithdrawable ) { return { ok: false, reason: 'card limit is below this amount' } } - // 2) Hand our invoice to the callback — the card's wallet pays it. - const cbUrl = appendQuery(params.callback, { k1: params.k1, pr: bolt11.trim() }) + const cbUrl = appendQuery(step.callback, { k1: step.k1, pr: bolt11.trim() }) let cb: { status?: string; reason?: string } try { const res = await doFetch(cbUrl, { signal: AbortSignal.timeout(timeoutMs) }) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 6cfcb20..b5801ad 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -24,26 +24,36 @@ import { markCommandExecuting, completeCommand, getLastKnownConfigCreatedAt, - getBootstrapPublishedAt, - markBootstrapPublished, - resetBootstrapGate, + getCountsUncertainSince, + getLastStatePublishedAt, + markCountsUncertain, + markStatePublished, + resetStatePublishWatermark, resetForRepair, - applyOperatorCassettesConfig, + applyOperatorCassetteOps, + getAppliedOpIds, + getCassetteStateSeq, getFeeConfig, getLastKnownFeeConfigCreatedAt, applyFeeConfig, getBunkerBinding, saveBunkerBinding, clearBunkerBinding, - type OperatorCassettesPayload, + type CassetteOp, + type ApplyOpsResult, type FeeConfigPayload, type FeeConfigRow, type ApplyResult, type StoredBunkerBinding, } from './state-store.js' import { initializeHal, type HalInstance } from './hal-service.js' -import { executeLnurlWithdraw } from './lnurl-withdraw.js' -import { resolveCardInvoice } from './lnurl-pay.js' +import { + executeLnurlWithdraw, + executeWithdrawCallback, + type WithdrawStep, +} from './lnurl-withdraw.js' +import { resolveCardInvoice, resolveInvoiceFromPayStep, type PayStep } from './lnurl-pay.js' +import { openCardSession, type OpenCardSessionResult } from './boltcard-session.js' import { startNfcReader, type NfcStatus } from './nfc-service.js' // ESM equivalent of __dirname @@ -164,6 +174,62 @@ function loadBranding(): BrandingConfig | null { return { title, theme, customColors, customColorsDark, logoDataUrl, logoDarkDataUrl } } +// Access-control config loader (ADR-003). Env toggles the gate; an optional +// /var/lib/bitspire/access.json carries the salt + allow-list. Defaults OFF — +// a machine with neither env nor file behaves as if there is no access layer. +// The allow-list shape mirrors the renderer's AllowListEntry (authorize.ts); +// duplicated here to avoid a cross-project (electron↔renderer) import. +interface AccessAllowListEntry { + idHash: string + role: 'user' | 'operator' + pinHash?: string + label?: string +} +function loadAccessControl() { + // Env provides defaults; access.json (writable, operator-provisioned — same + // spirit as branding/) overrides them, so the gate can be toggled on a + // deployed machine by dropping a file + restarting the service, with no image + // rebuild. Defaults OFF. + let enabled = process.env.ACCESS_CONTROL_ENABLED === 'true' + // Dev unlock is OFF unless explicitly enabled: a gated machine must not ship + // a visible bypass button by default. + let devUnlock = process.env.ACCESS_DEV_UNLOCK === 'true' + let openEnrollment = process.env.ACCESS_OPEN_ENROLLMENT === 'true' + let salt = process.env.ACCESS_SALT || '' + let allowList: AccessAllowListEntry[] = [] + + const jsonPath = path.join( + fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd(), + 'access.json' + ) + if (fs.existsSync(jsonPath)) { + try { + const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')) + if (typeof raw.enabled === 'boolean') enabled = raw.enabled + if (typeof raw.devUnlock === 'boolean') devUnlock = raw.devUnlock + if (typeof raw.openEnrollment === 'boolean') openEnrollment = raw.openEnrollment + if (typeof raw.salt === 'string' && raw.salt) salt = raw.salt + if (Array.isArray(raw.allowList)) { + allowList = (raw.allowList as unknown[]).filter( + (e): e is AccessAllowListEntry => + !!e && + typeof (e as AccessAllowListEntry).idHash === 'string' && + ((e as AccessAllowListEntry).role === 'user' || + (e as AccessAllowListEntry).role === 'operator') + ) + } + } catch (e) { + console.warn('[Electron] Failed to parse access.json:', e) + } + } + + // A gated machine needs a stable salt for deterministic hashing. Fall back to + // a fixed default (prototype); production should provision a real salt. + if (!salt) salt = 'bitspire-access-v1' + + return { enabled, devUnlock, openEnrollment, salt, allowList } +} + // Determine if we're in development const isDev = process.env.ELECTRON_FORCE_PROD !== '1' && @@ -311,6 +377,9 @@ ipcMain.handle('get-config', () => { // Operator branding (logo/title/theme) — null when no override branding: loadBranding(), + + // Access-control gate (ADR-003) — `enabled` defaults false (no gate). + accessControl: loadAccessControl(), } }) @@ -346,7 +415,7 @@ ipcMain.handle('get-atm-secrets', () => { }) // Bunker binding persistence — the renderer writes the binding after a -// successful pairing (connectNewSeed), and resets the bootstrap gate so the +// successful pairing (connectNewSeed), and resets the publish watermark so the // new operator receives the spire's hello-event (aiolabs/bitspire#52 / #56). ipcMain.handle('state:save-bunker-binding', (_event, binding: StoredBunkerBinding): void => { saveBunkerBinding(binding) @@ -354,8 +423,8 @@ ipcMain.handle('state:save-bunker-binding', (_event, binding: StoredBunkerBindin ipcMain.handle('state:clear-bunker-binding', (): void => { clearBunkerBinding() }) -ipcMain.handle('state:reset-bootstrap-gate', (): void => { - resetBootstrapGate() +ipcMain.handle('state:reset-state-publish-watermark', (): void => { + resetStatePublishWatermark() }) ipcMain.handle('state:reset-for-repair', (): void => { resetForRepair() @@ -444,6 +513,37 @@ ipcMain.handle( } ) +// Bolt Card tap-to-enter (ADR-003): open a verified session for a tapped card. +// Spends the tap's SUN once and returns balance + fiat + the withdraw/pay +// second steps the session reuses at Complete. See boltcard-session.ts. +ipcMain.handle( + 'lnurl:open-card-session', + async (_event, args: { lnurlw: string }): Promise => { + return openCardSession(args.lnurlw) + } +) + +// Session variants of the two Complete paths: no tap, no p/c — just the +// hit-keyed second step the session already holds. +ipcMain.handle( + 'lnurl:withdraw-session', + async ( + _event, + args: { withdraw: WithdrawStep; bolt11: string; amountMsat?: number } + ): Promise<{ ok: boolean; reason?: string }> => { + return executeWithdrawCallback(args.withdraw, args.bolt11, { amountMsat: args.amountMsat }) + } +) +ipcMain.handle( + 'lnurl:pay-session', + async ( + _event, + args: { pay: PayStep; amountMsat: number } + ): Promise<{ ok: boolean; bolt11?: string; reason?: string }> => { + return resolveInvoiceFromPayStep(args.pay, args.amountMsat) + } +) + // State persistence IPC handlers ipcMain.handle('state:load-cassettes', () => loadCassettes()) ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes)) @@ -460,15 +560,22 @@ ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedB ipcMain.handle('state:get-last-known-config-created-at', (): number => getLastKnownConfigCreatedAt() ) -ipcMain.handle('state:get-bootstrap-published-at', (): number | null => getBootstrapPublishedAt()) -ipcMain.handle('state:mark-bootstrap-published', (_event, unixTimestamp: number): void => { - markBootstrapPublished(unixTimestamp) +ipcMain.handle('state:get-last-state-published-at', (): number | null => getLastStatePublishedAt()) +ipcMain.handle('state:get-counts-uncertain-since', (): number | null => getCountsUncertainSince()) +ipcMain.handle('state:mark-counts-uncertain', (_event, unixTimestamp: number): void => { + markCountsUncertain(unixTimestamp) +}) +ipcMain.handle('state:mark-state-published', (_event, unixTimestamp: number): void => { + markStatePublished(unixTimestamp) }) ipcMain.handle( - 'state:apply-operator-cassettes-config', - (_event, payload: OperatorCassettesPayload, eventCreatedAt: number): ApplyResult => - applyOperatorCassettesConfig(payload, eventCreatedAt) + 'state:apply-operator-cassette-ops', + (_event, ops: CassetteOp[]): ApplyOpsResult => applyOperatorCassetteOps(ops) ) +ipcMain.handle('state:get-applied-op-ids', (_event, limit?: number): string[] => + getAppliedOpIds(limit) +) +ipcMain.handle('state:get-cassette-state-seq', (): number => getCassetteStateSeq()) // Operator-fees consumer (aiolabs/lamassu-next#57) — persisted singleton // fee config + per-d-tag replay watermark + atomic apply for kind-30078 @@ -746,6 +853,11 @@ function startCommandPoller(): void { error: result.error, }) + // This dispense happened entirely in the main process, so the renderer + // has no idea the bays moved — it would keep serving a stale inventory + // and would never republish the operator's view. Tell it. + mainWindow?.webContents.send('cassettes:changed') + // Only remediate the original tx if ALL requested bills were dispensed let refRemediated = false if (parsed.ref_txid && result.dispensed) { diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index 8ced2d5..8bebc13 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -108,16 +108,21 @@ contextBridge.exposeInMainWorld('electronAPI', { // Operator-config consumer (aiolabs/lamassu-next#56) getLastKnownConfigCreatedAt: (): Promise => ipcRenderer.invoke('state:get-last-known-config-created-at'), - getBootstrapPublishedAt: (): Promise => - ipcRenderer.invoke('state:get-bootstrap-published-at'), - markBootstrapPublished: (unixTimestamp: number): Promise => - ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp), + getLastStatePublishedAt: (): Promise => + ipcRenderer.invoke('state:get-last-state-published-at'), + getCountsUncertainSince: (): Promise => + ipcRenderer.invoke('state:get-counts-uncertain-since'), + markCountsUncertain: (unixTimestamp: number): Promise => + ipcRenderer.invoke('state:mark-counts-uncertain', unixTimestamp), + markStatePublished: (unixTimestamp: number): Promise => + ipcRenderer.invoke('state:mark-state-published', unixTimestamp), // Bunker binding persistence (aiolabs/bitspire#52) saveBunkerBinding: (binding: BunkerBindingRecord): Promise => ipcRenderer.invoke('state:save-bunker-binding', binding), clearBunkerBinding: (): Promise => ipcRenderer.invoke('state:clear-bunker-binding'), - resetBootstrapGate: (): Promise => ipcRenderer.invoke('state:reset-bootstrap-gate'), + resetStatePublishWatermark: (): Promise => + ipcRenderer.invoke('state:reset-state-publish-watermark'), resetForRepair: (): Promise => ipcRenderer.invoke('state:reset-for-repair'), // QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed, @@ -141,13 +146,40 @@ contextBridge.exposeInMainWorld('electronAPI', { }): Promise<{ ok: boolean; bolt11?: string; reason?: string }> => ipcRenderer.invoke('lnurl:pay-card', args), - applyOperatorCassettesConfig: ( - payload: { - positions: Record - }, - eventCreatedAt: number - ): Promise<{ applied: true } | { applied: false; reason: string }> => - ipcRenderer.invoke('state:apply-operator-cassettes-config', payload, eventCreatedAt), + // Bolt Card tap-to-enter: one verified session per tap (balance + fiat + + // the withdraw/pay second steps reused at Complete). Payload shapes are + // declared in src/types/electron.d.ts (CardSession). + openCardSession: (args: { lnurlw: string }): Promise => + ipcRenderer.invoke('lnurl:open-card-session', args), + withdrawWithSession: (args: { + withdraw: { callback: string; k1: string; minWithdrawable?: number; maxWithdrawable?: number } + bolt11: string + amountMsat?: number + }): Promise<{ ok: boolean; reason?: string }> => + ipcRenderer.invoke('lnurl:withdraw-session', args), + resolveSessionInvoice: (args: { + pay: { callback: string; minSendable?: number; maxSendable?: number; metadata?: string } + amountMsat: number + }): Promise<{ ok: boolean; bolt11?: string; reason?: string }> => + ipcRenderer.invoke('lnurl:pay-session', args), + + applyOperatorCassetteOps: ( + ops: { + id: string + at: number + type: 'refill' | 'empty' | 'recount' | 'set_denomination' + position: number + bills?: number + count?: number + denomination?: number + }[] + ): Promise<{ + applied: string[] + rejected: { id: string; reason: string }[] + }> => ipcRenderer.invoke('state:apply-operator-cassette-ops', ops), + getAppliedOpIds: (limit?: number): Promise => + ipcRenderer.invoke('state:get-applied-op-ids', limit), + getCassetteStateSeq: (): Promise => ipcRenderer.invoke('state:get-cassette-state-seq'), // Operator-fees consumer (aiolabs/lamassu-next#57) getFeeConfig: (): Promise<{ @@ -205,6 +237,13 @@ contextBridge.exposeInMainWorld('electronAPI', { // Bolt Card reader (main process → renderer). removeAllListeners first: a // renderer reload re-runs this, and a duplicated card-tap listener would // trigger the LNURL-withdraw twice. + // The main process changed the cassettes table (an operator-command dispense, + // boot seeding). The renderer reloads its inventory and republishes state. + onCassettesChanged: (callback: () => void) => { + ipcRenderer.removeAllListeners('cassettes:changed') + ipcRenderer.on('cassettes:changed', () => callback()) + }, + onNfcCardTapped: (callback: (lnurlw: string) => void) => { ipcRenderer.removeAllListeners('nfc:card-tapped') ipcRenderer.on('nfc:card-tapped', (_event, lnurlw) => callback(lnurlw)) @@ -265,11 +304,13 @@ declare global { emptyCashbox: () => Promise remediateTransaction: (txid: string, remediatedByTxid: string) => Promise getLastKnownConfigCreatedAt: () => Promise - getBootstrapPublishedAt: () => Promise - markBootstrapPublished: (unixTimestamp: number) => Promise + getLastStatePublishedAt: () => Promise + getCountsUncertainSince: () => Promise + markCountsUncertain: (unixTimestamp: number) => Promise + markStatePublished: (unixTimestamp: number) => Promise saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise - resetBootstrapGate: () => Promise + resetStatePublishWatermark: () => Promise resetForRepair: () => Promise saveSpireSeed: (seed: string) => Promise relaunchApp: () => Promise @@ -283,10 +324,22 @@ declare global { lnurlw: string amountMsat: number }) => Promise<{ ok: boolean; bolt11?: string; reason?: string }> - applyOperatorCassettesConfig: ( - payload: { positions: Record }, - eventCreatedAt: number - ) => Promise<{ applied: true } | { applied: false; reason: string }> + applyOperatorCassetteOps: ( + ops: { + id: string + at: number + type: 'refill' | 'empty' | 'recount' | 'set_denomination' + position: number + bills?: number + count?: number + denomination?: number + }[] + ) => Promise<{ + applied: string[] + rejected: { id: string; reason: string }[] + }> + getAppliedOpIds: (limit?: number) => Promise + getCassetteStateSeq: () => Promise getFeeConfig: () => Promise<{ cashInFeeFraction: number cashOutFeeFraction: number diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index cfbfd71..fa6f17f 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -15,7 +15,7 @@ import fs from 'node:fs' let db: Database.Database | null = null -const SCHEMA_VERSION = '12' +const SCHEMA_VERSION = '13' function getDbPath(): string { const prodDir = '/var/lib/bitspire' @@ -57,6 +57,17 @@ export function initDatabase(dbPath?: string): void { count INTEGER NOT NULL DEFAULT 0 ); + CREATE TABLE IF NOT EXISTS cassette_ops ( + id TEXT PRIMARY KEY, + position INTEGER NOT NULL, + op_type TEXT NOT NULL, + bills INTEGER, + count INTEGER, + denomination INTEGER, + op_at INTEGER NOT NULL, + applied_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS cashbox ( id INTEGER PRIMARY KEY CHECK (id = 1), total_bills INTEGER NOT NULL DEFAULT 0, @@ -295,7 +306,9 @@ export function initDatabase(dbPath?: string): void { `) db.pragma('foreign_keys = ON') db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('9', 'schema_version') - console.log('[StateStore] Migrated schema v8 → v9 (cassettes PK position; allow duplicate denominations)') + console.log( + '[StateStore] Migrated schema v8 → v9 (cassettes PK position; allow duplicate denominations)' + ) existing.value = '9' } @@ -371,12 +384,47 @@ export function initDatabase(dbPath?: string): void { console.log('[StateStore] Migrated schema v11 → v12 (bunker_binding transport config)') } + if (existing && existing.value === '12') { + // Migration v12 → v13: operator OPERATIONS replace operator counts + // (aiolabs/bitspire ADR-004). + // + // The operator used to publish absolute counts and this machine applied + // them outright. Both sides wrote the same value over a transport that + // never tells a writer it lost, so a dashboard form loaded before a + // dispense silently discarded that dispense — and nothing on either side + // could detect it afterwards. The operator now publishes what it DID and + // this machine, which holds the notes, owns the running total. + // + // `cassette_ops` is the dedup ledger. A delta applied twice is wrong, and + // addressable events are re-delivered on every reconnect, so the operator + // mints an id per operation and we record the ones we have applied. The + // operator's window is a slice of recent operations rather than just the + // newest, so one we missed arrives with the next publish; dedup is what + // makes re-delivery free instead of dangerous. + db.exec(` + CREATE TABLE IF NOT EXISTS cassette_ops ( + id TEXT PRIMARY KEY, + position INTEGER NOT NULL, + op_type TEXT NOT NULL, + bills INTEGER, + count INTEGER, + denomination INTEGER, + op_at INTEGER NOT NULL, + applied_at INTEGER NOT NULL + ); + `) + db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('13', 'schema_version') + console.log('[StateStore] Migrated schema v12 → v13 (added cassette_ops)') + existing.value = '13' + } + // Defensive: a fresh install at SCHEMA_VERSION skips all migrations. // Seed the operator-config meta rows if they're missing (idempotent). const seedMeta = db.prepare('INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)') seedMeta.run('lastKnownConfigCreatedAt', '0') seedMeta.run('bootstrapPublishedAt', '') seedMeta.run('lastKnownFeeConfigCreatedAt', '0') + seedMeta.run('cassetteStateSeq', '0') const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get() if (!cashboxRow) { @@ -397,32 +445,110 @@ export function initDatabase(dbPath?: string): void { */ export function getLastKnownConfigCreatedAt(): number { if (!db) throw new Error('Database not initialized') - const row = db - .prepare('SELECT value FROM meta WHERE key = ?') - .get('lastKnownConfigCreatedAt') as { value: string } | undefined + const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('lastKnownConfigCreatedAt') as + | { value: string } + | undefined return row ? Number(row.value) || 0 : 0 } /** - * Read the one-shot bootstrap-publish gate. Returns null if the ATM has - * not yet published its `bitspire-cassettes-state:` hello-event. + * The `created_at` of the last `bitspire-cassettes-state` event this machine + * published, or null if it has never published one. + * + * This used to be a one-shot gate ("have we said hello yet"), which meant a + * layout change after first boot was never announced (#94). It is now a + * high-water mark: every publish records its stamp, and the next one is forced + * strictly above it. Addressable events are ordered by `created_at` at second + * granularity, and a relay silently keeps the higher one, so a clock that steps + * backwards would otherwise make this machine's reports vanish with an `OK`. + * + * Stored under the original `bootstrapPublishedAt` meta key so no migration is + * needed; the name is historical, the meaning is not. */ -export function getBootstrapPublishedAt(): number | null { +export function getLastStatePublishedAt(): number | null { if (!db) throw new Error('Database not initialized') - const row = db - .prepare('SELECT value FROM meta WHERE key = ?') - .get('bootstrapPublishedAt') as { value: string } | undefined + const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('bootstrapPublishedAt') as + | { value: string } + | undefined if (!row || row.value === '') return null const n = Number(row.value) return Number.isFinite(n) ? n : null } /** - * Mark the bootstrap hello-event as published. Idempotent — only takes - * effect the first time it's set. Subsequent calls overwrite the - * timestamp (harmless; the gate just needs to be non-null). + * Whether the bay counts are known to be unverified, and since when. + * + * Set when a dispense ends without the dispenser reporting what it moved — a + * driver throw, or the dispense timeout. Bills may well have reached the + * customer, but nothing knows how many, so neither the rows here nor HAL's + * bays were debited and both now read high. Reporting that number as fact is + * the worst option available; saying the number is unverified is honest and + * tells the operator to open the machine and recount. + * + * Cleared when an operator asserts authoritative counts (a config apply), + * which is precisely what a recount is. Uses an upsert so no migration is + * needed for machines whose meta table predates the key. */ -export function markBootstrapPublished(unixTimestamp: number): void { +export function getCountsUncertainSince(): number | null { + if (!db) throw new Error('Database not initialized') + const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('countsUncertainSince') as + | { value: string } + | undefined + if (!row || row.value === '') return null + const n = Number(row.value) + return Number.isFinite(n) ? n : null +} + +/** Flag the counts as unverified. Keeps the earliest time it went bad. */ +export function markCountsUncertain(unixTimestamp: number): void { + if (!db) throw new Error('Database not initialized') + if (getCountsUncertainSince() !== null) return + db.prepare( + 'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value' + ).run('countsUncertainSince', String(unixTimestamp)) + console.warn('[StateStore] Cassette counts flagged unverified at', unixTimestamp) +} + +/** Clear the flag — an operator has asserted real counts. */ +export function clearCountsUncertain(): void { + if (!db) throw new Error('Database not initialized') + db.prepare( + 'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value' + ).run('countsUncertainSince', '') +} + +/** + * A counter bumped on every local change to a bay count, from any cause. + * + * It rides along in the state document so a reader can reject a regression + * without trusting a clock. `created_at` cannot carry that: it has + * second granularity, so two publishes in the same second are ordered by + * whichever event id hashes lower — and a machine whose clock stepped + * backwards would otherwise have every later report look older than the one + * already on the relay. + */ +export function getCassetteStateSeq(): number { + if (!db) throw new Error('Database not initialized') + const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('cassetteStateSeq') as + | { value: string } + | undefined + return row ? Number(row.value) || 0 : 0 +} + +/** + * Bump the counter. Safe to call inside an open transaction — every caller + * that mutates a count does, so the bump commits or rolls back with it. + */ +export function bumpCassetteStateSeq(): void { + if (!db) throw new Error('Database not initialized') + db.prepare( + 'INSERT INTO meta (key, value) VALUES (?, ?) ' + + 'ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(meta.value AS INTEGER) + 1 AS TEXT)' + ).run('cassetteStateSeq', '1') +} + +/** Record the `created_at` just published, as the next publish's floor. */ +export function markStatePublished(unixTimestamp: number): void { if (!db) throw new Error('Database not initialized') db.prepare('UPDATE meta SET value = ? WHERE key = ?').run( String(unixTimestamp), @@ -531,11 +657,12 @@ export function clearBunkerBinding(): void { } /** - * Reset the bootstrap-publish gate so the ATM re-publishes its - * `bitspire-cassettes-state` hello-event. Called on a re-pair (new seed) so - * the new operator receives the spire's current state (aiolabs/bitspire#56). + * Forget the publish high-water mark. Called on a re-pair (new seed): the + * next publish is then free to use the wall clock, which is what a fresh + * operator relationship wants. The state itself is republished on startup + * regardless, so the new operator always receives current counts. */ -export function resetBootstrapGate(): void { +export function resetStatePublishWatermark(): void { if (!db) throw new Error('Database not initialized') db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt') } @@ -566,108 +693,190 @@ export function resetForRepair(): void { })() } -export type OperatorCassettesPayload = { - positions: Record +/** + * Outcome of applying an operator-authored absolute config. Still the right + * shape for fee config, where the operator is the only writer of the value + * and a later event simply supersedes an earlier one. Cassette counts left + * this model in ADR-004 precisely because they had two writers. + */ +export type ApplyResult = { applied: true } | { applied: false; reason: string } + +/** One operator-authored operation, as it arrives on the wire. */ +export type CassetteOp = { + id: string + at: number + type: 'refill' | 'empty' | 'recount' | 'set_denomination' + position: number + bills?: number + count?: number + denomination?: number } -export type ApplyResult = - | { applied: true } - | { applied: false; reason: string } +export type ApplyOpsResult = { + /** Ids applied by this call. Empty when every op was already on file. */ + applied: string[] + /** Ids rejected, with why. These stay unapplied and unrecorded. */ + rejected: { id: string; reason: string }[] +} + +const CASSETTE_OP_TYPES = new Set(['refill', 'empty', 'recount', 'set_denomination']) /** - * Atomic apply of an operator-published cassette config (aiolabs/lamassu-next#56). + * Validate one operation in isolation. Returns null when it is well-formed. * - * Caller has already verified the event signature and decrypted the - * content. This function: - * - * 1. Rechecks replay-protection against `meta.lastKnownConfigCreatedAt` - * (defense-in-depth — caller should have done this too). - * 2. Validates the payload's `positions` key set is *exactly* the set of - * positions currently in the `cassettes` table. The bay count is - * hardware-determined and can't be added to or removed from via this - * path; only the per-bay denomination and count are operator-mutable. - * 3. Validates per-entry `denomination` is a positive int, `count` is a - * non-negative int. **Duplicate denominations across positions are - * intentionally permitted** — real machines load multiple cassettes - * with the same denomination for cash-out throughput. - * 4. In a single SQLite transaction: updates `cassettes` rows by position - * (denomination + count both mutable per row) AND advances - * `meta.lastKnownConfigCreatedAt` to `eventCreatedAt`. - * - * Mid-write crashes roll back cleanly; on restart the same event is - * re-delivered by the relay and the watermark check drops it as already - * consumed (or the watermark is pre-event because the tx rolled back, - * and the apply runs again from scratch). + * Shape errors and unknown positions are treated the same way by the caller: + * the op is neither applied nor recorded, so it stays pending on the + * operator's dashboard. That is the honest outcome — it did not happen — and + * it beats recording it as applied to stop the noise, which would tell the + * operator their refill landed when the notes are unaccounted for. */ -export function applyOperatorCassettesConfig( - payload: OperatorCassettesPayload, - eventCreatedAt: number -): ApplyResult { +function validateCassetteOp(op: CassetteOp, knownPositions: Set): string | null { + if (typeof op.id !== 'string' || op.id.length === 0) return 'missing id' + if (!CASSETTE_OP_TYPES.has(op.type)) return `unknown type ${String(op.type)}` + if (!Number.isInteger(op.position)) return `position must be an integer (got ${op.position})` + if (!knownPositions.has(op.position)) return `unknown position ${op.position}` + if (!Number.isFinite(op.at)) return 'missing at' + + if (op.type === 'refill') { + if (!Number.isInteger(op.bills) || (op.bills as number) <= 0) { + return `refill needs a positive integer bills (got ${op.bills})` + } + } + if (op.type === 'recount') { + if (!Number.isInteger(op.count) || (op.count as number) < 0) { + return `recount needs a non-negative integer count (got ${op.count})` + } + } + if (op.type === 'set_denomination') { + if (!Number.isInteger(op.denomination) || (op.denomination as number) <= 0) { + return `set_denomination needs a positive integer denomination (got ${op.denomination})` + } + } + return null +} + +/** + * Apply an operator's cassette operations, skipping any already on file. + * + * This replaces applying absolute counts. The operator authors what it DID — + * a refill in notes added, an empty, a recount, a denomination change — and + * this machine, which holds the physical notes, keeps the running total. + * Nobody but this process writes a count any more, so there is no second + * writer to lose a race to. + * + * Deltas are not idempotent and addressable events ARE re-delivered on every + * relay reconnect, so idempotency is carried explicitly: the operator mints an + * id per operation, `cassette_ops` records the ones applied, and a repeat is a + * no-op. That is also why there is no `created_at` watermark here any more. + * Under absolute counts the watermark was the only replay defence; with + * per-op ids it is strictly weaker than the dedup and would do active harm, + * because an event that arrives out of order may still carry an operation this + * machine has never seen. + * + * Applied oldest-first by `at`, ties broken by id so two operations stamped in + * the same second still order the same way on every machine. Ordering matters + * because a recount followed by a refill is not the same as the reverse. + * + * The whole batch runs in one SQLite transaction with the sequence bump, so a + * crash mid-apply rolls back to a coherent count and the next publish re-offers + * every op in the window. + */ +export function applyOperatorCassetteOps(ops: CassetteOp[]): ApplyOpsResult { if (!db) throw new Error('Database not initialized') + const database = db + const result: ApplyOpsResult = { applied: [], rejected: [] } + if (ops.length === 0) return result - const watermark = getLastKnownConfigCreatedAt() - if (eventCreatedAt <= watermark) { - return { - applied: false, - reason: `event.created_at (${eventCreatedAt}) <= lastKnownConfigCreatedAt (${watermark})`, - } - } - - const currentRows = db - .prepare('SELECT position FROM cassettes') - .all() as { position: number }[] - const currentPositions = new Set(currentRows.map((r) => r.position)) - const payloadPositions = new Set(Object.keys(payload.positions).map((k) => Number(k))) - - if (currentPositions.size !== payloadPositions.size) { - return { - applied: false, - reason: `position count mismatch: state.db has ${currentPositions.size}, payload has ${payloadPositions.size}`, - } - } - for (const p of currentPositions) { - if (!payloadPositions.has(p)) { - return { applied: false, reason: `payload missing position ${p}` } - } - } - for (const p of payloadPositions) { - if (!currentPositions.has(p)) { - return { applied: false, reason: `payload includes unknown position ${p}` } - } - } - - for (const [posKey, entry] of Object.entries(payload.positions)) { - if (!Number.isInteger(entry.denomination) || entry.denomination <= 0) { - return { - applied: false, - reason: `denomination must be positive int (position ${posKey}, got ${entry.denomination})`, - } - } - if (!Number.isInteger(entry.count) || entry.count < 0) { - return { - applied: false, - reason: `count must be non-negative int (position ${posKey}, got ${entry.count})`, - } - } - } - - const updateCassette = db.prepare( - 'UPDATE cassettes SET denomination = ?, count = ? WHERE position = ?' + const knownPositions = new Set( + (database.prepare('SELECT position FROM cassettes').all() as { position: number }[]).map( + (r) => r.position + ) ) - const setWatermark = db.prepare('UPDATE meta SET value = ? WHERE key = ?') + const seen = database.prepare('SELECT 1 FROM cassette_ops WHERE id = ?') - const run = db.transaction(() => { - for (const [posKey, entry] of Object.entries(payload.positions)) { - updateCassette.run(entry.denomination, entry.count, Number(posKey)) + const pending: CassetteOp[] = [] + for (const op of ops) { + if (op && typeof op.id === 'string' && seen.get(op.id)) continue + const reason = validateCassetteOp(op, knownPositions) + if (reason) { + result.rejected.push({ id: op?.id ?? '', reason }) + continue } - setWatermark.run(String(eventCreatedAt), 'lastKnownConfigCreatedAt') - }) + pending.push(op) + } + if (pending.length === 0) return result + + pending.sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + + const addBills = database.prepare( + 'UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?' + ) + const setCount = database.prepare('UPDATE cassettes SET count = ? WHERE position = ?') + const setDenomination = database.prepare( + 'UPDATE cassettes SET denomination = ? WHERE position = ?' + ) + const recordOp = database.prepare( + 'INSERT INTO cassette_ops (id, position, op_type, bills, count, denomination, op_at, applied_at) ' + + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ) + const upsertMeta = database.prepare( + 'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value' + ) + + const appliedAt = Math.floor(Date.now() / 1000) + let sawRecount = false + + database.transaction(() => { + for (const op of pending) { + if (op.type === 'refill') addBills.run(op.bills, op.position) + else if (op.type === 'empty') setCount.run(0, op.position) + else if (op.type === 'recount') { + setCount.run(op.count, op.position) + sawRecount = true + } else setDenomination.run(op.denomination, op.position) + + recordOp.run( + op.id, + op.position, + op.type, + op.bills ?? null, + op.count ?? null, + op.denomination ?? null, + Math.floor(op.at), + appliedAt + ) + result.applied.push(op.id) + } + bumpCassetteStateSeq() + // A recount is an operator opening the bay and counting it, which is + // exactly what resolves an unverified count. Nothing else does: a refill + // adds to a number still known to be wrong. + if (sawRecount) upsertMeta.run('countsUncertainSince', '') + })() - run() console.log( - `[StateStore] Applied operator cassettes config @ created_at=${eventCreatedAt} (${Object.keys(payload.positions).length} positions)` + `[StateStore] Applied ${result.applied.length} cassette op(s)` + + (result.rejected.length ? `, rejected ${result.rejected.length}` : '') ) - return { applied: true } + return result +} + +/** + * The ids most recently applied, newest first — the acknowledgement leg of + * the protocol. + * + * An addressable event gives its publisher no failure signal at all: the relay + * returns OK for an event it then discards, and a losing writer is never told. + * Echoing the ids back in this machine's own state document is the only way + * the operator can distinguish an operation that landed from one that was + * merely sent. + */ +export function getAppliedOpIds(limit = 50): string[] { + if (!db) throw new Error('Database not initialized') + const rows = db + .prepare('SELECT id FROM cassette_ops ORDER BY applied_at DESC, rowid DESC LIMIT ?') + .all(limit) as { id: string }[] + return rows.map((r) => r.id) } // --------------------------------------------------------------------------- @@ -752,10 +961,7 @@ export interface FeeConfigPayload { */ const FEE_CAP_PER_DIRECTION = 0.15 -export function applyFeeConfig( - payload: FeeConfigPayload, - eventCreatedAt: number -): ApplyResult { +export function applyFeeConfig(payload: FeeConfigPayload, eventCreatedAt: number): ApplyResult { if (!db) throw new Error('Database not initialized') const watermark = getLastKnownFeeConfigCreatedAt() @@ -848,6 +1054,7 @@ export function setCassettes( const row = rows[i]! upsert.run(row.position ?? i + 1, row.denomination, row.count) } + bumpCassetteStateSeq() } ) @@ -862,11 +1069,14 @@ export function setCassettes( */ export function updateCassetteCountByPosition(position: number, delta: number): void { if (!db) throw new Error('Database not initialized') + const database = db - db.prepare('UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?').run( - delta, - position - ) + database.transaction(() => { + database + .prepare('UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?') + .run(delta, position) + bumpCassetteStateSeq() + })() } /** @@ -879,9 +1089,14 @@ export function getInventory(): Record { const rows = loadCassettes() const inv: Record = {} for (const row of rows) { - if (row.count > 0) { - inv[row.denomination] = (inv[row.denomination] ?? 0) + row.count - } + // Zero-count bays are KEPT. Dropping them made a drained machine + // indistinguishable from an unconfigured one, and every caller reads an + // empty map as "I don't know, ask the hardware" — so the last non-empty + // snapshot stuck and the availability beacon went on advertising bills + // that had already been dispensed. An empty map now means exactly one + // thing: no cassettes are configured. Consumers already filter for + // `> 0` before offering a denomination (CashOutView, machine.ts). + inv[row.denomination] = (inv[row.denomination] ?? 0) + row.count } return inv } @@ -1026,7 +1241,15 @@ export function recordTransaction(tx: TransactionInput): void { } } - if (t.type === 'cash_out') { + // Any dispense empties bays, whoever asked for it. `manual_dispense` + // (operator remediation, via the command poller or a kind-21003 command) + // used to fall outside this branch: HAL decremented its in-memory bays but + // the rows here did not move, and on the next boot HAL re-seeds from these + // rows — so the machine came back believing it still held bills a customer + // had already been handed (#76). A remediation against a partly-dispensed + // original decrements again on purpose: the original only ever debited what + // physically left, and this is a second lot of bills leaving. + if (t.type === 'cash_out' || t.type === 'manual_dispense') { // Decrement cassettes by ACTUALLY dispensed count (not requested). // Position is the addressable unit (v9): duplicate denominations // across bays are legal, so a denomination-keyed UPDATE would @@ -1056,6 +1279,10 @@ export function recordTransaction(tx: TransactionInput): void { } } } + // The counts moved, so the sequence must move with them, inside this + // same transaction. It rides in the state document as the operator's + // way to reject a regression without trusting either clock. + bumpCassetteStateSeq() } if (t.type === 'cash_in') { diff --git a/apps/machine/index.html b/apps/machine/index.html index cc57859..81f3e36 100644 --- a/apps/machine/index.html +++ b/apps/machine/index.html @@ -2,7 +2,7 @@ - + + +