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

@ -261,6 +261,8 @@ interface LightningServices {
lightningPub: LightningBackend
clink: CLINKClient
identity: MachineIdentity
/** Operator pubkeys (hex) authorized for kind-21003 management + operator-config events. */
operatorPubkeys: string[]
atmServices: ATMServices
/** Set callback for when offer requests are received */
onOfferRequest: (callback: OfferRequestCallback) => void
@ -613,6 +615,7 @@ export async function initializeLightningServices(options?: {
lightningPub,
clink,
identity,
operatorPubkeys: CONFIG.operatorPubkeys,
atmServices,
onOfferRequest: (callback: OfferRequestCallback) => {
offerRequestCallback = callback

View file

@ -0,0 +1,236 @@
/**
* Operator-config consumer (aiolabs/lamassu-next#56).
*
* Subscribes to operator-published kind-30078 events carrying cassette
* config updates, validates + applies them to state.db, and hot-reloads
* the HAL dispenser. Also publishes a one-shot ATM-state hello-event on
* first boot so the operator dashboard (satmachineadmin) can auto-populate
* `cassette_configs` rows for this machine.
*
* Architecture (see ~/dev/coordination/log.md entries on 2026-05-30):
*
* - Operator → ATM: `kind=30078`, `["d", "bitspire-cassettes:<machine_id>"]`,
* `["p", <atm_npub>]`, NIP-44 v2 encrypted content, author = operator pubkey
* - ATM bootstrap: `kind=30078`, `["d", "bitspire-cassettes-state:<machine_id>"]`,
* `["p", <operator_pubkey>]`, NIP-44 v2 encrypted content, author = ATM pubkey
*
* The ATM's hex pubkey serves as `<machine_id>` — globally unique, no
* extra provisioning step required.
*
* v1 only publishes the one-shot bootstrap hello-event. The continuous
* ATM-state reverse channel (publish on every count change + heartbeat)
* is v2 territory.
*/
import {
type MachineIdentity,
type NostrClient,
type Event,
createSignedEvent,
decryptContentV2,
encryptContentV2,
validateEvent,
} from '@bitSpire/nostr-client'
import type {} from '@/types/electron'
const KIND_NIP78 = 30078
/** Accept operator events stamped up to this many seconds in the future. */
const MAX_FUTURE_SKEW_S = 60
const operatorConfigDTag = (machineId: string) => `bitspire-cassettes:${machineId}`
const atmStateDTag = (machineId: string) => `bitspire-cassettes-state:${machineId}`
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
export interface OperatorConfigServiceConfig {
/** Connected NostrClient — shared with the Lightning service. */
nostrClient: NostrClient
/** ATM's nostr identity. Used to decrypt operator events + sign the bootstrap. */
identity: MachineIdentity
/** Operator pubkeys (hex) authorized to publish cassette config. From VITE_OPERATOR_PUBKEYS. */
operatorPubkeys: string[]
/** Machine identifier for the d-tag. Defaults to identity.publicKey when omitted. */
machineId?: string
}
export interface OperatorConfigService {
/** Unsubscribe from operator events and free resources. */
stop(): void
}
export async function startOperatorConfigService(
cfg: OperatorConfigServiceConfig
): Promise<OperatorConfigService> {
if (cfg.operatorPubkeys.length === 0) {
console.log('[OperatorConfig] No operator pubkeys configured — service disabled')
return { stop: () => {} }
}
if (!isElectron || !window.electronAPI) {
console.log('[OperatorConfig] Not in Electron — service disabled (browser dev mode)')
return { stop: () => {} }
}
const api = window.electronAPI
const machineId = cfg.machineId ?? cfg.identity.publicKey
// Bootstrap hello-event on first boot (best-effort — failure leaves the
// gate null so the next boot retries).
try {
await maybePublishBootstrap(cfg, api, machineId)
} catch (err) {
console.warn('[OperatorConfig] Bootstrap publish failed (will retry next boot):', err)
}
// Subscribe to operator-published cassette config events.
const dTag = operatorConfigDTag(machineId)
const subscriptionId = cfg.nostrClient.subscribe(
[
{
kinds: [KIND_NIP78],
'#p': [cfg.identity.publicKey],
'#d': [dTag],
authors: cfg.operatorPubkeys,
},
],
{
onEvent: (event) => {
handleOperatorConfigEvent(event, cfg, api).catch((err) => {
console.error('[OperatorConfig] Apply failed:', err)
})
},
}
)
console.log('[OperatorConfig] Subscribed:', { dTag, subscriptionId })
return {
stop: () => cfg.nostrClient.unsubscribe(subscriptionId),
}
}
async function handleOperatorConfigEvent(
event: Event,
cfg: OperatorConfigServiceConfig,
api: NonNullable<typeof window.electronAPI>
): Promise<void> {
// 1. Signature + author whitelist (defense in depth — relay filter
// already constrained authors, but verify the relay didn't lie).
if (!validateEvent(event)) {
console.warn('[OperatorConfig] Event signature invalid — dropped:', event.id)
return
}
if (!cfg.operatorPubkeys.includes(event.pubkey)) {
console.warn('[OperatorConfig] Author not in operator whitelist — dropped:', event.pubkey)
return
}
// 2. Replay protection — drop stale events. NIP-78 replaceable events
// DO get re-delivered on reconnect/restart; without this check, the
// ATM would re-apply the same payload on every boot and clobber any
// cash-out decrements that landed between operator publishes.
const watermark = await api.getLastKnownConfigCreatedAt()
if (event.created_at <= watermark) {
console.log(
`[OperatorConfig] Stale event dropped (created_at=${event.created_at} <= watermark=${watermark})`
)
return
}
// 3. Clock-skew defense — reject events stamped too far in the future.
// Limits damage from a leaked operator nsec future-stamping a fake
// config to outrank legitimate publishes.
const nowSec = Math.floor(Date.now() / 1000)
if (event.created_at > nowSec + MAX_FUTURE_SKEW_S) {
console.warn(
`[OperatorConfig] Future-stamped event dropped (created_at=${event.created_at}, now=${nowSec})`
)
return
}
// 4. Decrypt content (NIP-44 v2).
let parsed: { denominations: Record<string, { position: number; count: number }> }
try {
const plaintext = decryptContentV2(cfg.identity, event.pubkey, event.content)
parsed = JSON.parse(plaintext) as typeof parsed
} catch (err) {
console.error('[OperatorConfig] Decrypt/parse failed:', err)
return
}
if (!parsed || typeof parsed !== 'object' || !parsed.denominations) {
console.error('[OperatorConfig] Payload missing `denominations` field')
return
}
// 5. Atomic apply (cassettes + meta watermark) via IPC. The state-store
// function re-validates watermark + denomination key-set equality +
// per-entry types inside the SQLite transaction.
const result = await api.applyOperatorCassettesConfig(
{ denominations: parsed.denominations },
event.created_at
)
if (!result.applied) {
console.warn('[OperatorConfig] Apply rejected:', result.reason)
return
}
// 6. Hot-reload the HAL with the new cassette layout so dispense math
// picks up the new denomination set. state.db is already updated;
// HAL re-init failure means the renderer's persistedInventory may
// be ahead of the HAL until next service restart — log loudly but
// don't unwind the state.db apply (the operator wants their config
// landed; HAL can catch up).
const cassettesAfter = await api.loadCassettes()
const halResult = await api.halReloadCassettes(
cassettesAfter.map((c) => ({ denomination: c.denomination, count: c.count }))
)
if (!halResult.ok) {
console.error('[OperatorConfig] HAL reload failed:', halResult.error)
}
console.log(
`[OperatorConfig] Applied — created_at=${event.created_at}, denominations=${Object.keys(parsed.denominations).join(',')}`
)
}
async function maybePublishBootstrap(
cfg: OperatorConfigServiceConfig,
api: NonNullable<typeof window.electronAPI>,
machineId: string
): Promise<void> {
const already = await api.getBootstrapPublishedAt()
if (already !== null) {
console.log('[OperatorConfig] Bootstrap already published at unix', already)
return
}
const cassettes = await api.loadCassettes()
if (cassettes.length === 0) {
console.log('[OperatorConfig] state.db.cassettes empty — skipping bootstrap')
return
}
const operatorPubkey = cfg.operatorPubkeys[0]
if (!operatorPubkey) {
console.log('[OperatorConfig] No operator pubkey — skipping bootstrap')
return
}
const denominations: Record<string, { position: number; count: number }> = {}
for (const c of cassettes) {
denominations[String(c.denomination)] = { position: c.position, count: c.count }
}
const ciphertext = encryptContentV2(cfg.identity, operatorPubkey, { denominations })
const dTag = atmStateDTag(machineId)
const event = createSignedEvent(cfg.identity, {
kind: KIND_NIP78,
content: ciphertext,
tags: [
['d', dTag],
['p', operatorPubkey],
],
created_at: Math.floor(Date.now() / 1000),
})
await cfg.nostrClient.publish(event)
await api.markBootstrapPublished(Math.floor(Date.now() / 1000))
console.log('[OperatorConfig] Bootstrap hello-event published:', { dTag, eventId: event.id })
}

View file

@ -10,6 +10,10 @@ import {
type ATMMachine,
} from '@bitSpire/state-machine'
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
import {
startOperatorConfigService,
type OperatorConfigService,
} from '@/services/operator-config'
import type { HalConfig, HalServices } from '@/services/hal'
import type { MachineModel } from '@/config'
import type { LightningBackend } from '@/services/lightning'
@ -310,6 +314,8 @@ export const useAtmStore = defineStore('atm', () => {
let atmServicesRef: ATMServices | null = null
// Cleanup function for LNURL sessions (set after Lightning init)
let lnurlCleanupFn: (() => void) | null = null
// Operator-config consumer (aiolabs/lamassu-next#56) — set after Lightning init
let operatorConfigSvc: OperatorConfigService | null = null
/**
* Persisted inventory loaded from SQLite — the source of truth for
@ -618,6 +624,15 @@ export const useAtmStore = defineStore('atm', () => {
// Start broadcasting availability (Kind 30078) with 5-minute heartbeat
startAvailabilityBroadcast(services.nostrClient, services.identity, machineModel.value)
// Start operator-config consumer (aiolabs/lamassu-next#56) — subscribes
// to kind-30078 cassette config events + publishes one-shot bootstrap
operatorConfigSvc?.stop()
operatorConfigSvc = await startOperatorConfigService({
nostrClient: services.nostrClient,
identity: services.identity,
operatorPubkeys: services.operatorPubkeys,
})
} catch (error) {
console.error('[ATM] Failed to connect to Lightning.Pub:', error)
connectionStatus.value = 'error'
@ -913,6 +928,14 @@ export const useAtmStore = defineStore('atm', () => {
// Start broadcasting availability (Kind 30078)
startAvailabilityBroadcast(lightning.nostrClient, lightning.identity, machineModel.value)
// Operator-config consumer (aiolabs/lamassu-next#56)
operatorConfigSvc?.stop()
operatorConfigSvc = await startOperatorConfigService({
nostrClient: lightning.nostrClient,
identity: lightning.identity,
operatorPubkeys: lightning.operatorPubkeys,
})
console.log('[ATM] Fully initialized with HAL + Lightning')
} catch (error) {
console.error('[ATM] HAL initialization failed:', error)
@ -1174,6 +1197,14 @@ export const useAtmStore = defineStore('atm', () => {
// Start broadcasting availability (Kind 30078)
startAvailabilityBroadcast(lightning.nostrClient, lightning.identity, machineModel.value)
// Operator-config consumer (aiolabs/lamassu-next#56)
operatorConfigSvc?.stop()
operatorConfigSvc = await startOperatorConfigService({
nostrClient: lightning.nostrClient,
identity: lightning.identity,
operatorPubkeys: lightning.operatorPubkeys,
})
console.log('[ATM] Fully initialized with HAL (IPC) + Lightning')
} catch (error) {
console.error('[ATM] HAL initialization failed:', error)

View file

@ -55,7 +55,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<{
@ -86,6 +86,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 }>
@ -95,6 +102,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