feat(machine): operator-config consumer over kind-30078 (#56 v1)

Wires the ATM-side consumer of operator-driven cassette config per
aiolabs/lamassu-next#56 v1. Operator → ATM only, with a one-shot ATM
bootstrap hello-event so satmachineadmin can auto-populate
`cassette_configs` rows on first boot.

Transport (decision rationale in coordination log 2026-05-30 entries):

- kind=30078 (NIP-78 replaceable), ["p", atm_npub]-tagged, ["d",
  "bitspire-cassettes:<machine_id>"], NIP-44 v2 encrypted content,
  authored by operator. Subscribed via filter
  {kinds:[30078], "#p":[my_npub], "#d":[...], authors:OPERATOR_PUBKEYS}
- machine_id = ATM hex pubkey (no extra provisioning step)

Wire payload is denomination-keyed (per satmachineadmin's 06:40Z
audit of the ATM stack — every layer beneath the wire keys on
denomination, position is a sortable display column):

  { "denominations": { "<denom>": { "position": N, "count": M } } }

Validation:
- event signature + author in VITE_OPERATOR_PUBKEYS allowlist
- replay protection via meta.lastKnownConfigCreatedAt (drops events
  re-delivered on relay reconnect or after restart)
- clock-skew defense: reject created_at > now + 60s
- denomination key set EXACTLY equal to state.db denominations
  (no add/remove cassettes from the dashboard)
- per-row position positive int, count non-negative int

Apply in a single SQLite transaction (cassettes upsert by denomination
PK + meta watermark update), then hot-reload HAL via new IPC
`hal:reload-cassettes` so dispense math picks up the new layout
without restarting the bitspire service.

Bootstrap hello-event (one-shot):
- on init, if meta.bootstrapPublishedAt IS NULL AND cassettes
  non-empty, publish kind=30078 with d=bitspire-cassettes-state:<id>,
  encrypted to operator pubkey, signed by ATM
- on success set meta.bootstrapPublishedAt; on failure leave null and
  retry next boot (best-effort; doesn't block service startup)

Schema v7 → v8: adds meta rows lastKnownConfigCreatedAt + bootstrap-
PublishedAt. Fresh installs at v8 seed via INSERT OR IGNORE.

HAL service grows setCassettes(cassettes) — closes + re-inits the
dispenser, rebuilds the inventory map + cassetteDenominations index.
Exposed as `hal:reload-cassettes` IPC + window.electronAPI.halReload-
Cassettes for the renderer.

Out of scope (v2 / separate issue):
- continuous ATM-state reverse-channel publish (dashboard
  reconciliation + ✅/⏳ apply confirmation + safe "Add N bills" UX)

12/12 typecheck + 18/18 state-machine + 11/11 clink + 11/11 lnbits
suites pass.

refs: aiolabs/lamassu-next#56, aiolabs/satmachineadmin#29,
~/dev/coordination/log.md 2026-05-30 entries (06:30Z, 06:40Z, 07:30Z,
07:50Z, 07:55Z), ~/dev/CLAUDE.md (Nostr architecture → "Respect
protocol semantics over friction reduction")

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-30 13:38:08 +02:00
commit 4612ff2155
8 changed files with 563 additions and 7 deletions

View file

@ -56,6 +56,14 @@ export interface HalInstance {
rejectBill: () => void
dispenseCash: (amounts: { denomination: number; count: number }[]) => Promise<DispenseResult>
getInventory: () => Record<number, number>
/**
* Hot-reload the cassette layout: rebuild the inventory map +
* denomination-index, close + re-init the dispenser with the new
* cassettes. Used by the operator-config consumer (aiolabs/lamassu-next#56)
* so a new operator-published cassette config takes effect without
* restarting the bitspire service.
*/
setCassettes: (cassettes: CassetteConfig[]) => Promise<void>
cleanup: () => Promise<void>
}
@ -72,8 +80,10 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
device: dispConfig.device,
})
// Initialize dispenser
const dispenserInitData = {
// Initialize dispenser. `dispenserInitData` is `let` because
// `setCassettes` swaps it in to re-init with a new layout (also used by
// the on-error re-init path at dispenseCash).
let dispenserInitData = {
fiatCode: valConfig.fiatCode,
cassettes: dispConfig.cassettes,
}
@ -110,13 +120,15 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
validator = null
}
// Track inventory
// Track inventory. Mutated in-place by dispense + setCassettes;
// `cassetteDenominations` is rebuilt fresh on setCassettes so the
// dispense lookup at line ~190 picks up the new index.
const inventory: Record<number, number> = {}
for (const cassette of dispConfig.cassettes) {
inventory[cassette.denomination] = cassette.count ?? 0
}
const cassetteDenominations = dispConfig.cassettes.map((c) => c.denomination)
let cassetteDenominations = dispConfig.cassettes.map((c) => c.denomination)
return {
connectValidator: (callbacks: ValidatorCallbacks) => {
@ -247,6 +259,31 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
getInventory: () => ({ ...inventory }),
setCassettes: async (cassettes: CassetteConfig[]): Promise<void> => {
console.log(
'[HAL] Hot-reloading cassette layout:',
cassettes.map((c) => `${c.denomination}×${c.count ?? 0}`).join(', ')
)
// Rebuild the local view first so subsequent dispense calls see the
// new layout even if the dispenser re-init is slow / fails.
for (const k of Object.keys(inventory)) delete inventory[Number(k)]
for (const cassette of cassettes) {
inventory[cassette.denomination] = cassette.count ?? 0
}
cassetteDenominations = cassettes.map((c) => c.denomination)
dispenserInitData = { fiatCode: valConfig.fiatCode, cassettes }
// Close + re-init the dispenser so its internal per-bay state matches
// the new layout. Errors here surface to the caller (operator-config
// consumer) — the renderer can decide whether to retry.
try {
dispenser.close()
} catch (err) {
console.warn('[HAL] Dispenser close during setCassettes raised:', err)
}
await dispenser.init(dispenserInitData)
console.log('[HAL] Dispenser re-initialized with new cassettes')
},
cleanup: async () => {
return new Promise<void>((resolve) => {
validator?.disable()

View file

@ -23,6 +23,12 @@ import {
getPendingCommand,
markCommandExecuting,
completeCommand,
getLastKnownConfigCreatedAt,
getBootstrapPublishedAt,
markBootstrapPublished,
applyOperatorCassettesConfig,
type OperatorCassettesPayload,
type ApplyResult,
} from './state-store.js'
import { initializeHal, type HalInstance } from './hal-service.js'
@ -344,6 +350,23 @@ ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedB
remediateTransaction(txid, remediatedByTxid)
)
// Operator-config consumer (aiolabs/lamassu-next#56) — meta watermark
// + atomic apply for kind-30078 cassette-config events
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:apply-operator-cassettes-config',
(_event, payload: OperatorCassettesPayload, eventCreatedAt: number): ApplyResult =>
applyOperatorCassettesConfig(payload, eventCreatedAt)
)
// Support pages — read .md files from /var/lib/bitspire/support/
ipcMain.handle('support:get-pages', () => {
const supportDir = path.join(
@ -499,6 +522,26 @@ ipcMain.handle('hal:get-inventory', () => {
return halInstance.getInventory()
})
ipcMain.handle(
'hal:reload-cassettes',
async (
_event,
cassettes: { denomination: number; count?: number }[]
): Promise<{ ok: boolean; error?: string }> => {
if (!halInstance) {
return { ok: false, error: 'HAL not initialized' }
}
try {
await halInstance.setCassettes(cassettes)
return { ok: true }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
console.error('[Electron] hal:reload-cassettes failed:', msg)
return { ok: false, error: msg }
}
}
)
ipcMain.handle('hal:cleanup', async () => {
if (halInstance) {
await halInstance.cleanup()

View file

@ -95,6 +95,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
remediateTransaction: (txid: string, remediatedByTxid: string): Promise<boolean> =>
ipcRenderer.invoke('state:remediate-transaction', txid, remediatedByTxid),
// 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),
applyOperatorCassettesConfig: (
payload: {
denominations: Record<string, { position: number; count: number }>
},
eventCreatedAt: number
): Promise<{ applied: true } | { applied: false; reason: string }> =>
ipcRenderer.invoke('state:apply-operator-cassettes-config', payload, eventCreatedAt),
// Support pages
getSupportPages: (): Promise<{ id: string; title: string; content: string }[]> =>
ipcRenderer.invoke('support:get-pages'),
@ -108,6 +123,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
halStackBill: (): Promise<void> => ipcRenderer.invoke('hal:stack-bill'),
halRejectBill: (): Promise<void> => ipcRenderer.invoke('hal:reject-bill'),
halGetInventory: (): Promise<Record<number, number>> => ipcRenderer.invoke('hal:get-inventory'),
halReloadCassettes: (
cassettes: { denomination: number; count?: number }[]
): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('hal:reload-cassettes', cassettes),
halCleanup: (): Promise<void> => ipcRenderer.invoke('hal:cleanup'),
// HAL event listeners (main process → renderer)
@ -141,7 +160,7 @@ declare global {
getVersion: () => Promise<string>
getConfig: () => Promise<RuntimeConfig>
getAtmSecrets: () => Promise<AtmSecrets>
loadCassettes: () => Promise<{ denomination: number; count: number }[]>
loadCassettes: () => Promise<{ denomination: number; count: number; position: number }[]>
setCassettes: (cassettes: { denomination: number; count: number }[]) => Promise<void>
getInventory: () => Promise<Record<number, number>>
getCashbox: () => Promise<{
@ -172,6 +191,13 @@ declare global {
}) => Promise<void>
emptyCashbox: () => Promise<void>
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
getLastKnownConfigCreatedAt: () => Promise<number>
getBootstrapPublishedAt: () => Promise<number | null>
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
applyOperatorCassettesConfig: (
payload: { denominations: Record<string, { position: number; count: number }> },
eventCreatedAt: number
) => Promise<{ applied: true } | { applied: false; reason: string }>
getSupportPages: () => Promise<{ id: string; title: string; content: string }[]>
// HAL hardware IPC
halInit: (config: any) => Promise<{ success: boolean; error?: string }>
@ -181,6 +207,9 @@ declare global {
halStackBill: () => Promise<void>
halRejectBill: () => Promise<void>
halGetInventory: () => Promise<Record<number, number>>
halReloadCassettes: (
cassettes: { denomination: number; count?: number }[]
) => Promise<{ ok: boolean; error?: string }>
halCleanup: () => Promise<void>
onHalBillRead: (callback: (denomination: number) => void) => void
onHalBillInserted: (callback: (denomination: number) => void) => void

View file

@ -15,7 +15,7 @@ import fs from 'node:fs'
let db: Database.Database | null = null
const SCHEMA_VERSION = '7'
const SCHEMA_VERSION = '8'
function getDbPath(): string {
const prodDir = '/var/lib/bitspire'
@ -226,8 +226,31 @@ export function initDatabase(dbPath?: string): void {
db.exec(`ALTER TABLE transactions RENAME COLUMN fee_percent TO fee_fraction`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('7', 'schema_version')
console.log('[StateStore] Migrated schema v6 → v7 (renamed fee_percent → fee_fraction)')
existing.value = '7'
}
if (existing && existing.value === '7') {
// Migration v7 → v8: seed `meta` rows for operator-config consumer.
// - lastKnownConfigCreatedAt — replay-protection watermark. Drop any
// incoming kind-30078 operator-config event whose created_at is
// ≤ this value. Default 0 = "haven't applied anything yet."
// - bootstrapPublishedAt — gate for the one-shot ATM-side
// bitspire-cassettes-state hello-event. NULL = "haven't published
// bootstrap yet"; once set, the hello-event publish path becomes
// a no-op (continuous reverse-channel publish is v2 territory).
// See aiolabs/lamassu-next#56 + ~/dev/coordination/log.md (2026-05-30).
db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run('lastKnownConfigCreatedAt', '0')
db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run('bootstrapPublishedAt', '')
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('8', 'schema_version')
console.log('[StateStore] Migrated schema v7 → v8 (seeded operator-config meta rows)')
}
// Defensive: a fresh install at SCHEMA_VERSION skips all migrations.
// Seed the operator-config meta rows if they're missing (idempotent).
const seedMeta = db.prepare('INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)')
seedMeta.run('lastKnownConfigCreatedAt', '0')
seedMeta.run('bootstrapPublishedAt', '')
const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get()
if (!cashboxRow) {
db.prepare('INSERT INTO cashbox (id) VALUES (1)').run()
@ -236,6 +259,150 @@ export function initDatabase(dbPath?: string): void {
console.log('[StateStore] Initialized database at', resolvedPath)
}
// ---------------------------------------------------------------------------
// Meta — operator-config consumer state
// (lastKnownConfigCreatedAt + bootstrapPublishedAt for aiolabs/lamassu-next#56)
// ---------------------------------------------------------------------------
/**
* Read replay-protection watermark. Returns 0 if no operator config has
* been applied yet (fresh ATM, pre-bootstrap).
*/
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
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.
*/
export function getBootstrapPublishedAt(): number | null {
if (!db) throw new Error('Database not initialized')
const row = db
.prepare('SELECT value FROM meta WHERE key = ?')
.get('bootstrapPublishedAt') as { value: string } | undefined
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).
*/
export function markBootstrapPublished(unixTimestamp: number): void {
if (!db) throw new Error('Database not initialized')
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run(
String(unixTimestamp),
'bootstrapPublishedAt'
)
}
export type OperatorCassettesPayload = {
denominations: Record<string, { position: number; count: number }>
}
export type ApplyResult =
| { applied: true }
| { applied: false; reason: string }
/**
* Atomic apply of an operator-published cassette config (aiolabs/lamassu-next#56).
*
* Caller has already verified the event signature and decrypted the
* content. This function:
*
* 1. Rechecks replay-protection against `meta.lastKnownConfigCreatedAt`
* (defense-in-depth — caller should have done this too).
* 2. Validates the payload's `denominations` key set is *exactly* the set
* of denominations currently in the `cassettes` table.
* 3. Validates per-entry `position` is a positive int, `count` is a
* non-negative int.
* 4. In a single SQLite transaction: updates `cassettes` rows (PK is
* denomination — only `position` and `count` mutate) AND advances the
* `meta.lastKnownConfigCreatedAt` watermark to `eventCreatedAt`.
*
* Mid-write crashes roll back cleanly; on restart the same event is
* re-delivered by the relay and the watermark check drops it as already
* consumed (or the watermark is pre-event because the tx rolled back,
* and the apply runs again from scratch).
*/
export function applyOperatorCassettesConfig(
payload: OperatorCassettesPayload,
eventCreatedAt: number
): ApplyResult {
if (!db) throw new Error('Database not initialized')
const watermark = getLastKnownConfigCreatedAt()
if (eventCreatedAt <= watermark) {
return {
applied: false,
reason: `event.created_at (${eventCreatedAt}) <= lastKnownConfigCreatedAt (${watermark})`,
}
}
const currentRows = db
.prepare('SELECT denomination FROM cassettes')
.all() as { denomination: number }[]
const currentDenoms = new Set(currentRows.map((r) => r.denomination))
const payloadDenoms = new Set(Object.keys(payload.denominations).map((k) => Number(k)))
if (currentDenoms.size !== payloadDenoms.size) {
return {
applied: false,
reason: `denomination count mismatch: state.db has ${currentDenoms.size}, payload has ${payloadDenoms.size}`,
}
}
for (const d of currentDenoms) {
if (!payloadDenoms.has(d)) {
return { applied: false, reason: `payload missing denomination ${d}` }
}
}
for (const d of payloadDenoms) {
if (!currentDenoms.has(d)) {
return { applied: false, reason: `payload includes unknown denomination ${d}` }
}
}
for (const [denomKey, entry] of Object.entries(payload.denominations)) {
if (!Number.isInteger(entry.position) || entry.position <= 0) {
return {
applied: false,
reason: `position must be positive int (denomination ${denomKey}, got ${entry.position})`,
}
}
if (!Number.isInteger(entry.count) || entry.count < 0) {
return {
applied: false,
reason: `count must be non-negative int (denomination ${denomKey}, got ${entry.count})`,
}
}
}
const updateCassette = db.prepare(
'UPDATE cassettes SET position = ?, count = ? WHERE denomination = ?'
)
const setWatermark = db.prepare('UPDATE meta SET value = ? WHERE key = ?')
const run = db.transaction(() => {
for (const [denomKey, entry] of Object.entries(payload.denominations)) {
updateCassette.run(entry.position, entry.count, Number(denomKey))
}
setWatermark.run(String(eventCreatedAt), 'lastKnownConfigCreatedAt')
})
run()
console.log(
`[StateStore] Applied operator cassettes config @ created_at=${eventCreatedAt} (${Object.keys(payload.denominations).length} denominations)`
)
return { applied: true }
}
// ---------------------------------------------------------------------------
// Cassettes
// ---------------------------------------------------------------------------