From e03782803b2df144269047d604e7403d3630cac4 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Mon, 23 Mar 2026 00:36:55 -0400 Subject: [PATCH] feat: operator command channel via Nostr (manual dispense) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/machine/.env.example | 8 ++ apps/machine/electron/main.ts | 7 ++ apps/machine/electron/preload.ts | 8 +- apps/machine/electron/state-store.ts | 57 +++++++++++- apps/machine/src/services/lightning.ts | 47 +++++++++- apps/machine/src/stores/atm.ts | 117 ++++++++++++++++++++++++- apps/machine/src/types/electron.d.ts | 6 +- apps/machine/src/types/state.ts | 5 +- packages/clink/src/client.ts | 14 +-- packages/clink/src/index.ts | 3 + packages/clink/src/types.ts | 26 +++++- 11 files changed, 274 insertions(+), 24 deletions(-) diff --git a/apps/machine/.env.example b/apps/machine/.env.example index cf0ce22..8124aa4 100644 --- a/apps/machine/.env.example +++ b/apps/machine/.env.example @@ -41,6 +41,14 @@ VITE_LIGHTNING_PUB_API_URL=http://localhost:1776 # If not set, generates ephemeral identity on each restart VITE_ATM_PRIVATE_KEY= +# ============================================================================= +# Operator Identity +# ============================================================================= + +# Comma-separated list of Nostr hex pubkeys authorized to send operator commands +# (manual dispense, remote management). Decoupled from Lightning.Pub identity. +# VITE_OPERATOR_PUBKEYS=abcd1234...,ef567890... + # ============================================================================= # Mock Fallback (Production Safety) # ============================================================================= diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index fb3570d..d1c4dcc 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -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 diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index d2d0296..952ede7 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -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 => + 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 emptyCashbox: () => Promise + remediateTransaction: (txid: string, remediatedByTxid: string) => Promise // HAL hardware IPC halInit: (config: any) => Promise<{ success: boolean; error?: string }> halDispense: (amounts: any) => Promise diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index ea39d4c..91c469e 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 = '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). */ diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index 3f8b1ac..b47b3b0 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -32,7 +32,7 @@ import { encodeNdebit, formatNdebitUri, } from '@lamassu/clink' -import type { OfferRequest } from '@lamassu/clink' +import type { OfferRequest, ManagementRequest, ManagementResponse } from '@lamassu/clink' import type { ATMServices, ATMContext } from '@lamassu/state-machine' // Import Electron types @@ -62,6 +62,7 @@ interface LightningConfig { adminToken: string atmPrivateKey: string appId: string + operatorPubkeys: string[] } /** @@ -81,6 +82,7 @@ async function loadLightningConfig(): Promise { adminToken: 'lamassu-dev-admin-token', atmPrivateKey: '', appId: '152fd75c134226824e5183cd9c02a35b4972e995f39e7c0e4ec215ae8c1fae1d', // ATM app ID + operatorPubkeys: [], } // In Electron, get runtime config from main process @@ -97,6 +99,12 @@ async function loadLightningConfig(): Promise { adminToken: secrets.adminToken || defaults.adminToken, atmPrivateKey: secrets.atmPrivateKey || defaults.atmPrivateKey, appId: runtimeConfig.appId || defaults.appId, + operatorPubkeys: runtimeConfig.operatorPubkeys + ? runtimeConfig.operatorPubkeys + .split(',') + .map((k: string) => k.trim()) + .filter(Boolean) + : defaults.operatorPubkeys, } } catch (e) { console.warn('[Lightning] Failed to get runtime config from Electron:', e) @@ -112,6 +120,12 @@ async function loadLightningConfig(): Promise { adminToken: import.meta.env.VITE_ADMIN_TOKEN || defaults.adminToken, atmPrivateKey: import.meta.env.VITE_ATM_PRIVATE_KEY || defaults.atmPrivateKey, appId: import.meta.env.VITE_APP_ID || defaults.appId, + operatorPubkeys: import.meta.env.VITE_OPERATOR_PUBKEYS + ? (import.meta.env.VITE_OPERATOR_PUBKEYS as string) + .split(',') + .map((k: string) => k.trim()) + .filter(Boolean) + : defaults.operatorPubkeys, } } @@ -669,6 +683,13 @@ interface LightningServices { onDebitPaymentApproved: (callback: DebitPaymentCallback) => void /** Stop the debit approval service */ stopDebitApproval: () => void + /** Set callback for operator management commands (Kind 21003) */ + onManagement: ( + callback: ( + request: ManagementRequest, + senderPubkey: string + ) => Promise + ) => void } // ============================================================================ @@ -896,7 +917,7 @@ export async function initializeLightningServices(options?: { const clink = new CLINKClient({ nostrClient, identity, - operatorPubkey: CONFIG.lightningPubPubkey, // Use Lightning.Pub as operator for dev + operatorPubkey: [CONFIG.lightningPubPubkey, ...CONFIG.operatorPubkeys].filter(Boolean), relays: [CONFIG.relayUrl], }) @@ -904,6 +925,9 @@ export async function initializeLightningServices(options?: { let offerRequestCallback: OfferRequestCallback | null = null let paymentReceivedCallback: PaymentReceivedCallback | null = null let debitPaymentCallback: DebitPaymentCallback | null = null + let managementCallback: + | ((request: ManagementRequest, senderPubkey: string) => Promise) + | null = null // Start the debit approval service const stopDebitApproval = startDebitApprovalService( @@ -968,8 +992,17 @@ export async function initializeLightningServices(options?: { } }) + // Set up management command handler (Kind 21003, resource='machine') + clink.onManagement(async (request, senderPubkey) => { + console.log('[CLINK] Received management command from', senderPubkey.slice(0, 16) + '...') + if (managementCallback) { + return managementCallback(request, senderPubkey) + } + return null + }) + clink.startListening() - console.log('[Lightning] CLINK client initialized with offer handler') + console.log('[Lightning] CLINK client initialized with offer + management handlers') // Create ATM services using Lightning.Pub's native LNURL-withdraw const atmServices = createATMServices(lightningPub, clink, identity, (preimage) => { @@ -994,6 +1027,14 @@ export async function initializeLightningServices(options?: { debitPaymentCallback = callback }, stopDebitApproval, + onManagement: ( + callback: ( + request: ManagementRequest, + senderPubkey: string + ) => Promise + ) => { + managementCallback = callback + }, } } diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index b8c93f2..befd9ed 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -13,7 +13,13 @@ import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning import type { HalConfig, HalServices } from '@/services/hal' import type { MachineModel } from '@/config' import type { LightningPubClient } from '@lamassu/lightning' -import type { CLINKClient } from '@lamassu/clink' +import { + isMachineDispenseRequest, + GFYCode, + type CLINKClient, + type ManagementRequest, + type ManagementResponse, +} from '@lamassu/clink' import type { TransactionRecord } from '@/types/state' // Check if we're running in Electron @@ -31,6 +37,85 @@ function computeFeeSats(ctx: ATMContext, isCashIn: boolean): number { return Math.max(0, feeSats) } +/** + * Handle an operator management command (Kind 21003, resource='machine'). + * Performs a direct HAL dispense when idle, bypassing the state machine. + */ +async function handleManagementCommand( + request: ManagementRequest, + dispenseFn: (amounts: { denomination: number; count: number }[]) => Promise, + machineIdle: boolean, + currency: string +): Promise { + if (!isMachineDispenseRequest(request)) return null + + console.log('[ATM] Manual dispense command received') + + if (!machineIdle) { + return { + res: 'GFY', + code: GFYCode.RequestDenied, + error: 'Machine is not idle', + } + } + + if (!request.bills || request.bills.length === 0) { + return { + res: 'GFY', + code: GFYCode.InvalidRequest, + error: 'No bills specified', + } + } + + try { + const result = await dispenseFn(request.bills) + const txid = `manual-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const totalFiatCents = request.bills.reduce((sum, b) => sum + b.denomination * b.count * 100, 0) + + await persistTransaction({ + txid, + type: 'manual_dispense', + status: result.dispensed ? 'complete' : 'dispense_error', + fiatCents: totalFiatCents, + sats: 0, + feeSats: 0, + feePercent: 0, + exchangeRate: 0, + currency, + bills: request.bills, + cassettes: result.cassettes, + error: result.error, + }) + + // Remediate referenced failed transaction + let refRemediated = false + if (request.ref_txid && isElectron && window.electronAPI) { + refRemediated = await window.electronAPI.remediateTransaction(request.ref_txid, txid) + if (refRemediated) { + console.log('[ATM] Remediated failed tx:', request.ref_txid) + } + } + + return { + res: 'ok', + resource: 'machine', + details: { + txid, + dispensed: result.bills, + error: result.error, + ref_txid_remediated: refRemediated, + }, + } + } catch (error) { + console.error('[ATM] Manual dispense failed:', error) + return { + res: 'GFY', + code: GFYCode.TemporaryFailure, + error: error instanceof Error ? error.message : 'Dispense failed', + } + } +} + /** * Load inventory from SQLite via IPC (Electron only). * Returns empty object in browser dev mode. @@ -430,6 +515,16 @@ export const useAtmStore = defineStore('atm', () => { services.stopDebitApproval() } + // Wire operator management commands (Lightning-only mode, mock dispense) + services.onManagement(async (request) => { + return handleManagementCommand( + request, + (amounts) => services.atmServices.dispenseCash(amounts), + isIdle.value, + fiatCode.value + ) + }) + // Inject DB-backed inventory into services const servicesWithInventory: ATMServices = { ...services.atmServices, @@ -657,6 +752,16 @@ export const useAtmStore = defineStore('atm', () => { lightning.stopDebitApproval() } + // Wire operator management commands (manual dispense via direct HAL) + lightning.onManagement(async (request) => { + return handleManagementCommand( + request, + (amounts) => hal.atmServices.dispenseCash(amounts), + isIdle.value, + fiatCode.value + ) + }) + // Merge HAL hardware services with Lightning payment services const mergedServices: ATMServices = { ...lightning.atmServices, @@ -897,6 +1002,16 @@ export const useAtmStore = defineStore('atm', () => { lightning.stopDebitApproval() } + // Wire operator management commands (manual dispense via IPC HAL) + lightning.onManagement(async (request) => { + return handleManagementCommand( + request, + (amounts) => api.halDispense(amounts), + isIdle.value, + fiatCode.value + ) + }) + // Merge HAL hardware services with Lightning payment services const mergedServices: ATMServices = { ...lightning.atmServices, diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 6379689..bccb0e8 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -14,6 +14,7 @@ export interface RuntimeConfig { dispenserDevice?: string cassettes?: string allowMockFallback: boolean + operatorPubkeys: string } export interface AtmSecrets { @@ -37,8 +38,8 @@ declare global { }> 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 @@ -57,6 +58,7 @@ declare global { error?: string | null }) => Promise emptyCashbox: () => Promise + remediateTransaction: (txid: string, remediatedByTxid: string) => Promise // HAL hardware IPC halInit: (config: any) => Promise<{ success: boolean; error?: string }> halDispense: (amounts: any) => Promise diff --git a/apps/machine/src/types/state.ts b/apps/machine/src/types/state.ts index 921b5b6..75813d6 100644 --- a/apps/machine/src/types/state.ts +++ b/apps/machine/src/types/state.ts @@ -15,8 +15,8 @@ export interface CashboxState { export interface TransactionRecord { 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 @@ -33,6 +33,7 @@ export interface TransactionRecord { rejected: number }[] error?: string | null + remediatedBy?: string | null } export interface ATMAvailability { diff --git a/packages/clink/src/client.ts b/packages/clink/src/client.ts index f7a2c4e..97b3a8b 100644 --- a/packages/clink/src/client.ts +++ b/packages/clink/src/client.ts @@ -69,8 +69,8 @@ export interface CLINKClientOptions { nostrClient: NostrClient /** Machine identity */ identity: MachineIdentity - /** Operator pubkey for management commands */ - operatorPubkey: string + /** Operator pubkey(s) for management commands */ + operatorPubkey: string | string[] /** Relays to use for offers */ relays: string[] /** Invoice generator function */ @@ -103,7 +103,7 @@ export type ManagementHandler = ( export class CLINKClient { private nostrClient: NostrClient private identity: MachineIdentity - private operatorPubkey: string + private operatorPubkeys: string[] private relays: string[] private generateInvoice?: GenerateInvoice private payInvoice?: PayInvoice @@ -117,7 +117,9 @@ export class CLINKClient { constructor(options: CLINKClientOptions) { this.nostrClient = options.nostrClient this.identity = options.identity - this.operatorPubkey = options.operatorPubkey + this.operatorPubkeys = Array.isArray(options.operatorPubkey) + ? options.operatorPubkey + : [options.operatorPubkey] this.relays = options.relays this.generateInvoice = options.generateInvoice this.payInvoice = options.payInvoice @@ -440,8 +442,8 @@ export class CLINKClient { * Handle management command (Kind 21003) */ private async handleManageEvent(event: Event): Promise { - // Only accept from operator - if (event.pubkey !== this.operatorPubkey) { + // Only accept from authorized operators + if (!this.operatorPubkeys.includes(event.pubkey)) { console.warn('Ignoring management command from non-operator:', event.pubkey) return } diff --git a/packages/clink/src/index.ts b/packages/clink/src/index.ts index b4cdf98..3d499d9 100644 --- a/packages/clink/src/index.ts +++ b/packages/clink/src/index.ts @@ -63,11 +63,14 @@ export { type DebitFailureResponse, // Management types (Kind 21003) type ManagementRequest, + type OfferManagementRequest, + type MachineDispenseRequest, type ManagementResponse, type ManagementSuccessResponse, type ManagementFailureResponse, type ManagementAction, type ManagementResource, + isMachineDispenseRequest, // Beacon types (Kind 30078) type ServiceBeacon, // Noffer types diff --git a/packages/clink/src/types.ts b/packages/clink/src/types.ts index ecc4e37..5a79eff 100644 --- a/packages/clink/src/types.ts +++ b/packages/clink/src/types.ts @@ -205,12 +205,12 @@ export interface OfferConfig { } /** Management resource types */ -export type ManagementResource = 'offer' +export type ManagementResource = 'offer' | 'machine' -/** Management request (Kind 21003) - per CLINK Manage spec */ -export interface ManagementRequest { +/** Offer management request (resource='offer') */ +export interface OfferManagementRequest { /** Resource type being managed */ - resource: ManagementResource + resource: 'offer' /** Action to perform */ action: ManagementAction /** Pointer ID (optional, for multi-account routing) */ @@ -232,6 +232,24 @@ export interface ManagementRequest { } } +/** Machine dispense request (resource='machine', action='dispense') */ +export interface MachineDispenseRequest { + resource: 'machine' + action: 'dispense' + /** Bills to dispense: [{denomination, count}] */ + bills: { denomination: number; count: number }[] + /** Optional: txid of a failed transaction to mark as remediated */ + ref_txid?: string +} + +/** Management request (Kind 21003) - per CLINK Manage spec */ +export type ManagementRequest = OfferManagementRequest | MachineDispenseRequest + +/** Type guard for machine dispense request */ +export function isMachineDispenseRequest(req: ManagementRequest): req is MachineDispenseRequest { + return req.resource === 'machine' && req.action === 'dispense' +} + /** Management success response (Kind 21003) - per CLINK Manage spec */ export interface ManagementSuccessResponse { /** Success indicator */