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
|
rejectBill: () => void
|
||||||
dispenseCash: (amounts: { denomination: number; count: number }[]) => Promise<DispenseResult>
|
dispenseCash: (amounts: { denomination: number; count: number }[]) => Promise<DispenseResult>
|
||||||
getInventory: () => Record<number, number>
|
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>
|
cleanup: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,8 +80,10 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||||||
device: dispConfig.device,
|
device: dispConfig.device,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize dispenser
|
// Initialize dispenser. `dispenserInitData` is `let` because
|
||||||
const dispenserInitData = {
|
// `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,
|
fiatCode: valConfig.fiatCode,
|
||||||
cassettes: dispConfig.cassettes,
|
cassettes: dispConfig.cassettes,
|
||||||
}
|
}
|
||||||
|
|
@ -110,13 +120,15 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||||||
validator = null
|
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> = {}
|
const inventory: Record<number, number> = {}
|
||||||
for (const cassette of dispConfig.cassettes) {
|
for (const cassette of dispConfig.cassettes) {
|
||||||
inventory[cassette.denomination] = cassette.count ?? 0
|
inventory[cassette.denomination] = cassette.count ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const cassetteDenominations = dispConfig.cassettes.map((c) => c.denomination)
|
let cassetteDenominations = dispConfig.cassettes.map((c) => c.denomination)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connectValidator: (callbacks: ValidatorCallbacks) => {
|
connectValidator: (callbacks: ValidatorCallbacks) => {
|
||||||
|
|
@ -247,6 +259,31 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||||||
|
|
||||||
getInventory: () => ({ ...inventory }),
|
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 () => {
|
cleanup: async () => {
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
validator?.disable()
|
validator?.disable()
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,12 @@ import {
|
||||||
getPendingCommand,
|
getPendingCommand,
|
||||||
markCommandExecuting,
|
markCommandExecuting,
|
||||||
completeCommand,
|
completeCommand,
|
||||||
|
getLastKnownConfigCreatedAt,
|
||||||
|
getBootstrapPublishedAt,
|
||||||
|
markBootstrapPublished,
|
||||||
|
applyOperatorCassettesConfig,
|
||||||
|
type OperatorCassettesPayload,
|
||||||
|
type ApplyResult,
|
||||||
} from './state-store.js'
|
} from './state-store.js'
|
||||||
import { initializeHal, type HalInstance } from './hal-service.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)
|
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/
|
// Support pages — read .md files from /var/lib/bitspire/support/
|
||||||
ipcMain.handle('support:get-pages', () => {
|
ipcMain.handle('support:get-pages', () => {
|
||||||
const supportDir = path.join(
|
const supportDir = path.join(
|
||||||
|
|
@ -499,6 +522,26 @@ ipcMain.handle('hal:get-inventory', () => {
|
||||||
return halInstance.getInventory()
|
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 () => {
|
ipcMain.handle('hal:cleanup', async () => {
|
||||||
if (halInstance) {
|
if (halInstance) {
|
||||||
await halInstance.cleanup()
|
await halInstance.cleanup()
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
remediateTransaction: (txid: string, remediatedByTxid: string): Promise<boolean> =>
|
remediateTransaction: (txid: string, remediatedByTxid: string): Promise<boolean> =>
|
||||||
ipcRenderer.invoke('state:remediate-transaction', txid, remediatedByTxid),
|
ipcRenderer.invoke('state:remediate-transaction', txid, remediatedByTxid),
|
||||||
|
|
||||||
|
// Operator-config consumer (aiolabs/lamassu-next#56)
|
||||||
|
getLastKnownConfigCreatedAt: (): Promise<number> =>
|
||||||
|
ipcRenderer.invoke('state:get-last-known-config-created-at'),
|
||||||
|
getBootstrapPublishedAt: (): Promise<number | null> =>
|
||||||
|
ipcRenderer.invoke('state:get-bootstrap-published-at'),
|
||||||
|
markBootstrapPublished: (unixTimestamp: number): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp),
|
||||||
|
applyOperatorCassettesConfig: (
|
||||||
|
payload: {
|
||||||
|
denominations: Record<string, { position: number; count: number }>
|
||||||
|
},
|
||||||
|
eventCreatedAt: number
|
||||||
|
): Promise<{ applied: true } | { applied: false; reason: string }> =>
|
||||||
|
ipcRenderer.invoke('state:apply-operator-cassettes-config', payload, eventCreatedAt),
|
||||||
|
|
||||||
// Support pages
|
// Support pages
|
||||||
getSupportPages: (): Promise<{ id: string; title: string; content: string }[]> =>
|
getSupportPages: (): Promise<{ id: string; title: string; content: string }[]> =>
|
||||||
ipcRenderer.invoke('support:get-pages'),
|
ipcRenderer.invoke('support:get-pages'),
|
||||||
|
|
@ -108,6 +123,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
halStackBill: (): Promise<void> => ipcRenderer.invoke('hal:stack-bill'),
|
halStackBill: (): Promise<void> => ipcRenderer.invoke('hal:stack-bill'),
|
||||||
halRejectBill: (): Promise<void> => ipcRenderer.invoke('hal:reject-bill'),
|
halRejectBill: (): Promise<void> => ipcRenderer.invoke('hal:reject-bill'),
|
||||||
halGetInventory: (): Promise<Record<number, number>> => ipcRenderer.invoke('hal:get-inventory'),
|
halGetInventory: (): Promise<Record<number, number>> => ipcRenderer.invoke('hal:get-inventory'),
|
||||||
|
halReloadCassettes: (
|
||||||
|
cassettes: { denomination: number; count?: number }[]
|
||||||
|
): Promise<{ ok: boolean; error?: string }> =>
|
||||||
|
ipcRenderer.invoke('hal:reload-cassettes', cassettes),
|
||||||
halCleanup: (): Promise<void> => ipcRenderer.invoke('hal:cleanup'),
|
halCleanup: (): Promise<void> => ipcRenderer.invoke('hal:cleanup'),
|
||||||
|
|
||||||
// HAL event listeners (main process → renderer)
|
// HAL event listeners (main process → renderer)
|
||||||
|
|
@ -141,7 +160,7 @@ declare global {
|
||||||
getVersion: () => Promise<string>
|
getVersion: () => Promise<string>
|
||||||
getConfig: () => Promise<RuntimeConfig>
|
getConfig: () => Promise<RuntimeConfig>
|
||||||
getAtmSecrets: () => Promise<AtmSecrets>
|
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>
|
setCassettes: (cassettes: { denomination: number; count: number }[]) => Promise<void>
|
||||||
getInventory: () => Promise<Record<number, number>>
|
getInventory: () => Promise<Record<number, number>>
|
||||||
getCashbox: () => Promise<{
|
getCashbox: () => Promise<{
|
||||||
|
|
@ -172,6 +191,13 @@ declare global {
|
||||||
}) => Promise<void>
|
}) => Promise<void>
|
||||||
emptyCashbox: () => Promise<void>
|
emptyCashbox: () => Promise<void>
|
||||||
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
|
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 }[]>
|
getSupportPages: () => Promise<{ id: string; title: string; content: string }[]>
|
||||||
// HAL hardware IPC
|
// HAL hardware IPC
|
||||||
halInit: (config: any) => Promise<{ success: boolean; error?: string }>
|
halInit: (config: any) => Promise<{ success: boolean; error?: string }>
|
||||||
|
|
@ -181,6 +207,9 @@ declare global {
|
||||||
halStackBill: () => Promise<void>
|
halStackBill: () => Promise<void>
|
||||||
halRejectBill: () => Promise<void>
|
halRejectBill: () => Promise<void>
|
||||||
halGetInventory: () => Promise<Record<number, number>>
|
halGetInventory: () => Promise<Record<number, number>>
|
||||||
|
halReloadCassettes: (
|
||||||
|
cassettes: { denomination: number; count?: number }[]
|
||||||
|
) => Promise<{ ok: boolean; error?: string }>
|
||||||
halCleanup: () => Promise<void>
|
halCleanup: () => Promise<void>
|
||||||
onHalBillRead: (callback: (denomination: number) => void) => void
|
onHalBillRead: (callback: (denomination: number) => void) => void
|
||||||
onHalBillInserted: (callback: (denomination: number) => void) => void
|
onHalBillInserted: (callback: (denomination: number) => void) => void
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import fs from 'node:fs'
|
||||||
|
|
||||||
let db: Database.Database | null = null
|
let db: Database.Database | null = null
|
||||||
|
|
||||||
const SCHEMA_VERSION = '7'
|
const SCHEMA_VERSION = '8'
|
||||||
|
|
||||||
function getDbPath(): string {
|
function getDbPath(): string {
|
||||||
const prodDir = '/var/lib/bitspire'
|
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.exec(`ALTER TABLE transactions RENAME COLUMN fee_percent TO fee_fraction`)
|
||||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('7', 'schema_version')
|
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('7', 'schema_version')
|
||||||
console.log('[StateStore] Migrated schema v6 → v7 (renamed fee_percent → fee_fraction)')
|
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()
|
const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get()
|
||||||
if (!cashboxRow) {
|
if (!cashboxRow) {
|
||||||
db.prepare('INSERT INTO cashbox (id) VALUES (1)').run()
|
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)
|
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:<machine_id>` 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<string, { position: number; count: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// Cassettes
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -261,6 +261,8 @@ interface LightningServices {
|
||||||
lightningPub: LightningBackend
|
lightningPub: LightningBackend
|
||||||
clink: CLINKClient
|
clink: CLINKClient
|
||||||
identity: MachineIdentity
|
identity: MachineIdentity
|
||||||
|
/** Operator pubkeys (hex) authorized for kind-21003 management + operator-config events. */
|
||||||
|
operatorPubkeys: string[]
|
||||||
atmServices: ATMServices
|
atmServices: ATMServices
|
||||||
/** Set callback for when offer requests are received */
|
/** Set callback for when offer requests are received */
|
||||||
onOfferRequest: (callback: OfferRequestCallback) => void
|
onOfferRequest: (callback: OfferRequestCallback) => void
|
||||||
|
|
@ -613,6 +615,7 @@ export async function initializeLightningServices(options?: {
|
||||||
lightningPub,
|
lightningPub,
|
||||||
clink,
|
clink,
|
||||||
identity,
|
identity,
|
||||||
|
operatorPubkeys: CONFIG.operatorPubkeys,
|
||||||
atmServices,
|
atmServices,
|
||||||
onOfferRequest: (callback: OfferRequestCallback) => {
|
onOfferRequest: (callback: OfferRequestCallback) => {
|
||||||
offerRequestCallback = callback
|
offerRequestCallback = callback
|
||||||
|
|
|
||||||
236
apps/machine/src/services/operator-config.ts
Normal file
236
apps/machine/src/services/operator-config.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,10 @@ import {
|
||||||
type ATMMachine,
|
type ATMMachine,
|
||||||
} from '@bitSpire/state-machine'
|
} from '@bitSpire/state-machine'
|
||||||
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
|
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
|
||||||
|
import {
|
||||||
|
startOperatorConfigService,
|
||||||
|
type OperatorConfigService,
|
||||||
|
} from '@/services/operator-config'
|
||||||
import type { HalConfig, HalServices } from '@/services/hal'
|
import type { HalConfig, HalServices } from '@/services/hal'
|
||||||
import type { MachineModel } from '@/config'
|
import type { MachineModel } from '@/config'
|
||||||
import type { LightningBackend } from '@/services/lightning'
|
import type { LightningBackend } from '@/services/lightning'
|
||||||
|
|
@ -310,6 +314,8 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
let atmServicesRef: ATMServices | null = null
|
let atmServicesRef: ATMServices | null = null
|
||||||
// Cleanup function for LNURL sessions (set after Lightning init)
|
// Cleanup function for LNURL sessions (set after Lightning init)
|
||||||
let lnurlCleanupFn: (() => void) | null = null
|
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
|
* 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
|
// Start broadcasting availability (Kind 30078) with 5-minute heartbeat
|
||||||
startAvailabilityBroadcast(services.nostrClient, services.identity, machineModel.value)
|
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) {
|
} catch (error) {
|
||||||
console.error('[ATM] Failed to connect to Lightning.Pub:', error)
|
console.error('[ATM] Failed to connect to Lightning.Pub:', error)
|
||||||
connectionStatus.value = 'error'
|
connectionStatus.value = 'error'
|
||||||
|
|
@ -913,6 +928,14 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
// Start broadcasting availability (Kind 30078)
|
// Start broadcasting availability (Kind 30078)
|
||||||
startAvailabilityBroadcast(lightning.nostrClient, lightning.identity, machineModel.value)
|
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')
|
console.log('[ATM] Fully initialized with HAL + Lightning')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ATM] HAL initialization failed:', error)
|
console.error('[ATM] HAL initialization failed:', error)
|
||||||
|
|
@ -1174,6 +1197,14 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
// Start broadcasting availability (Kind 30078)
|
// Start broadcasting availability (Kind 30078)
|
||||||
startAvailabilityBroadcast(lightning.nostrClient, lightning.identity, machineModel.value)
|
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')
|
console.log('[ATM] Fully initialized with HAL (IPC) + Lightning')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ATM] HAL initialization failed:', error)
|
console.error('[ATM] HAL initialization failed:', error)
|
||||||
|
|
|
||||||
12
apps/machine/src/types/electron.d.ts
vendored
12
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -55,7 +55,7 @@ declare global {
|
||||||
getVersion: () => Promise<string>
|
getVersion: () => Promise<string>
|
||||||
getConfig: () => Promise<RuntimeConfig>
|
getConfig: () => Promise<RuntimeConfig>
|
||||||
getAtmSecrets: () => Promise<AtmSecrets>
|
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>
|
setCassettes: (cassettes: { denomination: number; count: number }[]) => Promise<void>
|
||||||
getInventory: () => Promise<Record<number, number>>
|
getInventory: () => Promise<Record<number, number>>
|
||||||
getCashbox: () => Promise<{
|
getCashbox: () => Promise<{
|
||||||
|
|
@ -86,6 +86,13 @@ declare global {
|
||||||
}) => Promise<void>
|
}) => Promise<void>
|
||||||
emptyCashbox: () => Promise<void>
|
emptyCashbox: () => Promise<void>
|
||||||
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
|
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 }[]>
|
getSupportPages: () => Promise<{ id: string; title: string; content: string }[]>
|
||||||
// HAL hardware IPC
|
// HAL hardware IPC
|
||||||
halInit: (config: any) => Promise<{ success: boolean; error?: string }>
|
halInit: (config: any) => Promise<{ success: boolean; error?: string }>
|
||||||
|
|
@ -95,6 +102,9 @@ declare global {
|
||||||
halStackBill: () => Promise<void>
|
halStackBill: () => Promise<void>
|
||||||
halRejectBill: () => Promise<void>
|
halRejectBill: () => Promise<void>
|
||||||
halGetInventory: () => Promise<Record<number, number>>
|
halGetInventory: () => Promise<Record<number, number>>
|
||||||
|
halReloadCassettes: (
|
||||||
|
cassettes: { denomination: number; count?: number }[]
|
||||||
|
) => Promise<{ ok: boolean; error?: string }>
|
||||||
halCleanup: () => Promise<void>
|
halCleanup: () => Promise<void>
|
||||||
onHalBillRead: (callback: (denomination: number) => void) => void
|
onHalBillRead: (callback: (denomination: number) => void) => void
|
||||||
onHalBillInserted: (callback: (denomination: number) => void) => void
|
onHalBillInserted: (callback: (denomination: number) => void) => void
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue