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()