feat(machine): durable dispense-report outbox to spirekeeper (ADR-005 §2)
Every cash-out now produces one report_dispense — on success as well as failure — and the machine does not stop sending it until spirekeeper acknowledges it. state.db gains a dispense_reports table (migration v13 → v14): the report is written INSIDE recordTransaction's SQLite transaction, alongside the transactions row, so a crash between the two cannot lose it. Rows carry attempts / last_attempt_at / last_error / acked_at. Three IPC calls (pending / ack / note-attempt) expose it to the renderer. The store builds the report when a cash-out reaches complete, dispenseFault or outOfCash: txid, payment hash, dispense_confirmed, error / error_code / raw_code / error_class, per-denomination requested vs dispensed vs rejected, the per-bay cassette record verbatim, and counts_uncertain. The success report is what lets the server capture (distribute) the settlement; the failure report is what puts a customer on the owed-cash worklist instead of leaving the only record on the ATM. Delivery is at-least-once: a flusher drains pending rows after each persist, on relay (re)connect, and every 60 s, acking only on an OK reply and backing off 30 s · 2^attempts (capped 1 h) otherwise. While spirekeeper has not registered the RPC every send fails the same way; the backoff keeps that quiet and the rows wait — this half ships first. The lightning service exposes reportDispense; the function pointer is set at all three lightning-init sites so the flusher works on every path.
This commit is contained in:
parent
7120f306b6
commit
e8106b665a
7 changed files with 319 additions and 2 deletions
|
|
@ -31,6 +31,9 @@ import {
|
|||
setCashOutHold,
|
||||
clearCashOutHold,
|
||||
type CashOutHold,
|
||||
pendingDispenseReports,
|
||||
markDispenseReportAcked,
|
||||
noteDispenseReportAttempt,
|
||||
markStatePublished,
|
||||
resetStatePublishWatermark,
|
||||
resetForRepair,
|
||||
|
|
@ -578,6 +581,22 @@ ipcMain.handle('state:set-cash-out-hold', (_event, hold: CashOutHold): CashOutHo
|
|||
return setCashOutHold(hold)
|
||||
})
|
||||
ipcMain.handle('state:clear-cash-out-hold', (): boolean => clearCashOutHold())
|
||||
|
||||
// Dispense-report outbox (ADR-005 §2) — at-least-once to spirekeeper
|
||||
ipcMain.handle('state:pending-dispense-reports', (_event, limit?: number) =>
|
||||
pendingDispenseReports(typeof limit === 'number' ? limit : 20)
|
||||
)
|
||||
ipcMain.handle('state:ack-dispense-report', (_event, txid: string): boolean => {
|
||||
if (typeof txid !== 'string' || !txid) throw new Error('Invalid txid')
|
||||
return markDispenseReportAcked(txid)
|
||||
})
|
||||
ipcMain.handle(
|
||||
'state:note-dispense-report-attempt',
|
||||
(_event, txid: string, error: string | null): void => {
|
||||
if (typeof txid !== 'string' || !txid) throw new Error('Invalid txid')
|
||||
noteDispenseReportAttempt(txid, typeof error === 'string' ? error.slice(0, 512) : null)
|
||||
}
|
||||
)
|
||||
ipcMain.handle('state:mark-state-published', (_event, unixTimestamp: number): void => {
|
||||
markStatePublished(unixTimestamp)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,6 +15,16 @@ interface CashOutHold {
|
|||
since: number
|
||||
}
|
||||
|
||||
/** Mirrors state-store.PendingDispenseReport (ADR-005 §2). */
|
||||
interface PendingDispenseReport {
|
||||
txid: string
|
||||
payload: unknown
|
||||
createdAt: number
|
||||
attempts: number
|
||||
lastAttemptAt: number | null
|
||||
lastError: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime configuration interface (public info only)
|
||||
* These values are read from environment variables at runtime (not build time)
|
||||
|
|
@ -127,6 +137,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
setCashOutHold: (hold: CashOutHold): Promise<CashOutHold> =>
|
||||
ipcRenderer.invoke('state:set-cash-out-hold', hold),
|
||||
clearCashOutHold: (): Promise<boolean> => ipcRenderer.invoke('state:clear-cash-out-hold'),
|
||||
// Dispense-report outbox (ADR-005 §2)
|
||||
pendingDispenseReports: (limit?: number): Promise<PendingDispenseReport[]> =>
|
||||
ipcRenderer.invoke('state:pending-dispense-reports', limit),
|
||||
ackDispenseReport: (txid: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('state:ack-dispense-report', txid),
|
||||
noteDispenseReportAttempt: (txid: string, error: string | null): Promise<void> =>
|
||||
ipcRenderer.invoke('state:note-dispense-report-attempt', txid, error),
|
||||
markStatePublished: (unixTimestamp: number): Promise<void> =>
|
||||
ipcRenderer.invoke('state:mark-state-published', unixTimestamp),
|
||||
|
||||
|
|
@ -323,6 +340,9 @@ declare global {
|
|||
getCashOutHold: () => Promise<CashOutHold | null>
|
||||
setCashOutHold: (hold: CashOutHold) => Promise<CashOutHold>
|
||||
clearCashOutHold: () => Promise<boolean>
|
||||
pendingDispenseReports: (limit?: number) => Promise<PendingDispenseReport[]>
|
||||
ackDispenseReport: (txid: string) => Promise<boolean>
|
||||
noteDispenseReportAttempt: (txid: string, error: string | null) => Promise<void>
|
||||
markStatePublished: (unixTimestamp: number) => Promise<void>
|
||||
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
||||
clearBunkerBinding: () => Promise<void>
|
||||
|
|
|
|||
|
|
@ -10,12 +10,13 @@
|
|||
*/
|
||||
|
||||
import Database from 'better-sqlite3'
|
||||
import type { DispenseReportBody } from '@bitSpire/lnbits'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
|
||||
let db: Database.Database | null = null
|
||||
|
||||
const SCHEMA_VERSION = '13'
|
||||
const SCHEMA_VERSION = '14'
|
||||
|
||||
function getDbPath(): string {
|
||||
const prodDir = '/var/lib/bitspire'
|
||||
|
|
@ -136,6 +137,16 @@ export function initDatabase(dbPath?: string): void {
|
|||
relays TEXT,
|
||||
lnbits_server_pubkey TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dispense_reports (
|
||||
txid TEXT PRIMARY KEY REFERENCES transactions(txid),
|
||||
payload TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_attempt_at INTEGER,
|
||||
last_error TEXT,
|
||||
acked_at INTEGER
|
||||
);
|
||||
`)
|
||||
|
||||
// Seed meta + cashbox if first run, or run migrations
|
||||
|
|
@ -418,6 +429,32 @@ export function initDatabase(dbPath?: string): void {
|
|||
existing.value = '13'
|
||||
}
|
||||
|
||||
if (existing && existing.value === '13') {
|
||||
// Migration v13 → v14: the dispense-report outbox (ADR-005 §2).
|
||||
//
|
||||
// Every cash-out's outcome — success or failure — is reported to
|
||||
// spirekeeper over a kind-21000 RPC, and that report is what lets the
|
||||
// server capture (distribute) the settlement or surface a customer who
|
||||
// is owed cash. A relay gives the publisher no delivery guarantee, so the
|
||||
// report is written here, in the SAME transaction as the transactions
|
||||
// row, and resent until the server acknowledges it. Idempotent on txid
|
||||
// server-side; `attempts` / `last_error` drive the resend backoff.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS dispense_reports (
|
||||
txid TEXT PRIMARY KEY REFERENCES transactions(txid),
|
||||
payload TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_attempt_at INTEGER,
|
||||
last_error TEXT,
|
||||
acked_at INTEGER
|
||||
);
|
||||
`)
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('14', 'schema_version')
|
||||
console.log('[StateStore] Migrated schema v13 → v14 (added dispense_reports outbox)')
|
||||
existing.value = '14'
|
||||
}
|
||||
|
||||
// Defensive: a fresh install at SCHEMA_VERSION skips all migrations.
|
||||
// Seed the operator-config meta rows if they're missing (idempotent).
|
||||
const seedMeta = db.prepare('INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)')
|
||||
|
|
@ -580,6 +617,70 @@ export function clearCashOutHold(): boolean {
|
|||
return had
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispense-report outbox (ADR-005 §2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PendingDispenseReport {
|
||||
txid: string
|
||||
payload: DispenseReportBody
|
||||
createdAt: number
|
||||
attempts: number
|
||||
lastAttemptAt: number | null
|
||||
lastError: string | null
|
||||
}
|
||||
|
||||
/** Unacknowledged reports, oldest first. The renderer applies the backoff. */
|
||||
export function pendingDispenseReports(limit = 20): PendingDispenseReport[] {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const rows = db
|
||||
.prepare(
|
||||
'SELECT txid, payload, created_at, attempts, last_attempt_at, last_error FROM dispense_reports WHERE acked_at IS NULL ORDER BY created_at ASC LIMIT ?'
|
||||
)
|
||||
.all(limit) as Array<{
|
||||
txid: string
|
||||
payload: string
|
||||
created_at: number
|
||||
attempts: number
|
||||
last_attempt_at: number | null
|
||||
last_error: string | null
|
||||
}>
|
||||
const out: PendingDispenseReport[] = []
|
||||
for (const r of rows) {
|
||||
try {
|
||||
out.push({
|
||||
txid: r.txid,
|
||||
payload: JSON.parse(r.payload) as DispenseReportBody,
|
||||
createdAt: r.created_at,
|
||||
attempts: r.attempts,
|
||||
lastAttemptAt: r.last_attempt_at,
|
||||
lastError: r.last_error,
|
||||
})
|
||||
} catch {
|
||||
console.error('[StateStore] dispense_reports row has unparseable payload:', r.txid)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The server acknowledged this report. Returns whether a row changed. */
|
||||
export function markDispenseReportAcked(txid: string): boolean {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const res = db
|
||||
.prepare('UPDATE dispense_reports SET acked_at = ? WHERE txid = ? AND acked_at IS NULL')
|
||||
.run(Date.now(), txid)
|
||||
if (res.changes > 0) console.log('[StateStore] Dispense report acked:', txid)
|
||||
return res.changes > 0
|
||||
}
|
||||
|
||||
/** A send was attempted and did not get an OK. Drives the resend backoff. */
|
||||
export function noteDispenseReportAttempt(txid: string, error: string | null): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
db.prepare(
|
||||
'UPDATE dispense_reports SET attempts = attempts + 1, last_attempt_at = ?, last_error = ? WHERE txid = ?'
|
||||
).run(Date.now(), error, txid)
|
||||
}
|
||||
|
||||
/**
|
||||
* A counter bumped on every local change to a bay count, from any cause.
|
||||
*
|
||||
|
|
@ -1241,6 +1342,11 @@ interface TransactionInput {
|
|||
rejected: number
|
||||
}[]
|
||||
error?: string | null
|
||||
/**
|
||||
* ADR-005 §2: dispense outcome to queue for spirekeeper. Inserted in the
|
||||
* same transaction as the row so a crash between them cannot lose it.
|
||||
*/
|
||||
report?: DispenseReportBody
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1262,6 +1368,12 @@ export function recordTransaction(tx: TransactionInput): void {
|
|||
const insertBill = db.prepare(
|
||||
'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)'
|
||||
)
|
||||
// Outbox row (ADR-005 §2). REPLACE: a re-record of the same txid (should not
|
||||
// happen, but a crash-replay could) refreshes the payload and resets the
|
||||
// delivery state rather than failing the whole transaction.
|
||||
const insertReport = db.prepare(
|
||||
'INSERT OR REPLACE INTO dispense_reports (txid, payload, created_at, attempts, last_attempt_at, last_error, acked_at) VALUES (?, ?, ?, 0, NULL, NULL, NULL)'
|
||||
)
|
||||
const insertCassetteBill = db.prepare(
|
||||
'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
|
|
@ -1294,6 +1406,10 @@ export function recordTransaction(tx: TransactionInput): void {
|
|||
insertBill.run(t.txid, bill.denomination, bill.count)
|
||||
}
|
||||
|
||||
if (t.report) {
|
||||
insertReport.run(t.txid, JSON.stringify(t.report), Date.now())
|
||||
}
|
||||
|
||||
// Insert per-cassette detail when available
|
||||
if (t.cassettes) {
|
||||
for (const c of t.cassettes) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue