feat(machine): consume operator fee config over kind-30078 (#57)

Layer 3 of the operator-configurable fee architecture (parent
aiolabs/satmachineadmin#37). Replaces the hardcoded
`ref(0.0333)` / `ref(0.0777)` constants in `atm.ts` with a Nostr-
delivered, operator-pushed fee config sourced from satmachineadmin.

Wire envelope (locked with sat-side at #39 + coord log 2026-06-01):

  kind=30078 (NIP-78 replaceable), NIP-44 v2 encrypted
  d-tag: bitspire-fees:<atm_pubkey_hex>
  ["p", atm_pubkey], signed by operator account
  watermark: event.created_at (no envelope-level published_at)

  Plaintext:
    { schema_version: 1,
      cash_in_fee_fraction: …,    sum ≤ 0.15
      cash_out_fee_fraction: …,   sum ≤ 0.15
      components: { super_cash_in, super_cash_out,
                    operator_cash_in, operator_cash_out } }

Consumer-side invariants:
- Signature + author whitelist + watermark + clock-skew gates
- 15% per-direction hardcoded cap (defense in depth with sat's
  producer-side refuse-to-publish at the same threshold)
- Consistency assert when `components` present: sum of super+operator
  must equal each total within 1e-6; drift logs WARN + still applies
  (totals are authoritative — see coord log §`07:33Z` and §`14:25Z`)
- Unknown top-level keys silently ignored (v2 forward-compat for
  future promo additions); absent `schema_version` treated as v1
- Apply-mid-transaction defers to next tx by XState's context-snapshot
  boundary; no explicit timer/lock code needed

Persistence (state.db schema v9→v10):
- New `fee_config` singleton row (id=1) with the totals, schema_version,
  event_created_at watermark, and applied_at audit timestamp.
- New `meta.lastKnownFeeConfigCreatedAt` row — independent from the
  cassette watermark per the d-tag-per-lifecycle convention.
- Super/operator components are NOT persisted on the ATM —
  satmachineadmin is the canonical audit substrate per Layer 1 #38
  (dumb-machine / smart-server split, see coord log §`07:56Z`). The
  breakdown survives in the parser's receipt log line in journalctl
  for offline forensics.

Fail-closed posture:
- First boot with no persisted config + no inbound event →
  `initError = 'awaiting-fees'` → maintenance screen ("Awaiting fee
  configuration from operator. Contact operator to publish initial
  fee config."). Matches path-B `roster_required` posture.
- Persisted config present + relay unreachable → ATM operates with
  the persisted values; subscriber catches up when relay returns.

Env-var fallback dropped:
- `VITE_CASH_IN_FEE` / `VITE_CASH_OUT_FEE` no longer read by the
  Electron main process. Operator-config-over-Nostr is the single
  source of truth — removes the env-vs-Nostr ambiguity surface.
- `parseFee` helper deleted (was its only caller).

Subscriber wired into all three init paths (Lightning-only,
direct-HAL, HAL-via-IPC) alongside the existing cassette-config
subscriber from #56. `onApply` callback receives just the totals
(components stay parser-side per the architectural split above).

IPC surface:
- state:get-fee-config → persisted singleton or null
- state:get-last-known-fee-config-created-at → watermark
- state:apply-fee-config → atomic upsert + watermark advance

Closes aiolabs/lamassu-next#57.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-01 14:24:16 +02:00
commit 20b146363f
7 changed files with 710 additions and 20 deletions

View file

@ -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:<atm_pubkey>`)
// 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:<atm_pubkey>` 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(

View file

@ -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<number> =>
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<string, { denomination: number; count: number }> },
eventCreatedAt: number
) => Promise<{ applied: true } | { applied: false; reason: string }>
getFeeConfig: () => Promise<{
cashInFeeFraction: number
cashOutFeeFraction: number
schemaVersion: number
eventCreatedAt: number
appliedAt: number
} | null>
getLastKnownFeeConfigCreatedAt: () => Promise<number>
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 }>

View file

@ -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
// ---------------------------------------------------------------------------