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 {
|
||||
applyOperatorCassettesConfig,
|
||||
closeDatabase,
|
||||
getCashbox,
|
||||
getCountsUncertainSince,
|
||||
getInventory,
|
||||
initDatabase,
|
||||
loadCassettes,
|
||||
markCountsUncertain,
|
||||
recordTransaction,
|
||||
setCassettes,
|
||||
} from '../state-store.js'
|
||||
|
|
@ -168,3 +172,196 @@ describe('state-store: recordTransaction cash_in cashbox', () => {
|
|||
expect(countsByPosition()).toEqual({ 1: 50, 2: 50, 3: 30 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('state-store: recordTransaction manual_dispense inventory (#76)', () => {
|
||||
it('decrements the bays an operator remediation actually emptied', () => {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-manual',
|
||||
type: 'manual_dispense',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 20, count: 2 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette1',
|
||||
position: 1,
|
||||
denomination: 20,
|
||||
provisioned: 2,
|
||||
dispensed: 2,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
// Bills physically left bay 1; before #76 this row was untouched and the
|
||||
// inflated count became truth on the next boot.
|
||||
expect(countsByPosition()).toEqual({ 1: 48, 2: 50, 3: 30 })
|
||||
})
|
||||
|
||||
it('decrements again when remediating a partly-dispensed cash-out', () => {
|
||||
// Original cash-out managed 1 of the 2 notes it provisioned.
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-partial',
|
||||
type: 'cash_out',
|
||||
status: 'partial',
|
||||
bills: [{ denomination: 50, count: 1 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette3',
|
||||
position: 3,
|
||||
denomination: 50,
|
||||
provisioned: 2,
|
||||
dispensed: 1,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(countsByPosition()[3]).toBe(29)
|
||||
|
||||
// The operator dispenses the missing note by hand. That is a second lot of
|
||||
// bills leaving the bay, so it debits again — the original only ever
|
||||
// debited what physically left.
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-remediate',
|
||||
type: 'manual_dispense',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 50, count: 1 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette3',
|
||||
position: 3,
|
||||
denomination: 50,
|
||||
provisioned: 1,
|
||||
dispensed: 1,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(countsByPosition()[3]).toBe(28)
|
||||
})
|
||||
|
||||
it('leaves the cashbox alone (bills leave, they do not arrive)', () => {
|
||||
const before = getCashbox()
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-manual-cashbox',
|
||||
type: 'manual_dispense',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 20, count: 1 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette2',
|
||||
position: 2,
|
||||
denomination: 20,
|
||||
provisioned: 1,
|
||||
dispensed: 1,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(getCashbox()).toEqual(before)
|
||||
expect(countsByPosition()[2]).toBe(49)
|
||||
})
|
||||
})
|
||||
|
||||
describe('state-store: getInventory represents a drained machine', () => {
|
||||
it('keeps configured bays at zero rather than dropping them', () => {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-drain-50s',
|
||||
type: 'cash_out',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 50, count: 30 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette3',
|
||||
position: 3,
|
||||
denomination: 50,
|
||||
provisioned: 30,
|
||||
dispensed: 30,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
// The $50 bay is empty but still configured. Dropping the key made this
|
||||
// look like "no inventory known", and callers then fell back to a stale
|
||||
// snapshot or to HAL.
|
||||
expect(getInventory()).toEqual({ 20: 100, 50: 0 })
|
||||
})
|
||||
|
||||
it('reports every bay at zero when the machine is fully drained', () => {
|
||||
for (const [txid, position, denomination, count] of [
|
||||
['d1', 1, 20, 50],
|
||||
['d2', 2, 20, 50],
|
||||
['d3', 3, 50, 30],
|
||||
] as const) {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid,
|
||||
type: 'cash_out',
|
||||
status: 'complete',
|
||||
bills: [{ denomination, count }],
|
||||
cassettes: [
|
||||
{
|
||||
name: `cassette${position}`,
|
||||
position,
|
||||
denomination,
|
||||
provisioned: count,
|
||||
dispensed: count,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
expect(getInventory()).toEqual({ 20: 0, 50: 0 })
|
||||
})
|
||||
|
||||
it('returns an empty map only when no cassettes are configured', () => {
|
||||
// A fresh DB with no bays at all — the one case that should read as
|
||||
// "nothing known", so callers may legitimately defer to the hardware.
|
||||
closeDatabase()
|
||||
initDatabase(':memory:')
|
||||
expect(getInventory()).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('state-store: unverified counts after a silent dispense', () => {
|
||||
it('starts clear, latches the first time, and keeps the earliest time', () => {
|
||||
expect(getCountsUncertainSince()).toBeNull()
|
||||
markCountsUncertain(1000)
|
||||
expect(getCountsUncertainSince()).toBe(1000)
|
||||
// A second failure does not move the clock forward — the question is how
|
||||
// long the numbers have been untrustworthy, not when we last noticed.
|
||||
markCountsUncertain(2000)
|
||||
expect(getCountsUncertainSince()).toBe(1000)
|
||||
})
|
||||
|
||||
it('clears when an operator asserts real counts', () => {
|
||||
markCountsUncertain(1000)
|
||||
const applied = applyOperatorCassettesConfig(
|
||||
{
|
||||
positions: {
|
||||
'1': { denomination: 20, count: 40 },
|
||||
'2': { denomination: 20, count: 40 },
|
||||
'3': { denomination: 50, count: 25 },
|
||||
},
|
||||
},
|
||||
1_700_000_000
|
||||
)
|
||||
expect(applied.applied).toBe(true)
|
||||
// A recount is exactly the operator asserting authoritative counts.
|
||||
expect(getCountsUncertainSince()).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves the flag alone when the operator config is rejected', () => {
|
||||
markCountsUncertain(1000)
|
||||
// Position key-set mismatch — the bay layout is hardware-determined.
|
||||
const applied = applyOperatorCassettesConfig(
|
||||
{ positions: { '1': { denomination: 20, count: 40 } } },
|
||||
1_700_000_001
|
||||
)
|
||||
expect(applied.applied).toBe(false)
|
||||
expect(getCountsUncertainSince()).toBe(1000)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -24,9 +24,11 @@ import {
|
|||
markCommandExecuting,
|
||||
completeCommand,
|
||||
getLastKnownConfigCreatedAt,
|
||||
getBootstrapPublishedAt,
|
||||
markBootstrapPublished,
|
||||
resetBootstrapGate,
|
||||
getCountsUncertainSince,
|
||||
getLastStatePublishedAt,
|
||||
markCountsUncertain,
|
||||
markStatePublished,
|
||||
resetStatePublishWatermark,
|
||||
resetForRepair,
|
||||
applyOperatorCassettesConfig,
|
||||
getFeeConfig,
|
||||
|
|
@ -410,7 +412,7 @@ ipcMain.handle('get-atm-secrets', () => {
|
|||
})
|
||||
|
||||
// Bunker binding persistence — the renderer writes the binding after a
|
||||
// successful pairing (connectNewSeed), and resets the bootstrap gate so the
|
||||
// successful pairing (connectNewSeed), and resets the publish watermark so the
|
||||
// new operator receives the spire's hello-event (aiolabs/bitspire#52 / #56).
|
||||
ipcMain.handle('state:save-bunker-binding', (_event, binding: StoredBunkerBinding): void => {
|
||||
saveBunkerBinding(binding)
|
||||
|
|
@ -418,8 +420,8 @@ ipcMain.handle('state:save-bunker-binding', (_event, binding: StoredBunkerBindin
|
|||
ipcMain.handle('state:clear-bunker-binding', (): void => {
|
||||
clearBunkerBinding()
|
||||
})
|
||||
ipcMain.handle('state:reset-bootstrap-gate', (): void => {
|
||||
resetBootstrapGate()
|
||||
ipcMain.handle('state:reset-state-publish-watermark', (): void => {
|
||||
resetStatePublishWatermark()
|
||||
})
|
||||
ipcMain.handle('state:reset-for-repair', (): void => {
|
||||
resetForRepair()
|
||||
|
|
@ -555,9 +557,13 @@ ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedB
|
|||
ipcMain.handle('state:get-last-known-config-created-at', (): number =>
|
||||
getLastKnownConfigCreatedAt()
|
||||
)
|
||||
ipcMain.handle('state:get-bootstrap-published-at', (): number | null => getBootstrapPublishedAt())
|
||||
ipcMain.handle('state:mark-bootstrap-published', (_event, unixTimestamp: number): void => {
|
||||
markBootstrapPublished(unixTimestamp)
|
||||
ipcMain.handle('state:get-last-state-published-at', (): number | null => getLastStatePublishedAt())
|
||||
ipcMain.handle('state:get-counts-uncertain-since', (): number | null => getCountsUncertainSince())
|
||||
ipcMain.handle('state:mark-counts-uncertain', (_event, unixTimestamp: number): void => {
|
||||
markCountsUncertain(unixTimestamp)
|
||||
})
|
||||
ipcMain.handle('state:mark-state-published', (_event, unixTimestamp: number): void => {
|
||||
markStatePublished(unixTimestamp)
|
||||
})
|
||||
ipcMain.handle(
|
||||
'state:apply-operator-cassettes-config',
|
||||
|
|
@ -841,6 +847,11 @@ function startCommandPoller(): void {
|
|||
error: result.error,
|
||||
})
|
||||
|
||||
// This dispense happened entirely in the main process, so the renderer
|
||||
// has no idea the bays moved — it would keep serving a stale inventory
|
||||
// and would never republish the operator's view. Tell it.
|
||||
mainWindow?.webContents.send('cassettes:changed')
|
||||
|
||||
// Only remediate the original tx if ALL requested bills were dispensed
|
||||
let refRemediated = false
|
||||
if (parsed.ref_txid && result.dispensed) {
|
||||
|
|
|
|||
|
|
@ -108,16 +108,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
// Operator-config consumer (aiolabs/lamassu-next#56)
|
||||
getLastKnownConfigCreatedAt: (): Promise<number> =>
|
||||
ipcRenderer.invoke('state:get-last-known-config-created-at'),
|
||||
getBootstrapPublishedAt: (): Promise<number | null> =>
|
||||
ipcRenderer.invoke('state:get-bootstrap-published-at'),
|
||||
markBootstrapPublished: (unixTimestamp: number): Promise<void> =>
|
||||
ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp),
|
||||
getLastStatePublishedAt: (): Promise<number | null> =>
|
||||
ipcRenderer.invoke('state:get-last-state-published-at'),
|
||||
getCountsUncertainSince: (): Promise<number | null> =>
|
||||
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)
|
||||
saveBunkerBinding: (binding: BunkerBindingRecord): Promise<void> =>
|
||||
ipcRenderer.invoke('state:save-bunker-binding', 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'),
|
||||
|
||||
// QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed,
|
||||
|
|
@ -222,6 +227,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
// Bolt Card reader (main process → renderer). removeAllListeners first: a
|
||||
// renderer reload re-runs this, and a duplicated card-tap listener would
|
||||
// trigger the LNURL-withdraw twice.
|
||||
// The main process changed the cassettes table (an operator-command dispense,
|
||||
// boot seeding). The renderer reloads its inventory and republishes state.
|
||||
onCassettesChanged: (callback: () => void) => {
|
||||
ipcRenderer.removeAllListeners('cassettes:changed')
|
||||
ipcRenderer.on('cassettes:changed', () => callback())
|
||||
},
|
||||
|
||||
onNfcCardTapped: (callback: (lnurlw: string) => void) => {
|
||||
ipcRenderer.removeAllListeners('nfc:card-tapped')
|
||||
ipcRenderer.on('nfc:card-tapped', (_event, lnurlw) => callback(lnurlw))
|
||||
|
|
@ -282,11 +294,13 @@ declare global {
|
|||
emptyCashbox: () => Promise<void>
|
||||
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
|
||||
getLastKnownConfigCreatedAt: () => Promise<number>
|
||||
getBootstrapPublishedAt: () => Promise<number | null>
|
||||
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
|
||||
getLastStatePublishedAt: () => Promise<number | null>
|
||||
getCountsUncertainSince: () => Promise<number | null>
|
||||
markCountsUncertain: (unixTimestamp: number) => Promise<void>
|
||||
markStatePublished: (unixTimestamp: number) => Promise<void>
|
||||
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
||||
clearBunkerBinding: () => Promise<void>
|
||||
resetBootstrapGate: () => Promise<void>
|
||||
resetStatePublishWatermark: () => Promise<void>
|
||||
resetForRepair: () => Promise<void>
|
||||
saveSpireSeed: (seed: string) => Promise<void>
|
||||
relaunchApp: () => Promise<void>
|
||||
|
|
|
|||
|
|
@ -295,7 +295,9 @@ export function initDatabase(dbPath?: string): void {
|
|||
`)
|
||||
db.pragma('foreign_keys = ON')
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('9', 'schema_version')
|
||||
console.log('[StateStore] Migrated schema v8 → v9 (cassettes PK position; allow duplicate denominations)')
|
||||
console.log(
|
||||
'[StateStore] Migrated schema v8 → v9 (cassettes PK position; allow duplicate denominations)'
|
||||
)
|
||||
existing.value = '9'
|
||||
}
|
||||
|
||||
|
|
@ -397,32 +399,80 @@ export function initDatabase(dbPath?: string): void {
|
|||
*/
|
||||
export function getLastKnownConfigCreatedAt(): number {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const row = db
|
||||
.prepare('SELECT value FROM meta WHERE key = ?')
|
||||
.get('lastKnownConfigCreatedAt') as { value: string } | undefined
|
||||
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('lastKnownConfigCreatedAt') as
|
||||
| { value: string }
|
||||
| undefined
|
||||
return row ? Number(row.value) || 0 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the one-shot bootstrap-publish gate. Returns null if the ATM has
|
||||
* not yet published its `bitspire-cassettes-state:<machine_id>` hello-event.
|
||||
* The `created_at` of the last `bitspire-cassettes-state` event this machine
|
||||
* published, or null if it has never published one.
|
||||
*
|
||||
* This used to be a one-shot gate ("have we said hello yet"), which meant a
|
||||
* layout change after first boot was never announced (#94). It is now a
|
||||
* high-water mark: every publish records its stamp, and the next one is forced
|
||||
* strictly above it. Addressable events are ordered by `created_at` at second
|
||||
* granularity, and a relay silently keeps the higher one, so a clock that steps
|
||||
* backwards would otherwise make this machine's reports vanish with an `OK`.
|
||||
*
|
||||
* Stored under the original `bootstrapPublishedAt` meta key so no migration is
|
||||
* needed; the name is historical, the meaning is not.
|
||||
*/
|
||||
export function getBootstrapPublishedAt(): number | null {
|
||||
export function getLastStatePublishedAt(): number | null {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const row = db
|
||||
.prepare('SELECT value FROM meta WHERE key = ?')
|
||||
.get('bootstrapPublishedAt') as { value: string } | undefined
|
||||
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('bootstrapPublishedAt') as
|
||||
| { value: string }
|
||||
| undefined
|
||||
if (!row || row.value === '') return null
|
||||
const n = Number(row.value)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the bootstrap hello-event as published. Idempotent — only takes
|
||||
* effect the first time it's set. Subsequent calls overwrite the
|
||||
* timestamp (harmless; the gate just needs to be non-null).
|
||||
* Whether the bay counts are known to be unverified, and since when.
|
||||
*
|
||||
* Set when a dispense ends without the dispenser reporting what it moved — a
|
||||
* driver throw, or the dispense timeout. Bills may well have reached the
|
||||
* customer, but nothing knows how many, so neither the rows here nor HAL's
|
||||
* bays were debited and both now read high. Reporting that number as fact is
|
||||
* the worst option available; saying the number is unverified is honest and
|
||||
* tells the operator to open the machine and recount.
|
||||
*
|
||||
* Cleared when an operator asserts authoritative counts (a config apply),
|
||||
* which is precisely what a recount is. Uses an upsert so no migration is
|
||||
* needed for machines whose meta table predates the key.
|
||||
*/
|
||||
export function markBootstrapPublished(unixTimestamp: number): void {
|
||||
export function getCountsUncertainSince(): number | null {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('countsUncertainSince') as
|
||||
| { value: string }
|
||||
| undefined
|
||||
if (!row || row.value === '') return null
|
||||
const n = Number(row.value)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
/** Flag the counts as unverified. Keeps the earliest time it went bad. */
|
||||
export function markCountsUncertain(unixTimestamp: number): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
if (getCountsUncertainSince() !== null) return
|
||||
db.prepare(
|
||||
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
).run('countsUncertainSince', String(unixTimestamp))
|
||||
console.warn('[StateStore] Cassette counts flagged unverified at', unixTimestamp)
|
||||
}
|
||||
|
||||
/** Clear the flag — an operator has asserted real counts. */
|
||||
export function clearCountsUncertain(): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
db.prepare(
|
||||
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
).run('countsUncertainSince', '')
|
||||
}
|
||||
|
||||
/** Record the `created_at` just published, as the next publish's floor. */
|
||||
export function markStatePublished(unixTimestamp: number): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run(
|
||||
String(unixTimestamp),
|
||||
|
|
@ -531,11 +581,12 @@ export function clearBunkerBinding(): void {
|
|||
}
|
||||
|
||||
/**
|
||||
* Reset the bootstrap-publish gate so the ATM re-publishes its
|
||||
* `bitspire-cassettes-state` hello-event. Called on a re-pair (new seed) so
|
||||
* the new operator receives the spire's current state (aiolabs/bitspire#56).
|
||||
* Forget the publish high-water mark. Called on a re-pair (new seed): the
|
||||
* next publish is then free to use the wall clock, which is what a fresh
|
||||
* operator relationship wants. The state itself is republished on startup
|
||||
* regardless, so the new operator always receives current counts.
|
||||
*/
|
||||
export function resetBootstrapGate(): void {
|
||||
export function resetStatePublishWatermark(): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt')
|
||||
}
|
||||
|
|
@ -570,9 +621,7 @@ export type OperatorCassettesPayload = {
|
|||
positions: Record<string, { denomination: number; count: number }>
|
||||
}
|
||||
|
||||
export type ApplyResult =
|
||||
| { applied: true }
|
||||
| { applied: false; reason: string }
|
||||
export type ApplyResult = { applied: true } | { applied: false; reason: string }
|
||||
|
||||
/**
|
||||
* Atomic apply of an operator-published cassette config (aiolabs/lamassu-next#56).
|
||||
|
|
@ -613,9 +662,7 @@ export function applyOperatorCassettesConfig(
|
|||
}
|
||||
}
|
||||
|
||||
const currentRows = db
|
||||
.prepare('SELECT position FROM cassettes')
|
||||
.all() as { position: number }[]
|
||||
const currentRows = db.prepare('SELECT position FROM cassettes').all() as { position: number }[]
|
||||
const currentPositions = new Set(currentRows.map((r) => r.position))
|
||||
const payloadPositions = new Set(Object.keys(payload.positions).map((k) => Number(k)))
|
||||
|
||||
|
|
@ -656,11 +703,18 @@ export function applyOperatorCassettesConfig(
|
|||
)
|
||||
const setWatermark = db.prepare('UPDATE meta SET value = ? WHERE key = ?')
|
||||
|
||||
const clearUncertain = db.prepare(
|
||||
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
)
|
||||
|
||||
const run = db.transaction(() => {
|
||||
for (const [posKey, entry] of Object.entries(payload.positions)) {
|
||||
updateCassette.run(entry.denomination, entry.count, Number(posKey))
|
||||
}
|
||||
setWatermark.run(String(eventCreatedAt), 'lastKnownConfigCreatedAt')
|
||||
// The operator just asserted real counts, which is what a recount is.
|
||||
// Whatever made the old numbers untrustworthy no longer applies.
|
||||
clearUncertain.run('countsUncertainSince', '')
|
||||
})
|
||||
|
||||
run()
|
||||
|
|
@ -752,10 +806,7 @@ export interface FeeConfigPayload {
|
|||
*/
|
||||
const FEE_CAP_PER_DIRECTION = 0.15
|
||||
|
||||
export function applyFeeConfig(
|
||||
payload: FeeConfigPayload,
|
||||
eventCreatedAt: number
|
||||
): ApplyResult {
|
||||
export function applyFeeConfig(payload: FeeConfigPayload, eventCreatedAt: number): ApplyResult {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
|
||||
const watermark = getLastKnownFeeConfigCreatedAt()
|
||||
|
|
@ -879,10 +930,15 @@ export function getInventory(): Record<number, number> {
|
|||
const rows = loadCassettes()
|
||||
const inv: Record<number, number> = {}
|
||||
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
|
||||
}
|
||||
}
|
||||
return inv
|
||||
}
|
||||
|
||||
|
|
@ -1026,7 +1082,15 @@ export function recordTransaction(tx: TransactionInput): void {
|
|||
}
|
||||
}
|
||||
|
||||
if (t.type === 'cash_out') {
|
||||
// Any dispense empties bays, whoever asked for it. `manual_dispense`
|
||||
// (operator remediation, via the command poller or a kind-21003 command)
|
||||
// used to fall outside this branch: HAL decremented its in-memory bays but
|
||||
// the rows here did not move, and on the next boot HAL re-seeds from these
|
||||
// rows — so the machine came back believing it still held bills a customer
|
||||
// had already been handed (#76). A remediation against a partly-dispensed
|
||||
// original decrements again on purpose: the original only ever debited what
|
||||
// physically left, and this is a second lot of bills leaving.
|
||||
if (t.type === 'cash_out' || t.type === 'manual_dispense') {
|
||||
// Decrement cassettes by ACTUALLY dispensed count (not requested).
|
||||
// Position is the addressable unit (v9): duplicate denominations
|
||||
// across bays are legal, so a denomination-keyed UPDATE would
|
||||
|
|
|
|||
|
|
@ -11,15 +11,16 @@
|
|||
*
|
||||
* - Operator → ATM: `kind=30078`, `["d", "bitspire-cassettes:<machine_id>"]`,
|
||||
* `["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
|
||||
*
|
||||
* The ATM's hex pubkey serves as `<machine_id>` — globally unique, no
|
||||
* extra provisioning step required.
|
||||
*
|
||||
* v1 only publishes the one-shot bootstrap hello-event. The continuous
|
||||
* ATM-state reverse channel (publish on every count change + heartbeat)
|
||||
* is v2 territory.
|
||||
* The ATM publishes its state on startup, after every change to the bays, and
|
||||
* on a heartbeat. It was once a single hello-event gated on a one-shot flag,
|
||||
* which left the operator validating against a layout the machine no longer
|
||||
* had (#94), and left a dispense published during a relay outage lost for good.
|
||||
*/
|
||||
|
||||
import {
|
||||
|
|
@ -37,6 +38,19 @@ const KIND_NIP78 = 30078
|
|||
/** Accept operator events stamped up to this many seconds in the future. */
|
||||
const MAX_FUTURE_SKEW_S = 60
|
||||
|
||||
/**
|
||||
* Republish the cassette state on this interval even when nothing changed.
|
||||
*
|
||||
* A publish is a single fire-and-forget event with no retry: if the relay is
|
||||
* unreachable at the moment of a dispense, that update is simply gone and the
|
||||
* operator's view stays wrong until the next customer happens to buy cash. A
|
||||
* relay also acknowledges an event it then discards, so a publish that returns
|
||||
* cleanly is not proof of anything. The heartbeat is what makes the channel
|
||||
* self-healing, and it is also the only way an out-of-band edit to the table
|
||||
* (atm-tui, direct SQL) ever reaches the operator.
|
||||
*/
|
||||
const STATE_HEARTBEAT_MS = 5 * 60 * 1000
|
||||
|
||||
const operatorConfigDTag = (machineId: string) => `bitspire-cassettes:${machineId}`
|
||||
const atmStateDTag = (machineId: string) => `bitspire-cassettes-state:${machineId}`
|
||||
|
||||
|
|
@ -45,7 +59,7 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
|
|||
export interface OperatorConfigServiceConfig {
|
||||
/** Connected NostrClient — shared with the Lightning service. */
|
||||
nostrClient: NostrClient
|
||||
/** Signer for the ATM identity. Decrypts operator events + signs the bootstrap. */
|
||||
/** Signer for the ATM identity. Decrypts operator events + signs our state. */
|
||||
signer: Signer
|
||||
/** Operator pubkeys (hex) authorized to publish cassette config. From VITE_OPERATOR_PUBKEYS. */
|
||||
operatorPubkeys: string[]
|
||||
|
|
@ -83,12 +97,15 @@ export async function startOperatorConfigService(
|
|||
const api = window.electronAPI
|
||||
const machineId = cfg.machineId ?? cfg.signer.pubkey
|
||||
|
||||
// Bootstrap hello-event on first boot (best-effort — failure leaves the
|
||||
// gate null so the next boot retries).
|
||||
// Announce current state on every start. This used to be gated on a
|
||||
// one-shot "have we said hello" flag, so any later change to the layout —
|
||||
// a reseed, an atm-tui edit, direct SQL — was never published and the
|
||||
// operator's dashboard kept validating against a bay set that no longer
|
||||
// existed (#94). Best-effort; the heartbeat below is the safety net.
|
||||
try {
|
||||
await maybePublishBootstrap(cfg, api, machineId)
|
||||
await publishCassettesState(cfg, api, machineId)
|
||||
} catch (err) {
|
||||
console.warn('[OperatorConfig] Bootstrap publish failed (will retry next boot):', err)
|
||||
console.warn('[OperatorConfig] Startup cassettes-state publish failed:', err)
|
||||
}
|
||||
|
||||
// Subscribe to operator-published cassette config events.
|
||||
|
|
@ -112,8 +129,17 @@ export async function startOperatorConfigService(
|
|||
)
|
||||
console.log('[OperatorConfig] Subscribed:', { dTag, subscriptionId })
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
publishCassettesState(cfg, api, machineId).catch((err) =>
|
||||
console.warn('[OperatorConfig] cassettes-state heartbeat failed:', err)
|
||||
)
|
||||
}, STATE_HEARTBEAT_MS)
|
||||
|
||||
return {
|
||||
stop: () => cfg.nostrClient.unsubscribe(subscriptionId),
|
||||
stop: () => {
|
||||
clearInterval(heartbeat)
|
||||
cfg.nostrClient.unsubscribe(subscriptionId)
|
||||
},
|
||||
publishCassettesState: () =>
|
||||
publishCassettesState(cfg, api, machineId)
|
||||
.then(() => {})
|
||||
|
|
@ -224,11 +250,11 @@ async function handleOperatorConfigEvent(
|
|||
* Publish the ATM's current cassette state as a replaceable kind-30078 event
|
||||
* (`bitspire-cassettes-state:<machineId>`), NIP-44-encrypted to the operator.
|
||||
* Replaceable → latest wins; the operator consumes every update. Call after a
|
||||
* dispense and on a cassette reload so the operator view tracks reality, not
|
||||
* the frozen bootstrap snapshot (coord 2026-06-21 / lamassu-next#56).
|
||||
* dispense, on a cassette reload, at startup and on a heartbeat, so the
|
||||
* operator view tracks reality (coord 2026-06-21 / lamassu-next#56).
|
||||
*
|
||||
* NOT gated on the bootstrap flag — this is the live update. Returns whether an
|
||||
* event was published (false when there are no cassettes / no operator).
|
||||
* Returns whether an event was published (false when there are no cassettes /
|
||||
* no operator).
|
||||
*/
|
||||
async function publishCassettesState(
|
||||
cfg: OperatorConfigServiceConfig,
|
||||
|
|
@ -244,7 +270,23 @@ async function publishCassettesState(
|
|||
for (const c of cassettes) {
|
||||
positions[String(c.position)] = { denomination: c.denomination, count: c.count }
|
||||
}
|
||||
const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify({ positions }))
|
||||
// Additive field: an operator on the old consumer reads `positions` and
|
||||
// ignores this, so it needs no coordinated release. When set, the counts
|
||||
// above are the machine's best guess, not a measurement.
|
||||
const countsUncertainSince = await api.getCountsUncertainSince()
|
||||
const payload = countsUncertainSince
|
||||
? { positions, counts_uncertain_since: countsUncertainSince }
|
||||
: { positions }
|
||||
const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify(payload))
|
||||
|
||||
// Force the stamp strictly above our last one. Addressable events are ordered
|
||||
// by `created_at` at second granularity, ties broken by lowest event id, and
|
||||
// the relay keeps one and silently drops the other while acknowledging both.
|
||||
// So two publishes inside one second would leave the winner decided by a hash,
|
||||
// permanently — and a clock that stepped backwards would make every report
|
||||
// from this machine disappear. Neither failure is visible from here.
|
||||
const lastPublished = (await api.getLastStatePublishedAt()) ?? 0
|
||||
const createdAt = Math.max(Math.floor(Date.now() / 1000), lastPublished + 1)
|
||||
|
||||
const dTag = atmStateDTag(machineId)
|
||||
const event = await createSignedEvent(cfg.signer, {
|
||||
|
|
@ -254,34 +296,11 @@ async function publishCassettesState(
|
|||
['d', dTag],
|
||||
['p', operatorPubkey],
|
||||
],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
created_at: createdAt,
|
||||
})
|
||||
|
||||
await cfg.nostrClient.publish(event)
|
||||
console.log('[OperatorConfig] cassettes-state published:', { dTag, eventId: event.id })
|
||||
await api.markStatePublished(createdAt)
|
||||
console.log('[OperatorConfig] cassettes-state published:', { dTag, eventId: event.id, createdAt })
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* First-boot hello: publish the cassette state once and mark the gate. The
|
||||
* gate (lamassu-next#56) prevents re-emitting the *bootstrap* on every boot;
|
||||
* live updates after dispenses go through `publishCassettesState` directly.
|
||||
*/
|
||||
async function maybePublishBootstrap(
|
||||
cfg: OperatorConfigServiceConfig,
|
||||
api: NonNullable<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
|
||||
* (first pair or re-pair) → generate a fresh NIP-46 transport key, redeem
|
||||
* the one-shot connect secret, persist the binding, and reset the
|
||||
* bootstrap gate so the (possibly new) operator gets a hello-event (#56).
|
||||
* publish watermark so the (possibly new) operator gets current state (#56).
|
||||
* 2. A seed is present matching the stored binding, OR no seed but a stored
|
||||
* binding exists → resume the bunker session with the persisted transport
|
||||
* key (no re-redeem — the binding is server-persistent).
|
||||
|
|
@ -121,7 +121,7 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise<Resolve
|
|||
if (binding) {
|
||||
console.warn(
|
||||
'[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) }
|
||||
}
|
||||
|
|
@ -150,7 +150,9 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise<Resolve
|
|||
// first pair (no prior binding) has nothing to reset. Cash accounting is
|
||||
// preserved — see resetForRepair; a full wipe is the factory-reset path.
|
||||
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()
|
||||
}
|
||||
// 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,
|
||||
})
|
||||
// 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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,13 @@ async function handleManagementCommand(
|
|||
request: ManagementRequest,
|
||||
dispenseFn: (amounts: { denomination: number; count: number }[]) => Promise<any>,
|
||||
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> {
|
||||
if (!isMachineDispenseRequest(request)) return null
|
||||
|
||||
|
|
@ -130,6 +136,7 @@ async function handleManagementCommand(
|
|||
cassettes: result.cassettes,
|
||||
error: result.error,
|
||||
})
|
||||
await onCassettesChanged?.()
|
||||
|
||||
// Only remediate the original tx if ALL requested bills were dispensed
|
||||
let refRemediated = false
|
||||
|
|
@ -161,10 +168,14 @@ async function handleManagementCommand(
|
|||
}
|
||||
|
||||
/**
|
||||
* Load inventory from SQLite via IPC (Electron only).
|
||||
* Returns empty object in browser dev mode.
|
||||
* Load inventory from SQLite via IPC.
|
||||
*
|
||||
* Returns `null` when the DB could not be asked at all — browser dev mode, or
|
||||
* a failed IPC call — so callers can tell "no answer" from an answer of "the
|
||||
* bays are empty". An empty map is a real, actionable reading: cassettes are
|
||||
* configured and drained, or none are configured.
|
||||
*/
|
||||
async function loadInventoryFromDb(): Promise<Record<number, number>> {
|
||||
async function loadInventoryFromDb(): Promise<Record<number, number> | null> {
|
||||
if (isElectron && window.electronAPI) {
|
||||
try {
|
||||
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)
|
||||
}
|
||||
}
|
||||
return {}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -446,13 +457,25 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
*/
|
||||
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() {
|
||||
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
|
||||
console.log('[ATM] Persisted inventory updated:', inv)
|
||||
}
|
||||
}
|
||||
|
||||
/** Detect Bitcoin network from a BOLT-11 invoice prefix (called once, persisted) */
|
||||
function detectNetworkFromInvoice(invoice: string) {
|
||||
|
|
@ -610,6 +633,19 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
bills = dr.bills
|
||||
.filter((b) => b.dispensed > 0)
|
||||
.map((b) => ({ denomination: b.denomination, count: b.dispensed }))
|
||||
} else {
|
||||
// The dispenser threw, or the dispense timed out, so there is no
|
||||
// per-bay report. Bills may well have reached the customer, and
|
||||
// nothing knows how many: neither the cassette rows nor HAL's bays
|
||||
// were debited, so both now read high. Record that the counts are
|
||||
// unverified instead of letting a number we know may be wrong go
|
||||
// on being treated as fact.
|
||||
console.error(
|
||||
`[ATM] Dispense ended with no report — bay counts are unverified (txid=${ctx.txid})`
|
||||
)
|
||||
void window.electronAPI
|
||||
?.markCountsUncertain(Math.floor(Date.now() / 1000))
|
||||
.catch((e) => console.warn('[ATM] Could not flag counts unverified:', e))
|
||||
}
|
||||
|
||||
persistTransaction({
|
||||
|
|
@ -703,6 +739,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
// Start the machine
|
||||
actor.value.start()
|
||||
setupNfcListener()
|
||||
setupCassettesChangedListener()
|
||||
console.log('[ATM] State machine initialized')
|
||||
}
|
||||
|
||||
|
|
@ -916,6 +953,21 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The main process can move the bays without the renderer knowing — an
|
||||
* operator-command dispense runs entirely there, and boot seeding writes the
|
||||
* table before the store exists. Listen for that and catch up, otherwise the
|
||||
* renderer serves a stale inventory and the operator's view never updates.
|
||||
* Idempotent via preload removeAllListeners.
|
||||
*/
|
||||
function setupCassettesChangedListener() {
|
||||
if (!isElectron || !window.electronAPI?.onCassettesChanged) return
|
||||
window.electronAPI.onCassettesChanged(() => {
|
||||
console.log('[ATM] Cassettes changed in the main process — refreshing')
|
||||
void refreshAndPublishCassettes()
|
||||
})
|
||||
}
|
||||
|
||||
/** Wire the main-process reader once (idempotent via preload removeAllListeners). */
|
||||
function setupNfcListener() {
|
||||
if (!isElectron || !window.electronAPI?.onNfcCardTapped) return
|
||||
|
|
@ -1064,7 +1116,8 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
}),
|
||||
getInventory: async () => {
|
||||
const fresh = await loadInventoryFromDb()
|
||||
return Object.keys(fresh).length > 0 ? fresh : services.atmServices.getInventory()
|
||||
// null == the DB could not be asked; an empty map is a real reading.
|
||||
return fresh ?? services.atmServices.getInventory()
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -1312,7 +1365,8 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
request,
|
||||
(amounts) => hal.atmServices.dispenseCash(amounts),
|
||||
isIdle.value,
|
||||
fiatCode.value
|
||||
fiatCode.value,
|
||||
refreshAndPublishCassettes
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -1327,7 +1381,8 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
// If DB has inventory, use it; otherwise fall back to HAL
|
||||
getInventory: async () => {
|
||||
const fresh = await loadInventoryFromDb()
|
||||
return Object.keys(fresh).length > 0 ? fresh : hal.atmServices.getInventory()
|
||||
// null == the DB could not be asked; an empty map is a real reading.
|
||||
return fresh ?? hal.atmServices.getInventory()
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -1611,9 +1666,11 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
return await api.halDispense(amounts)
|
||||
},
|
||||
getInventory: async () => {
|
||||
// Priority: DB inventory > HAL hardware inventory > empty
|
||||
// Priority: DB inventory > HAL hardware inventory > empty. Only a
|
||||
// null (unreadable) DB defers to HAL — a drained machine reports
|
||||
// drained rather than borrowing the hardware's view.
|
||||
const fresh = await loadInventoryFromDb()
|
||||
if (Object.keys(fresh).length > 0) return fresh
|
||||
if (fresh !== null) return fresh
|
||||
// Fall back to HAL's cassette-based inventory
|
||||
try {
|
||||
const halInv = await api.halGetInventory()
|
||||
|
|
@ -1641,7 +1698,8 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
request,
|
||||
(amounts) => api.halDispense(amounts),
|
||||
isIdle.value,
|
||||
fiatCode.value
|
||||
fiatCode.value,
|
||||
refreshAndPublishCassettes
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
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>
|
||||
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
|
||||
getLastKnownConfigCreatedAt: () => Promise<number>
|
||||
getBootstrapPublishedAt: () => Promise<number | null>
|
||||
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
|
||||
getLastStatePublishedAt: () => Promise<number | null>
|
||||
/** 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>
|
||||
clearBunkerBinding: () => Promise<void>
|
||||
resetBootstrapGate: () => Promise<void>
|
||||
resetStatePublishWatermark: () => Promise<void>
|
||||
resetForRepair: () => Promise<void>
|
||||
saveSpireSeed: (seed: string) => Promise<void>
|
||||
relaunchApp: () => Promise<void>
|
||||
|
|
@ -217,6 +220,8 @@ declare global {
|
|||
onHalBillInserted: (callback: (denomination: number) => void) => void
|
||||
onHalBillRejected: (callback: (reason: string) => void) => void
|
||||
onHalError: (callback: (error: string) => void) => void
|
||||
/** The main process mutated the cassettes table; reload + republish. */
|
||||
onCassettesChanged: (callback: () => void) => void
|
||||
/** Bolt Card reader: a tapped card's lnurlw voucher. */
|
||||
onNfcCardTapped: (callback: (lnurlw: string) => void) => void
|
||||
/** Bolt Card reader status (ready / reading / error / unavailable). */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue