diff --git a/apps/machine/electron/hal-service.ts b/apps/machine/electron/hal-service.ts index 90c57d4..1331d15 100644 --- a/apps/machine/electron/hal-service.ts +++ b/apps/machine/electron/hal-service.ts @@ -56,6 +56,14 @@ export interface HalInstance { rejectBill: () => void dispenseCash: (amounts: { denomination: number; count: number }[]) => Promise getInventory: () => Record + /** + * 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 cleanup: () => Promise } @@ -72,8 +80,10 @@ export async function initializeHal(config: HalConfig): Promise { 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 { 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 = {} 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 { getInventory: () => ({ ...inventory }), + setCassettes: async (cassettes: CassetteConfig[]): Promise => { + 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((resolve) => { validator?.disable() diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 56b916c..fb4f9d8 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -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() diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index 068846c..524ca06 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -95,6 +95,21 @@ contextBridge.exposeInMainWorld('electronAPI', { remediateTransaction: (txid: string, remediatedByTxid: string): Promise => ipcRenderer.invoke('state:remediate-transaction', txid, remediatedByTxid), + // Operator-config consumer (aiolabs/lamassu-next#56) + getLastKnownConfigCreatedAt: (): Promise => + ipcRenderer.invoke('state:get-last-known-config-created-at'), + getBootstrapPublishedAt: (): Promise => + ipcRenderer.invoke('state:get-bootstrap-published-at'), + markBootstrapPublished: (unixTimestamp: number): Promise => + ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp), + applyOperatorCassettesConfig: ( + payload: { + denominations: Record + }, + eventCreatedAt: number + ): Promise<{ applied: true } | { applied: false; reason: string }> => + ipcRenderer.invoke('state:apply-operator-cassettes-config', payload, eventCreatedAt), + // Support pages getSupportPages: (): Promise<{ id: string; title: string; content: string }[]> => ipcRenderer.invoke('support:get-pages'), @@ -108,6 +123,10 @@ contextBridge.exposeInMainWorld('electronAPI', { halStackBill: (): Promise => ipcRenderer.invoke('hal:stack-bill'), halRejectBill: (): Promise => ipcRenderer.invoke('hal:reject-bill'), halGetInventory: (): Promise> => ipcRenderer.invoke('hal:get-inventory'), + halReloadCassettes: ( + cassettes: { denomination: number; count?: number }[] + ): Promise<{ ok: boolean; error?: string }> => + ipcRenderer.invoke('hal:reload-cassettes', cassettes), halCleanup: (): Promise => ipcRenderer.invoke('hal:cleanup'), // HAL event listeners (main process → renderer) @@ -141,7 +160,7 @@ declare global { getVersion: () => Promise getConfig: () => Promise getAtmSecrets: () => Promise - loadCassettes: () => Promise<{ denomination: number; count: number }[]> + loadCassettes: () => Promise<{ denomination: number; count: number; position: number }[]> setCassettes: (cassettes: { denomination: number; count: number }[]) => Promise getInventory: () => Promise> getCashbox: () => Promise<{ @@ -172,6 +191,13 @@ declare global { }) => Promise emptyCashbox: () => Promise remediateTransaction: (txid: string, remediatedByTxid: string) => Promise + getLastKnownConfigCreatedAt: () => Promise + getBootstrapPublishedAt: () => Promise + markBootstrapPublished: (unixTimestamp: number) => Promise + applyOperatorCassettesConfig: ( + payload: { denominations: Record }, + 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 }> @@ -181,6 +207,9 @@ declare global { halStackBill: () => Promise halRejectBill: () => Promise halGetInventory: () => Promise> + halReloadCassettes: ( + cassettes: { denomination: number; count?: number }[] + ) => Promise<{ ok: boolean; error?: string }> halCleanup: () => Promise onHalBillRead: (callback: (denomination: number) => void) => void onHalBillInserted: (callback: (denomination: number) => void) => void diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index 96186b8..a9ab8f0 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -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:` 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 +} + +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 // --------------------------------------------------------------------------- diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index 982fd64..92c6af5 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -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 diff --git a/apps/machine/src/services/operator-config.ts b/apps/machine/src/services/operator-config.ts new file mode 100644 index 0000000..21b9b93 --- /dev/null +++ b/apps/machine/src/services/operator-config.ts @@ -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:"]`, + * `["p", ]`, NIP-44 v2 encrypted content, author = operator pubkey + * - ATM bootstrap: `kind=30078`, `["d", "bitspire-cassettes-state:"]`, + * `["p", ]`, NIP-44 v2 encrypted content, author = ATM pubkey + * + * The ATM's hex pubkey serves as `` — 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 { + 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 +): Promise { + // 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 } + 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, + machineId: string +): Promise { + 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 = {} + 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 }) +} diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 945bbb4..e1f756b 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -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) diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index fec9664..0ff2f3b 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -55,7 +55,7 @@ declare global { getVersion: () => Promise getConfig: () => Promise getAtmSecrets: () => Promise - loadCassettes: () => Promise<{ denomination: number; count: number }[]> + loadCassettes: () => Promise<{ denomination: number; count: number; position: number }[]> setCassettes: (cassettes: { denomination: number; count: number }[]) => Promise getInventory: () => Promise> getCashbox: () => Promise<{ @@ -86,6 +86,13 @@ declare global { }) => Promise emptyCashbox: () => Promise remediateTransaction: (txid: string, remediatedByTxid: string) => Promise + getLastKnownConfigCreatedAt: () => Promise + getBootstrapPublishedAt: () => Promise + markBootstrapPublished: (unixTimestamp: number) => Promise + applyOperatorCassettesConfig: ( + payload: { denominations: Record }, + 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 halRejectBill: () => Promise halGetInventory: () => Promise> + halReloadCassettes: ( + cassettes: { denomination: number; count?: number }[] + ) => Promise<{ ok: boolean; error?: string }> halCleanup: () => Promise onHalBillRead: (callback: (denomination: number) => void) => void onHalBillInserted: (callback: (denomination: number) => void) => void