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

@ -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<LightningConfig> {
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<LightningConfig> {
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<LightningConfig> {
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<ManagementResponse | null>
) => 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<ManagementResponse | null>)
| 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<ManagementResponse | null>
) => {
managementCallback = callback
},
}
}

View file

@ -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<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).
* 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,

View file

@ -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<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,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 {