bitspire/apps/machine/electron/state-store.ts
Padreug c889f7f0df feat(cassettes): consume operator operations instead of counts
The machine now owns its bay counts outright. The operator publishes what
it did — a refill in notes added, an empty, a recount, a denomination
change — and this process applies it to the total it already holds.

Both sides used to write the same value over a transport that never tells
a writer it lost. Addressable events order by created_at at second
granularity with ties broken on event id, and a relay returns OK for an
event it then discards, so a dashboard form loaded before a dispense
silently discarded that dispense and neither side could detect it. A
value with one writer cannot be clobbered.

Schema v13 adds cassette_ops, the dedup ledger. A delta applied twice is
wrong and addressable events are re-delivered on every reconnect, so the
operator mints an id per operation and this table records the ones
applied. That also retires the created_at watermark on this path: it was
the only replay defence under absolute counts, but it drops an
out-of-order event whole, operations included, where per-op ids let the
unseen ones through and no-op the rest.

A window is applied oldest-first by `at`, ties broken by id, in one
transaction with the count mutation. A recount then a refill is not the
same as the reverse, and a crash mid-apply must roll back to a coherent
count rather than a partial one.

A malformed op or one naming a bay this machine does not have is neither
applied nor recorded, so it stays pending on the operator's dashboard.
That is the honest outcome. Recording it as applied would stop the noise
by telling the operator their refill landed.

The state document gains applied_ops, seq and schema_version. applied_ops
is the acknowledgement leg — echoing the ids back is the only way the
operator can tell an operation that landed from one merely sent. seq is
bumped on every local count change from any cause, so a reader can reject
a regression without trusting either clock.
2026-09-23 12:55:52 +02:00

