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
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue