feat(machine): add SQLite persistence and remove npub linking

Add crash-safe persistence for cassette inventory, cashbox state, and
transaction history using better-sqlite3 in the Electron main process.
The state machine now loads inventory from the database at runtime
instead of using hardcoded values, and transactions are automatically
persisted on completion.

Remove the unnecessary npub linking code — Lightning.Pub auto-creates
and associates Nostr users when appId is included in RPC requests,
making the HTTP-based user creation and token linking redundant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-02-19 11:45:09 -05:00
commit f37555565e
15 changed files with 739 additions and 171 deletions

View file

@ -0,0 +1,101 @@
/**
* Availability Broadcast Composable
*
* Watches ATM inventory and Lightning.Pub balance, then publishes a public
* replaceable event (kind 30078) indicating whether the machine can accept
* cash-in and cash-out transactions.
*
* Event format:
* kind: 30078
* tags: [["d", "atm-availability"]]
* content: {"cash_in":true,"cash_out":true,"fiat":"USD","model":"sintra"}
*
* No exact amounts are revealed — just boolean per direction.
*/
import { watch, type Ref } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import type { NostrClient, MachineIdentity } from '@lamassu/nostr-client'
import { createSignedEvent } from '@lamassu/nostr-client'
import type { ATMAvailability } from '@/types/state'
interface UseAvailabilityBroadcastOptions {
nostrClient: NostrClient
identity: MachineIdentity
/** Reactive inventory: denomination -> count */
inventory: Ref<Record<number, number>>
/** Reactive Lightning.Pub balance in sats (null = unknown) */
balanceSats: Ref<number | null>
/** Fiat currency code */
fiatCode: string
/** Machine model */
model: string
}
export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOptions) {
const { nostrClient, identity, inventory, balanceSats, fiatCode, model } = options
let lastPublished: ATMAvailability | null = null
function computeAvailability(): ATMAvailability {
// cash_out: machine has at least one bill in any cassette
const totalBills = Object.values(inventory.value).reduce((s, c) => s + c, 0)
const cashOut = totalBills > 0
// cash_in: Lightning.Pub has sats balance to sell
const cashIn = (balanceSats.value ?? 0) > 0
return { cashIn, cashOut }
}
async function publish(avail: ATMAvailability) {
const content = JSON.stringify({
cash_in: avail.cashIn,
cash_out: avail.cashOut,
fiat: fiatCode,
model,
})
const event = createSignedEvent(identity, {
kind: 30078,
created_at: Math.floor(Date.now() / 1000),
tags: [['d', 'atm-availability']],
content,
})
try {
await nostrClient.publish(event)
lastPublished = avail
console.log('[Availability] Published:', content)
} catch (e) {
console.warn('[Availability] Failed to publish:', e)
}
}
const debouncedPublish = useDebounceFn(() => {
const avail = computeAvailability()
// Only publish if availability actually changed
if (
!lastPublished ||
avail.cashIn !== lastPublished.cashIn ||
avail.cashOut !== lastPublished.cashOut
) {
publish(avail)
}
}, 5000) // 5s debounce to avoid spamming
// Watch reactive sources
watch(
[inventory, balanceSats],
() => {
debouncedPublish()
},
{ deep: true }
)
// Publish immediately on setup
const initial = computeAvailability()
publish(initial)
return { computeAvailability }
}

View file

@ -61,8 +61,6 @@ interface LightningConfig {
adminToken: string
atmPrivateKey: string
appId: string
appToken: string // JWT token for app API calls (user management, linking)
linkingToken: string
}
/**
@ -78,8 +76,6 @@ async function loadLightningConfig(): Promise<LightningConfig> {
adminToken: 'lamassu-dev-admin-token',
atmPrivateKey: '',
appId: '152fd75c134226824e5183cd9c02a35b4972e995f39e7c0e4ec215ae8c1fae1d', // ATM app ID
appToken: '', // JWT token from app creation
linkingToken: '', // Token to link Nostr pubkey to app user balance
}
// In Electron, get runtime config from main process
@ -94,8 +90,6 @@ async function loadLightningConfig(): Promise<LightningConfig> {
adminToken: runtimeConfig.adminToken || defaults.adminToken,
atmPrivateKey: runtimeConfig.atmPrivateKey || defaults.atmPrivateKey,
appId: runtimeConfig.appId || defaults.appId,
appToken: runtimeConfig.appToken || defaults.appToken,
linkingToken: runtimeConfig.linkingToken || defaults.linkingToken,
}
} catch (e) {
console.warn('[Lightning] Failed to get runtime config from Electron:', e)
@ -111,122 +105,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,
appToken: import.meta.env.VITE_APP_TOKEN || defaults.appToken,
linkingToken: import.meta.env.VITE_LINKING_TOKEN || defaults.linkingToken,
}
}
// Config is loaded async now - will be set in initializeLightningServices
let CONFIG: LightningConfig
// ATM user identifier - consistent across restarts
const ATM_USER_IDENTIFIER = 'atm-primary-user'
/**
* Link Nostr pubkey to App User
*
* This is critical for LNURL-withdraw to work correctly. The Extension API
* uses App Users (created via HTTP API), but Nostr RPC creates separate
* Nostr Users. By linking our npub to the App User BEFORE any operations,
* all subsequent Nostr RPC calls will find and use the linked App User.
*
* Flow:
* 1. Create App User via admin API (if not exists)
* 2. Request a fresh linking token via admin API
* 3. Immediately link via Nostr RPC (before 2-min token expiry)
*/
async function linkNostrPubkeyToAppUser(
config: LightningConfig,
identity: MachineIdentity
): Promise<void> {
console.log('[Linking] Starting npub linking process...')
// Step 1: Create App User if needed
console.log('[Linking] Ensuring App User exists:', ATM_USER_IDENTIFIER)
const authToken = config.appToken || `app_${config.appId}`
console.log(
'[Linking] Using auth token:',
authToken.startsWith('eyJ') ? 'JWT token' : 'app_id fallback'
)
const createUserResponse = await fetch(`${config.lightningPubApiUrl}/api/app/user/add`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({
identifier: ATM_USER_IDENTIFIER,
fail_if_exists: false,
balance: 0,
}),
})
if (!createUserResponse.ok) {
const errorText = await createUserResponse.text()
// User might already exist - that's OK
if (!errorText.includes('already exists')) {
console.log('[Linking] Create user response:', createUserResponse.status, errorText)
}
} else {
console.log('[Linking] App User created/exists')
}
// Step 2: Request fresh linking token
console.log('[Linking] Requesting fresh linking token...')
const linkingTokenResponse = await fetch(`${config.lightningPubApiUrl}/api/app/user/npub/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({
user_identifier: ATM_USER_IDENTIFIER,
}),
})
if (!linkingTokenResponse.ok) {
const errorText = await linkingTokenResponse.text()
// May fail if already linked - check for that
if (errorText.includes('already has an npub')) {
console.log('[Linking] User already has npub linked')
return
}
throw new Error(`Failed to get linking token: ${linkingTokenResponse.status} ${errorText}`)
}
const linkingData = await linkingTokenResponse.json()
const token = linkingData.token
if (!token) {
throw new Error('No token in linking response')
}
console.log('[Linking] Got fresh token, linking immediately...')
// Step 3: Link via Nostr RPC immediately
// Create a temporary LightningPub client just for linking
const tempClient = new NostrClient({
relays: [{ url: config.relayUrl }],
identity,
})
await tempClient.connect()
const tempLP = new LightningPubClient({
accountPubkey: config.lightningPubPubkey,
relays: [config.relayUrl],
appId: config.appId,
})
tempLP.initialize(tempClient, identity)
try {
await tempLP.linkNpub(token)
console.log('[Linking] Successfully linked npub to App User')
} finally {
tempLP.disconnect()
tempClient.disconnect()
}
}
// ============================================================================
// Cash-in Session Management (for ndebit single-use protection)
// ============================================================================
@ -800,22 +684,6 @@ export async function initializeLightningServices(): Promise<LightningServices>
lightningPub.initialize(nostrClient, identity)
console.log('[Lightning] Lightning.Pub client initialized')
// Link Nostr pubkey to app user - CRITICAL for LNURL-withdraw
// The Extension API uses App Users, but Nostr RPC creates separate Nostr Users.
// We fetch a fresh linking token from the admin API and link immediately,
// before the 2-minute token expiry. This ensures:
// 1. Subsequent Nostr RPC calls find and use the linked App User
// 2. Funding and LNURL-withdraw use the same user (same balance)
if (CONFIG.appId && CONFIG.adminToken) {
try {
await linkNostrPubkeyToAppUser(CONFIG, identity)
} catch (e) {
console.log('[Lightning] Note: linking skipped or already linked:', e)
}
} else {
console.log('[Lightning] Skipping npub linking (no appId or adminToken configured)')
}
// Create CLINK client
const clink = new CLINKClient({
nostrClient,

View file

@ -14,6 +14,41 @@ import type { HalConfig, HalServices } from '@/services/hal'
import { deviceConfig, toHalConfig } from '@/config'
import type { LightningPubClient } from '@lamassu/lightning'
import type { CLINKClient } from '@lamassu/clink'
import type { TransactionRecord } from '@/types/state'
// Check if we're running in Electron
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
/**
* Load inventory from SQLite via IPC (Electron only).
* Returns empty object in browser dev mode.
*/
async function loadInventoryFromDb(): Promise<Record<number, number>> {
if (isElectron && window.electronAPI) {
try {
const inv = await window.electronAPI.getInventory()
console.log('[ATM] Loaded inventory from DB:', inv)
return inv
} catch (e) {
console.warn('[ATM] Failed to load inventory from DB:', e)
}
}
return {}
}
/**
* Persist a completed transaction to SQLite via IPC.
*/
async function persistTransaction(tx: TransactionRecord): Promise<void> {
if (isElectron && window.electronAPI) {
try {
await window.electronAPI.recordTransaction(tx)
console.log('[ATM] Transaction persisted:', tx.txid)
} catch (e) {
console.error('[ATM] Failed to persist transaction:', e)
}
}
}
// Mock services for development/fallback
const mockServices: ATMServices = {
@ -156,10 +191,42 @@ export const useAtmStore = defineStore('atm', () => {
const machine = createATMMachine(services)
actor.value = createActor(machine)
// Track previous state to detect transitions into 'complete'
let prevNestedState: string | null = null
// Subscribe to state changes
actor.value.subscribe((newSnapshot: SnapshotFrom<ATMMachine>) => {
const prevSnapshot = snapshot.value
snapshot.value = newSnapshot
console.log('[ATM] State:', newSnapshot.value)
// Detect transition into a complete state
const state = newSnapshot.value
let currentNested: string | null = null
if (typeof state === 'object') {
if ('cashIn' in state) currentNested = state.cashIn as string
if ('cashOut' in state) currentNested = state.cashOut as string
}
if (currentNested === 'complete' && prevNestedState !== 'complete' && prevSnapshot) {
const ctx = newSnapshot.context
if (ctx.txid) {
const isCashInTx = typeof state === 'object' && 'cashIn' in state
const bills: { denomination: number; count: number }[] = isCashInTx
? buildBillsFromInserted(ctx.billsInserted)
: ctx.dispenseAmounts
persistTransaction({
txid: ctx.txid,
type: isCashInTx ? 'cash_in' : 'cash_out',
fiatCents: ctx.fiatAmount,
sats: ctx.satsAmount,
bills,
})
}
}
prevNestedState = currentNested
})
// Start the machine
@ -167,6 +234,17 @@ export const useAtmStore = defineStore('atm', () => {
console.log('[ATM] State machine initialized')
}
/**
* Group an array of inserted bill denominations into { denomination, count } pairs.
*/
function buildBillsFromInserted(bills: number[]): { denomination: number; count: number }[] {
const counts = new Map<number, number>()
for (const d of bills) {
counts.set(d, (counts.get(d) || 0) + 1)
}
return Array.from(counts.entries()).map(([denomination, count]) => ({ denomination, count }))
}
/**
* Initialize with real Lightning.Pub services
*/
@ -235,8 +313,17 @@ export const useAtmStore = defineStore('atm', () => {
}
})
// Inject DB-backed inventory into services
const servicesWithInventory: ATMServices = {
...services.atmServices,
getInventory: async () => {
const fresh = await loadInventoryFromDb()
return Object.keys(fresh).length > 0 ? fresh : services.atmServices.getInventory()
},
}
// Initialize state machine with real services
initialize(services.atmServices)
initialize(servicesWithInventory)
} catch (error) {
console.error('[ATM] Failed to connect to Lightning.Pub:', error)
connectionStatus.value = 'error'
@ -443,6 +530,11 @@ export const useAtmStore = defineStore('atm', () => {
const mergedServices: ATMServices = {
...lightning.atmServices,
...hal.atmServices, // Override dispenseCash and getInventory with real hardware
// If DB has inventory, use it; otherwise fall back to HAL
getInventory: async () => {
const fresh = await loadInventoryFromDb()
return Object.keys(fresh).length > 0 ? fresh : hal.atmServices.getInventory()
},
}
// Initialize state machine with merged services
@ -586,6 +678,20 @@ export const useAtmStore = defineStore('atm', () => {
send({ type: 'CONFIRM_AMOUNT' })
}
/**
* Refresh inventory from DB and update the state machine context.
* Called after operator sets cassettes or after transactions.
*/
async function refreshInventory() {
const inv = await loadInventoryFromDb()
if (actor.value && Object.keys(inv).length > 0) {
// Directly update the actor's context with new inventory
// XState v5: send a custom event that the machine can handle
// For now, we update via the next getInventory call on transition
console.log('[ATM] Inventory refreshed:', inv)
}
}
function toggleDebug() {
debugMode.value = !debugMode.value
}
@ -634,6 +740,7 @@ export const useAtmStore = defineStore('atm', () => {
removeDenomination,
clearSelection,
confirmAmount,
refreshInventory,
toggleDebug,
}
})

View file

@ -10,8 +10,6 @@ export interface RuntimeConfig {
atmPrivateKey: string
adminToken: string
appId: string
appToken: string
linkingToken: string
machineModel: string
fiatCode: string
validatorDevice?: string
@ -24,6 +22,22 @@ declare global {
electronAPI?: {
getVersion: () => Promise<string>
getConfig: () => Promise<RuntimeConfig>
loadCassettes: () => Promise<{ denomination: number; count: number }[]>
setCassettes: (cassettes: { denomination: number; count: number }[]) => Promise<void>
getInventory: () => Promise<Record<number, number>>
getCashbox: () => Promise<{
totalBills: number
totalFiatCents: number
lastEmptiedAt: number | null
}>
recordTransaction: (tx: {
txid: string
type: 'cash_in' | 'cash_out'
fiatCents: number
sats: number
bills: { denomination: number; count: number }[]
}) => Promise<void>
emptyCashbox: () => Promise<void>
platform: NodeJS.Platform
}
}

View file

@ -0,0 +1,27 @@
/**
* Shared types for persistent ATM state (SQLite <-> IPC <-> Renderer)
*/
export interface CassetteState {
denomination: number
count: number
}
export interface CashboxState {
totalBills: number
totalFiatCents: number
lastEmptiedAt: number | null
}
export interface TransactionRecord {
txid: string
type: 'cash_in' | 'cash_out'
fiatCents: number
sats: number
bills: { denomination: number; count: number }[]
}
export interface ATMAvailability {
cashIn: boolean
cashOut: boolean
}