From 5b22447dae27dae3f11e0881eca5e83c930f43b1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 22 Sep 2026 22:05:42 +0200 Subject: [PATCH 1/5] fix(cassettes): decrement bays on an operator remediation dispense MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recordTransaction only debited the cassette rows for type 'cash_out'. An operator remediation is recorded as 'manual_dispense', so HAL's in-memory bays went down while the persisted rows did not — and HAL re-seeds from those rows on the next boot, so the machine came back believing it still held bills a customer had already been handed. A remediation against a partly-dispensed original debits again on purpose: the original only ever debited what physically left, and this is a second lot of bills leaving the bay. Closes #76 Co-Authored-By: Claude Fable 5.1 --- .../state-store-transactions.test.ts | 92 +++++++++++++++++++ apps/machine/electron/state-store.ts | 39 ++++---- 2 files changed, 113 insertions(+), 18 deletions(-) diff --git a/apps/machine/electron/__tests__/state-store-transactions.test.ts b/apps/machine/electron/__tests__/state-store-transactions.test.ts index fbb9a87..4c91a78 100644 --- a/apps/machine/electron/__tests__/state-store-transactions.test.ts +++ b/apps/machine/electron/__tests__/state-store-transactions.test.ts @@ -168,3 +168,95 @@ 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) + }) +}) diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index cfbfd71..5fe4d05 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -295,7 +295,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' } @@ -397,9 +399,9 @@ 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 } @@ -409,9 +411,9 @@ export function getLastKnownConfigCreatedAt(): number { */ export function getBootstrapPublishedAt(): 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 @@ -570,9 +572,7 @@ export type OperatorCassettesPayload = { positions: Record } -export type ApplyResult = - | { applied: true } - | { applied: false; reason: string } +export type ApplyResult = { applied: true } | { applied: false; reason: string } /** * Atomic apply of an operator-published cassette config (aiolabs/lamassu-next#56). @@ -613,9 +613,7 @@ export function applyOperatorCassettesConfig( } } - const currentRows = db - .prepare('SELECT position FROM cassettes') - .all() as { position: number }[] + 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))) @@ -752,10 +750,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() @@ -1026,7 +1021,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 From 0d43c4e033e46d61478a63efd9b2701552a83c5a Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 22 Sep 2026 22:07:35 +0200 Subject: [PATCH 2/5] fix(cassettes): report a drained machine as drained MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getInventory dropped zero-count bays, so a fully dispensed machine returned an empty map — identical to a machine with no cassettes configured. Every caller reads an empty map as "nothing known, ask the hardware": reloadPersistedInventory skipped the update entirely, so the last non-empty snapshot stuck and the public availability beacon went on advertising bills that had already gone out the slot. Zero-count bays are kept, so an empty map now means exactly one thing: no cassettes are configured. Consumers already filter for > 0 before offering a denomination. loadInventoryFromDb returns null when the DB could not be asked at all (browser dev, failed IPC) so callers can still tell "no answer" from an answer of "the bays are empty", and only the former defers to HAL. Co-Authored-By: Claude Fable 5.1 --- .../state-store-transactions.test.ts | 62 +++++++++++++++++++ apps/machine/electron/state-store.ts | 11 +++- apps/machine/src/stores/atm.ts | 34 ++++++---- 3 files changed, 92 insertions(+), 15 deletions(-) diff --git a/apps/machine/electron/__tests__/state-store-transactions.test.ts b/apps/machine/electron/__tests__/state-store-transactions.test.ts index 4c91a78..cb64aa5 100644 --- a/apps/machine/electron/__tests__/state-store-transactions.test.ts +++ b/apps/machine/electron/__tests__/state-store-transactions.test.ts @@ -14,6 +14,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { closeDatabase, getCashbox, + getInventory, initDatabase, loadCassettes, recordTransaction, @@ -260,3 +261,64 @@ describe('state-store: recordTransaction manual_dispense inventory (#76)', () => 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({}) + }) +}) diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index 5fe4d05..39e32b6 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -874,9 +874,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 } diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 0ed4719..8751861 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -161,10 +161,14 @@ async function handleManagementCommand( } /** - * Load inventory from SQLite via IPC (Electron only). - * Returns empty object in browser dev mode. + * Load inventory from SQLite via IPC. + * + * Returns `null` when the DB could not be asked at all — browser dev mode, or + * a failed IPC call — so callers can tell "no answer" from an answer of "the + * bays are empty". An empty map is a real, actionable reading: cassettes are + * configured and drained, or none are configured. */ -async function loadInventoryFromDb(): Promise> { +async function loadInventoryFromDb(): Promise | null> { if (isElectron && window.electronAPI) { try { const inv = await window.electronAPI.getInventory() @@ -174,7 +178,7 @@ async function loadInventoryFromDb(): Promise> { console.warn('[ATM] Failed to load inventory from DB:', e) } } - return {} + return null } /** @@ -448,10 +452,12 @@ export const useAtmStore = defineStore('atm', () => { async function reloadPersistedInventory() { const inv = await loadInventoryFromDb() - if (Object.keys(inv).length > 0) { - persistedInventory.value = inv - console.log('[ATM] Persisted inventory updated:', inv) - } + // Only a failed read is ignored. An empty map used to be skipped too, + // which meant the last bill out of the machine never updated anything and + // the availability beacon kept advertising a full cassette. + if (inv === null) return + persistedInventory.value = inv + console.log('[ATM] Persisted inventory updated:', inv) } /** Detect Bitcoin network from a BOLT-11 invoice prefix (called once, persisted) */ @@ -1064,7 +1070,8 @@ export const useAtmStore = defineStore('atm', () => { }), getInventory: async () => { const fresh = await loadInventoryFromDb() - return Object.keys(fresh).length > 0 ? fresh : services.atmServices.getInventory() + // null == the DB could not be asked; an empty map is a real reading. + return fresh ?? services.atmServices.getInventory() }, } @@ -1327,7 +1334,8 @@ export const useAtmStore = defineStore('atm', () => { // If DB has inventory, use it; otherwise fall back to HAL getInventory: async () => { const fresh = await loadInventoryFromDb() - return Object.keys(fresh).length > 0 ? fresh : hal.atmServices.getInventory() + // null == the DB could not be asked; an empty map is a real reading. + return fresh ?? hal.atmServices.getInventory() }, } @@ -1611,9 +1619,11 @@ export const useAtmStore = defineStore('atm', () => { return await api.halDispense(amounts) }, getInventory: async () => { - // Priority: DB inventory > HAL hardware inventory > empty + // Priority: DB inventory > HAL hardware inventory > empty. Only a + // null (unreadable) DB defers to HAL — a drained machine reports + // drained rather than borrowing the hardware's view. const fresh = await loadInventoryFromDb() - if (Object.keys(fresh).length > 0) return fresh + if (fresh !== null) return fresh // Fall back to HAL's cassette-based inventory try { const halInv = await api.halGetInventory() From db68e6e244f2f59d5360355958abf724da5ae2d7 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 22 Sep 2026 22:09:23 +0200 Subject: [PATCH 3/5] fix(cassettes): republish after a dispense the renderer didn't run Two dispense paths bypassed the refresh-and-publish step that cash-out does. The kind-21003 management command persisted the transaction and stopped there, and the operator-command poller runs entirely in the main process, where the renderer cannot see the bays move at all. In both cases the renderer kept serving a stale inventory and the operator's cassette view stayed frozen until the next customer cash-out. The management handler takes an after-hook, and the main process emits 'cassettes:changed' when it mutates the table so the renderer can catch up. Both land on one helper that reloads the inventory and republishes the state document. Co-Authored-By: Claude Fable 5.1 --- apps/machine/electron/main.ts | 5 ++++ apps/machine/electron/preload.ts | 7 +++++ apps/machine/src/stores/atm.ts | 41 ++++++++++++++++++++++++++-- apps/machine/src/types/electron.d.ts | 2 ++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 0331392..f74d534 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -841,6 +841,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 df4ba0b..78b0e13 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -222,6 +222,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)) diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 8751861..ec8b41e 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -72,7 +72,13 @@ async function handleManagementCommand( request: ManagementRequest, dispenseFn: (amounts: { denomination: number; count: number }[]) => Promise, machineIdle: boolean, - currency: string + currency: string, + /** + * Called once the dispense has been persisted. Bills left the bays whether or + * not the dispense completed, so the caller refreshes its inventory and + * republishes the operator's view — this path used to do neither. + */ + onCassettesChanged?: () => Promise ): Promise { if (!isMachineDispenseRequest(request)) return null @@ -130,6 +136,7 @@ async function handleManagementCommand( cassettes: result.cassettes, error: result.error, }) + await onCassettesChanged?.() // Only remediate the original tx if ALL requested bills were dispensed let refRemediated = false @@ -450,6 +457,16 @@ export const useAtmStore = defineStore('atm', () => { */ const persistedInventory = ref>({}) + /** + * The bays moved outside the normal cash-out flow — an operator-command + * dispense, a main-process seed. Refresh the renderer's view and push the + * operator's. Best-effort: a publish failure must not fail the dispense. + */ + async function refreshAndPublishCassettes() { + await reloadPersistedInventory() + await operatorConfigSvc?.publishCassettesState() + } + async function reloadPersistedInventory() { const inv = await loadInventoryFromDb() // Only a failed read is ignored. An empty map used to be skipped too, @@ -709,6 +726,7 @@ export const useAtmStore = defineStore('atm', () => { // Start the machine actor.value.start() setupNfcListener() + setupCassettesChangedListener() console.log('[ATM] State machine initialized') } @@ -922,6 +940,21 @@ export const useAtmStore = defineStore('atm', () => { } } + /** + * The main process can move the bays without the renderer knowing — an + * operator-command dispense runs entirely there, and boot seeding writes the + * table before the store exists. Listen for that and catch up, otherwise the + * renderer serves a stale inventory and the operator's view never updates. + * Idempotent via preload removeAllListeners. + */ + function setupCassettesChangedListener() { + if (!isElectron || !window.electronAPI?.onCassettesChanged) return + window.electronAPI.onCassettesChanged(() => { + console.log('[ATM] Cassettes changed in the main process — refreshing') + void refreshAndPublishCassettes() + }) + } + /** Wire the main-process reader once (idempotent via preload removeAllListeners). */ function setupNfcListener() { if (!isElectron || !window.electronAPI?.onNfcCardTapped) return @@ -1319,7 +1352,8 @@ export const useAtmStore = defineStore('atm', () => { request, (amounts) => hal.atmServices.dispenseCash(amounts), isIdle.value, - fiatCode.value + fiatCode.value, + refreshAndPublishCassettes ) }) @@ -1651,7 +1685,8 @@ export const useAtmStore = defineStore('atm', () => { request, (amounts) => api.halDispense(amounts), isIdle.value, - fiatCode.value + fiatCode.value, + refreshAndPublishCassettes ) }) diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 1ab609c..20e0e6a 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -217,6 +217,8 @@ declare global { onHalBillInserted: (callback: (denomination: number) => void) => void onHalBillRejected: (callback: (reason: string) => void) => void onHalError: (callback: (error: string) => void) => void + /** The main process mutated the cassettes table; reload + republish. */ + onCassettesChanged: (callback: () => void) => void /** Bolt Card reader: a tapped card's lnurlw voucher. */ onNfcCardTapped: (callback: (lnurlw: string) => void) => void /** Bolt Card reader status (ready / reading / error / unavailable). */ From 54c59fadcc9f08873684939261346f5dce5d2ebe Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 22 Sep 2026 22:12:07 +0200 Subject: [PATCH 4/5] fix(cassettes): publish state on every change, and make the stamps monotonic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three linked failures in one mechanism, so one commit. The state publish was gated on a one-shot 'have we said hello' flag. It fired once on first boot and then only after a dispense or an applied operator config, so any change to the layout itself — a reseed, an atm-tui edit, direct SQL — was never announced. The operator kept validating against a bay set the machine no longer had, and a publish from the dashboard could overwrite a fresh seed (#94). State is now published on every start. A publish is one fire-and-forget event with no retry. If the relay was unreachable at the moment of a dispense, that update was gone until the next customer bought cash. A five-minute heartbeat makes the channel self-healing and is also the only way an out-of-band edit to the table ever reaches the operator. Addressable events are ordered by created_at at second granularity with ties broken by lowest event id, and a relay acknowledges an event it then discards. Two publishes inside one second therefore left the winner decided by a hash, permanently, and a clock stepping backwards would have made every report from this machine vanish silently. Each publish now takes a stamp strictly above the last, recorded in the meta row that used to hold the gate — same key, no migration, honest name. Closes #94 Co-Authored-By: Claude Fable 5.1 --- apps/machine/electron/main.ts | 18 ++-- apps/machine/electron/preload.ts | 16 ++-- apps/machine/electron/state-store.ts | 33 ++++--- apps/machine/src/services/operator-config.ts | 92 +++++++++++--------- apps/machine/src/services/signer-resolver.ts | 10 ++- apps/machine/src/types/electron.d.ts | 6 +- 6 files changed, 98 insertions(+), 77 deletions(-) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index f74d534..ae06f43 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -24,9 +24,9 @@ import { markCommandExecuting, completeCommand, getLastKnownConfigCreatedAt, - getBootstrapPublishedAt, - markBootstrapPublished, - resetBootstrapGate, + getLastStatePublishedAt, + markStatePublished, + resetStatePublishWatermark, resetForRepair, applyOperatorCassettesConfig, getFeeConfig, @@ -410,7 +410,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) @@ -418,8 +418,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() @@ -555,9 +555,9 @@ 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:mark-state-published', (_event, unixTimestamp: number): void => { + markStatePublished(unixTimestamp) }) ipcMain.handle( 'state:apply-operator-cassettes-config', diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index 78b0e13..cae791e 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -108,16 +108,16 @@ 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'), + 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, @@ -289,11 +289,11 @@ declare global { emptyCashbox: () => Promise remediateTransaction: (txid: string, remediatedByTxid: string) => Promise getLastKnownConfigCreatedAt: () => Promise - getBootstrapPublishedAt: () => Promise - markBootstrapPublished: (unixTimestamp: number) => Promise + getLastStatePublishedAt: () => Promise + markStatePublished: (unixTimestamp: number) => Promise saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise - resetBootstrapGate: () => Promise + resetStatePublishWatermark: () => Promise resetForRepair: () => Promise saveSpireSeed: (seed: string) => Promise relaunchApp: () => Promise diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index 39e32b6..dca1170 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -406,10 +406,20 @@ export function getLastKnownConfigCreatedAt(): number { } /** - * 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 } @@ -419,12 +429,8 @@ export function getBootstrapPublishedAt(): number | null { 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). - */ -export function markBootstrapPublished(unixTimestamp: number): void { +/** 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), @@ -533,11 +539,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') } diff --git a/apps/machine/src/services/operator-config.ts b/apps/machine/src/services/operator-config.ts index d853114..44b1979 100644 --- a/apps/machine/src/services/operator-config.ts +++ b/apps/machine/src/services/operator-config.ts @@ -11,15 +11,16 @@ * * - Operator → ATM: `kind=30078`, `["d", "bitspire-cassettes:"]`, * `["p", ]`, NIP-44 v2 encrypted content, author = operator pubkey - * - ATM bootstrap: `kind=30078`, `["d", "bitspire-cassettes-state:"]`, + * - ATM state: `kind=30078`, `["d", "bitspire-cassettes-state:"]`, * `["p", ]`, NIP-44 v2 encrypted content, author = ATM pubkey * * The ATM's hex pubkey serves as `` — globally unique, no * extra provisioning step required. * - * v1 only publishes the one-shot bootstrap hello-event. The continuous - * ATM-state reverse channel (publish on every count change + heartbeat) - * is v2 territory. + * The ATM publishes its state on startup, after every change to the bays, and + * on a heartbeat. It was once a single hello-event gated on a one-shot flag, + * which left the operator validating against a layout the machine no longer + * had (#94), and left a dispense published during a relay outage lost for good. */ import { @@ -37,6 +38,19 @@ const KIND_NIP78 = 30078 /** Accept operator events stamped up to this many seconds in the future. */ const MAX_FUTURE_SKEW_S = 60 +/** + * Republish the cassette state on this interval even when nothing changed. + * + * A publish is a single fire-and-forget event with no retry: if the relay is + * unreachable at the moment of a dispense, that update is simply gone and the + * operator's view stays wrong until the next customer happens to buy cash. A + * relay also acknowledges an event it then discards, so a publish that returns + * cleanly is not proof of anything. The heartbeat is what makes the channel + * self-healing, and it is also the only way an out-of-band edit to the table + * (atm-tui, direct SQL) ever reaches the operator. + */ +const STATE_HEARTBEAT_MS = 5 * 60 * 1000 + const operatorConfigDTag = (machineId: string) => `bitspire-cassettes:${machineId}` const atmStateDTag = (machineId: string) => `bitspire-cassettes-state:${machineId}` @@ -45,7 +59,7 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef export interface OperatorConfigServiceConfig { /** Connected NostrClient — shared with the Lightning service. */ nostrClient: NostrClient - /** Signer for the ATM identity. Decrypts operator events + signs the bootstrap. */ + /** Signer for the ATM identity. Decrypts operator events + signs our state. */ signer: Signer /** Operator pubkeys (hex) authorized to publish cassette config. From VITE_OPERATOR_PUBKEYS. */ operatorPubkeys: string[] @@ -83,12 +97,15 @@ export async function startOperatorConfigService( const api = window.electronAPI const machineId = cfg.machineId ?? cfg.signer.pubkey - // Bootstrap hello-event on first boot (best-effort — failure leaves the - // gate null so the next boot retries). + // Announce current state on every start. This used to be gated on a + // one-shot "have we said hello" flag, so any later change to the layout — + // a reseed, an atm-tui edit, direct SQL — was never published and the + // operator's dashboard kept validating against a bay set that no longer + // existed (#94). Best-effort; the heartbeat below is the safety net. try { - await maybePublishBootstrap(cfg, api, machineId) + await publishCassettesState(cfg, api, machineId) } catch (err) { - console.warn('[OperatorConfig] Bootstrap publish failed (will retry next boot):', err) + console.warn('[OperatorConfig] Startup cassettes-state publish failed:', err) } // Subscribe to operator-published cassette config events. @@ -112,8 +129,17 @@ export async function startOperatorConfigService( ) console.log('[OperatorConfig] Subscribed:', { dTag, subscriptionId }) + const heartbeat = setInterval(() => { + publishCassettesState(cfg, api, machineId).catch((err) => + console.warn('[OperatorConfig] cassettes-state heartbeat failed:', err) + ) + }, STATE_HEARTBEAT_MS) + return { - stop: () => cfg.nostrClient.unsubscribe(subscriptionId), + stop: () => { + clearInterval(heartbeat) + cfg.nostrClient.unsubscribe(subscriptionId) + }, publishCassettesState: () => publishCassettesState(cfg, api, machineId) .then(() => {}) @@ -224,11 +250,11 @@ async function handleOperatorConfigEvent( * Publish the ATM's current cassette state as a replaceable kind-30078 event * (`bitspire-cassettes-state:`), NIP-44-encrypted to the operator. * Replaceable → latest wins; the operator consumes every update. Call after a - * dispense and on a cassette reload so the operator view tracks reality, not - * the frozen bootstrap snapshot (coord 2026-06-21 / lamassu-next#56). + * dispense, on a cassette reload, at startup and on a heartbeat, so the + * operator view tracks reality (coord 2026-06-21 / lamassu-next#56). * - * NOT gated on the bootstrap flag — this is the live update. Returns whether an - * event was published (false when there are no cassettes / no operator). + * Returns whether an event was published (false when there are no cassettes / + * no operator). */ async function publishCassettesState( cfg: OperatorConfigServiceConfig, @@ -246,6 +272,15 @@ async function publishCassettesState( } const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify({ positions })) + // Force the stamp strictly above our last one. Addressable events are ordered + // by `created_at` at second granularity, ties broken by lowest event id, and + // the relay keeps one and silently drops the other while acknowledging both. + // So two publishes inside one second would leave the winner decided by a hash, + // permanently — and a clock that stepped backwards would make every report + // from this machine disappear. Neither failure is visible from here. + const lastPublished = (await api.getLastStatePublishedAt()) ?? 0 + const createdAt = Math.max(Math.floor(Date.now() / 1000), lastPublished + 1) + const dTag = atmStateDTag(machineId) const event = await createSignedEvent(cfg.signer, { kind: KIND_NIP78, @@ -254,34 +289,11 @@ async function publishCassettesState( ['d', dTag], ['p', operatorPubkey], ], - created_at: Math.floor(Date.now() / 1000), + created_at: createdAt, }) await cfg.nostrClient.publish(event) - console.log('[OperatorConfig] cassettes-state published:', { dTag, eventId: event.id }) + await api.markStatePublished(createdAt) + console.log('[OperatorConfig] cassettes-state published:', { dTag, eventId: event.id, createdAt }) return true } - -/** - * First-boot hello: publish the cassette state once and mark the gate. The - * gate (lamassu-next#56) prevents re-emitting the *bootstrap* on every boot; - * live updates after dispenses go through `publishCassettesState` directly. - */ -async function maybePublishBootstrap( - cfg: OperatorConfigServiceConfig, - api: NonNullable, - machineId: string -): Promise { - const already = await api.getBootstrapPublishedAt() - if (already !== null) { - console.log('[OperatorConfig] Bootstrap already published at unix', already) - return - } - const published = await publishCassettesState(cfg, api, machineId) - if (published) { - await api.markBootstrapPublished(Math.floor(Date.now() / 1000)) - console.log('[OperatorConfig] Bootstrap hello-event published') - } else { - console.log('[OperatorConfig] No cassettes/operator — skipping bootstrap') - } -} diff --git a/apps/machine/src/services/signer-resolver.ts b/apps/machine/src/services/signer-resolver.ts index f830073..9db99b1 100644 --- a/apps/machine/src/services/signer-resolver.ts +++ b/apps/machine/src/services/signer-resolver.ts @@ -5,7 +5,7 @@ * 1. A seed is present whose fingerprint differs from the stored binding * (first pair or re-pair) → generate a fresh NIP-46 transport key, redeem * the one-shot connect secret, persist the binding, and reset the - * bootstrap gate so the (possibly new) operator gets a hello-event (#56). + * publish watermark so the (possibly new) operator gets current state (#56). * 2. A seed is present matching the stored binding, OR no seed but a stored * binding exists → resume the bunker session with the persisted transport * key (no re-redeem — the binding is server-persistent). @@ -121,7 +121,7 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise Promise remediateTransaction: (txid: string, remediatedByTxid: string) => Promise getLastKnownConfigCreatedAt: () => Promise - getBootstrapPublishedAt: () => Promise - markBootstrapPublished: (unixTimestamp: number) => Promise + getLastStatePublishedAt: () => Promise + markStatePublished: (unixTimestamp: number) => Promise saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise - resetBootstrapGate: () => Promise + resetStatePublishWatermark: () => Promise resetForRepair: () => Promise saveSpireSeed: (seed: string) => Promise relaunchApp: () => Promise From 474903c38bbd7337d83f32b5a3508c61bc401ec8 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 22 Sep 2026 22:14:06 +0200 Subject: [PATCH 5/5] fix(cassettes): say when the counts are unverified instead of reporting a guess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the dispenser throws or the dispense times out there is no per-bay report, so nothing is debited — not the cassette rows, not HAL's bays. Bills may well have reached the customer, and both counters then read high with nothing to indicate it. The machine went on treating a number it had reason to doubt as measurement. A dispense that ends with no report now latches a countsUncertainSince flag, which rides along in the state document as counts_uncertain_since so the operator can see the numbers need a recount. The field is additive: a consumer reading positions ignores it, so this needs no coordinated release. An operator config apply clears the flag inside the same transaction, since asserting authoritative counts is precisely what a recount is. Co-Authored-By: Claude Fable 5.1 --- .../state-store-transactions.test.ts | 43 ++++++++++++++++ apps/machine/electron/main.ts | 6 +++ apps/machine/electron/preload.ts | 9 +++- apps/machine/electron/state-store.ts | 49 +++++++++++++++++++ apps/machine/src/services/operator-config.ts | 9 +++- apps/machine/src/stores/atm.ts | 13 +++++ apps/machine/src/types/electron.d.ts | 3 ++ 7 files changed, 130 insertions(+), 2 deletions(-) diff --git a/apps/machine/electron/__tests__/state-store-transactions.test.ts b/apps/machine/electron/__tests__/state-store-transactions.test.ts index cb64aa5..93c3434 100644 --- a/apps/machine/electron/__tests__/state-store-transactions.test.ts +++ b/apps/machine/electron/__tests__/state-store-transactions.test.ts @@ -12,11 +12,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { + applyOperatorCassettesConfig, closeDatabase, getCashbox, + getCountsUncertainSince, getInventory, initDatabase, loadCassettes, + markCountsUncertain, recordTransaction, setCassettes, } from '../state-store.js' @@ -322,3 +325,43 @@ describe('state-store: getInventory represents a drained machine', () => { 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 when an operator asserts real counts', () => { + markCountsUncertain(1000) + const applied = applyOperatorCassettesConfig( + { + positions: { + '1': { denomination: 20, count: 40 }, + '2': { denomination: 20, count: 40 }, + '3': { denomination: 50, count: 25 }, + }, + }, + 1_700_000_000 + ) + expect(applied.applied).toBe(true) + // A recount is exactly the operator asserting authoritative counts. + expect(getCountsUncertainSince()).toBeNull() + }) + + it('leaves the flag alone when the operator config is rejected', () => { + markCountsUncertain(1000) + // Position key-set mismatch — the bay layout is hardware-determined. + const applied = applyOperatorCassettesConfig( + { positions: { '1': { denomination: 20, count: 40 } } }, + 1_700_000_001 + ) + expect(applied.applied).toBe(false) + expect(getCountsUncertainSince()).toBe(1000) + }) +}) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index ae06f43..7383552 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -24,7 +24,9 @@ import { markCommandExecuting, completeCommand, getLastKnownConfigCreatedAt, + getCountsUncertainSince, getLastStatePublishedAt, + markCountsUncertain, markStatePublished, resetStatePublishWatermark, resetForRepair, @@ -556,6 +558,10 @@ ipcMain.handle('state:get-last-known-config-created-at', (): number => getLastKnownConfigCreatedAt() ) 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) }) diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index cae791e..c376170 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -110,6 +110,10 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('state:get-last-known-config-created-at'), 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), @@ -117,7 +121,8 @@ contextBridge.exposeInMainWorld('electronAPI', { saveBunkerBinding: (binding: BunkerBindingRecord): Promise => ipcRenderer.invoke('state:save-bunker-binding', binding), clearBunkerBinding: (): Promise => ipcRenderer.invoke('state:clear-bunker-binding'), - resetStatePublishWatermark: (): Promise => ipcRenderer.invoke('state:reset-state-publish-watermark'), + 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, @@ -290,6 +295,8 @@ declare global { remediateTransaction: (txid: string, remediatedByTxid: string) => Promise getLastKnownConfigCreatedAt: () => Promise getLastStatePublishedAt: () => Promise + getCountsUncertainSince: () => Promise + markCountsUncertain: (unixTimestamp: number) => Promise markStatePublished: (unixTimestamp: number) => Promise saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index dca1170..ab3e304 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -429,6 +429,48 @@ export function getLastStatePublishedAt(): number | null { return Number.isFinite(n) ? n : 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 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', '') +} + /** 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') @@ -661,11 +703,18 @@ export function applyOperatorCassettesConfig( ) const setWatermark = db.prepare('UPDATE meta SET value = ? WHERE key = ?') + const clearUncertain = db.prepare( + 'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value' + ) + const run = db.transaction(() => { for (const [posKey, entry] of Object.entries(payload.positions)) { updateCassette.run(entry.denomination, entry.count, Number(posKey)) } setWatermark.run(String(eventCreatedAt), 'lastKnownConfigCreatedAt') + // The operator just asserted real counts, which is what a recount is. + // Whatever made the old numbers untrustworthy no longer applies. + clearUncertain.run('countsUncertainSince', '') }) run() diff --git a/apps/machine/src/services/operator-config.ts b/apps/machine/src/services/operator-config.ts index 44b1979..1627f5a 100644 --- a/apps/machine/src/services/operator-config.ts +++ b/apps/machine/src/services/operator-config.ts @@ -270,7 +270,14 @@ async function publishCassettesState( for (const c of cassettes) { positions[String(c.position)] = { denomination: c.denomination, count: c.count } } - const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify({ positions })) + // Additive field: an operator on the old consumer reads `positions` and + // ignores this, so it needs no coordinated release. When set, the counts + // above are the machine's best guess, not a measurement. + const countsUncertainSince = await api.getCountsUncertainSince() + const payload = countsUncertainSince + ? { positions, counts_uncertain_since: countsUncertainSince } + : { positions } + const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify(payload)) // Force the stamp strictly above our last one. Addressable events are ordered // by `created_at` at second granularity, ties broken by lowest event id, and diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index ec8b41e..23fcd2e 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -633,6 +633,19 @@ export const useAtmStore = defineStore('atm', () => { bills = dr.bills .filter((b) => b.dispensed > 0) .map((b) => ({ denomination: b.denomination, count: b.dispensed })) + } else { + // The dispenser threw, or the dispense timed out, so there is no + // per-bay report. Bills may well have reached the customer, and + // nothing knows how many: neither the cassette rows nor HAL's bays + // were debited, so both now read high. Record that the counts are + // unverified instead of letting a number we know may be wrong go + // on being treated as fact. + console.error( + `[ATM] Dispense ended with no report — bay counts are unverified (txid=${ctx.txid})` + ) + void window.electronAPI + ?.markCountsUncertain(Math.floor(Date.now() / 1000)) + .catch((e) => console.warn('[ATM] Could not flag counts unverified:', e)) } persistTransaction({ diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 3cf73fe..e366f6d 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -147,6 +147,9 @@ declare global { remediateTransaction: (txid: string, remediatedByTxid: string) => Promise getLastKnownConfigCreatedAt: () => Promise getLastStatePublishedAt: () => Promise + /** When the bay counts became unverified (a dispense that reported nothing), or null. */ + getCountsUncertainSince: () => Promise + markCountsUncertain: (unixTimestamp: number) => Promise markStatePublished: (unixTimestamp: number) => Promise saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise