diff --git a/apps/machine/electron/__tests__/state-store-transactions.test.ts b/apps/machine/electron/__tests__/state-store-transactions.test.ts index fbb9a87..93c3434 100644 --- a/apps/machine/electron/__tests__/state-store-transactions.test.ts +++ b/apps/machine/electron/__tests__/state-store-transactions.test.ts @@ -12,10 +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' @@ -168,3 +172,196 @@ 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 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 0331392..7383552 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -24,9 +24,11 @@ import { markCommandExecuting, completeCommand, getLastKnownConfigCreatedAt, - getBootstrapPublishedAt, - markBootstrapPublished, - resetBootstrapGate, + getCountsUncertainSince, + getLastStatePublishedAt, + markCountsUncertain, + markStatePublished, + resetStatePublishWatermark, resetForRepair, applyOperatorCassettesConfig, getFeeConfig, @@ -410,7 +412,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 +420,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 +557,13 @@ 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', @@ -841,6 +847,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..c376170 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, @@ -222,6 +227,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)) @@ -282,11 +294,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 diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index cfbfd71..ab3e304 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,32 +399,80 @@ 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', '') +} + +/** 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 +581,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') } @@ -570,9 +621,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 +662,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))) @@ -656,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() @@ -752,10 +806,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() @@ -879,9 +930,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 +1082,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 diff --git a/apps/machine/src/services/operator-config.ts b/apps/machine/src/services/operator-config.ts index d853114..1627f5a 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, @@ -244,7 +270,23 @@ 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 + // 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, { @@ -254,34 +296,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, 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 @@ -161,10 +168,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 +185,7 @@ async function loadInventoryFromDb(): Promise> { console.warn('[ATM] Failed to load inventory from DB:', e) } } - return {} + return null } /** @@ -446,12 +457,24 @@ 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() - 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) */ @@ -610,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({ @@ -703,6 +739,7 @@ export const useAtmStore = defineStore('atm', () => { // Start the machine actor.value.start() setupNfcListener() + setupCassettesChangedListener() console.log('[ATM] State machine initialized') } @@ -916,6 +953,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 @@ -1064,7 +1116,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() }, } @@ -1312,7 +1365,8 @@ export const useAtmStore = defineStore('atm', () => { request, (amounts) => hal.atmServices.dispenseCash(amounts), isIdle.value, - fiatCode.value + fiatCode.value, + refreshAndPublishCassettes ) }) @@ -1327,7 +1381,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 +1666,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() @@ -1641,7 +1698,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..e366f6d 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -146,11 +146,14 @@ declare global { emptyCashbox: () => Promise remediateTransaction: (txid: string, remediatedByTxid: string) => Promise getLastKnownConfigCreatedAt: () => Promise - getBootstrapPublishedAt: () => Promise - markBootstrapPublished: (unixTimestamp: number) => 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 - resetBootstrapGate: () => Promise + resetStatePublishWatermark: () => Promise resetForRepair: () => Promise saveSpireSeed: (seed: string) => Promise relaunchApp: () => Promise @@ -217,6 +220,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). */