1378 lines
53 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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/bitspire/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 = '13'
function getDbPath(): string {
const prodDir = '/var/lib/bitspire'
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 (
position INTEGER PRIMARY KEY,
denomination INTEGER NOT NULL,
count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS cassette_ops (
id TEXT PRIMARY KEY,
position INTEGER NOT NULL,
op_type TEXT NOT NULL,
bills INTEGER,
count INTEGER,
denomination INTEGER,
op_at INTEGER NOT NULL,
applied_at INTEGER NOT NULL
);
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', 'manual_dispense')),
fiat_cents INTEGER NOT NULL,
sats INTEGER NOT NULL,
fee_sats INTEGER NOT NULL DEFAULT 0,
fee_fraction 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,
remediated_by TEXT,
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
);
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
);
CREATE TABLE IF NOT EXISTS operator_commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
command TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
result TEXT,
created_at INTEGER NOT NULL,
completed_at INTEGER
);
CREATE TABLE IF NOT EXISTS fee_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
cash_in_fee_fraction REAL NOT NULL,
cash_out_fee_fraction REAL NOT NULL,
schema_version INTEGER NOT NULL DEFAULT 1,
event_created_at INTEGER NOT NULL,
applied_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS bunker_binding (
id INTEGER PRIMARY KEY CHECK (id = 1),
client_secret_hex TEXT NOT NULL,
spire_pubkey TEXT NOT NULL,
bunker_url TEXT NOT NULL,
seed_fingerprint TEXT NOT NULL,
paired_at INTEGER NOT NULL,
relays TEXT,
lnbits_server_pubkey TEXT
);
`)
// Seed meta + cashbox if first run, or run migrations
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)
} else if (existing.value === '1') {
// Migration v1 → v2: add fee columns to transactions
db.exec(`
ALTER TABLE transactions ADD COLUMN fee_sats INTEGER NOT NULL DEFAULT 0;
ALTER TABLE transactions ADD COLUMN fee_percent REAL NOT NULL DEFAULT 0;
`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('2', 'schema_version')
console.log('[StateStore] Migrated schema v1 → v2 (added fee columns)')
existing.value = '2'
}
if (existing && existing.value === '2') {
// Migration v2 → v3: add exchange rate and currency
db.exec(`
ALTER TABLE transactions ADD COLUMN exchange_rate REAL NOT NULL DEFAULT 0;
ALTER TABLE transactions ADD COLUMN currency TEXT NOT NULL DEFAULT 'GTQ';
`)
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)')
existing.value = '4'
}
if (existing && existing.value === '4') {
// Migration v4 → v5: add manual_dispense type and remediated_by column
// SQLite cannot ALTER CHECK constraints, so recreate the table.
// Disable FK constraints during migration to avoid errors from
// transaction_bills/cassette_bills referencing transactions(txid).
db.pragma('foreign_keys = OFF')
db.exec(`
DROP TABLE IF EXISTS transactions_new;
CREATE TABLE transactions_new (
txid TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK (type IN ('cash_in', 'cash_out', 'manual_dispense')),
fiat_cents INTEGER NOT NULL,
sats INTEGER NOT NULL,
fee_sats INTEGER NOT NULL DEFAULT 0,
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,
remediated_by TEXT,
created_at INTEGER NOT NULL
);
INSERT INTO transactions_new SELECT txid, type, fiat_cents, sats, fee_sats, fee_percent,
exchange_rate, currency, status, error, NULL, created_at FROM transactions;
DROP TABLE transactions;
ALTER TABLE transactions_new RENAME TO transactions;
`)
db.pragma('foreign_keys = ON')
db.exec(`
CREATE TABLE IF NOT EXISTS operator_commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
command TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
result TEXT,
created_at INTEGER NOT NULL,
completed_at INTEGER
);
`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('5', 'schema_version')
console.log(
'[StateStore] Migrated schema v4 → v5 (added manual_dispense type, remediated_by, operator_commands)'
)
existing.value = '5'
}
if (existing && existing.value === '5') {
// Migration v5 → v6: add position column to cassettes for physical cartridge ordering
db.exec(`ALTER TABLE cassettes ADD COLUMN position INTEGER NOT NULL DEFAULT 0`)
// Backfill position from existing row order (by denomination)
const rows = db.prepare('SELECT denomination FROM cassettes ORDER BY denomination').all() as {
denomination: number
}[]
const update = db.prepare('UPDATE cassettes SET position = ? WHERE denomination = ?')
for (let i = 0; i < rows.length; i++) {
update.run(i + 1, rows[i]!.denomination)
}
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('6', 'schema_version')
console.log('[StateStore] Migrated schema v5 → v6 (added cassette position)')
existing.value = '6'
}
if (existing && existing.value === '6') {
// Migration v6 → v7: rename fee_percent → fee_fraction. Same semantics
// (unit fraction in [0, 1]); the rename closes the 100× misinterpretation
// risk between satmachineadmin's consumer and bitspire's stamp.
// Coordinated with satmachineadmin commit d717a6e (v2-bitspire branch).
db.exec(`ALTER TABLE transactions RENAME COLUMN fee_percent TO fee_fraction`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('7', 'schema_version')
console.log('[StateStore] Migrated schema v6 → v7 (renamed fee_percent → fee_fraction)')
existing.value = '7'
}
if (existing && existing.value === '7') {
// Migration v7 → v8: seed `meta` rows for operator-config consumer.
// - lastKnownConfigCreatedAt — replay-protection watermark. Drop any
// incoming kind-30078 operator-config event whose created_at is
// ≤ this value. Default 0 = "haven't applied anything yet."
// - bootstrapPublishedAt — gate for the one-shot ATM-side
// bitspire-cassettes-state hello-event. NULL = "haven't published
// bootstrap yet"; once set, the hello-event publish path becomes
// a no-op (continuous reverse-channel publish is v2 territory).
// See aiolabs/lamassu-next#56 + ~/dev/coordination/log.md (2026-05-30).
db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run('lastKnownConfigCreatedAt', '0')
db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run('bootstrapPublishedAt', '')
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('8', 'schema_version')
console.log('[StateStore] Migrated schema v7 → v8 (seeded operator-config meta rows)')
existing.value = '8'
}
if (existing && existing.value === '8') {
// Migration v8 → v9: rebuild `cassettes` so `position` is the PK, NOT
// `denomination`. Real production machines (Tejo, batm3) load multiple
// cassettes with the same denomination for cash-out throughput on a
// single bill class — the v1.0 schema's denomination-PK silently
// collapsed duplicates and the HAL `indexOf(denomination)` first-match
// semantics meant only one bay of any given denom could ever dispense.
// v9 makes `position` the addressable unit (matches the hardware bay
// layout) and allows `denomination` to repeat across rows.
//
// The cassette-config wire shape (operator → ATM kind-30078) flips
// alongside this from `{denominations: {...}}` → `{positions: {...}}`.
// See aiolabs/lamassu-next#56 + ~/dev/coordination/log.md 2026-05-30
// entries (06:30Z → 18:45Z) for the design history.
db.pragma('foreign_keys = OFF')
db.exec(`
DROP TABLE IF EXISTS cassettes_new;
CREATE TABLE cassettes_new (
position INTEGER PRIMARY KEY,
denomination INTEGER NOT NULL,
count INTEGER NOT NULL DEFAULT 0
);
INSERT INTO cassettes_new (position, denomination, count)
SELECT position, denomination, count FROM cassettes;
DROP TABLE cassettes;
ALTER TABLE cassettes_new RENAME TO cassettes;
`)
db.pragma('foreign_keys = ON')
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('9', 'schema_version')
console.log(
'[StateStore] Migrated schema v8 → v9 (cassettes PK position; allow duplicate denominations)'
)
existing.value = '9'
}
if (existing && existing.value === '9') {
// Migration v9 → v10: operator-pushed fee config consumer
// (aiolabs/lamassu-next#57).
// - fee_config table — singleton row (id=1) carrying the last
// applied cash-in/cash-out fee fractions. Persisted so a restart
// restores the operator's policy without waiting on relay. The
// super/operator breakdown is NOT mirrored here — satmachineadmin
// is the canonical audit substrate for the split per settlement
// (Layer 1 #38). See coord log 2026-06-01T07:56Z for the dumb-
// machine/smart-server rationale (the components stay on the
// wire — they get parser-side consistency-asserted + logged at
// receipt — but don't propagate beyond the parse boundary).
// - meta.lastKnownFeeConfigCreatedAt — replay-protection watermark
// (separate from `lastKnownConfigCreatedAt` for cassettes, per
// d-tag-per-lifecycle convention). Default 0 = "fresh ATM,
// nothing applied yet → fail-closed into maintenance screen."
db.exec(`
CREATE TABLE IF NOT EXISTS fee_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
cash_in_fee_fraction REAL NOT NULL,
cash_out_fee_fraction REAL NOT NULL,
schema_version INTEGER NOT NULL DEFAULT 1,
event_created_at INTEGER NOT NULL,
applied_at INTEGER NOT NULL
);
`)
db.prepare('INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)').run(
'lastKnownFeeConfigCreatedAt',
'0'
)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('10', 'schema_version')
console.log('[StateStore] Migrated schema v9 → v10 (added fee_config + watermark)')
existing.value = '10'
}
if (existing && existing.value === '10') {
// Migration v10 → v11: NIP-46 bunker binding (aiolabs/bitspire#52).
// - bunker_binding singleton — the ATM's own NIP-46 transport key
// (client_nsec) plus the spire signing identity, bunker URL, and a
// fingerprint of the seed it was paired from. Persisted so a restart
// resumes the bunker session without re-redeeming the one-shot connect
// secret. A new/changed seed_fingerprint signals a re-pair (which also
// resets bootstrapPublishedAt — see lightning.ts / bitspire#56).
db.exec(`
CREATE TABLE IF NOT EXISTS bunker_binding (
id INTEGER PRIMARY KEY CHECK (id = 1),
client_secret_hex TEXT NOT NULL,
spire_pubkey TEXT NOT NULL,
bunker_url TEXT NOT NULL,
seed_fingerprint TEXT NOT NULL,
paired_at INTEGER NOT NULL
);
`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('11', 'schema_version')
console.log('[StateStore] Migrated schema v10 → v11 (added bunker_binding)')
existing.value = '11'
}
if (existing && existing.value === '11') {
// Migration v11 → v12: carry the LNbits transport config in the binding
// (aiolabs/bitspire#70). relays (JSON array) + lnbits_server_pubkey let a
// paired machine reach the backend from the pairing alone — no VITE_RELAY_URL
// / VITE_LNBITS_SERVER_PUBKEY provisioning. Nullable: bindings written before
// this (the seed didn't carry them) resume fine and fall back to env.
db.exec(`
ALTER TABLE bunker_binding ADD COLUMN relays TEXT;
ALTER TABLE bunker_binding ADD COLUMN lnbits_server_pubkey TEXT;
`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('12', 'schema_version')
console.log('[StateStore] Migrated schema v11 → v12 (bunker_binding transport config)')
}
if (existing && existing.value === '12') {
// Migration v12 → v13: operator OPERATIONS replace operator counts
// (aiolabs/bitspire ADR-004).
//
// The operator used to publish absolute counts and this machine applied
// them outright. Both sides wrote the same value over a transport that
// never tells a writer it lost, so a dashboard form loaded before a
// dispense silently discarded that dispense — and nothing on either side
// could detect it afterwards. The operator now publishes what it DID and
// this machine, which holds the notes, owns the running total.
//
// `cassette_ops` is the dedup ledger. A delta applied twice is wrong, and
// addressable events are re-delivered on every reconnect, so the operator
// mints an id per operation and we record the ones we have applied. The
// operator's window is a slice of recent operations rather than just the
// newest, so one we missed arrives with the next publish; dedup is what
// makes re-delivery free instead of dangerous.
db.exec(`
CREATE TABLE IF NOT EXISTS cassette_ops (
id TEXT PRIMARY KEY,
position INTEGER NOT NULL,
op_type TEXT NOT NULL,
bills INTEGER,
count INTEGER,
denomination INTEGER,
op_at INTEGER NOT NULL,
applied_at INTEGER NOT NULL
);
`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('13', 'schema_version')
console.log('[StateStore] Migrated schema v12 → v13 (added cassette_ops)')
existing.value = '13'
}
// 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 (?, ?)')
seedMeta.run('lastKnownConfigCreatedAt', '0')
seedMeta.run('bootstrapPublishedAt', '')
seedMeta.run('lastKnownFeeConfigCreatedAt', '0')
seedMeta.run('cassetteStateSeq', '0')
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)
}
// ---------------------------------------------------------------------------
// Meta — operator-config consumer state
// (lastKnownConfigCreatedAt + bootstrapPublishedAt for aiolabs/lamassu-next#56)
// ---------------------------------------------------------------------------
/**
* Read replay-protection watermark. Returns 0 if no operator config has
* been applied yet (fresh ATM, pre-bootstrap).
*/
export function getLastKnownConfigCreatedAt(): number {
if (!db) throw new Error('Database not initialized')
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('lastKnownConfigCreatedAt') as
| { value: string }
| undefined
return row ? Number(row.value) || 0 : 0
}
/**
* The `created_at` of the last `bitspire-cassettes-state` event this machine
* published, or null if it has never published one.
*
* This used to be a one-shot gate ("have we said hello yet"), which meant a
* layout change after first boot was never announced (#94). It is now a
* high-water mark: every publish records its stamp, and the next one is forced
* strictly above it. Addressable events are ordered by `created_at` at second
* granularity, and a relay silently keeps the higher one, so a clock that steps
* backwards would otherwise make this machine's reports vanish with an `OK`.
*
* Stored under the original `bootstrapPublishedAt` meta key so no migration is
* needed; the name is historical, the meaning is not.
*/
export function getLastStatePublishedAt(): number | null {
if (!db) throw new Error('Database not initialized')
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('bootstrapPublishedAt') as
| { value: string }
| undefined
if (!row || row.value === '') return null
const n = Number(row.value)
return Number.isFinite(n) ? n : null
}
/**
* Whether the bay counts are known to be unverified, and since when.
*
* Set when a dispense ends without the dispenser reporting what it moved — a
* driver throw, or the dispense timeout. Bills may well have reached the
* customer, but nothing knows how many, so neither the rows here nor HAL's
* bays were debited and both now read high. Reporting that number as fact is
* the worst option available; saying the number is unverified is honest and
* tells the operator to open the machine and recount.
*
* Cleared when an operator asserts authoritative counts (a config apply),
* which is precisely what a recount is. Uses an upsert so no migration is
* needed for machines whose meta table predates the key.
*/
export function getCountsUncertainSince(): number | null {
if (!db) throw new Error('Database not initialized')
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('countsUncertainSince') as
| { value: string }
| undefined
if (!row || row.value === '') return null
const n = Number(row.value)
return Number.isFinite(n) ? n : null
}
/** Flag the counts as unverified. Keeps the earliest time it went bad. */
export function markCountsUncertain(unixTimestamp: number): void {
if (!db) throw new Error('Database not initialized')
if (getCountsUncertainSince() !== null) return
db.prepare(
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run('countsUncertainSince', String(unixTimestamp))
console.warn('[StateStore] Cassette counts flagged unverified at', unixTimestamp)
}
/** Clear the flag — an operator has asserted real counts. */
export function clearCountsUncertain(): void {
if (!db) throw new Error('Database not initialized')
db.prepare(
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run('countsUncertainSince', '')
}
/**
* A counter bumped on every local change to a bay count, from any cause.
*
* It rides along in the state document so a reader can reject a regression
* without trusting a clock. `created_at` cannot carry that: it has
* second granularity, so two publishes in the same second are ordered by
* whichever event id hashes lower — and a machine whose clock stepped
* backwards would otherwise have every later report look older than the one
* already on the relay.
*/
export function getCassetteStateSeq(): number {
if (!db) throw new Error('Database not initialized')
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('cassetteStateSeq') as
| { value: string }
| undefined
return row ? Number(row.value) || 0 : 0
}
/**
* Bump the counter. Safe to call inside an open transaction — every caller
* that mutates a count does, so the bump commits or rolls back with it.
*/
export function bumpCassetteStateSeq(): void {
if (!db) throw new Error('Database not initialized')
db.prepare(
'INSERT INTO meta (key, value) VALUES (?, ?) ' +
'ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(meta.value AS INTEGER) + 1 AS TEXT)'
).run('cassetteStateSeq', '1')
}
/** Record the `created_at` just published, as the next publish's floor. */
export function markStatePublished(unixTimestamp: number): void {
if (!db) throw new Error('Database not initialized')
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run(
String(unixTimestamp),
'bootstrapPublishedAt'
)
}
// ---------------------------------------------------------------------------
// Bunker binding — NIP-46 transport key + spire identity (aiolabs/bitspire#52)
// ---------------------------------------------------------------------------
export interface StoredBunkerBinding {
/** The ATM's own NIP-46 transport secret key (`client_nsec`), hex. */
clientSecretHex: string
/** The spire's signing pubkey (hex) — the identity events are signed as. */
spirePubkey: string
/** `bunker://…` URL, re-parsed into a pointer on resume. */
bunkerUrl: string
/** Fingerprint of the seed this binding was paired from (re-pair detection). */
seedFingerprint: string
/** Unix seconds when the pairing was redeemed. */
pairedAt: number
/**
* LNbits transport relays from the pairing seed (aiolabs/bitspire#70). Lets a
* resumed (seedless) boot reach the backend without env provisioning.
* Undefined for bindings written before the seed carried them.
*/
relays?: string[]
/** LNbits nostr-transport server pubkey (hex) from the seed (#70). */
lnbitsServerPubkey?: string
}
/** Read the persisted bunker binding, or null if the ATM is unpaired. */
export function getBunkerBinding(): StoredBunkerBinding | null {
if (!db) throw new Error('Database not initialized')
const row = db
.prepare(
'SELECT client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey FROM bunker_binding WHERE id = 1'
)
.get() as
| {
client_secret_hex: string
spire_pubkey: string
bunker_url: string
seed_fingerprint: string
paired_at: number
relays: string | null
lnbits_server_pubkey: string | null
}
| undefined
if (!row) return null
return {
clientSecretHex: row.client_secret_hex,
spirePubkey: row.spire_pubkey,
bunkerUrl: row.bunker_url,
seedFingerprint: row.seed_fingerprint,
pairedAt: row.paired_at,
relays: parseRelaysColumn(row.relays),
lnbitsServerPubkey: row.lnbits_server_pubkey ?? undefined,
}
}
/** Decode the JSON-array `relays` column, tolerating null/legacy/garbage. */
function parseRelaysColumn(value: string | null): string[] | undefined {
if (!value) return undefined
try {
const parsed = JSON.parse(value)
if (Array.isArray(parsed) && parsed.every((r) => typeof r === 'string')) {
return parsed as string[]
}
} catch {
// fall through
}
return undefined
}
/** Upsert the bunker binding after a successful (re-)pairing. */
export function saveBunkerBinding(binding: StoredBunkerBinding): void {
if (!db) throw new Error('Database not initialized')
db.prepare(
`INSERT INTO bunker_binding (id, client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey)
VALUES (1, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
client_secret_hex = excluded.client_secret_hex,
spire_pubkey = excluded.spire_pubkey,
bunker_url = excluded.bunker_url,
seed_fingerprint = excluded.seed_fingerprint,
paired_at = excluded.paired_at,
relays = excluded.relays,
lnbits_server_pubkey = excluded.lnbits_server_pubkey`
).run(
binding.clientSecretHex,
binding.spirePubkey,
binding.bunkerUrl,
binding.seedFingerprint,
binding.pairedAt,
binding.relays ? JSON.stringify(binding.relays) : null,
binding.lnbitsServerPubkey ?? null
)
}
/** Drop the bunker binding (e.g. after an operator revoke → force re-pair). */
export function clearBunkerBinding(): void {
if (!db) throw new Error('Database not initialized')
db.prepare('DELETE FROM bunker_binding WHERE id = 1').run()
}
/**
* Forget the publish high-water mark. Called on a re-pair (new seed): the
* next publish is then free to use the wall clock, which is what a fresh
* operator relationship wants. The state itself is republished on startup
* regardless, so the new operator always receives current counts.
*/
export function resetStatePublishWatermark(): void {
if (!db) throw new Error('Database not initialized')
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt')
}
/**
* Wipe operator-scoped CONFIG/TRUST state on a re-pair to a new operator/backend,
* so stale policy from the previous pairing can't linger or silently reject the
* new operator's config.
*
* Clears the fee config and resets BOTH replay watermarks to 0. The watermark
* reset is the load-bearing part: without it, a new backend whose first config
* event has a lower `created_at` than the old operator's last event is silently
* dropped as a replay — the exact remnant trap where re-pairing a long-lived
* install to a fresh backend appears to "work" but never picks up new config.
*
* Deliberately does NOT touch cassettes / cashbox / transactions: those track
* PHYSICAL cash, which survives an operator handover. A full wipe (decommission
* or a truly-fresh test) is the factory-reset path, not this.
*/
export function resetForRepair(): void {
if (!db) throw new Error('Database not initialized')
const database = db
database.transaction(() => {
database.prepare('DELETE FROM fee_config').run()
const setWatermark = database.prepare('UPDATE meta SET value = ? WHERE key = ?')
setWatermark.run('0', 'lastKnownFeeConfigCreatedAt')
setWatermark.run('0', 'lastKnownConfigCreatedAt')
})()
}
/**
* Outcome of applying an operator-authored absolute config. Still the right
* shape for fee config, where the operator is the only writer of the value
* and a later event simply supersedes an earlier one. Cassette counts left
* this model in ADR-004 precisely because they had two writers.
*/
export type ApplyResult = { applied: true } | { applied: false; reason: string }
/** One operator-authored operation, as it arrives on the wire. */
export type CassetteOp = {
id: string
at: number
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
position: number
bills?: number
count?: number
denomination?: number
}
export type ApplyOpsResult = {
/** Ids applied by this call. Empty when every op was already on file. */
applied: string[]
/** Ids rejected, with why. These stay unapplied and unrecorded. */
rejected: { id: string; reason: string }[]
}
const CASSETTE_OP_TYPES = new Set(['refill', 'empty', 'recount', 'set_denomination'])
/**
* Validate one operation in isolation. Returns null when it is well-formed.
*
* Shape errors and unknown positions are treated the same way by the caller:
* the op is neither applied nor recorded, so it stays pending on the
* operator's dashboard. That is the honest outcome — it did not happen — and
* it beats recording it as applied to stop the noise, which would tell the
* operator their refill landed when the notes are unaccounted for.
*/
function validateCassetteOp(op: CassetteOp, knownPositions: Set<number>): string | null {
if (typeof op.id !== 'string' || op.id.length === 0) return 'missing id'
if (!CASSETTE_OP_TYPES.has(op.type)) return `unknown type ${String(op.type)}`
if (!Number.isInteger(op.position)) return `position must be an integer (got ${op.position})`
if (!knownPositions.has(op.position)) return `unknown position ${op.position}`
if (!Number.isFinite(op.at)) return 'missing at'
if (op.type === 'refill') {
if (!Number.isInteger(op.bills) || (op.bills as number) <= 0) {
return `refill needs a positive integer bills (got ${op.bills})`
}
}
if (op.type === 'recount') {
if (!Number.isInteger(op.count) || (op.count as number) < 0) {
return `recount needs a non-negative integer count (got ${op.count})`
}
}
if (op.type === 'set_denomination') {
if (!Number.isInteger(op.denomination) || (op.denomination as number) <= 0) {
return `set_denomination needs a positive integer denomination (got ${op.denomination})`
}
}
return null
}
/**
* Apply an operator's cassette operations, skipping any already on file.
*
* This replaces applying absolute counts. The operator authors what it DID —
* a refill in notes added, an empty, a recount, a denomination change — and
* this machine, which holds the physical notes, keeps the running total.
* Nobody but this process writes a count any more, so there is no second
* writer to lose a race to.
*
* Deltas are not idempotent and addressable events ARE re-delivered on every
* relay reconnect, so idempotency is carried explicitly: the operator mints an
* id per operation, `cassette_ops` records the ones applied, and a repeat is a
* no-op. That is also why there is no `created_at` watermark here any more.
* Under absolute counts the watermark was the only replay defence; with
* per-op ids it is strictly weaker than the dedup and would do active harm,
* because an event that arrives out of order may still carry an operation this
* machine has never seen.
*
* Applied oldest-first by `at`, ties broken by id so two operations stamped in
* the same second still order the same way on every machine. Ordering matters
* because a recount followed by a refill is not the same as the reverse.
*
* The whole batch runs in one SQLite transaction with the sequence bump, so a
* crash mid-apply rolls back to a coherent count and the next publish re-offers
* every op in the window.
*/
export function applyOperatorCassetteOps(ops: CassetteOp[]): ApplyOpsResult {
if (!db) throw new Error('Database not initialized')
const database = db
const result: ApplyOpsResult = { applied: [], rejected: [] }
if (ops.length === 0) return result
const knownPositions = new Set(
(database.prepare('SELECT position FROM cassettes').all() as { position: number }[]).map(
(r) => r.position
)
)
const seen = database.prepare('SELECT 1 FROM cassette_ops WHERE id = ?')
const pending: CassetteOp[] = []
for (const op of ops) {
if (op && typeof op.id === 'string' && seen.get(op.id)) continue
const reason = validateCassetteOp(op, knownPositions)
if (reason) {
result.rejected.push({ id: op?.id ?? '<no id>', reason })
continue
}
pending.push(op)
}
if (pending.length === 0) return result
pending.sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const addBills = database.prepare(
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?'
)
const setCount = database.prepare('UPDATE cassettes SET count = ? WHERE position = ?')
const setDenomination = database.prepare(
'UPDATE cassettes SET denomination = ? WHERE position = ?'
)
const recordOp = database.prepare(
'INSERT INTO cassette_ops (id, position, op_type, bills, count, denomination, op_at, applied_at) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
)
const upsertMeta = database.prepare(
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
)
const appliedAt = Math.floor(Date.now() / 1000)
let sawRecount = false
database.transaction(() => {
for (const op of pending) {
if (op.type === 'refill') addBills.run(op.bills, op.position)
else if (op.type === 'empty') setCount.run(0, op.position)
else if (op.type === 'recount') {
setCount.run(op.count, op.position)
sawRecount = true
} else setDenomination.run(op.denomination, op.position)
recordOp.run(
op.id,
op.position,
op.type,
op.bills ?? null,
op.count ?? null,
op.denomination ?? null,
Math.floor(op.at),
appliedAt
)
result.applied.push(op.id)
}
bumpCassetteStateSeq()
// A recount is an operator opening the bay and counting it, which is
// exactly what resolves an unverified count. Nothing else does: a refill
// adds to a number still known to be wrong.
if (sawRecount) upsertMeta.run('countsUncertainSince', '')
})()
console.log(
`[StateStore] Applied ${result.applied.length} cassette op(s)` +
(result.rejected.length ? `, rejected ${result.rejected.length}` : '')
)
return result
}
/**
* The ids most recently applied, newest first — the acknowledgement leg of
* the protocol.
*
* An addressable event gives its publisher no failure signal at all: the relay
* returns OK for an event it then discards, and a losing writer is never told.
* Echoing the ids back in this machine's own state document is the only way
* the operator can distinguish an operation that landed from one that was
* merely sent.
*/
export function getAppliedOpIds(limit = 50): string[] {
if (!db) throw new Error('Database not initialized')
const rows = db
.prepare('SELECT id FROM cassette_ops ORDER BY applied_at DESC, rowid DESC LIMIT ?')
.all(limit) as { id: string }[]
return rows.map((r) => r.id)
}
// ---------------------------------------------------------------------------
// Fee config — operator-pushed fee fractions (aiolabs/lamassu-next#57)
// ---------------------------------------------------------------------------
export interface FeeConfigRow {
cashInFeeFraction: number
cashOutFeeFraction: number
schemaVersion: number
/** Watermark — the event's created_at (NIP-78 replaceable event timestamp). */
eventCreatedAt: number
/** Local Date.now() at the moment this row was upserted via IPC. Triage primitive. */
appliedAt: number
}
/**
* Read the last-applied operator fee config. Returns null when no event
* has ever been applied (fresh ATM, pre-operator-publish). The renderer
* uses null → maintenance screen ("Awaiting fee configuration from
* operator") per the fail-closed posture from issue #57.
*/
export function getFeeConfig(): FeeConfigRow | null {
if (!db) throw new Error('Database not initialized')
const row = db
.prepare(
'SELECT cash_in_fee_fraction, cash_out_fee_fraction, schema_version, event_created_at, applied_at FROM fee_config WHERE id = 1'
)
.get() as
| {
cash_in_fee_fraction: number
cash_out_fee_fraction: number
schema_version: number
event_created_at: number
applied_at: number
}
| undefined
if (!row) return null
return {
cashInFeeFraction: row.cash_in_fee_fraction,
cashOutFeeFraction: row.cash_out_fee_fraction,
schemaVersion: row.schema_version,
eventCreatedAt: row.event_created_at,
appliedAt: row.applied_at,
}
}
/**
* Read replay-protection watermark. Returns 0 if no fee config has ever
* been applied. Separate from `lastKnownConfigCreatedAt` (cassettes) per
* the d-tag-per-lifecycle convention — independent watermarks isolate
* blast radius across operator-pushed config types.
*/
export function getLastKnownFeeConfigCreatedAt(): number {
if (!db) throw new Error('Database not initialized')
const row = db
.prepare('SELECT value FROM meta WHERE key = ?')
.get('lastKnownFeeConfigCreatedAt') as { value: string } | undefined
return row ? Number(row.value) || 0 : 0
}
export interface FeeConfigPayload {
cashInFeeFraction: number
cashOutFeeFraction: number
schemaVersion: number
}
/**
* Atomic apply of an operator-published fee config (aiolabs/lamassu-next#57).
*
* Caller has already verified the event signature, decrypted the content,
* enforced the per-direction 15% cap, and ignored any unknown top-level
* keys (v2 forward-compat). This function:
*
* 1. Rechecks replay-protection against `meta.lastKnownFeeConfigCreatedAt`
* (defense-in-depth — caller should have done this too).
* 2. Re-validates both fractions are in [0, 0.15] (defense in depth — the
* renderer-side cap is the primary check; the state-store guard catches
* bypass via a buggy or tampered renderer).
* 3. In a single SQLite transaction: upserts the singleton fee_config row
* AND advances `meta.lastKnownFeeConfigCreatedAt` to `eventCreatedAt`.
*/
const FEE_CAP_PER_DIRECTION = 0.15
export function applyFeeConfig(payload: FeeConfigPayload, eventCreatedAt: number): ApplyResult {
if (!db) throw new Error('Database not initialized')
const watermark = getLastKnownFeeConfigCreatedAt()
if (eventCreatedAt <= watermark) {
return {
applied: false,
reason: `event.created_at (${eventCreatedAt}) <= lastKnownFeeConfigCreatedAt (${watermark})`,
}
}
const rangeChecks: [string, number][] = [
['cash_in_fee_fraction', payload.cashInFeeFraction],
['cash_out_fee_fraction', payload.cashOutFeeFraction],
]
for (const [name, value] of rangeChecks) {
if (!Number.isFinite(value) || value < 0 || value > FEE_CAP_PER_DIRECTION) {
return {
applied: false,
reason: `${name} out of range [0, ${FEE_CAP_PER_DIRECTION}]: ${value}`,
}
}
}
if (!Number.isInteger(payload.schemaVersion) || payload.schemaVersion < 1) {
return { applied: false, reason: `invalid schema_version: ${payload.schemaVersion}` }
}
if (!Number.isInteger(eventCreatedAt) || eventCreatedAt < 0) {
return { applied: false, reason: `invalid event_created_at: ${eventCreatedAt}` }
}
const upsert = db.prepare(
'INSERT INTO fee_config (id, cash_in_fee_fraction, cash_out_fee_fraction, schema_version, event_created_at, applied_at) VALUES (1, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET cash_in_fee_fraction = excluded.cash_in_fee_fraction, cash_out_fee_fraction = excluded.cash_out_fee_fraction, schema_version = excluded.schema_version, event_created_at = excluded.event_created_at, applied_at = excluded.applied_at'
)
const setWatermark = db.prepare('UPDATE meta SET value = ? WHERE key = ?')
const run = db.transaction(() => {
upsert.run(
payload.cashInFeeFraction,
payload.cashOutFeeFraction,
payload.schemaVersion,
eventCreatedAt,
Date.now()
)
setWatermark.run(String(eventCreatedAt), 'lastKnownFeeConfigCreatedAt')
})
run()
console.log(
`[StateStore] Applied fee config @ event_created_at=${eventCreatedAt} ` +
`cash_in=${payload.cashInFeeFraction} cash_out=${payload.cashOutFeeFraction} ` +
`schema=${payload.schemaVersion}`
)
return { applied: true }
}
// ---------------------------------------------------------------------------
// Cassettes
// ---------------------------------------------------------------------------
export interface CassetteRow {
denomination: number
count: number
position: number
}
export function loadCassettes(): CassetteRow[] {
if (!db) throw new Error('Database not initialized')
return db
.prepare('SELECT denomination, count, position FROM cassettes ORDER BY position, denomination')
.all() as CassetteRow[]
}
/**
* Upsert cassette rows. Position is the addressable unit; the same
* denomination may legitimately appear on multiple positions (real
* machines load N cassettes of the same denomination for cash-out
* throughput).
*/
export function setCassettes(
cassettes: { denomination: number; count: number; position?: number }[]
): void {
if (!db) throw new Error('Database not initialized')
const upsert = db.prepare(
'INSERT INTO cassettes (position, denomination, count) VALUES (?, ?, ?) ON CONFLICT(position) DO UPDATE SET denomination = excluded.denomination, count = excluded.count'
)
const run = db.transaction(
(rows: { denomination: number; count: number; position?: number }[]) => {
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!
upsert.run(row.position ?? i + 1, row.denomination, row.count)
}
bumpCassetteStateSeq()
}
)
run(cassettes)
console.log('[StateStore] Cassettes updated:', cassettes)
}
/**
* Decrement the count for a specific physical bay (position). Use this
* after a successful dispense — the dispenser returns per-position
* results, so the caller already knows which bay drained how many.
*/
export function updateCassetteCountByPosition(position: number, delta: number): void {
if (!db) throw new Error('Database not initialized')
const database = db
database.transaction(() => {
database
.prepare('UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?')
.run(delta, position)
bumpCassetteStateSeq()
})()
}
/**
* Get aggregate inventory as denomination → total-count-across-positions.
* Used by the renderer / state machine for "do we have enough $20s to
* cover this withdraw" gating where the per-bay breakdown doesn't matter.
* For per-bay state use `loadCassettes()`.
*/
export function getInventory(): Record<number, number> {
const rows = loadCassettes()
const inv: Record<number, number> = {}
for (const row of rows) {
// Zero-count bays are KEPT. Dropping them made a drained machine
// indistinguishable from an unconfigured one, and every caller reads an
// empty map as "I don't know, ask the hardware" — so the last non-empty
// snapshot stuck and the availability beacon went on advertising bills
// that had already been dispensed. An empty map now means exactly one
// thing: no cassettes are configured. Consumers already filter for
// `> 0` before offering a denomination (CashOutView, machine.ts).
inv[row.denomination] = (inv[row.denomination] ?? 0) + 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' | 'manual_dispense'
status: 'complete' | 'dispense_error' | 'partial' | 'remediated'
fiatCents: number
sats: number
feeSats: number
feeFraction: 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
}
/**
* Record a completed transaction and update cassette/cashbox state atomically.
*/
export function recordTransaction(tx: TransactionInput): void {
if (!db) throw new Error('Database not initialized')
if (tx.feeFraction < 0 || tx.feeFraction > 1) {
throw new Error(
`[StateStore] feeFraction out of range [0, 1]: ${tx.feeFraction}. ` +
`Unit fraction expected (0.05 = 5%), not a percentage.`
)
}
const insertTx = db.prepare(
'INSERT INTO transactions (txid, type, status, error, fiat_cents, sats, fee_sats, fee_fraction, 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 updateCassetteByPosition = db.prepare(
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?'
)
const selectBaysByDenom = db.prepare(
'SELECT position, count FROM cassettes WHERE denomination = ? ORDER BY position'
)
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.status,
t.error ?? null,
t.fiatCents,
t.sats,
t.feeSats,
t.feeFraction,
t.exchangeRate,
t.currency,
Date.now()
)
for (const bill of t.bills) {
insertBill.run(t.txid, bill.denomination, bill.count)
}
// 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
)
}
}
// Any dispense empties bays, whoever asked for it. `manual_dispense`
// (operator remediation, via the command poller or a kind-21003 command)
// used to fall outside this branch: HAL decremented its in-memory bays but
// the rows here did not move, and on the next boot HAL re-seeds from these
// rows — so the machine came back believing it still held bills a customer
// had already been handed (#76). A remediation against a partly-dispensed
// original decrements again on purpose: the original only ever debited what
// physically left, and this is a second lot of bills leaving.
if (t.type === 'cash_out' || t.type === 'manual_dispense') {
// Decrement cassettes by ACTUALLY dispensed count (not requested).
// Position is the addressable unit (v9): duplicate denominations
// across bays are legal, so a denomination-keyed UPDATE would
// decrement every matching bay.
if (t.cassettes) {
for (const c of t.cassettes) {
if (c.dispensed > 0) {
updateCassetteByPosition.run(-c.dispensed, c.position)
}
}
} else {
// Fallback: per-denomination bill counts (mocks without per-bay
// results). Drain matching bays greedily in position order —
// the dispenser's own fill order.
for (const bill of t.bills) {
let remaining = bill.count
const bays = selectBaysByDenom.all(bill.denomination) as {
position: number
count: number
}[]
for (const bay of bays) {
if (remaining <= 0) break
const take = Math.min(remaining, bay.count)
if (take <= 0) continue
updateCassetteByPosition.run(-take, bay.position)
remaining -= take
}
}
}
// The counts moved, so the sequence must move with them, inside this
// same transaction. It rides in the state document as the operator's
// way to reject a regression without trusting either clock.
bumpCassetteStateSeq()
}
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, `(${tx.status})`)
}
// ---------------------------------------------------------------------------
// Operator Command Queue
// ---------------------------------------------------------------------------
export interface OperatorCommand {
id: number
command: string
status: string
created_at: number
}
/**
* Get the next pending operator command (FIFO).
*/
export function getPendingCommand(): OperatorCommand | null {
if (!db) throw new Error('Database not initialized')
const row = db
.prepare(
"SELECT id, command, status, created_at FROM operator_commands WHERE status = 'pending' ORDER BY id LIMIT 1"
)
.get() as { id: number; command: string; status: string; created_at: number } | undefined
return row ?? null
}
/**
* Mark a command as executing (prevents double-pickup).
*/
export function markCommandExecuting(id: number): void {
if (!db) throw new Error('Database not initialized')
db.prepare(
"UPDATE operator_commands SET status = 'executing' WHERE id = ? AND status = 'pending'"
).run(id)
}
/**
* Complete a command with a result.
*/
export function completeCommand(id: number, result: string, error?: boolean): void {
if (!db) throw new Error('Database not initialized')
db.prepare(
'UPDATE operator_commands SET status = ?, result = ?, completed_at = ? WHERE id = ?'
).run(error ? 'error' : 'complete', result, Date.now(), id)
}
// ---------------------------------------------------------------------------
// Transaction Remediation
// ---------------------------------------------------------------------------
/**
* Mark a failed transaction as remediated by a manual dispense.
* Only updates transactions with status 'dispense_error' or 'partial'.
* Returns true if the transaction was updated, false if not found or not in error state.
*/
export function remediateTransaction(txid: string, remediatedByTxid: string): boolean {
if (!db) throw new Error('Database not initialized')
const result = db
.prepare(
'UPDATE transactions SET status = ?, remediated_by = ? WHERE txid = ? AND status IN (?, ?)'
)
.run('remediated', remediatedByTxid, txid, 'dispense_error', 'partial')
if (result.changes > 0) {
console.log('[StateStore] Remediated transaction:', txid, '→', remediatedByTxid)
}
return result.changes > 0
}
/**
* Close the database (for clean shutdown).
*/
export function closeDatabase(): void {
if (db) {
db.close()
db = null
console.log('[StateStore] Database closed')
}
}