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