diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index f76cfb2..a2a9d3a 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -27,7 +27,12 @@ import { getBootstrapPublishedAt, markBootstrapPublished, applyOperatorCassettesConfig, + getFeeConfig, + getLastKnownFeeConfigCreatedAt, + applyFeeConfig, type OperatorCassettesPayload, + type FeeConfigPayload, + type FeeConfigRow, type ApplyResult, } from './state-store.js' import { initializeHal, type HalInstance } from './hal-service.js' @@ -36,12 +41,6 @@ import { initializeHal, type HalInstance } from './hal-service.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -/** Parse fee value: accepts percentage (5.55 → 0.0555) or decimal (0.0555 → 0.0555) */ -function parseFee(val: string): number { - const n = parseFloat(val) - return n >= 1 ? n / 100 : n -} - // Load .env file manually (Electron main process doesn't have Vite's env loading) function loadEnvFile() { const envPath = path.join(__dirname, '..', '.env') @@ -303,9 +302,10 @@ ipcMain.handle('get-config', () => { // Maintenance mode — show "out of service" screen maintenanceMode: process.env.VITE_MAINTENANCE_MODE === 'true', - // Fee rates — accepts percentage (5.55) or decimal (0.0555), auto-detected - cashInFeeFraction: parseFee(process.env.VITE_CASH_IN_FEE || '0.0333'), - cashOutFeeFraction: parseFee(process.env.VITE_CASH_OUT_FEE || '0.0777'), + // Fee rates — operator-pushed via Nostr (kind-30078 `bitspire-fees:`) + // from satmachineadmin; see aiolabs/lamassu-next#57. No env-var fallback — + // first boot without a persisted fee config (and no inbound event) shows + // a maintenance screen until the operator publishes initial config. // Operator branding (logo/title/theme) — null when no override branding: loadBranding(), @@ -367,6 +367,20 @@ ipcMain.handle( applyOperatorCassettesConfig(payload, eventCreatedAt) ) +// Operator-fees consumer (aiolabs/lamassu-next#57) — persisted singleton +// fee config + per-d-tag replay watermark + atomic apply for kind-30078 +// `bitspire-fees:` events. Independent from the cassette +// watermark/apply path per the d-tag-per-lifecycle convention. +ipcMain.handle('state:get-fee-config', (): FeeConfigRow | null => getFeeConfig()) +ipcMain.handle('state:get-last-known-fee-config-created-at', (): number => + getLastKnownFeeConfigCreatedAt() +) +ipcMain.handle( + 'state:apply-fee-config', + (_event, payload: FeeConfigPayload, eventCreatedAt: number): ApplyResult => + applyFeeConfig(payload, eventCreatedAt) +) + // Support pages — read .md files from /var/lib/bitspire/support/ ipcMain.handle('support:get-pages', () => { const supportDir = path.join( diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index 4ac7306..a69536d 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -110,6 +110,26 @@ contextBridge.exposeInMainWorld('electronAPI', { ): Promise<{ applied: true } | { applied: false; reason: string }> => ipcRenderer.invoke('state:apply-operator-cassettes-config', payload, eventCreatedAt), + // Operator-fees consumer (aiolabs/lamassu-next#57) + getFeeConfig: (): Promise<{ + cashInFeeFraction: number + cashOutFeeFraction: number + schemaVersion: number + eventCreatedAt: number + appliedAt: number + } | null> => ipcRenderer.invoke('state:get-fee-config'), + getLastKnownFeeConfigCreatedAt: (): Promise => + ipcRenderer.invoke('state:get-last-known-fee-config-created-at'), + applyFeeConfig: ( + payload: { + cashInFeeFraction: number + cashOutFeeFraction: number + schemaVersion: number + }, + eventCreatedAt: number + ): Promise<{ applied: true } | { applied: false; reason: string }> => + ipcRenderer.invoke('state:apply-fee-config', payload, eventCreatedAt), + // Support pages getSupportPages: (): Promise<{ id: string; title: string; content: string }[]> => ipcRenderer.invoke('support:get-pages'), @@ -198,6 +218,22 @@ declare global { payload: { positions: Record }, eventCreatedAt: number ) => Promise<{ applied: true } | { applied: false; reason: string }> + getFeeConfig: () => Promise<{ + cashInFeeFraction: number + cashOutFeeFraction: number + schemaVersion: number + eventCreatedAt: number + appliedAt: number + } | null> + getLastKnownFeeConfigCreatedAt: () => Promise + applyFeeConfig: ( + payload: { + cashInFeeFraction: number + cashOutFeeFraction: number + schemaVersion: number + }, + eventCreatedAt: number + ) => Promise<{ applied: true } | { applied: false; reason: string }> getSupportPages: () => Promise<{ id: string; title: string; content: string }[]> // HAL hardware IPC halInit: (config: any) => Promise<{ success: boolean; error?: string }> diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index 11db2ac..fcf83e5 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 = '9' +const SCHEMA_VERSION = '10' function getDbPath(): string { const prodDir = '/var/lib/bitspire' @@ -105,6 +105,15 @@ export function initDatabase(dbPath?: string): void { 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 + ); `) // Seed meta + cashbox if first run, or run migrations @@ -276,6 +285,41 @@ export function initDatabase(dbPath?: string): void { 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)') } // Defensive: a fresh install at SCHEMA_VERSION skips all migrations. @@ -283,6 +327,7 @@ export function initDatabase(dbPath?: string): void { const seedMeta = db.prepare('INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)') seedMeta.run('lastKnownConfigCreatedAt', '0') seedMeta.run('bootstrapPublishedAt', '') + seedMeta.run('lastKnownFeeConfigCreatedAt', '0') const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get() if (!cashboxRow) { @@ -440,6 +485,146 @@ export function applyOperatorCassettesConfig( return { applied: true } } +// --------------------------------------------------------------------------- +// 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 // --------------------------------------------------------------------------- diff --git a/apps/machine/src/App.vue b/apps/machine/src/App.vue index df0d352..c5730e3 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -142,17 +142,29 @@ function toggleLiveServices() {

- {{ atmStore.initError === 'maintenance' ? 'Under Service' : 'ATM Unavailable' }} + {{ + atmStore.initError === 'maintenance' + ? 'Under Service' + : atmStore.initError === 'awaiting-fees' + ? 'Awaiting Configuration' + : 'ATM Unavailable' + }}

{{ atmStore.initError === 'maintenance' ? 'This machine is currently being serviced. We will be back shortly.' - : 'This machine is temporarily out of service. Please try again later or use another machine.' + : atmStore.initError === 'awaiting-fees' + ? 'Awaiting fee configuration from operator. Contact operator to publish initial fee config.' + : 'This machine is temporarily out of service. Please try again later or use another machine.' }}

{{ atmStore.initError }} diff --git a/apps/machine/src/services/operator-fees.ts b/apps/machine/src/services/operator-fees.ts new file mode 100644 index 0000000..8581ce8 --- /dev/null +++ b/apps/machine/src/services/operator-fees.ts @@ -0,0 +1,354 @@ +/** + * Operator-fees consumer (aiolabs/lamassu-next#57). + * + * Sibling to `operator-config.ts` (cassette config) — same kind, same + * encryption envelope, different d-tag and payload shape. Subscribes to + * operator-published kind-30078 events carrying fee config updates, + * validates + persists them, and pushes the new fractions through a + * callback into the renderer store. + * + * Architecture (see ~/dev/coordination/log.md 2026-05-31 → 2026-06-01): + * + * - Operator → ATM: `kind=30078`, `["d", "bitspire-fees:"]`, + * `["p", ]`, NIP-44 v2 encrypted content, author = operator pubkey. + * - Per-d-tag-per-lifecycle convention: independent watermark + independent + * failure mode from cassette config. A malformed fee payload does NOT + * brick cassette consumption (and vice versa). + * + * Wire payload (v1): + * + * { + * "schema_version": 1, + * "cash_in_fee_fraction": 0.0633, + * "cash_out_fee_fraction": 0.1077, + * "components": { // informational + audit-feeding + * "super_cash_in": 0.03, + * "super_cash_out": 0.03, + * "operator_cash_in": 0.0333, + * "operator_cash_out": 0.0777 + * } + * } + * + * `schema_version` MAY be absent — treat absence as v1 (per the + * contract direction lnbits proposed in §`07:00Z`). v1 consumers ignore + * unknown top-level keys (future-proofing for v2 promo fields). + * + * `components` MAY be absent — consumer-optional per the wire-format + * spec. When present, the parser runs a consistency assert (sum of + * super_*+operator_* must match the published total within 1e-6) and + * logs the breakdown on the receipt log line. The breakdown is NOT + * persisted on the ATM side — satmachineadmin is the canonical audit + * substrate for the super/operator split per settlement (Layer 1 of + * aiolabs/satmachineadmin#38). See coord log 2026-06-01T07:56Z for + * the dumb-machine / smart-server rationale. + * + * Hard cap: both fractions must be in [0, 0.15] per Padreug's 2026-06-01 + * decision (between sat's 25% straw and lnbits's tighter lean). Events + * over cap → log + drop; prior persisted config remains authoritative. + * + * Apply-mid-transaction: the XState state machine snapshots + * `cashInFeeFraction` / `cashOutFeeFraction` into context at construction + * time, then reads `context.feeFraction` from the per-flow entry action + * for the lifetime of a transaction. A mid-flight ref update therefore + * does NOT propagate to the in-flight tx — it applies to the next one. + * Hold-until-completion is structurally free; no explicit deferral + * needed here. + */ + +import { + type MachineIdentity, + type NostrClient, + type Event, + decryptContentV2, + validateEvent, +} from '@bitSpire/nostr-client' + +import type {} from '@/types/electron' + +const KIND_NIP78 = 30078 + +/** Accept operator events stamped up to this many seconds in the future. */ +const MAX_FUTURE_SKEW_S = 60 + +/** Per-direction cap. Either fraction above this → reject as malformed. */ +const FEE_CAP_PER_DIRECTION = 0.15 + +const feeConfigDTag = (machineId: string) => `bitspire-fees:${machineId}` + +const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined + +export interface OperatorFeesServiceConfig { + /** Connected NostrClient — shared with the Lightning service. */ + nostrClient: NostrClient + /** ATM's nostr identity. Used to decrypt operator events. */ + identity: MachineIdentity + /** Operator pubkeys (hex) authorized to publish fee config. From VITE_OPERATOR_PUBKEYS. */ + operatorPubkeys: string[] + /** Machine identifier for the d-tag. Defaults to identity.publicKey when omitted. */ + machineId?: string + /** + * Called when a valid fee-config event is applied. Renderer should + * mutate the pinia refs that back the state-machine fee fractions — + * XState's context-snapshot boundary defers the change to the next + * transaction (see file header). + */ + onApply: (fees: { cashInFeeFraction: number; cashOutFeeFraction: number }) => void +} + +export interface OperatorFeesService { + /** Unsubscribe from operator events and free resources. */ + stop(): void +} + +export async function startOperatorFeesService( + cfg: OperatorFeesServiceConfig +): Promise { + if (cfg.operatorPubkeys.length === 0) { + console.log('[Fees] No operator pubkeys configured — service disabled') + return { stop: () => {} } + } + if (!isElectron || !window.electronAPI) { + console.log('[Fees] Not in Electron — service disabled (browser dev mode)') + return { stop: () => {} } + } + const api = window.electronAPI + const machineId = cfg.machineId ?? cfg.identity.publicKey + + // Subscribe to operator-published fee config events. + const dTag = feeConfigDTag(machineId) + const subscriptionId = cfg.nostrClient.subscribe( + [ + { + kinds: [KIND_NIP78], + '#p': [cfg.identity.publicKey], + '#d': [dTag], + authors: cfg.operatorPubkeys, + }, + ], + { + onEvent: (event) => { + handleFeeConfigEvent(event, cfg, api).catch((err) => { + console.error('[Fees] Apply failed:', err) + }) + }, + } + ) + console.log('[Fees] Subscribed:', { dTag, subscriptionId }) + + return { + stop: () => cfg.nostrClient.unsubscribe(subscriptionId), + } +} + +interface ParsedFeePayload { + cashInFeeFraction: number + cashOutFeeFraction: number + superCashInFraction: number + superCashOutFraction: number + operatorCashInFraction: number + operatorCashOutFraction: number + schemaVersion: number +} + +async function handleFeeConfigEvent( + event: Event, + cfg: OperatorFeesServiceConfig, + api: NonNullable +): Promise { + // 1. Signature + author whitelist (defense in depth — relay filter + // already constrained authors, but verify the relay didn't lie). + if (!validateEvent(event)) { + console.warn('[Fees] Event signature invalid — dropped:', event.id) + return + } + if (!cfg.operatorPubkeys.includes(event.pubkey)) { + console.warn('[Fees] Author not in operator whitelist — dropped:', event.pubkey) + return + } + + // 2. Replay protection — drop stale events. Independent watermark + // from cassette config per the d-tag-per-lifecycle convention. + const watermark = await api.getLastKnownFeeConfigCreatedAt() + if (event.created_at <= watermark) { + console.log( + `[Fees] Stale event dropped (created_at=${event.created_at} <= watermark=${watermark})` + ) + return + } + + // 3. Clock-skew defense — reject events stamped too far in the future. + const nowSec = Math.floor(Date.now() / 1000) + if (event.created_at > nowSec + MAX_FUTURE_SKEW_S) { + console.warn( + `[Fees] Future-stamped event dropped (created_at=${event.created_at}, now=${nowSec})` + ) + return + } + + // 4. Decrypt + parse content (NIP-44 v2). Tolerates extra top-level + // fields (v2 forward-compat — future promo payloads). + let parsed: ParsedFeePayload + try { + const plaintext = decryptContentV2(cfg.identity, event.pubkey, event.content) + const raw = JSON.parse(plaintext) as Record + parsed = parseV1Payload(raw) + } catch (err) { + console.error('[Fees] Decrypt/parse failed:', err) + return + } + + // 5. Per-direction cap — defense against operator typos AND a tampered + // publisher. State-store re-validates at the IPC boundary. + if ( + parsed.cashInFeeFraction > FEE_CAP_PER_DIRECTION || + parsed.cashOutFeeFraction > FEE_CAP_PER_DIRECTION + ) { + console.warn( + `[Fees] Event rejected — fraction above ${FEE_CAP_PER_DIRECTION} cap ` + + `(cash_in=${parsed.cashInFeeFraction}, cash_out=${parsed.cashOutFeeFraction})` + ) + return + } + if (parsed.cashInFeeFraction < 0 || parsed.cashOutFeeFraction < 0) { + console.warn( + `[Fees] Event rejected — negative fraction ` + + `(cash_in=${parsed.cashInFeeFraction}, cash_out=${parsed.cashOutFeeFraction})` + ) + return + } + + // 6. Atomic apply via IPC. State-store re-checks watermark + range + // inside the SQLite transaction (defense in depth). Components + // are NOT persisted — they stay on the wire for the consistency + // assert + log line, but satmachineadmin is the canonical audit + // substrate for the super/operator split. + const result = await api.applyFeeConfig( + { + cashInFeeFraction: parsed.cashInFeeFraction, + cashOutFeeFraction: parsed.cashOutFeeFraction, + schemaVersion: parsed.schemaVersion, + }, + event.created_at + ) + if (!result.applied) { + console.warn('[Fees] Apply rejected:', result.reason) + return + } + + // 7. Push the new totals to the renderer store. XState's context- + // snapshot boundary defers the change to the next transaction + // (see file header). + cfg.onApply({ + cashInFeeFraction: parsed.cashInFeeFraction, + cashOutFeeFraction: parsed.cashOutFeeFraction, + }) + + // The breakdown stays in this log line — journalctl is the forensic + // substrate for "what super/operator split was active at time T?" + // when satmachineadmin is unreachable. + console.log( + `[Fees] applied cash_in=${parsed.cashInFeeFraction} ` + + `(super=${parsed.superCashInFraction} operator=${parsed.operatorCashInFraction}) ` + + `cash_out=${parsed.cashOutFeeFraction} ` + + `(super=${parsed.superCashOutFraction} operator=${parsed.operatorCashOutFraction}) ` + + `schema=${parsed.schemaVersion} event_created_at=${event.created_at}` + ) +} + +/** + * Parse a v1 fee payload from an already-decoded JSON object. Tolerates + * extra unknown top-level keys (v2 forward-compat). Throws when the v1 + * required fields are missing or shape-mismatched — caller logs + drops. + * + * `components` is consumer-optional per the wire-format spec — absence + * fills zeros (no audit data available for transactions under that + * fee config). When present, it must be a four-key sub-object with all + * fractions being finite non-negative numbers. + */ +function parseV1Payload(raw: Record): ParsedFeePayload { + if (!raw || typeof raw !== 'object') { + throw new Error('payload not an object') + } + const cashIn = raw['cash_in_fee_fraction'] + const cashOut = raw['cash_out_fee_fraction'] + if (typeof cashIn !== 'number' || !Number.isFinite(cashIn)) { + throw new Error(`cash_in_fee_fraction missing or not a number: ${String(cashIn)}`) + } + if (typeof cashOut !== 'number' || !Number.isFinite(cashOut)) { + throw new Error(`cash_out_fee_fraction missing or not a number: ${String(cashOut)}`) + } + // Absent schema_version field is treated as v1 (per the contract + // direction from coordination log §`07:00Z` — cassette config shipped + // without one and is the principled default). + const schemaRaw = raw['schema_version'] + const schemaVersion = + schemaRaw === undefined ? 1 : Number.isInteger(schemaRaw) ? (schemaRaw as number) : NaN + if (!Number.isInteger(schemaVersion) || schemaVersion < 1) { + throw new Error(`schema_version invalid: ${String(schemaRaw)}`) + } + + let superCashIn = 0 + let superCashOut = 0 + let operatorCashIn = 0 + let operatorCashOut = 0 + const componentsRaw = raw['components'] + if (componentsRaw === undefined) { + // Producer is v1-mandatory on `components` (locked in coord log §`14:25Z`). + // Falling through to zeros gracefully, but log WARN — this only fires on + // a hand-edited debug payload or a future producer impl with a bug. + console.warn( + '[Fees] payload missing `components` — applying totals with zero breakdown ' + + '(producer should always emit components in v1; check publisher)' + ) + } else { + if (!componentsRaw || typeof componentsRaw !== 'object') { + throw new Error(`components present but not an object: ${String(componentsRaw)}`) + } + const components = componentsRaw as Record + const pull = (key: string): number => { + const v = components[key] + if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) { + throw new Error(`components.${key} missing or not a non-negative number: ${String(v)}`) + } + return v + } + superCashIn = pull('super_cash_in') + superCashOut = pull('super_cash_out') + operatorCashIn = pull('operator_cash_in') + operatorCashOut = pull('operator_cash_out') + + // Consistency assert (coord log §`07:33Z` + §`14:25Z`): components MUST sum + // to the published totals within 1e-6. On drift: WARN + apply anyway, sums + // are authoritative. This catches producer rounding bugs / off-by-one + // composition / hand-edited debug payloads without bricking the consumer. + const sumIn = superCashIn + operatorCashIn + const sumOut = superCashOut + operatorCashOut + if (Math.abs(cashIn - sumIn) > 1e-6) { + console.warn( + `[Fees] payload internally inconsistent (producer bug): ` + + `cash_in_fee_fraction=${cashIn} != super_cash_in+operator_cash_in=${sumIn}; ` + + `applying total ${cashIn} (sum is authoritative)` + ) + } + if (Math.abs(cashOut - sumOut) > 1e-6) { + console.warn( + `[Fees] payload internally inconsistent (producer bug): ` + + `cash_out_fee_fraction=${cashOut} != super_cash_out+operator_cash_out=${sumOut}; ` + + `applying total ${cashOut} (sum is authoritative)` + ) + } + } + + return { + cashInFeeFraction: cashIn, + cashOutFeeFraction: cashOut, + superCashInFraction: superCashIn, + superCashOutFraction: superCashOut, + operatorCashInFraction: operatorCashIn, + operatorCashOutFraction: operatorCashOut, + schemaVersion, + } +} + +/** Exported for tests. Internal use only. */ +export const __testing = { parseV1Payload, FEE_CAP_PER_DIRECTION } diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index e1f756b..126ddd8 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -14,6 +14,10 @@ import { startOperatorConfigService, type OperatorConfigService, } from '@/services/operator-config' +import { + startOperatorFeesService, + type OperatorFeesService, +} from '@/services/operator-fees' import type { HalConfig, HalServices } from '@/services/hal' import type { MachineModel } from '@/config' import type { LightningBackend } from '@/services/lightning' @@ -287,8 +291,17 @@ export const useAtmStore = defineStore('atm', () => { const allowMockFallback = ref(true) // default true for browser dev const initError = ref(null) // fatal error → maintenance screen const fiatCode = ref('USD') - const cashInFeeFraction = ref(0.0333) - const cashOutFeeFraction = ref(0.0777) + // Defaults are 0 — the operator's fee config (received via Nostr + // kind-30078 `bitspire-fees:` envelope from satmachineadmin) + // is the source of truth. If no config is received and none persisted, + // the ATM refuses to operate (maintenance screen, see + // initializeForProduction below). See aiolabs/lamassu-next#57. + // The super/operator breakdown stays on the wire (consistency-asserted + // + log-line by the parser) but is NOT persisted on the ATM side — + // satmachineadmin is the canonical audit substrate per coord log + // 2026-06-01T07:56Z (dumb-machine / smart-server split). + const cashInFeeFraction = ref(0) + const cashOutFeeFraction = ref(0) const machineModel = ref('atm') const useLiveServices = ref(false) const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>( @@ -316,6 +329,20 @@ export const useAtmStore = defineStore('atm', () => { let lnurlCleanupFn: (() => void) | null = null // Operator-config consumer (aiolabs/lamassu-next#56) — set after Lightning init let operatorConfigSvc: OperatorConfigService | null = null + // Operator-fees consumer (aiolabs/lamassu-next#57) — set after Lightning init + let operatorFeesSvc: OperatorFeesService | null = null + + /** + * Push a freshly-applied fee config from the operator-fees consumer + * into the renderer's reactive refs. XState's per-flow context-snapshot + * boundary defers the effective change to the next transaction (the + * in-flight flow keeps using its captured fractions). See operator- + * fees.ts file header for the full reasoning. + */ + function applyFeeConfig(fees: { cashInFeeFraction: number; cashOutFeeFraction: number }) { + cashInFeeFraction.value = fees.cashInFeeFraction + cashOutFeeFraction.value = fees.cashOutFeeFraction + } /** * Persisted inventory loaded from SQLite — the source of truth for @@ -633,6 +660,16 @@ export const useAtmStore = defineStore('atm', () => { identity: services.identity, operatorPubkeys: services.operatorPubkeys, }) + + // Start operator-fees consumer (aiolabs/lamassu-next#57) — subscribes + // to kind-30078 fee config events under bitspire-fees: + operatorFeesSvc?.stop() + operatorFeesSvc = await startOperatorFeesService({ + nostrClient: services.nostrClient, + identity: services.identity, + operatorPubkeys: services.operatorPubkeys, + onApply: applyFeeConfig, + }) } catch (error) { console.error('[ATM] Failed to connect to Lightning.Pub:', error) connectionStatus.value = 'error' @@ -936,6 +973,15 @@ export const useAtmStore = defineStore('atm', () => { operatorPubkeys: lightning.operatorPubkeys, }) + // Operator-fees consumer (aiolabs/lamassu-next#57) + operatorFeesSvc?.stop() + operatorFeesSvc = await startOperatorFeesService({ + nostrClient: lightning.nostrClient, + identity: lightning.identity, + operatorPubkeys: lightning.operatorPubkeys, + onApply: applyFeeConfig, + }) + console.log('[ATM] Fully initialized with HAL + Lightning') } catch (error) { console.error('[ATM] HAL initialization failed:', error) @@ -977,9 +1023,29 @@ export const useAtmStore = defineStore('atm', () => { machineModel.value = model const runtimeFiatCode = runtimeConfig.fiatCode || 'USD' fiatCode.value = runtimeFiatCode - if (runtimeConfig.cashInFeeFraction !== undefined) cashInFeeFraction.value = runtimeConfig.cashInFeeFraction - if (runtimeConfig.cashOutFeeFraction !== undefined) - cashOutFeeFraction.value = runtimeConfig.cashOutFeeFraction + + // Load persisted operator fee config (aiolabs/lamassu-next#57). If no + // config has ever been applied (fresh ATM, pre-operator-publish), + // fail-closed into maintenance screen. The operator-fees subscriber + // started below will unblock this once it receives a valid event from + // the operator's satmachineadmin (publish triggered by machine create + // / update / super-config change per aiolabs/satmachineadmin#39). + const persistedFees = await api.getFeeConfig() + if (persistedFees === null) { + initError.value = 'awaiting-fees' + console.warn( + '[ATM] No persisted fee config and no inbound event yet — entering maintenance state. ' + + 'Operator must publish initial fee config via satmachineadmin.' + ) + return + } + cashInFeeFraction.value = persistedFees.cashInFeeFraction + cashOutFeeFraction.value = persistedFees.cashOutFeeFraction + console.log( + `[ATM] Restored fee config from state.db: ` + + `cash_in=${persistedFees.cashInFeeFraction} cash_out=${persistedFees.cashOutFeeFraction} ` + + `(event_created_at=${persistedFees.eventCreatedAt})` + ) // Build device config from runtime values const { getDeviceConfig, toHalConfig, MACHINE_PRESETS } = await import('@/config') @@ -1205,6 +1271,15 @@ export const useAtmStore = defineStore('atm', () => { operatorPubkeys: lightning.operatorPubkeys, }) + // Operator-fees consumer (aiolabs/lamassu-next#57) + operatorFeesSvc?.stop() + operatorFeesSvc = await startOperatorFeesService({ + nostrClient: lightning.nostrClient, + identity: lightning.identity, + operatorPubkeys: lightning.operatorPubkeys, + onApply: applyFeeConfig, + }) + console.log('[ATM] Fully initialized with HAL (IPC) + Lightning') } catch (error) { console.error('[ATM] HAL initialization failed:', error) diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 48aaef4..9fc11a3 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -21,8 +21,6 @@ export interface RuntimeConfig { allowMockFallback: boolean operatorPubkeys: string maintenanceMode: boolean - cashInFeeFraction: number - cashOutFeeFraction: number /** Operator branding override loaded from /var/lib/bitspire/branding/. Null when no override. */ branding: BrandingConfig | null } @@ -93,6 +91,22 @@ declare global { payload: { positions: Record }, eventCreatedAt: number ) => Promise<{ applied: true } | { applied: false; reason: string }> + getFeeConfig: () => Promise<{ + cashInFeeFraction: number + cashOutFeeFraction: number + schemaVersion: number + eventCreatedAt: number + appliedAt: number + } | null> + getLastKnownFeeConfigCreatedAt: () => Promise + applyFeeConfig: ( + payload: { + cashInFeeFraction: number + cashOutFeeFraction: number + schemaVersion: number + }, + eventCreatedAt: number + ) => Promise<{ applied: true } | { applied: false; reason: string }> getSupportPages: () => Promise<{ id: string; title: string; content: string }[]> // HAL hardware IPC halInit: (config: any) => Promise<{ success: boolean; error?: string }>