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:
Padreug 2026-10-10 21:37:17 +02:00
commit e8106b665a
7 changed files with 319 additions and 2 deletions

View file

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