diff --git a/CLAUDE.md b/CLAUDE.md index 8193682..4584f12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,16 +149,16 @@ alice-pay For advanced testing with multiple Lightning implementations, use the regtest environment at `~/dev/local/docker/regtest`. This provides: -| Service | Description | -| ----------------- | ------------------------------------- | -| 4 LND nodes | lnd-1 (hub), lnd-2 (Boltz), lnd-3 (LNbits), lnd-4 (standalone) | -| 3 CLN nodes | Core Lightning with REST/gRPC | -| 1 Eclair node | ACINQ Eclair implementation | -| LNbits | Lightning wallet platform (port 5001) | -| Boltz | Submarine swaps (port 9001) | -| Electrs | Electrum server (port 3002) | -| Lightning Terminal| Web UI for lnd-1 (port 8443) | -| Elements/Liquid | Sidechain (port 18884) | +| Service | Description | +| ------------------ | -------------------------------------------------------------- | +| 4 LND nodes | lnd-1 (hub), lnd-2 (Boltz), lnd-3 (LNbits), lnd-4 (standalone) | +| 3 CLN nodes | Core Lightning with REST/gRPC | +| 1 Eclair node | ACINQ Eclair implementation | +| LNbits | Lightning wallet platform (port 5001) | +| Boltz | Submarine swaps (port 9001) | +| Electrs | Electrum server (port 3002) | +| Lightning Terminal | Web UI for lnd-1 (port 8443) | +| Elements/Liquid | Sidechain (port 18884) | ```bash # Start regtest environment @@ -289,13 +289,26 @@ Drivers are ported from `lamassu-machine/lib/`: | ---------- | ------------------------------------------------------------ | | Validators | id003, ccnet, cashflow_sc, bnr_advance, genmega, hcm2, gsr50 | | Dispensers | puloon, f56, genmega, hcm2, gsr50 | +| Recyclers | MEI SCR (planned — hardware exists in BATM3, no driver yet) | | Printers | nippon, zebra, genmega | +### BATM3 Hardware Topology + +The GeneralBytes BATM3 has two separate cash-handling units: + +| Unit | Hardware | Role | Cassettes | +| ----------------- | ---------- | ---------------------------------- | ------------------------------------ | +| MEI Cash Recycler | MEI SCR | Cash-in (accept bills) + recycling | 2 recycler (60 each) + cashbox (600) | +| F56 Dispenser | Puloon F56 | Cash-out (dispense only) | 2 dispenser cassettes | + +**Current status**: MEI used as validator-only (`cashflowSc` driver), F56 for dispensing. +The MEI's recycler cassettes are untapped — accepted bills go to cashbox, not recycled. + When porting: 1. Read JS driver thoroughly 2. Document protocol from JS code -3. Implement Rust version +3. Implement TypeScript version 4. Test against same hardware 5. Use `/hal-check port ` to validate diff --git a/apps/machine/electron/hal-service.ts b/apps/machine/electron/hal-service.ts index f13bfdc..2d21019 100644 --- a/apps/machine/electron/hal-service.ts +++ b/apps/machine/electron/hal-service.ts @@ -38,6 +38,14 @@ export interface DispenseResult { bills: { denomination: number; dispensed: number; rejected: number }[] dispensed: boolean error?: string + cassettes?: { + name: string + position: number + denomination: number + provisioned: number + dispensed: number + rejected: number + }[] } export interface HalInstance { @@ -175,26 +183,37 @@ export async function initializeHal(config: HalConfig): Promise { const result = await dispenser.dispense(notes) - // Update inventory - for (let i = 0; i < result.value.length; i++) { - const denom = cassetteDenominations[i] - if (denom !== undefined && inventory[denom] !== undefined) { - inventory[denom] -= result.value[i]?.dispensed ?? 0 - } - } - - // Build per-denomination result - const bills = amounts.map((a, i) => ({ - denomination: a.denomination, + // Build per-cassette results (position-aware, matches result.value ordering) + const cassetteResults = cassetteDenominations.map((denom, i) => ({ + name: `cassette${i + 1}`, + position: i, + denomination: denom, + provisioned: notes[i] ?? 0, dispensed: result.value[i]?.dispensed ?? 0, rejected: result.value[i]?.rejected ?? 0, })) + // Update inventory based on what was ACTUALLY dispensed (per-cassette) + for (const c of cassetteResults) { + if (c.dispensed > 0 && inventory[c.denomination] !== undefined) { + inventory[c.denomination] -= c.dispensed + } + } + + // Build per-denomination result (backward compatible, only requested denoms) + const bills = cassetteResults + .filter((c) => c.provisioned > 0) + .map((c) => ({ + denomination: c.denomination, + dispensed: c.dispensed, + rejected: c.rejected, + })) + const totalRequested = amounts.reduce((s, a) => s + a.count, 0) const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0) if (result.error) { - return { bills, dispensed: false, error: result.error.message } + return { bills, cassettes: cassetteResults, dispensed: false, error: result.error.message } } // Wait for customer to take bills @@ -203,7 +222,7 @@ export async function initializeHal(config: HalConfig): Promise { console.log('[HAL] Bills removed by customer') } - return { bills, dispensed: totalRequested === totalDispensed } + return { bills, cassettes: cassetteResults, dispensed: totalRequested === totalDispensed } }, getInventory: () => ({ ...inventory }), diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index ef19fe3..d2d0296 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -55,9 +55,23 @@ contextBridge.exposeInMainWorld('electronAPI', { recordTransaction: (tx: { txid: string type: 'cash_in' | 'cash_out' + status: 'complete' | 'dispense_error' | 'partial' fiatCents: number sats: number + feeSats: number + feePercent: number + exchangeRate: number + currency: string bills: { denomination: number; count: number }[] + cassettes?: { + name: string + position: number + denomination: number + provisioned: number + dispensed: number + rejected: number + }[] + error?: string | null }) => ipcRenderer.invoke('state:record-transaction', tx), emptyCashbox: () => ipcRenderer.invoke('state:empty-cashbox'), @@ -114,9 +128,23 @@ declare global { recordTransaction: (tx: { txid: string type: 'cash_in' | 'cash_out' + status: 'complete' | 'dispense_error' | 'partial' fiatCents: number sats: number + feeSats: number + feePercent: number + exchangeRate: number + currency: string bills: { denomination: number; count: number }[] + cassettes?: { + name: string + position: number + denomination: number + provisioned: number + dispensed: number + rejected: number + }[] + error?: string | null }) => Promise emptyCashbox: () => Promise // HAL hardware IPC diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index 6606a6b..ea39d4c 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 = '3' +const SCHEMA_VERSION = '4' function getDbPath(): string { const prodDir = '/var/lib/lamassu-atm' @@ -72,6 +72,8 @@ export function initDatabase(dbPath?: string): void { 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, created_at INTEGER NOT NULL ); @@ -81,6 +83,17 @@ export function initDatabase(dbPath?: string): void { denomination INTEGER NOT NULL, count INTEGER NOT NULL ); + + CREATE TABLE IF NOT EXISTS cassette_bills ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + txid TEXT NOT NULL REFERENCES transactions(txid), + name TEXT NOT NULL, + position INTEGER NOT NULL, + denomination INTEGER NOT NULL, + provisioned INTEGER NOT NULL DEFAULT 0, + dispensed INTEGER NOT NULL DEFAULT 0, + rejected INTEGER NOT NULL DEFAULT 0 + ); `) // Seed meta + cashbox if first run, or run migrations @@ -108,6 +121,28 @@ export function initDatabase(dbPath?: string): void { `) db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('3', 'schema_version') console.log('[StateStore] Migrated schema v2 → v3 (added exchange_rate, currency)') + existing.value = '3' + } + + if (existing && existing.value === '3') { + // Migration v3 → v4: add status/error to transactions, cassette_bills table + db.exec(` + ALTER TABLE transactions ADD COLUMN status TEXT NOT NULL DEFAULT 'complete'; + ALTER TABLE transactions ADD COLUMN error TEXT; + + CREATE TABLE IF NOT EXISTS cassette_bills ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + txid TEXT NOT NULL REFERENCES transactions(txid), + name TEXT NOT NULL, + position INTEGER NOT NULL, + denomination INTEGER NOT NULL, + provisioned INTEGER NOT NULL DEFAULT 0, + dispensed INTEGER NOT NULL DEFAULT 0, + rejected INTEGER NOT NULL DEFAULT 0 + ); + `) + db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('4', 'schema_version') + console.log('[StateStore] Migrated schema v3 → v4 (added status, error, cassette_bills)') } const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get() @@ -235,6 +270,7 @@ export function emptyCashbox(): void { interface TransactionInput { txid: string type: 'cash_in' | 'cash_out' + status: 'complete' | 'dispense_error' | 'partial' fiatCents: number sats: number feeSats: number @@ -242,6 +278,15 @@ interface TransactionInput { exchangeRate: number currency: string bills: { denomination: number; count: number }[] + cassettes?: { + name: string + position: number + denomination: number + provisioned: number + dispensed: number + rejected: number + }[] + error?: string | null } /** @@ -251,11 +296,14 @@ 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, fee_sats, fee_percent, exchange_rate, currency, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + 'INSERT INTO transactions (txid, type, status, error, fiat_cents, sats, fee_sats, fee_percent, exchange_rate, currency, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ) const insertBill = db.prepare( 'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)' ) + const insertCassetteBill = db.prepare( + 'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)' + ) const updateCassette = db.prepare( 'UPDATE cassettes SET count = MAX(0, count + ?) WHERE denomination = ?' ) @@ -267,6 +315,8 @@ export function recordTransaction(tx: TransactionInput): void { insertTx.run( t.txid, t.type, + t.status, + t.error ?? null, t.fiatCents, t.sats, t.feeSats, @@ -278,10 +328,36 @@ export function recordTransaction(tx: TransactionInput): void { 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) + // Insert per-cassette detail when available + if (t.cassettes) { + for (const c of t.cassettes) { + insertCassetteBill.run( + t.txid, + c.name, + c.position, + c.denomination, + c.provisioned, + c.dispensed, + c.rejected + ) + } + } + + if (t.type === 'cash_out') { + // Decrement cassettes by ACTUALLY dispensed count (not requested) + if (t.cassettes) { + for (const c of t.cassettes) { + if (c.dispensed > 0) { + updateCassette.run(-c.dispensed, c.denomination) + } + } + } else { + // Fallback: use bill counts (backward compat for mocks without cassette data) + for (const bill of t.bills) { + updateCassette.run(-bill.count, bill.denomination) + } } } @@ -293,7 +369,7 @@ export function recordTransaction(tx: TransactionInput): void { }) run(tx) - console.log('[StateStore] Recorded transaction:', tx.txid, tx.type) + console.log('[StateStore] Recorded transaction:', tx.txid, tx.type, `(${tx.status})`) } /** diff --git a/apps/machine/src/services/hal.ts b/apps/machine/src/services/hal.ts index d89339b..22f2778 100644 --- a/apps/machine/src/services/hal.ts +++ b/apps/machine/src/services/hal.ts @@ -149,26 +149,37 @@ export async function initializeHalServices(config: HalConfig): Promise ({ - denomination: a.denomination, + // Build per-cassette results (position-aware, matches result.value ordering) + const cassetteResults = cassetteDenominations.map((denom, i) => ({ + name: `cassette${i + 1}`, + position: i, + denomination: denom, + provisioned: notes[i] ?? 0, dispensed: result.value[i]?.dispensed ?? 0, rejected: result.value[i]?.rejected ?? 0, })) + // Update inventory based on what was ACTUALLY dispensed (per-cassette) + for (const c of cassetteResults) { + if (c.dispensed > 0 && c.denomination in inventory) { + inventory[c.denomination] = (inventory[c.denomination] ?? 0) - c.dispensed + } + } + + // Build per-denomination result (backward compatible, only requested denoms) + const bills = cassetteResults + .filter((c) => c.provisioned > 0) + .map((c) => ({ + denomination: c.denomination, + dispensed: c.dispensed, + rejected: c.rejected, + })) + const totalRequested = amounts.reduce((s, a) => s + a.count, 0) const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0) if (result.error) { - return { bills, dispensed: false, error: result.error.message } + return { bills, cassettes: cassetteResults, dispensed: false, error: result.error.message } } // Wait for customer to take bills (only if bills were dispensed) @@ -177,7 +188,7 @@ export async function initializeHalServices(config: HalConfig): Promise { diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 98d420b..39aea18 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -19,6 +19,18 @@ import type { TransactionRecord } from '@/types/state' // Check if we're running in Electron const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined +/** + * Compute fee in sats from context. + * Cash-in: customer gets net (gross - fee). Cash-out: customer pays gross + fee. + */ +function computeFeeSats(ctx: ATMContext, isCashIn: boolean): number { + const grossSats = ctx.exchangeRate > 0 ? Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate) : 0 + const feeSats = isCashIn + ? grossSats - ctx.satsAmount // cash-in: customer gets net + : ctx.satsAmount - grossSats // cash-out: customer pays gross + fee + return Math.max(0, feeSats) +} + /** * Load inventory from SQLite via IPC (Electron only). * Returns empty object in browser dev mode. @@ -261,31 +273,67 @@ export const useAtmStore = defineStore('atm', () => { send({ type: 'CASH_DISPENSED' }) } - if (currentNested === 'complete' && prevNestedState !== 'complete' && prevSnapshot) { + // Record failed cash-out dispenses (sats debited but cash not dispensed) + if (currentNested === 'dispenseError' && prevNestedState !== 'dispenseError') { 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 + const dr = ctx.dispenseResult - // Compute fee in sats from context - const grossSats = - ctx.exchangeRate > 0 ? Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate) : 0 - const feeSats = isCashInTx - ? grossSats - ctx.satsAmount // cash-in: customer gets net (gross - fee) - : ctx.satsAmount - grossSats // cash-out: customer pays gross + fee + // Determine status from dispense result (if available) + let status: 'dispense_error' | 'partial' = 'dispense_error' + let bills: { denomination: number; count: number }[] = [] + + if (dr) { + const totalDispensed = dr.bills.reduce((s, b) => s + b.dispensed, 0) + if (totalDispensed > 0) status = 'partial' + bills = dr.bills + .filter((b) => b.dispensed > 0) + .map((b) => ({ denomination: b.denomination, count: b.dispensed })) + } persistTransaction({ txid: ctx.txid, - type: isCashInTx ? 'cash_in' : 'cash_out', + type: 'cash_out', + status, fiatCents: ctx.fiatCents, sats: ctx.satsAmount, - feeSats: Math.max(0, feeSats), + feeSats: computeFeeSats(ctx, false), feePercent: ctx.feePercent, exchangeRate: ctx.exchangeRate, currency: ctx.currency, bills, + cassettes: dr?.cassettes, + error: dr?.error ?? ctx.error, + }) + } + } + + if (currentNested === 'complete' && prevNestedState !== 'complete' && prevSnapshot) { + const ctx = newSnapshot.context + if (ctx.txid) { + const isCashInTx = typeof state === 'object' && 'cashIn' in state + const dr = ctx.dispenseResult + + // For cash-out with dispense results, use actual dispensed counts + const bills: { denomination: number; count: number }[] = isCashInTx + ? buildBillsFromInserted(ctx.billsInserted) + : dr + ? dr.bills.map((b) => ({ denomination: b.denomination, count: b.dispensed })) + : ctx.dispenseAmounts + + persistTransaction({ + txid: ctx.txid, + type: isCashInTx ? 'cash_in' : 'cash_out', + status: 'complete', + fiatCents: ctx.fiatCents, + sats: ctx.satsAmount, + feeSats: computeFeeSats(ctx, isCashInTx), + feePercent: ctx.feePercent, + exchangeRate: ctx.exchangeRate, + currency: ctx.currency, + bills, + cassettes: dr?.cassettes, + error: dr?.error, }) } } diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index a6dd52b..6379689 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -38,6 +38,7 @@ declare global { recordTransaction: (tx: { txid: string type: 'cash_in' | 'cash_out' + status: 'complete' | 'dispense_error' | 'partial' fiatCents: number sats: number feeSats: number @@ -45,6 +46,15 @@ declare global { exchangeRate: number currency: string bills: { denomination: number; count: number }[] + cassettes?: { + name: string + position: number + denomination: number + provisioned: number + dispensed: number + rejected: number + }[] + error?: string | null }) => Promise emptyCashbox: () => Promise // HAL hardware IPC diff --git a/apps/machine/src/types/state.ts b/apps/machine/src/types/state.ts index ef869dc..921b5b6 100644 --- a/apps/machine/src/types/state.ts +++ b/apps/machine/src/types/state.ts @@ -16,6 +16,7 @@ export interface CashboxState { export interface TransactionRecord { txid: string type: 'cash_in' | 'cash_out' + status: 'complete' | 'dispense_error' | 'partial' fiatCents: number sats: number feeSats: number @@ -23,6 +24,15 @@ export interface TransactionRecord { exchangeRate: number currency: string bills: { denomination: number; count: number }[] + cassettes?: { + name: string + position: number + denomination: number + provisioned: number + dispensed: number + rejected: number + }[] + error?: string | null } export interface ATMAvailability { diff --git a/packages/state-machine/src/index.ts b/packages/state-machine/src/index.ts index c23ee48..a90c163 100644 --- a/packages/state-machine/src/index.ts +++ b/packages/state-machine/src/index.ts @@ -51,6 +51,7 @@ export { type PaymentStatus, type PaymentMethod, type DispenseCashResult, + type CassetteBillResult, initialContext, } from './types.js' diff --git a/packages/state-machine/src/types.ts b/packages/state-machine/src/types.ts index 83d78c1..aa67a9c 100644 --- a/packages/state-machine/src/types.ts +++ b/packages/state-machine/src/types.ts @@ -5,14 +5,32 @@ /** Payment status */ export type PaymentStatus = 'pending' | 'paid' | 'failed' | null +/** Per-cassette dispense result (position-aware) */ +export interface CassetteBillResult { + /** Cassette name, e.g. "cassette1", "cassette2" */ + name: string + /** 0-indexed physical position */ + position: number + /** Bill denomination in this cassette */ + denomination: number + /** Bills requested from this cassette */ + provisioned: number + /** Bills actually dispensed */ + dispensed: number + /** Bills rejected/jammed */ + rejected: number +} + /** Result of a dispense operation (always resolves, never throws) */ export interface DispenseCashResult { - /** Per-cassette results (what was actually dispensed) */ + /** Per-denomination results (what was actually dispensed) */ bills: { denomination: number; dispensed: number; rejected: number }[] /** Whether the full requested amount was dispensed */ dispensed: boolean /** Error message if dispense failed or was partial */ error?: string + /** Per-cassette detail (position-aware, produced by HAL) */ + cassettes?: CassetteBillResult[] } /** Payment methods supported */