feat: operator command channel via Nostr (manual dispense)

Add a Nostr-native operator command channel using Kind 21003 (CLINK
Manage) events. Operators listed in OPERATOR_PUBKEYS can send encrypted
commands to the machine.

Phase 1 implements manual dispense: operator sends a dispense command,
machine verifies sender, checks it's idle, performs a direct HAL
dispense (bypassing state machine), and records the transaction.

When ref_txid is provided, the referenced failed transaction is updated
to status 'remediated', closing the loop on dispense errors.

Changes:
- CLINK types: add 'machine' resource, MachineDispenseRequest type
- CLINK client: support operator pubkey list (string | string[])
- Runtime config: VITE_OPERATOR_PUBKEYS env var
- Schema v4→v5: manual_dispense type, remediated_by column
- Lightning services: wire onManagement callback
- ATM store: handleManagementCommand with idle check + remediation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-03-23 00:36:55 -04:00
commit e03782803b
11 changed files with 274 additions and 24 deletions

View file

@ -17,6 +17,7 @@ import {
setCassettes,
getInventory,
recordTransaction,
remediateTransaction,
emptyCashbox,
getCashbox,
} from './state-store.js'
@ -180,6 +181,9 @@ ipcMain.handle('get-config', () => {
// SECURITY: In production (packaged app), mock fallback is always disabled.
// Only allow it in development mode, and only when explicitly opted in via env.
allowMockFallback: isDev && process.env.VITE_ALLOW_MOCK_FALLBACK === 'true',
// Operator identity (comma-separated hex pubkeys)
operatorPubkeys: process.env.VITE_OPERATOR_PUBKEYS || '',
}
})
@ -213,6 +217,9 @@ ipcMain.handle('state:get-inventory', () => getInventory())
ipcMain.handle('state:get-cashbox', () => getCashbox())
ipcMain.handle('state:record-transaction', (_event, tx) => recordTransaction(tx))
ipcMain.handle('state:empty-cashbox', () => emptyCashbox())
ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedByTxid: string) =>
remediateTransaction(txid, remediatedByTxid)
)
// =============================================================================
// HAL Hardware IPC Handlers

View file

@ -25,6 +25,7 @@ export interface RuntimeConfig {
dispenserDevice?: string
cassettes?: string
allowMockFallback: boolean
operatorPubkeys: string
}
/**
@ -54,8 +55,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
getCashbox: () => ipcRenderer.invoke('state:get-cashbox'),
recordTransaction: (tx: {
txid: string
type: 'cash_in' | 'cash_out'
status: 'complete' | 'dispense_error' | 'partial'
type: 'cash_in' | 'cash_out' | 'manual_dispense'
status: 'complete' | 'dispense_error' | 'partial' | 'remediated'
fiatCents: number
sats: number
feeSats: number
@ -74,6 +75,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
error?: string | null
}) => ipcRenderer.invoke('state:record-transaction', tx),
emptyCashbox: () => ipcRenderer.invoke('state:empty-cashbox'),
remediateTransaction: (txid: string, remediatedByTxid: string): Promise<boolean> =>
ipcRenderer.invoke('state:remediate-transaction', txid, remediatedByTxid),
// HAL hardware (runs in main process, exposed via IPC)
halInit: (config: any): Promise<{ success: boolean; error?: string }> =>
@ -147,6 +150,7 @@ declare global {
error?: string | null
}) => Promise<void>
emptyCashbox: () => Promise<void>
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
// HAL hardware IPC
halInit: (config: any) => Promise<{ success: boolean; error?: string }>
halDispense: (amounts: any) => Promise<any>

View file

@ -15,7 +15,7 @@ import fs from 'node:fs'
let db: Database.Database | null = null
const SCHEMA_VERSION = '4'
const SCHEMA_VERSION = '5'
function getDbPath(): string {
const prodDir = '/var/lib/lamassu-atm'
@ -65,7 +65,7 @@ export function initDatabase(dbPath?: string): void {
CREATE TABLE IF NOT EXISTS transactions (
txid TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK (type IN ('cash_in', 'cash_out')),
type TEXT NOT NULL CHECK (type IN ('cash_in', 'cash_out', 'manual_dispense')),
fiat_cents INTEGER NOT NULL,
sats INTEGER NOT NULL,
fee_sats INTEGER NOT NULL DEFAULT 0,
@ -74,6 +74,7 @@ export function initDatabase(dbPath?: string): void {
currency TEXT NOT NULL DEFAULT 'GTQ',
status TEXT NOT NULL DEFAULT 'complete',
error TEXT,
remediated_by TEXT,
created_at INTEGER NOT NULL
);
@ -143,6 +144,34 @@ export function initDatabase(dbPath?: string): void {
`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('4', 'schema_version')
console.log('[StateStore] Migrated schema v3 → v4 (added status, error, cassette_bills)')
existing.value = '4'
}
if (existing && existing.value === '4') {
// Migration v4 → v5: add manual_dispense type and remediated_by column
// SQLite cannot ALTER CHECK constraints, so recreate the table
db.exec(`
CREATE TABLE transactions_new (
txid TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK (type IN ('cash_in', 'cash_out', 'manual_dispense')),
fiat_cents INTEGER NOT NULL,
sats INTEGER NOT NULL,
fee_sats INTEGER NOT NULL DEFAULT 0,
fee_percent REAL NOT NULL DEFAULT 0,
exchange_rate REAL NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'GTQ',
status TEXT NOT NULL DEFAULT 'complete',
error TEXT,
remediated_by TEXT,
created_at INTEGER NOT NULL
);
INSERT INTO transactions_new SELECT txid, type, fiat_cents, sats, fee_sats, fee_percent,
exchange_rate, currency, status, error, NULL, created_at FROM transactions;
DROP TABLE transactions;
ALTER TABLE transactions_new RENAME TO transactions;
`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('5', 'schema_version')
console.log('[StateStore] Migrated schema v4 → v5 (added manual_dispense type, remediated_by)')
}
const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get()
@ -269,8 +298,8 @@ export function emptyCashbox(): void {
interface TransactionInput {
txid: string
type: 'cash_in' | 'cash_out'
status: 'complete' | 'dispense_error' | 'partial'
type: 'cash_in' | 'cash_out' | 'manual_dispense'
status: 'complete' | 'dispense_error' | 'partial' | 'remediated'
fiatCents: number
sats: number
feeSats: number
@ -372,6 +401,26 @@ export function recordTransaction(tx: TransactionInput): void {
console.log('[StateStore] Recorded transaction:', tx.txid, tx.type, `(${tx.status})`)
}
/**
* Mark a failed transaction as remediated by a manual dispense.
* Only updates transactions with status 'dispense_error' or 'partial'.
* Returns true if the transaction was updated, false if not found or not in error state.
*/
export function remediateTransaction(txid: string, remediatedByTxid: string): boolean {
if (!db) throw new Error('Database not initialized')
const result = db
.prepare(
'UPDATE transactions SET status = ?, remediated_by = ? WHERE txid = ? AND status IN (?, ?)'
)
.run('remediated', remediatedByTxid, txid, 'dispense_error', 'partial')
if (result.changes > 0) {
console.log('[StateStore] Remediated transaction:', txid, '→', remediatedByTxid)
}
return result.changes > 0
}
/**
* Close the database (for clean shutdown).
*/