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:
parent
e4079cb787
commit
4612ff2155
8 changed files with 563 additions and 7 deletions
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue