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:
parent
6e271d8ce4
commit
20b146363f
7 changed files with 710 additions and 20 deletions
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue