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:
parent
88a52cf769
commit
e03782803b
11 changed files with 274 additions and 24 deletions
|
|
@ -41,6 +41,14 @@ VITE_LIGHTNING_PUB_API_URL=http://localhost:1776
|
||||||
# If not set, generates ephemeral identity on each restart
|
# If not set, generates ephemeral identity on each restart
|
||||||
VITE_ATM_PRIVATE_KEY=
|
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)
|
# Mock Fallback (Production Safety)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
setCassettes,
|
setCassettes,
|
||||||
getInventory,
|
getInventory,
|
||||||
recordTransaction,
|
recordTransaction,
|
||||||
|
remediateTransaction,
|
||||||
emptyCashbox,
|
emptyCashbox,
|
||||||
getCashbox,
|
getCashbox,
|
||||||
} from './state-store.js'
|
} from './state-store.js'
|
||||||
|
|
@ -180,6 +181,9 @@ ipcMain.handle('get-config', () => {
|
||||||
// SECURITY: In production (packaged app), mock fallback is always disabled.
|
// SECURITY: In production (packaged app), mock fallback is always disabled.
|
||||||
// Only allow it in development mode, and only when explicitly opted in via env.
|
// Only allow it in development mode, and only when explicitly opted in via env.
|
||||||
allowMockFallback: isDev && process.env.VITE_ALLOW_MOCK_FALLBACK === 'true',
|
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:get-cashbox', () => getCashbox())
|
||||||
ipcMain.handle('state:record-transaction', (_event, tx) => recordTransaction(tx))
|
ipcMain.handle('state:record-transaction', (_event, tx) => recordTransaction(tx))
|
||||||
ipcMain.handle('state:empty-cashbox', () => emptyCashbox())
|
ipcMain.handle('state:empty-cashbox', () => emptyCashbox())
|
||||||
|
ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedByTxid: string) =>
|
||||||
|
remediateTransaction(txid, remediatedByTxid)
|
||||||
|
)
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// HAL Hardware IPC Handlers
|
// HAL Hardware IPC Handlers
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export interface RuntimeConfig {
|
||||||
dispenserDevice?: string
|
dispenserDevice?: string
|
||||||
cassettes?: string
|
cassettes?: string
|
||||||
allowMockFallback: boolean
|
allowMockFallback: boolean
|
||||||
|
operatorPubkeys: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -54,8 +55,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
getCashbox: () => ipcRenderer.invoke('state:get-cashbox'),
|
getCashbox: () => ipcRenderer.invoke('state:get-cashbox'),
|
||||||
recordTransaction: (tx: {
|
recordTransaction: (tx: {
|
||||||
txid: string
|
txid: string
|
||||||
type: 'cash_in' | 'cash_out'
|
type: 'cash_in' | 'cash_out' | 'manual_dispense'
|
||||||
status: 'complete' | 'dispense_error' | 'partial'
|
status: 'complete' | 'dispense_error' | 'partial' | 'remediated'
|
||||||
fiatCents: number
|
fiatCents: number
|
||||||
sats: number
|
sats: number
|
||||||
feeSats: number
|
feeSats: number
|
||||||
|
|
@ -74,6 +75,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
error?: string | null
|
error?: string | null
|
||||||
}) => ipcRenderer.invoke('state:record-transaction', tx),
|
}) => ipcRenderer.invoke('state:record-transaction', tx),
|
||||||
emptyCashbox: () => ipcRenderer.invoke('state:empty-cashbox'),
|
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)
|
// HAL hardware (runs in main process, exposed via IPC)
|
||||||
halInit: (config: any): Promise<{ success: boolean; error?: string }> =>
|
halInit: (config: any): Promise<{ success: boolean; error?: string }> =>
|
||||||
|
|
@ -147,6 +150,7 @@ declare global {
|
||||||
error?: string | null
|
error?: string | null
|
||||||
}) => Promise<void>
|
}) => Promise<void>
|
||||||
emptyCashbox: () => Promise<void>
|
emptyCashbox: () => Promise<void>
|
||||||
|
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
|
||||||
// HAL hardware IPC
|
// HAL hardware IPC
|
||||||
halInit: (config: any) => Promise<{ success: boolean; error?: string }>
|
halInit: (config: any) => Promise<{ success: boolean; error?: string }>
|
||||||
halDispense: (amounts: any) => Promise<any>
|
halDispense: (amounts: any) => Promise<any>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import fs from 'node:fs'
|
||||||
|
|
||||||
let db: Database.Database | null = null
|
let db: Database.Database | null = null
|
||||||
|
|
||||||
const SCHEMA_VERSION = '4'
|
const SCHEMA_VERSION = '5'
|
||||||
|
|
||||||
function getDbPath(): string {
|
function getDbPath(): string {
|
||||||
const prodDir = '/var/lib/lamassu-atm'
|
const prodDir = '/var/lib/lamassu-atm'
|
||||||
|
|
@ -65,7 +65,7 @@ export function initDatabase(dbPath?: string): void {
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS transactions (
|
CREATE TABLE IF NOT EXISTS transactions (
|
||||||
txid TEXT PRIMARY KEY,
|
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,
|
fiat_cents INTEGER NOT NULL,
|
||||||
sats INTEGER NOT NULL,
|
sats INTEGER NOT NULL,
|
||||||
fee_sats INTEGER NOT NULL DEFAULT 0,
|
fee_sats INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
@ -74,6 +74,7 @@ export function initDatabase(dbPath?: string): void {
|
||||||
currency TEXT NOT NULL DEFAULT 'GTQ',
|
currency TEXT NOT NULL DEFAULT 'GTQ',
|
||||||
status TEXT NOT NULL DEFAULT 'complete',
|
status TEXT NOT NULL DEFAULT 'complete',
|
||||||
error TEXT,
|
error TEXT,
|
||||||
|
remediated_by TEXT,
|
||||||
created_at INTEGER NOT NULL
|
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')
|
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('4', 'schema_version')
|
||||||
console.log('[StateStore] Migrated schema v3 → v4 (added status, error, cassette_bills)')
|
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()
|
const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get()
|
||||||
|
|
@ -269,8 +298,8 @@ export function emptyCashbox(): void {
|
||||||
|
|
||||||
interface TransactionInput {
|
interface TransactionInput {
|
||||||
txid: string
|
txid: string
|
||||||
type: 'cash_in' | 'cash_out'
|
type: 'cash_in' | 'cash_out' | 'manual_dispense'
|
||||||
status: 'complete' | 'dispense_error' | 'partial'
|
status: 'complete' | 'dispense_error' | 'partial' | 'remediated'
|
||||||
fiatCents: number
|
fiatCents: number
|
||||||
sats: number
|
sats: number
|
||||||
feeSats: number
|
feeSats: number
|
||||||
|
|
@ -372,6 +401,26 @@ export function recordTransaction(tx: TransactionInput): void {
|
||||||
console.log('[StateStore] Recorded transaction:', tx.txid, tx.type, `(${tx.status})`)
|
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).
|
* Close the database (for clean shutdown).
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import {
|
||||||
encodeNdebit,
|
encodeNdebit,
|
||||||
formatNdebitUri,
|
formatNdebitUri,
|
||||||
} from '@lamassu/clink'
|
} 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 type { ATMServices, ATMContext } from '@lamassu/state-machine'
|
||||||
|
|
||||||
// Import Electron types
|
// Import Electron types
|
||||||
|
|
@ -62,6 +62,7 @@ interface LightningConfig {
|
||||||
adminToken: string
|
adminToken: string
|
||||||
atmPrivateKey: string
|
atmPrivateKey: string
|
||||||
appId: string
|
appId: string
|
||||||
|
operatorPubkeys: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -81,6 +82,7 @@ async function loadLightningConfig(): Promise<LightningConfig> {
|
||||||
adminToken: 'lamassu-dev-admin-token',
|
adminToken: 'lamassu-dev-admin-token',
|
||||||
atmPrivateKey: '',
|
atmPrivateKey: '',
|
||||||
appId: '152fd75c134226824e5183cd9c02a35b4972e995f39e7c0e4ec215ae8c1fae1d', // ATM app ID
|
appId: '152fd75c134226824e5183cd9c02a35b4972e995f39e7c0e4ec215ae8c1fae1d', // ATM app ID
|
||||||
|
operatorPubkeys: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
// In Electron, get runtime config from main process
|
// In Electron, get runtime config from main process
|
||||||
|
|
@ -97,6 +99,12 @@ async function loadLightningConfig(): Promise<LightningConfig> {
|
||||||
adminToken: secrets.adminToken || defaults.adminToken,
|
adminToken: secrets.adminToken || defaults.adminToken,
|
||||||
atmPrivateKey: secrets.atmPrivateKey || defaults.atmPrivateKey,
|
atmPrivateKey: secrets.atmPrivateKey || defaults.atmPrivateKey,
|
||||||
appId: runtimeConfig.appId || defaults.appId,
|
appId: runtimeConfig.appId || defaults.appId,
|
||||||
|
operatorPubkeys: runtimeConfig.operatorPubkeys
|
||||||
|
? runtimeConfig.operatorPubkeys
|
||||||
|
.split(',')
|
||||||
|
.map((k: string) => k.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: defaults.operatorPubkeys,
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Lightning] Failed to get runtime config from Electron:', e)
|
console.warn('[Lightning] Failed to get runtime config from Electron:', e)
|
||||||
|
|
@ -112,6 +120,12 @@ async function loadLightningConfig(): Promise<LightningConfig> {
|
||||||
adminToken: import.meta.env.VITE_ADMIN_TOKEN || defaults.adminToken,
|
adminToken: import.meta.env.VITE_ADMIN_TOKEN || defaults.adminToken,
|
||||||
atmPrivateKey: import.meta.env.VITE_ATM_PRIVATE_KEY || defaults.atmPrivateKey,
|
atmPrivateKey: import.meta.env.VITE_ATM_PRIVATE_KEY || defaults.atmPrivateKey,
|
||||||
appId: import.meta.env.VITE_APP_ID || defaults.appId,
|
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
|
onDebitPaymentApproved: (callback: DebitPaymentCallback) => void
|
||||||
/** Stop the debit approval service */
|
/** Stop the debit approval service */
|
||||||
stopDebitApproval: () => void
|
stopDebitApproval: () => void
|
||||||
|
/** Set callback for operator management commands (Kind 21003) */
|
||||||
|
onManagement: (
|
||||||
|
callback: (
|
||||||
|
request: ManagementRequest,
|
||||||
|
senderPubkey: string
|
||||||
|
) => Promise<ManagementResponse | null>
|
||||||
|
) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -896,7 +917,7 @@ export async function initializeLightningServices(options?: {
|
||||||
const clink = new CLINKClient({
|
const clink = new CLINKClient({
|
||||||
nostrClient,
|
nostrClient,
|
||||||
identity,
|
identity,
|
||||||
operatorPubkey: CONFIG.lightningPubPubkey, // Use Lightning.Pub as operator for dev
|
operatorPubkey: [CONFIG.lightningPubPubkey, ...CONFIG.operatorPubkeys].filter(Boolean),
|
||||||
relays: [CONFIG.relayUrl],
|
relays: [CONFIG.relayUrl],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -904,6 +925,9 @@ export async function initializeLightningServices(options?: {
|
||||||
let offerRequestCallback: OfferRequestCallback | null = null
|
let offerRequestCallback: OfferRequestCallback | null = null
|
||||||
let paymentReceivedCallback: PaymentReceivedCallback | null = null
|
let paymentReceivedCallback: PaymentReceivedCallback | null = null
|
||||||
let debitPaymentCallback: DebitPaymentCallback | null = null
|
let debitPaymentCallback: DebitPaymentCallback | null = null
|
||||||
|
let managementCallback:
|
||||||
|
| ((request: ManagementRequest, senderPubkey: string) => Promise<ManagementResponse | null>)
|
||||||
|
| null = null
|
||||||
|
|
||||||
// Start the debit approval service
|
// Start the debit approval service
|
||||||
const stopDebitApproval = startDebitApprovalService(
|
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()
|
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
|
// Create ATM services using Lightning.Pub's native LNURL-withdraw
|
||||||
const atmServices = createATMServices(lightningPub, clink, identity, (preimage) => {
|
const atmServices = createATMServices(lightningPub, clink, identity, (preimage) => {
|
||||||
|
|
@ -994,6 +1027,14 @@ export async function initializeLightningServices(options?: {
|
||||||
debitPaymentCallback = callback
|
debitPaymentCallback = callback
|
||||||
},
|
},
|
||||||
stopDebitApproval,
|
stopDebitApproval,
|
||||||
|
onManagement: (
|
||||||
|
callback: (
|
||||||
|
request: ManagementRequest,
|
||||||
|
senderPubkey: string
|
||||||
|
) => Promise<ManagementResponse | null>
|
||||||
|
) => {
|
||||||
|
managementCallback = callback
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,13 @@ import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning
|
||||||
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 { LightningPubClient } from '@lamassu/lightning'
|
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'
|
import type { TransactionRecord } from '@/types/state'
|
||||||
|
|
||||||
// Check if we're running in Electron
|
// Check if we're running in Electron
|
||||||
|
|
@ -31,6 +37,85 @@ function computeFeeSats(ctx: ATMContext, isCashIn: boolean): number {
|
||||||
return Math.max(0, feeSats)
|
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<any>,
|
||||||
|
machineIdle: boolean,
|
||||||
|
currency: string
|
||||||
|
): Promise<ManagementResponse | null> {
|
||||||
|
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).
|
* Load inventory from SQLite via IPC (Electron only).
|
||||||
* Returns empty object in browser dev mode.
|
* Returns empty object in browser dev mode.
|
||||||
|
|
@ -430,6 +515,16 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
services.stopDebitApproval()
|
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
|
// Inject DB-backed inventory into services
|
||||||
const servicesWithInventory: ATMServices = {
|
const servicesWithInventory: ATMServices = {
|
||||||
...services.atmServices,
|
...services.atmServices,
|
||||||
|
|
@ -657,6 +752,16 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
lightning.stopDebitApproval()
|
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
|
// Merge HAL hardware services with Lightning payment services
|
||||||
const mergedServices: ATMServices = {
|
const mergedServices: ATMServices = {
|
||||||
...lightning.atmServices,
|
...lightning.atmServices,
|
||||||
|
|
@ -897,6 +1002,16 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
lightning.stopDebitApproval()
|
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
|
// Merge HAL hardware services with Lightning payment services
|
||||||
const mergedServices: ATMServices = {
|
const mergedServices: ATMServices = {
|
||||||
...lightning.atmServices,
|
...lightning.atmServices,
|
||||||
|
|
|
||||||
6
apps/machine/src/types/electron.d.ts
vendored
6
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -14,6 +14,7 @@ export interface RuntimeConfig {
|
||||||
dispenserDevice?: string
|
dispenserDevice?: string
|
||||||
cassettes?: string
|
cassettes?: string
|
||||||
allowMockFallback: boolean
|
allowMockFallback: boolean
|
||||||
|
operatorPubkeys: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AtmSecrets {
|
export interface AtmSecrets {
|
||||||
|
|
@ -37,8 +38,8 @@ declare global {
|
||||||
}>
|
}>
|
||||||
recordTransaction: (tx: {
|
recordTransaction: (tx: {
|
||||||
txid: string
|
txid: string
|
||||||
type: 'cash_in' | 'cash_out'
|
type: 'cash_in' | 'cash_out' | 'manual_dispense'
|
||||||
status: 'complete' | 'dispense_error' | 'partial'
|
status: 'complete' | 'dispense_error' | 'partial' | 'remediated'
|
||||||
fiatCents: number
|
fiatCents: number
|
||||||
sats: number
|
sats: number
|
||||||
feeSats: number
|
feeSats: number
|
||||||
|
|
@ -57,6 +58,7 @@ declare global {
|
||||||
error?: string | null
|
error?: string | null
|
||||||
}) => Promise<void>
|
}) => Promise<void>
|
||||||
emptyCashbox: () => Promise<void>
|
emptyCashbox: () => Promise<void>
|
||||||
|
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
|
||||||
// HAL hardware IPC
|
// HAL hardware IPC
|
||||||
halInit: (config: any) => Promise<{ success: boolean; error?: string }>
|
halInit: (config: any) => Promise<{ success: boolean; error?: string }>
|
||||||
halDispense: (amounts: any) => Promise<any>
|
halDispense: (amounts: any) => Promise<any>
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ export interface CashboxState {
|
||||||
|
|
||||||
export interface TransactionRecord {
|
export interface TransactionRecord {
|
||||||
txid: string
|
txid: string
|
||||||
type: 'cash_in' | 'cash_out'
|
type: 'cash_in' | 'cash_out' | 'manual_dispense'
|
||||||
status: 'complete' | 'dispense_error' | 'partial'
|
status: 'complete' | 'dispense_error' | 'partial' | 'remediated'
|
||||||
fiatCents: number
|
fiatCents: number
|
||||||
sats: number
|
sats: number
|
||||||
feeSats: number
|
feeSats: number
|
||||||
|
|
@ -33,6 +33,7 @@ export interface TransactionRecord {
|
||||||
rejected: number
|
rejected: number
|
||||||
}[]
|
}[]
|
||||||
error?: string | null
|
error?: string | null
|
||||||
|
remediatedBy?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ATMAvailability {
|
export interface ATMAvailability {
|
||||||
|
|
|
||||||
|
|
@ -69,8 +69,8 @@ export interface CLINKClientOptions {
|
||||||
nostrClient: NostrClient
|
nostrClient: NostrClient
|
||||||
/** Machine identity */
|
/** Machine identity */
|
||||||
identity: MachineIdentity
|
identity: MachineIdentity
|
||||||
/** Operator pubkey for management commands */
|
/** Operator pubkey(s) for management commands */
|
||||||
operatorPubkey: string
|
operatorPubkey: string | string[]
|
||||||
/** Relays to use for offers */
|
/** Relays to use for offers */
|
||||||
relays: string[]
|
relays: string[]
|
||||||
/** Invoice generator function */
|
/** Invoice generator function */
|
||||||
|
|
@ -103,7 +103,7 @@ export type ManagementHandler = (
|
||||||
export class CLINKClient {
|
export class CLINKClient {
|
||||||
private nostrClient: NostrClient
|
private nostrClient: NostrClient
|
||||||
private identity: MachineIdentity
|
private identity: MachineIdentity
|
||||||
private operatorPubkey: string
|
private operatorPubkeys: string[]
|
||||||
private relays: string[]
|
private relays: string[]
|
||||||
private generateInvoice?: GenerateInvoice
|
private generateInvoice?: GenerateInvoice
|
||||||
private payInvoice?: PayInvoice
|
private payInvoice?: PayInvoice
|
||||||
|
|
@ -117,7 +117,9 @@ export class CLINKClient {
|
||||||
constructor(options: CLINKClientOptions) {
|
constructor(options: CLINKClientOptions) {
|
||||||
this.nostrClient = options.nostrClient
|
this.nostrClient = options.nostrClient
|
||||||
this.identity = options.identity
|
this.identity = options.identity
|
||||||
this.operatorPubkey = options.operatorPubkey
|
this.operatorPubkeys = Array.isArray(options.operatorPubkey)
|
||||||
|
? options.operatorPubkey
|
||||||
|
: [options.operatorPubkey]
|
||||||
this.relays = options.relays
|
this.relays = options.relays
|
||||||
this.generateInvoice = options.generateInvoice
|
this.generateInvoice = options.generateInvoice
|
||||||
this.payInvoice = options.payInvoice
|
this.payInvoice = options.payInvoice
|
||||||
|
|
@ -440,8 +442,8 @@ export class CLINKClient {
|
||||||
* Handle management command (Kind 21003)
|
* Handle management command (Kind 21003)
|
||||||
*/
|
*/
|
||||||
private async handleManageEvent(event: Event): Promise<void> {
|
private async handleManageEvent(event: Event): Promise<void> {
|
||||||
// Only accept from operator
|
// Only accept from authorized operators
|
||||||
if (event.pubkey !== this.operatorPubkey) {
|
if (!this.operatorPubkeys.includes(event.pubkey)) {
|
||||||
console.warn('Ignoring management command from non-operator:', event.pubkey)
|
console.warn('Ignoring management command from non-operator:', event.pubkey)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,11 +63,14 @@ export {
|
||||||
type DebitFailureResponse,
|
type DebitFailureResponse,
|
||||||
// Management types (Kind 21003)
|
// Management types (Kind 21003)
|
||||||
type ManagementRequest,
|
type ManagementRequest,
|
||||||
|
type OfferManagementRequest,
|
||||||
|
type MachineDispenseRequest,
|
||||||
type ManagementResponse,
|
type ManagementResponse,
|
||||||
type ManagementSuccessResponse,
|
type ManagementSuccessResponse,
|
||||||
type ManagementFailureResponse,
|
type ManagementFailureResponse,
|
||||||
type ManagementAction,
|
type ManagementAction,
|
||||||
type ManagementResource,
|
type ManagementResource,
|
||||||
|
isMachineDispenseRequest,
|
||||||
// Beacon types (Kind 30078)
|
// Beacon types (Kind 30078)
|
||||||
type ServiceBeacon,
|
type ServiceBeacon,
|
||||||
// Noffer types
|
// Noffer types
|
||||||
|
|
|
||||||
|
|
@ -205,12 +205,12 @@ export interface OfferConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Management resource types */
|
/** Management resource types */
|
||||||
export type ManagementResource = 'offer'
|
export type ManagementResource = 'offer' | 'machine'
|
||||||
|
|
||||||
/** Management request (Kind 21003) - per CLINK Manage spec */
|
/** Offer management request (resource='offer') */
|
||||||
export interface ManagementRequest {
|
export interface OfferManagementRequest {
|
||||||
/** Resource type being managed */
|
/** Resource type being managed */
|
||||||
resource: ManagementResource
|
resource: 'offer'
|
||||||
/** Action to perform */
|
/** Action to perform */
|
||||||
action: ManagementAction
|
action: ManagementAction
|
||||||
/** Pointer ID (optional, for multi-account routing) */
|
/** 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 */
|
/** Management success response (Kind 21003) - per CLINK Manage spec */
|
||||||
export interface ManagementSuccessResponse {
|
export interface ManagementSuccessResponse {
|
||||||
/** Success indicator */
|
/** Success indicator */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue