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

@ -10,6 +10,16 @@ import { app, BrowserWindow, ipcMain } from 'electron'
import path from 'node:path'
import fs from 'node:fs'
import { fileURLToPath } from 'node:url'
import {
initDatabase,
closeDatabase,
loadCassettes,
setCassettes,
getInventory,
recordTransaction,
emptyCashbox,
getCashbox,
} from './state-store.js'
// ESM equivalent of __dirname
const __filename = fileURLToPath(import.meta.url)
@ -100,8 +110,6 @@ ipcMain.handle('get-config', () => {
atmPrivateKey: process.env.VITE_ATM_PRIVATE_KEY || '',
adminToken: process.env.VITE_ADMIN_TOKEN || '',
appId: process.env.VITE_APP_ID || '',
appToken: process.env.VITE_APP_TOKEN || '',
linkingToken: process.env.VITE_LINKING_TOKEN || '',
// Hardware configuration
machineModel: process.env.VITE_LAMASSU_MACHINE_MODEL || 'sintra',
@ -112,8 +120,17 @@ ipcMain.handle('get-config', () => {
}
})
// State persistence IPC handlers
ipcMain.handle('state:load-cassettes', () => loadCassettes())
ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes))
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())
// App lifecycle
app.whenReady().then(() => {
initDatabase()
createWindow()
app.on('activate', () => {
@ -125,6 +142,7 @@ app.whenReady().then(() => {
})
app.on('window-all-closed', () => {
closeDatabase()
// Quit on all platforms (ATM doesn't need macOS dock behavior)
app.quit()
})

View file

@ -19,7 +19,6 @@ export interface RuntimeConfig {
atmPrivateKey: string
adminToken: string
appId: string
appToken: string
machineModel: string
fiatCode: string
validatorDevice?: string
@ -35,6 +34,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Get runtime configuration (read from environment at runtime)
getConfig: (): Promise<RuntimeConfig> => ipcRenderer.invoke('get-config'),
// State persistence
loadCassettes: () => ipcRenderer.invoke('state:load-cassettes'),
setCassettes: (cassettes: { denomination: number; count: number }[]) =>
ipcRenderer.invoke('state:set-cassettes', cassettes),
getInventory: () => ipcRenderer.invoke('state:get-inventory'),
getCashbox: () => ipcRenderer.invoke('state:get-cashbox'),
recordTransaction: (tx: {
txid: string
type: 'cash_in' | 'cash_out'
fiatCents: number
sats: number
bills: { denomination: number; count: number }[]
}) => ipcRenderer.invoke('state:record-transaction', tx),
emptyCashbox: () => ipcRenderer.invoke('state:empty-cashbox'),
// Platform info
platform: process.platform,
})
@ -45,6 +59,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,271 @@
/**
* SQLite State Store for ATM Cash Tracking
*
* Persists cassette inventory, cashbox state, and transaction history
* across reboots. Runs in the Electron main process using better-sqlite3
* (synchronous API, safe for single-process use).
*
* Database location: /var/lib/lamassu-atm/state.db (production)
* ./state.db (development)
*/
import Database from 'better-sqlite3'
import path from 'node:path'
import fs from 'node:fs'
let db: Database.Database | null = null
const SCHEMA_VERSION = '1'
function getDbPath(): string {
const prodDir = '/var/lib/lamassu-atm'
if (fs.existsSync(prodDir)) {
return path.join(prodDir, 'state.db')
}
// Development fallback: next to the app
return path.join(process.cwd(), 'state.db')
}
/**
* Initialize the database, creating tables if they don't exist.
* Must be called once at app startup before any other functions.
*/
export function initDatabase(dbPath?: string): void {
const resolvedPath = dbPath ?? getDbPath()
// Ensure parent directory exists
const dir = path.dirname(resolvedPath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
db = new Database(resolvedPath)
// Enable WAL mode for better concurrent read performance
db.pragma('journal_mode = WAL')
// Create tables
db.exec(`
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS cassettes (
denomination INTEGER PRIMARY KEY,
count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS cashbox (
id INTEGER PRIMARY KEY CHECK (id = 1),
total_bills INTEGER NOT NULL DEFAULT 0,
total_fiat_cents INTEGER NOT NULL DEFAULT 0,
last_emptied_at INTEGER
);
CREATE TABLE IF NOT EXISTS transactions (
txid TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK (type IN ('cash_in', 'cash_out')),
fiat_cents INTEGER NOT NULL,
sats INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS transaction_bills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
txid TEXT NOT NULL REFERENCES transactions(txid),
denomination INTEGER NOT NULL,
count INTEGER NOT NULL
);
`)
// Seed meta + cashbox if first run
const existing = db.prepare('SELECT value FROM meta WHERE key = ?').get('schema_version') as
| { value: string }
| undefined
if (!existing) {
db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run('schema_version', SCHEMA_VERSION)
}
const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get()
if (!cashboxRow) {
db.prepare('INSERT INTO cashbox (id) VALUES (1)').run()
}
console.log('[StateStore] Initialized database at', resolvedPath)
}
// ---------------------------------------------------------------------------
// Cassettes
// ---------------------------------------------------------------------------
export interface CassetteRow {
denomination: number
count: number
}
export function loadCassettes(): CassetteRow[] {
if (!db) throw new Error('Database not initialized')
return db
.prepare('SELECT denomination, count FROM cassettes ORDER BY denomination')
.all() as CassetteRow[]
}
/**
* Upsert cassette counts. Replaces all existing rows.
*/
export function setCassettes(cassettes: CassetteRow[]): void {
if (!db) throw new Error('Database not initialized')
const upsert = db.prepare(
'INSERT INTO cassettes (denomination, count) VALUES (?, ?) ON CONFLICT(denomination) DO UPDATE SET count = excluded.count'
)
const run = db.transaction((rows: CassetteRow[]) => {
for (const row of rows) {
upsert.run(row.denomination, row.count)
}
})
run(cassettes)
console.log('[StateStore] Cassettes updated:', cassettes)
}
/**
* Increment or decrement a cassette count (e.g., after dispensing).
*/
export function updateCassetteCount(denomination: number, delta: number): void {
if (!db) throw new Error('Database not initialized')
db.prepare('UPDATE cassettes SET count = MAX(0, count + ?) WHERE denomination = ?').run(
delta,
denomination
)
}
/**
* Get inventory as a denomination -> count map.
*/
export function getInventory(): Record<number, number> {
const rows = loadCassettes()
const inv: Record<number, number> = {}
for (const row of rows) {
if (row.count > 0) {
inv[row.denomination] = row.count
}
}
return inv
}
// ---------------------------------------------------------------------------
// Cashbox
// ---------------------------------------------------------------------------
export interface CashboxRow {
totalBills: number
totalFiatCents: number
lastEmptiedAt: number | null
}
export function getCashbox(): CashboxRow {
if (!db) throw new Error('Database not initialized')
const row = db
.prepare('SELECT total_bills, total_fiat_cents, last_emptied_at FROM cashbox WHERE id = 1')
.get() as { total_bills: number; total_fiat_cents: number; last_emptied_at: number | null }
return {
totalBills: row.total_bills,
totalFiatCents: row.total_fiat_cents,
lastEmptiedAt: row.last_emptied_at,
}
}
/**
* Increment cashbox totals after a cash-in transaction.
*/
export function addToCashbox(bills: number, fiatCents: number): void {
if (!db) throw new Error('Database not initialized')
db.prepare(
'UPDATE cashbox SET total_bills = total_bills + ?, total_fiat_cents = total_fiat_cents + ? WHERE id = 1'
).run(bills, fiatCents)
}
/**
* Reset the cashbox (operator emptied it).
*/
export function emptyCashbox(): void {
if (!db) throw new Error('Database not initialized')
db.prepare(
'UPDATE cashbox SET total_bills = 0, total_fiat_cents = 0, last_emptied_at = ? WHERE id = 1'
).run(Date.now())
console.log('[StateStore] Cashbox emptied')
}
// ---------------------------------------------------------------------------
// Transactions
// ---------------------------------------------------------------------------
interface TransactionInput {
txid: string
type: 'cash_in' | 'cash_out'
fiatCents: number
sats: number
bills: { denomination: number; count: number }[]
}
/**
* Record a completed transaction and update cassette/cashbox state atomically.
*/
export function recordTransaction(tx: TransactionInput): void {
if (!db) throw new Error('Database not initialized')
const insertTx = db.prepare(
'INSERT INTO transactions (txid, type, fiat_cents, sats, created_at) VALUES (?, ?, ?, ?, ?)'
)
const insertBill = db.prepare(
'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)'
)
const updateCassette = db.prepare(
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE denomination = ?'
)
const updateCashboxStmt = db.prepare(
'UPDATE cashbox SET total_bills = total_bills + ?, total_fiat_cents = total_fiat_cents + ? WHERE id = 1'
)
const run = db.transaction((t: TransactionInput) => {
insertTx.run(t.txid, t.type, t.fiatCents, t.sats, Date.now())
for (const bill of t.bills) {
insertBill.run(t.txid, bill.denomination, bill.count)
if (t.type === 'cash_out') {
// Dispensed bills: decrement cassettes
updateCassette.run(-bill.count, bill.denomination)
}
}
if (t.type === 'cash_in') {
// Bills inserted by customer go into cashbox
const totalBills = t.bills.reduce((sum, b) => sum + b.count, 0)
updateCashboxStmt.run(totalBills, t.fiatCents)
}
})
run(tx)
console.log('[StateStore] Recorded transaction:', tx.txid, tx.type)
}
/**
* Close the database (for clean shutdown).
*/
export function closeDatabase(): void {
if (db) {
db.close()
db = null
console.log('[StateStore] Database closed')
}
}

View file

@ -9,6 +9,6 @@
"outDir": "../dist-electron",
"rootDir": "."
},
"include": ["main.ts"],
"include": ["main.ts", "state-store.ts"],
"exclude": ["preload.ts"]
}