fix(cassettes): close the machine-side divergence paths #104
8 changed files with 482 additions and 112 deletions
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,9 +930,14 @@ 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
|
||||||
inv[row.denomination] = (inv[row.denomination] ?? 0) + row.count
|
// 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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,12 +457,24 @@ 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,
|
||||||
persistedInventory.value = inv
|
// which meant the last bill out of the machine never updated anything and
|
||||||
console.log('[ATM] Persisted inventory updated:', inv)
|
// 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) */
|
/** Detect Bitcoin network from a BOLT-11 invoice prefix (called once, persisted) */
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
11
apps/machine/src/types/electron.d.ts
vendored
11
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -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). */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue