fix(cassettes): close the machine-side divergence paths #104

Merged
padreug merged 5 commits from fix/cassette-sync-machine into dev 2026-09-22 20:30:24 +00:00
8 changed files with 482 additions and 112 deletions

View file

@ -12,10 +12,14 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { import {
applyOperatorCassettesConfig,
closeDatabase, closeDatabase,
getCashbox, getCashbox,
getCountsUncertainSince,
getInventory,
initDatabase, initDatabase,
loadCassettes, loadCassettes,
markCountsUncertain,
recordTransaction, recordTransaction,
setCassettes, setCassettes,
} from '../state-store.js' } from '../state-store.js'
@ -168,3 +172,196 @@ describe('state-store: recordTransaction cash_in cashbox', () => {
expect(countsByPosition()).toEqual({ 1: 50, 2: 50, 3: 30 }) 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)
})
})

View file

@ -24,9 +24,11 @@ import {
markCommandExecuting, markCommandExecuting,
completeCommand, completeCommand,
getLastKnownConfigCreatedAt, getLastKnownConfigCreatedAt,
getBootstrapPublishedAt, getCountsUncertainSince,
markBootstrapPublished, getLastStatePublishedAt,
resetBootstrapGate, markCountsUncertain,
markStatePublished,
resetStatePublishWatermark,
resetForRepair, resetForRepair,
applyOperatorCassettesConfig, applyOperatorCassettesConfig,
getFeeConfig, getFeeConfig,
@ -410,7 +412,7 @@ ipcMain.handle('get-atm-secrets', () => {
}) })
// Bunker binding persistence — the renderer writes the binding after a // 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). // new operator receives the spire's hello-event (aiolabs/bitspire#52 / #56).
ipcMain.handle('state:save-bunker-binding', (_event, binding: StoredBunkerBinding): void => { ipcMain.handle('state:save-bunker-binding', (_event, binding: StoredBunkerBinding): void => {
saveBunkerBinding(binding) saveBunkerBinding(binding)
@ -418,8 +420,8 @@ ipcMain.handle('state:save-bunker-binding', (_event, binding: StoredBunkerBindin
ipcMain.handle('state:clear-bunker-binding', (): void => { ipcMain.handle('state:clear-bunker-binding', (): void => {
clearBunkerBinding() clearBunkerBinding()
}) })
ipcMain.handle('state:reset-bootstrap-gate', (): void => { ipcMain.handle('state:reset-state-publish-watermark', (): void => {
resetBootstrapGate() resetStatePublishWatermark()
}) })
ipcMain.handle('state:reset-for-repair', (): void => { ipcMain.handle('state:reset-for-repair', (): void => {
resetForRepair() resetForRepair()
@ -555,9 +557,13 @@ ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedB
ipcMain.handle('state:get-last-known-config-created-at', (): number => ipcMain.handle('state:get-last-known-config-created-at', (): number =>
getLastKnownConfigCreatedAt() getLastKnownConfigCreatedAt()
) )
ipcMain.handle('state:get-bootstrap-published-at', (): number | null => getBootstrapPublishedAt()) ipcMain.handle('state:get-last-state-published-at', (): number | null => getLastStatePublishedAt())
ipcMain.handle('state:mark-bootstrap-published', (_event, unixTimestamp: number): void => { ipcMain.handle('state:get-counts-uncertain-since', (): number | null => getCountsUncertainSince())
markBootstrapPublished(unixTimestamp) 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( ipcMain.handle(
'state:apply-operator-cassettes-config', 'state:apply-operator-cassettes-config',
@ -841,6 +847,11 @@ function startCommandPoller(): void {
error: result.error, 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 // Only remediate the original tx if ALL requested bills were dispensed
let refRemediated = false let refRemediated = false
if (parsed.ref_txid && result.dispensed) { if (parsed.ref_txid && result.dispensed) {

View file

@ -108,16 +108,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Operator-config consumer (aiolabs/lamassu-next#56) // Operator-config consumer (aiolabs/lamassu-next#56)
getLastKnownConfigCreatedAt: (): Promise<number> => getLastKnownConfigCreatedAt: (): Promise<number> =>
ipcRenderer.invoke('state:get-last-known-config-created-at'), ipcRenderer.invoke('state:get-last-known-config-created-at'),
getBootstrapPublishedAt: (): Promise<number | null> => getLastStatePublishedAt: (): Promise<number | null> =>
ipcRenderer.invoke('state:get-bootstrap-published-at'), ipcRenderer.invoke('state:get-last-state-published-at'),
markBootstrapPublished: (unixTimestamp: number): Promise<void> => getCountsUncertainSince: (): Promise<number | null> =>
ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp), ipcRenderer.invoke('state:get-counts-uncertain-since'),
markCountsUncertain: (unixTimestamp: number): Promise<void> =>
ipcRenderer.invoke('state:mark-counts-uncertain', unixTimestamp),
markStatePublished: (unixTimestamp: number): Promise<void> =>
ipcRenderer.invoke('state:mark-state-published', unixTimestamp),
// Bunker binding persistence (aiolabs/bitspire#52) // Bunker binding persistence (aiolabs/bitspire#52)
saveBunkerBinding: (binding: BunkerBindingRecord): Promise<void> => saveBunkerBinding: (binding: BunkerBindingRecord): Promise<void> =>
ipcRenderer.invoke('state:save-bunker-binding', binding), ipcRenderer.invoke('state:save-bunker-binding', binding),
clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'), clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'),
resetBootstrapGate: (): Promise<void> => ipcRenderer.invoke('state:reset-bootstrap-gate'), resetStatePublishWatermark: (): Promise<void> =>
ipcRenderer.invoke('state:reset-state-publish-watermark'),
resetForRepair: (): Promise<void> => ipcRenderer.invoke('state:reset-for-repair'), resetForRepair: (): Promise<void> => ipcRenderer.invoke('state:reset-for-repair'),
// QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed, // 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 // Bolt Card reader (main process → renderer). removeAllListeners first: a
// renderer reload re-runs this, and a duplicated card-tap listener would // renderer reload re-runs this, and a duplicated card-tap listener would
// trigger the LNURL-withdraw twice. // 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) => { onNfcCardTapped: (callback: (lnurlw: string) => void) => {
ipcRenderer.removeAllListeners('nfc:card-tapped') ipcRenderer.removeAllListeners('nfc:card-tapped')
ipcRenderer.on('nfc:card-tapped', (_event, lnurlw) => callback(lnurlw)) ipcRenderer.on('nfc:card-tapped', (_event, lnurlw) => callback(lnurlw))
@ -282,11 +294,13 @@ declare global {
emptyCashbox: () => Promise<void> emptyCashbox: () => Promise<void>
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean> remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
getLastKnownConfigCreatedAt: () => Promise<number> getLastKnownConfigCreatedAt: () => Promise<number>
getBootstrapPublishedAt: () => Promise<number | null> getLastStatePublishedAt: () => Promise<number | null>
markBootstrapPublished: (unixTimestamp: number) => Promise<void> getCountsUncertainSince: () => Promise<number | null>
markCountsUncertain: (unixTimestamp: number) => Promise<void>
markStatePublished: (unixTimestamp: number) => Promise<void>
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void> saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void> clearBunkerBinding: () => Promise<void>
resetBootstrapGate: () => Promise<void> resetStatePublishWatermark: () => Promise<void>
resetForRepair: () => Promise<void> resetForRepair: () => Promise<void>
saveSpireSeed: (seed: string) => Promise<void> saveSpireSeed: (seed: string) => Promise<void>
relaunchApp: () => Promise<void> relaunchApp: () => Promise<void>

View file

@ -295,7 +295,9 @@ export function initDatabase(dbPath?: string): void {
`) `)
db.pragma('foreign_keys = ON') db.pragma('foreign_keys = ON')
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('9', 'schema_version') 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' existing.value = '9'
} }
@ -397,32 +399,80 @@ export function initDatabase(dbPath?: string): void {
*/ */
export function getLastKnownConfigCreatedAt(): number { export function getLastKnownConfigCreatedAt(): number {
if (!db) throw new Error('Database not initialized') if (!db) throw new Error('Database not initialized')
const row = db const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('lastKnownConfigCreatedAt') as
.prepare('SELECT value FROM meta WHERE key = ?') | { value: string }
.get('lastKnownConfigCreatedAt') as { value: string } | undefined | undefined
return row ? Number(row.value) || 0 : 0 return row ? Number(row.value) || 0 : 0
} }
/** /**
* Read the one-shot bootstrap-publish gate. Returns null if the ATM has * The `created_at` of the last `bitspire-cassettes-state` event this machine
* not yet published its `bitspire-cassettes-state:<machine_id>` hello-event. * 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') if (!db) throw new Error('Database not initialized')
const row = db const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('bootstrapPublishedAt') as
.prepare('SELECT value FROM meta WHERE key = ?') | { value: string }
.get('bootstrapPublishedAt') as { value: string } | undefined | undefined
if (!row || row.value === '') return null if (!row || row.value === '') return null
const n = Number(row.value) const n = Number(row.value)
return Number.isFinite(n) ? n : null return Number.isFinite(n) ? n : null
} }
/** /**
* Mark the bootstrap hello-event as published. Idempotent — only takes * Whether the bay counts are known to be unverified, and since when.
* effect the first time it's set. Subsequent calls overwrite the *
* timestamp (harmless; the gate just needs to be non-null). * 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') if (!db) throw new Error('Database not initialized')
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run( db.prepare('UPDATE meta SET value = ? WHERE key = ?').run(
String(unixTimestamp), String(unixTimestamp),
@ -531,11 +581,12 @@ export function clearBunkerBinding(): void {
} }
/** /**
* Reset the bootstrap-publish gate so the ATM re-publishes its * Forget the publish high-water mark. Called on a re-pair (new seed): the
* `bitspire-cassettes-state` hello-event. Called on a re-pair (new seed) so * next publish is then free to use the wall clock, which is what a fresh
* the new operator receives the spire's current state (aiolabs/bitspire#56). * 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') if (!db) throw new Error('Database not initialized')
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt') db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt')
} }
@ -570,9 +621,7 @@ export type OperatorCassettesPayload = {
positions: Record<string, { denomination: number; count: number }> positions: Record<string, { denomination: number; count: number }>
} }
export type ApplyResult = export type ApplyResult = { applied: true } | { applied: false; reason: string }
| { applied: true }
| { applied: false; reason: string }
/** /**
* Atomic apply of an operator-published cassette config (aiolabs/lamassu-next#56). * Atomic apply of an operator-published cassette config (aiolabs/lamassu-next#56).
@ -613,9 +662,7 @@ export function applyOperatorCassettesConfig(
} }
} }
const currentRows = db const currentRows = db.prepare('SELECT position FROM cassettes').all() as { position: number }[]
.prepare('SELECT position FROM cassettes')
.all() as { position: number }[]
const currentPositions = new Set(currentRows.map((r) => r.position)) const currentPositions = new Set(currentRows.map((r) => r.position))
const payloadPositions = new Set(Object.keys(payload.positions).map((k) => Number(k))) 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 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(() => { const run = db.transaction(() => {
for (const [posKey, entry] of Object.entries(payload.positions)) { for (const [posKey, entry] of Object.entries(payload.positions)) {
updateCassette.run(entry.denomination, entry.count, Number(posKey)) updateCassette.run(entry.denomination, entry.count, Number(posKey))
} }
setWatermark.run(String(eventCreatedAt), 'lastKnownConfigCreatedAt') 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() run()
@ -752,10 +806,7 @@ export interface FeeConfigPayload {
*/ */
const FEE_CAP_PER_DIRECTION = 0.15 const FEE_CAP_PER_DIRECTION = 0.15
export function applyFeeConfig( export function applyFeeConfig(payload: FeeConfigPayload, eventCreatedAt: number): ApplyResult {
payload: FeeConfigPayload,
eventCreatedAt: number
): ApplyResult {
if (!db) throw new Error('Database not initialized') if (!db) throw new Error('Database not initialized')
const watermark = getLastKnownFeeConfigCreatedAt() const watermark = getLastKnownFeeConfigCreatedAt()
@ -879,10 +930,15 @@ export function getInventory(): Record<number, number> {
const rows = loadCassettes() const rows = loadCassettes()
const inv: Record<number, number> = {} const inv: Record<number, number> = {}
for (const row of rows) { for (const row of rows) {
if (row.count > 0) { // 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 inv[row.denomination] = (inv[row.denomination] ?? 0) + row.count
} }
}
return inv 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). // Decrement cassettes by ACTUALLY dispensed count (not requested).
// Position is the addressable unit (v9): duplicate denominations // Position is the addressable unit (v9): duplicate denominations
// across bays are legal, so a denomination-keyed UPDATE would // across bays are legal, so a denomination-keyed UPDATE would

View file

@ -11,15 +11,16 @@
* *
* - Operator → ATM: `kind=30078`, `["d", "bitspire-cassettes:<machine_id>"]`, * - Operator → ATM: `kind=30078`, `["d", "bitspire-cassettes:<machine_id>"]`,
* `["p", <atm_npub>]`, NIP-44 v2 encrypted content, author = operator pubkey * `["p", <atm_npub>]`, NIP-44 v2 encrypted content, author = operator pubkey
* - ATM bootstrap: `kind=30078`, `["d", "bitspire-cassettes-state:<machine_id>"]`, * - ATM state: `kind=30078`, `["d", "bitspire-cassettes-state:<machine_id>"]`,
* `["p", <operator_pubkey>]`, NIP-44 v2 encrypted content, author = ATM pubkey * `["p", <operator_pubkey>]`, NIP-44 v2 encrypted content, author = ATM pubkey
* *
* The ATM's hex pubkey serves as `<machine_id>` — globally unique, no * The ATM's hex pubkey serves as `<machine_id>` — globally unique, no
* extra provisioning step required. * extra provisioning step required.
* *
* v1 only publishes the one-shot bootstrap hello-event. The continuous * The ATM publishes its state on startup, after every change to the bays, and
* ATM-state reverse channel (publish on every count change + heartbeat) * on a heartbeat. It was once a single hello-event gated on a one-shot flag,
* is v2 territory. * 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 { import {
@ -37,6 +38,19 @@ const KIND_NIP78 = 30078
/** Accept operator events stamped up to this many seconds in the future. */ /** Accept operator events stamped up to this many seconds in the future. */
const MAX_FUTURE_SKEW_S = 60 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 operatorConfigDTag = (machineId: string) => `bitspire-cassettes:${machineId}`
const atmStateDTag = (machineId: string) => `bitspire-cassettes-state:${machineId}` const atmStateDTag = (machineId: string) => `bitspire-cassettes-state:${machineId}`
@ -45,7 +59,7 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
export interface OperatorConfigServiceConfig { export interface OperatorConfigServiceConfig {
/** Connected NostrClient — shared with the Lightning service. */ /** Connected NostrClient — shared with the Lightning service. */
nostrClient: NostrClient 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 signer: Signer
/** Operator pubkeys (hex) authorized to publish cassette config. From VITE_OPERATOR_PUBKEYS. */ /** Operator pubkeys (hex) authorized to publish cassette config. From VITE_OPERATOR_PUBKEYS. */
operatorPubkeys: string[] operatorPubkeys: string[]
@ -83,12 +97,15 @@ export async function startOperatorConfigService(
const api = window.electronAPI const api = window.electronAPI
const machineId = cfg.machineId ?? cfg.signer.pubkey const machineId = cfg.machineId ?? cfg.signer.pubkey
// Bootstrap hello-event on first boot (best-effort — failure leaves the // Announce current state on every start. This used to be gated on a
// gate null so the next boot retries). // 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 { try {
await maybePublishBootstrap(cfg, api, machineId) await publishCassettesState(cfg, api, machineId)
} catch (err) { } 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. // Subscribe to operator-published cassette config events.
@ -112,8 +129,17 @@ export async function startOperatorConfigService(
) )
console.log('[OperatorConfig] Subscribed:', { dTag, subscriptionId }) 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 { return {
stop: () => cfg.nostrClient.unsubscribe(subscriptionId), stop: () => {
clearInterval(heartbeat)
cfg.nostrClient.unsubscribe(subscriptionId)
},
publishCassettesState: () => publishCassettesState: () =>
publishCassettesState(cfg, api, machineId) publishCassettesState(cfg, api, machineId)
.then(() => {}) .then(() => {})
@ -224,11 +250,11 @@ async function handleOperatorConfigEvent(
* Publish the ATM's current cassette state as a replaceable kind-30078 event * Publish the ATM's current cassette state as a replaceable kind-30078 event
* (`bitspire-cassettes-state:<machineId>`), NIP-44-encrypted to the operator. * (`bitspire-cassettes-state:<machineId>`), NIP-44-encrypted to the operator.
* Replaceable → latest wins; the operator consumes every update. Call after a * Replaceable → latest wins; the operator consumes every update. Call after a
* dispense and on a cassette reload so the operator view tracks reality, not * dispense, on a cassette reload, at startup and on a heartbeat, so the
* the frozen bootstrap snapshot (coord 2026-06-21 / lamassu-next#56). * 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 * Returns whether an event was published (false when there are no cassettes /
* event was published (false when there are no cassettes / no operator). * no operator).
*/ */
async function publishCassettesState( async function publishCassettesState(
cfg: OperatorConfigServiceConfig, cfg: OperatorConfigServiceConfig,
@ -244,7 +270,23 @@ async function publishCassettesState(
for (const c of cassettes) { for (const c of cassettes) {
positions[String(c.position)] = { denomination: c.denomination, count: c.count } 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 dTag = atmStateDTag(machineId)
const event = await createSignedEvent(cfg.signer, { const event = await createSignedEvent(cfg.signer, {
@ -254,34 +296,11 @@ async function publishCassettesState(
['d', dTag], ['d', dTag],
['p', operatorPubkey], ['p', operatorPubkey],
], ],
created_at: Math.floor(Date.now() / 1000), created_at: createdAt,
}) })
await cfg.nostrClient.publish(event) 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 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<typeof window.electronAPI>,
machineId: string
): Promise<void> {
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')
}
}

View file

@ -5,7 +5,7 @@
* 1. A seed is present whose fingerprint differs from the stored binding * 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 * (first pair or re-pair) → generate a fresh NIP-46 transport key, redeem
* the one-shot connect secret, persist the binding, and reset the * 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 * 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 * binding exists → resume the bunker session with the persisted transport
* key (no re-redeem — the binding is server-persistent). * key (no re-redeem — the binding is server-persistent).
@ -121,7 +121,7 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise<Resolve
if (binding) { if (binding) {
console.warn( console.warn(
'[Signer] Stored spire seed is unparseable; resuming from existing binding:', '[Signer] Stored spire seed is unparseable; resuming from existing binding:',
(err as Error).message, (err as Error).message
) )
return { signer: await resume(binding), transport: transportFromBinding(binding) } return { signer: await resume(binding), transport: transportFromBinding(binding) }
} }
@ -150,7 +150,9 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise<Resolve
// first pair (no prior binding) has nothing to reset. Cash accounting is // first pair (no prior binding) has nothing to reset. Cash accounting is
// preserved — see resetForRepair; a full wipe is the factory-reset path. // preserved — see resetForRepair; a full wipe is the factory-reset path.
if (binding) { if (binding) {
console.log('[Signer] Re-pair (new seed fingerprint) — clearing prior operator config state') console.log(
'[Signer] Re-pair (new seed fingerprint) — clearing prior operator config state'
)
await window.electronAPI.resetForRepair() await window.electronAPI.resetForRepair()
} }
// Persist the seed's transport config alongside the binding so a later // Persist the seed's transport config alongside the binding so a later
@ -165,7 +167,7 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise<Resolve
lnbitsServerPubkey: seed.lnbitsServerPubkey, lnbitsServerPubkey: seed.lnbitsServerPubkey,
}) })
// Re-pair → re-publish the cassette-state hello to the new operator (#56). // Re-pair → re-publish the cassette-state hello to the new operator (#56).
await window.electronAPI.resetBootstrapGate() await window.electronAPI.resetStatePublishWatermark()
} }
return { signer, transport: transportFromSeed(seed) } return { signer, transport: transportFromSeed(seed) }
} }

View file

@ -72,7 +72,13 @@ async function handleManagementCommand(
request: ManagementRequest, request: ManagementRequest,
dispenseFn: (amounts: { denomination: number; count: number }[]) => Promise<any>, dispenseFn: (amounts: { denomination: number; count: number }[]) => Promise<any>,
machineIdle: boolean, 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<void>
): Promise<ManagementResponse | null> { ): Promise<ManagementResponse | null> {
if (!isMachineDispenseRequest(request)) return null if (!isMachineDispenseRequest(request)) return null
@ -130,6 +136,7 @@ async function handleManagementCommand(
cassettes: result.cassettes, cassettes: result.cassettes,
error: result.error, error: result.error,
}) })
await onCassettesChanged?.()
// Only remediate the original tx if ALL requested bills were dispensed // Only remediate the original tx if ALL requested bills were dispensed
let refRemediated = false let refRemediated = false
@ -161,10 +168,14 @@ async function handleManagementCommand(
} }
/** /**
* Load inventory from SQLite via IPC (Electron only). * Load inventory from SQLite via IPC.
* Returns empty object in browser dev mode. *
* 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<Record<number, number>> { async function loadInventoryFromDb(): Promise<Record<number, number> | null> {
if (isElectron && window.electronAPI) { if (isElectron && window.electronAPI) {
try { try {
const inv = await window.electronAPI.getInventory() const inv = await window.electronAPI.getInventory()
@ -174,7 +185,7 @@ async function loadInventoryFromDb(): Promise<Record<number, number>> {
console.warn('[ATM] Failed to load inventory from DB:', e) console.warn('[ATM] Failed to load inventory from DB:', e)
} }
} }
return {} return null
} }
/** /**
@ -446,13 +457,25 @@ export const useAtmStore = defineStore('atm', () => {
*/ */
const persistedInventory = ref<Record<number, number>>({}) const persistedInventory = ref<Record<number, number>>({})
/**
* 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() { async function reloadPersistedInventory() {
const inv = await loadInventoryFromDb() const inv = await loadInventoryFromDb()
if (Object.keys(inv).length > 0) { // 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 persistedInventory.value = inv
console.log('[ATM] Persisted inventory updated:', inv) console.log('[ATM] Persisted inventory updated:', inv)
} }
}
/** Detect Bitcoin network from a BOLT-11 invoice prefix (called once, persisted) */ /** Detect Bitcoin network from a BOLT-11 invoice prefix (called once, persisted) */
function detectNetworkFromInvoice(invoice: string) { function detectNetworkFromInvoice(invoice: string) {
@ -610,6 +633,19 @@ export const useAtmStore = defineStore('atm', () => {
bills = dr.bills bills = dr.bills
.filter((b) => b.dispensed > 0) .filter((b) => b.dispensed > 0)
.map((b) => ({ denomination: b.denomination, count: b.dispensed })) .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({ persistTransaction({
@ -703,6 +739,7 @@ export const useAtmStore = defineStore('atm', () => {
// Start the machine // Start the machine
actor.value.start() actor.value.start()
setupNfcListener() setupNfcListener()
setupCassettesChangedListener()
console.log('[ATM] State machine initialized') 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). */ /** Wire the main-process reader once (idempotent via preload removeAllListeners). */
function setupNfcListener() { function setupNfcListener() {
if (!isElectron || !window.electronAPI?.onNfcCardTapped) return if (!isElectron || !window.electronAPI?.onNfcCardTapped) return
@ -1064,7 +1116,8 @@ export const useAtmStore = defineStore('atm', () => {
}), }),
getInventory: async () => { getInventory: async () => {
const fresh = await loadInventoryFromDb() 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, request,
(amounts) => hal.atmServices.dispenseCash(amounts), (amounts) => hal.atmServices.dispenseCash(amounts),
isIdle.value, 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 // If DB has inventory, use it; otherwise fall back to HAL
getInventory: async () => { getInventory: async () => {
const fresh = await loadInventoryFromDb() 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) return await api.halDispense(amounts)
}, },
getInventory: async () => { 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() 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 // Fall back to HAL's cassette-based inventory
try { try {
const halInv = await api.halGetInventory() const halInv = await api.halGetInventory()
@ -1641,7 +1698,8 @@ export const useAtmStore = defineStore('atm', () => {
request, request,
(amounts) => api.halDispense(amounts), (amounts) => api.halDispense(amounts),
isIdle.value, isIdle.value,
fiatCode.value fiatCode.value,
refreshAndPublishCassettes
) )
}) })

View file

@ -146,11 +146,14 @@ declare global {
emptyCashbox: () => Promise<void> emptyCashbox: () => Promise<void>
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean> remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
getLastKnownConfigCreatedAt: () => Promise<number> getLastKnownConfigCreatedAt: () => Promise<number>
getBootstrapPublishedAt: () => Promise<number | null> getLastStatePublishedAt: () => Promise<number | null>
markBootstrapPublished: (unixTimestamp: number) => Promise<void> /** When the bay counts became unverified (a dispense that reported nothing), or null. */
getCountsUncertainSince: () => Promise<number | null>
markCountsUncertain: (unixTimestamp: number) => Promise<void>
markStatePublished: (unixTimestamp: number) => Promise<void>
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void> saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void> clearBunkerBinding: () => Promise<void>
resetBootstrapGate: () => Promise<void> resetStatePublishWatermark: () => Promise<void>
resetForRepair: () => Promise<void> resetForRepair: () => Promise<void>
saveSpireSeed: (seed: string) => Promise<void> saveSpireSeed: (seed: string) => Promise<void>
relaunchApp: () => Promise<void> relaunchApp: () => Promise<void>
@ -217,6 +220,8 @@ declare global {
onHalBillInserted: (callback: (denomination: number) => void) => void onHalBillInserted: (callback: (denomination: number) => void) => void
onHalBillRejected: (callback: (reason: string) => void) => void onHalBillRejected: (callback: (reason: string) => void) => void
onHalError: (callback: (error: 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. */ /** Bolt Card reader: a tapped card's lnurlw voucher. */
onNfcCardTapped: (callback: (lnurlw: string) => void) => void onNfcCardTapped: (callback: (lnurlw: string) => void) => void
/** Bolt Card reader status (ready / reading / error / unavailable). */ /** Bolt Card reader status (ready / reading / error / unavailable). */