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
|
|
@ -27,7 +27,12 @@ import {
|
||||||
getBootstrapPublishedAt,
|
getBootstrapPublishedAt,
|
||||||
markBootstrapPublished,
|
markBootstrapPublished,
|
||||||
applyOperatorCassettesConfig,
|
applyOperatorCassettesConfig,
|
||||||
|
getFeeConfig,
|
||||||
|
getLastKnownFeeConfigCreatedAt,
|
||||||
|
applyFeeConfig,
|
||||||
type OperatorCassettesPayload,
|
type OperatorCassettesPayload,
|
||||||
|
type FeeConfigPayload,
|
||||||
|
type FeeConfigRow,
|
||||||
type ApplyResult,
|
type ApplyResult,
|
||||||
} from './state-store.js'
|
} from './state-store.js'
|
||||||
import { initializeHal, type HalInstance } from './hal-service.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 __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
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)
|
// Load .env file manually (Electron main process doesn't have Vite's env loading)
|
||||||
function loadEnvFile() {
|
function loadEnvFile() {
|
||||||
const envPath = path.join(__dirname, '..', '.env')
|
const envPath = path.join(__dirname, '..', '.env')
|
||||||
|
|
@ -303,9 +302,10 @@ ipcMain.handle('get-config', () => {
|
||||||
// Maintenance mode — show "out of service" screen
|
// Maintenance mode — show "out of service" screen
|
||||||
maintenanceMode: process.env.VITE_MAINTENANCE_MODE === 'true',
|
maintenanceMode: process.env.VITE_MAINTENANCE_MODE === 'true',
|
||||||
|
|
||||||
// Fee rates — accepts percentage (5.55) or decimal (0.0555), auto-detected
|
// Fee rates — operator-pushed via Nostr (kind-30078 `bitspire-fees:<atm_pubkey>`)
|
||||||
cashInFeeFraction: parseFee(process.env.VITE_CASH_IN_FEE || '0.0333'),
|
// from satmachineadmin; see aiolabs/lamassu-next#57. No env-var fallback —
|
||||||
cashOutFeeFraction: parseFee(process.env.VITE_CASH_OUT_FEE || '0.0777'),
|
// 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
|
// Operator branding (logo/title/theme) — null when no override
|
||||||
branding: loadBranding(),
|
branding: loadBranding(),
|
||||||
|
|
@ -367,6 +367,20 @@ ipcMain.handle(
|
||||||
applyOperatorCassettesConfig(payload, eventCreatedAt)
|
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/
|
// Support pages — read .md files from /var/lib/bitspire/support/
|
||||||
ipcMain.handle('support:get-pages', () => {
|
ipcMain.handle('support:get-pages', () => {
|
||||||
const supportDir = path.join(
|
const supportDir = path.join(
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,26 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
): Promise<{ applied: true } | { applied: false; reason: string }> =>
|
): Promise<{ applied: true } | { applied: false; reason: string }> =>
|
||||||
ipcRenderer.invoke('state:apply-operator-cassettes-config', payload, eventCreatedAt),
|
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
|
// Support pages
|
||||||
getSupportPages: (): Promise<{ id: string; title: string; content: string }[]> =>
|
getSupportPages: (): Promise<{ id: string; title: string; content: string }[]> =>
|
||||||
ipcRenderer.invoke('support:get-pages'),
|
ipcRenderer.invoke('support:get-pages'),
|
||||||
|
|
@ -198,6 +218,22 @@ declare global {
|
||||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||||
eventCreatedAt: number
|
eventCreatedAt: number
|
||||||
) => Promise<{ applied: true } | { applied: false; reason: string }>
|
) => 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 }[]>
|
getSupportPages: () => Promise<{ id: string; title: string; content: string }[]>
|
||||||
// HAL hardware IPC
|
// HAL hardware IPC
|
||||||
halInit: (config: any) => Promise<{ success: boolean; error?: string }>
|
halInit: (config: any) => Promise<{ success: boolean; error?: string }>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import fs from 'node:fs'
|
||||||
|
|
||||||
let db: Database.Database | null = null
|
let db: Database.Database | null = null
|
||||||
|
|
||||||
const SCHEMA_VERSION = '9'
|
const SCHEMA_VERSION = '10'
|
||||||
|
|
||||||
function getDbPath(): string {
|
function getDbPath(): string {
|
||||||
const prodDir = '/var/lib/bitspire'
|
const prodDir = '/var/lib/bitspire'
|
||||||
|
|
@ -105,6 +105,15 @@ export function initDatabase(dbPath?: string): void {
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
completed_at INTEGER
|
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
|
// 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.pragma('foreign_keys = ON')
|
||||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('9', 'schema_version')
|
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)')
|
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.
|
// 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 (?, ?)')
|
const seedMeta = db.prepare('INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)')
|
||||||
seedMeta.run('lastKnownConfigCreatedAt', '0')
|
seedMeta.run('lastKnownConfigCreatedAt', '0')
|
||||||
seedMeta.run('bootstrapPublishedAt', '')
|
seedMeta.run('bootstrapPublishedAt', '')
|
||||||
|
seedMeta.run('lastKnownFeeConfigCreatedAt', '0')
|
||||||
|
|
||||||
const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get()
|
const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get()
|
||||||
if (!cashboxRow) {
|
if (!cashboxRow) {
|
||||||
|
|
@ -440,6 +485,146 @@ export function applyOperatorCassettesConfig(
|
||||||
return { applied: true }
|
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
|
// Cassettes
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -142,17 +142,29 @@ function toggleLiveServices() {
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
</svg>
|
</svg>
|
||||||
<h1 class="text-2xl lg:text-[3.5rem] font-bold">
|
<h1 class="text-2xl lg:text-[3.5rem] font-bold">
|
||||||
{{ atmStore.initError === 'maintenance' ? 'Under Service' : 'ATM Unavailable' }}
|
{{
|
||||||
|
atmStore.initError === 'maintenance'
|
||||||
|
? 'Under Service'
|
||||||
|
: atmStore.initError === 'awaiting-fees'
|
||||||
|
? 'Awaiting Configuration'
|
||||||
|
: 'ATM Unavailable'
|
||||||
|
}}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="max-w-md text-center text-base lg:text-2xl text-muted-foreground">
|
<p class="max-w-md text-center text-base lg:text-2xl text-muted-foreground">
|
||||||
{{
|
{{
|
||||||
atmStore.initError === 'maintenance'
|
atmStore.initError === 'maintenance'
|
||||||
? 'This machine is currently being serviced. We will be back shortly.'
|
? 'This machine is currently being serviced. We will be back shortly.'
|
||||||
|
: 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.'
|
: 'This machine is temporarily out of service. Please try again later or use another machine.'
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
v-if="atmStore.debugMode && atmStore.initError !== 'maintenance'"
|
v-if="
|
||||||
|
atmStore.debugMode &&
|
||||||
|
atmStore.initError !== 'maintenance' &&
|
||||||
|
atmStore.initError !== 'awaiting-fees'
|
||||||
|
"
|
||||||
class="max-w-lg text-center font-mono text-sm text-destructive"
|
class="max-w-lg text-center font-mono text-sm text-destructive"
|
||||||
>
|
>
|
||||||
{{ atmStore.initError }}
|
{{ atmStore.initError }}
|
||||||
|
|
|
||||||
354
apps/machine/src/services/operator-fees.ts
Normal file
354
apps/machine/src/services/operator-fees.ts
Normal file
|
|
@ -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:<atm_pubkey_hex>"]`,
|
||||||
|
* `["p", <atm_npub>]`, 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<OperatorFeesService> {
|
||||||
|
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<typeof window.electronAPI>
|
||||||
|
): Promise<void> {
|
||||||
|
// 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<string, unknown>
|
||||||
|
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<string, unknown>): 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<string, unknown>
|
||||||
|
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 }
|
||||||
|
|
@ -14,6 +14,10 @@ import {
|
||||||
startOperatorConfigService,
|
startOperatorConfigService,
|
||||||
type OperatorConfigService,
|
type OperatorConfigService,
|
||||||
} from '@/services/operator-config'
|
} from '@/services/operator-config'
|
||||||
|
import {
|
||||||
|
startOperatorFeesService,
|
||||||
|
type OperatorFeesService,
|
||||||
|
} from '@/services/operator-fees'
|
||||||
import type { HalConfig, HalServices } from '@/services/hal'
|
import type { HalConfig, HalServices } from '@/services/hal'
|
||||||
import type { MachineModel } from '@/config'
|
import type { MachineModel } from '@/config'
|
||||||
import type { LightningBackend } from '@/services/lightning'
|
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 allowMockFallback = ref(true) // default true for browser dev
|
||||||
const initError = ref<string | null>(null) // fatal error → maintenance screen
|
const initError = ref<string | null>(null) // fatal error → maintenance screen
|
||||||
const fiatCode = ref('USD')
|
const fiatCode = ref('USD')
|
||||||
const cashInFeeFraction = ref(0.0333)
|
// Defaults are 0 — the operator's fee config (received via Nostr
|
||||||
const cashOutFeeFraction = ref(0.0777)
|
// kind-30078 `bitspire-fees:<atm_pubkey>` 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 machineModel = ref('atm')
|
||||||
const useLiveServices = ref(false)
|
const useLiveServices = ref(false)
|
||||||
const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>(
|
const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>(
|
||||||
|
|
@ -316,6 +329,20 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
let lnurlCleanupFn: (() => void) | null = null
|
let lnurlCleanupFn: (() => void) | null = null
|
||||||
// Operator-config consumer (aiolabs/lamassu-next#56) — set after Lightning init
|
// Operator-config consumer (aiolabs/lamassu-next#56) — set after Lightning init
|
||||||
let operatorConfigSvc: OperatorConfigService | null = null
|
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
|
* Persisted inventory loaded from SQLite — the source of truth for
|
||||||
|
|
@ -633,6 +660,16 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
identity: services.identity,
|
identity: services.identity,
|
||||||
operatorPubkeys: services.operatorPubkeys,
|
operatorPubkeys: services.operatorPubkeys,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Start operator-fees consumer (aiolabs/lamassu-next#57) — subscribes
|
||||||
|
// to kind-30078 fee config events under bitspire-fees:<atm_pubkey>
|
||||||
|
operatorFeesSvc?.stop()
|
||||||
|
operatorFeesSvc = await startOperatorFeesService({
|
||||||
|
nostrClient: services.nostrClient,
|
||||||
|
identity: services.identity,
|
||||||
|
operatorPubkeys: services.operatorPubkeys,
|
||||||
|
onApply: applyFeeConfig,
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ATM] Failed to connect to Lightning.Pub:', error)
|
console.error('[ATM] Failed to connect to Lightning.Pub:', error)
|
||||||
connectionStatus.value = 'error'
|
connectionStatus.value = 'error'
|
||||||
|
|
@ -936,6 +973,15 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
operatorPubkeys: lightning.operatorPubkeys,
|
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')
|
console.log('[ATM] Fully initialized with HAL + Lightning')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ATM] HAL initialization failed:', error)
|
console.error('[ATM] HAL initialization failed:', error)
|
||||||
|
|
@ -977,9 +1023,29 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
machineModel.value = model
|
machineModel.value = model
|
||||||
const runtimeFiatCode = runtimeConfig.fiatCode || 'USD'
|
const runtimeFiatCode = runtimeConfig.fiatCode || 'USD'
|
||||||
fiatCode.value = runtimeFiatCode
|
fiatCode.value = runtimeFiatCode
|
||||||
if (runtimeConfig.cashInFeeFraction !== undefined) cashInFeeFraction.value = runtimeConfig.cashInFeeFraction
|
|
||||||
if (runtimeConfig.cashOutFeeFraction !== undefined)
|
// Load persisted operator fee config (aiolabs/lamassu-next#57). If no
|
||||||
cashOutFeeFraction.value = runtimeConfig.cashOutFeeFraction
|
// 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
|
// Build device config from runtime values
|
||||||
const { getDeviceConfig, toHalConfig, MACHINE_PRESETS } = await import('@/config')
|
const { getDeviceConfig, toHalConfig, MACHINE_PRESETS } = await import('@/config')
|
||||||
|
|
@ -1205,6 +1271,15 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
operatorPubkeys: lightning.operatorPubkeys,
|
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')
|
console.log('[ATM] Fully initialized with HAL (IPC) + Lightning')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ATM] HAL initialization failed:', error)
|
console.error('[ATM] HAL initialization failed:', error)
|
||||||
|
|
|
||||||
18
apps/machine/src/types/electron.d.ts
vendored
18
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -21,8 +21,6 @@ export interface RuntimeConfig {
|
||||||
allowMockFallback: boolean
|
allowMockFallback: boolean
|
||||||
operatorPubkeys: string
|
operatorPubkeys: string
|
||||||
maintenanceMode: boolean
|
maintenanceMode: boolean
|
||||||
cashInFeeFraction: number
|
|
||||||
cashOutFeeFraction: number
|
|
||||||
/** Operator branding override loaded from /var/lib/bitspire/branding/. Null when no override. */
|
/** Operator branding override loaded from /var/lib/bitspire/branding/. Null when no override. */
|
||||||
branding: BrandingConfig | null
|
branding: BrandingConfig | null
|
||||||
}
|
}
|
||||||
|
|
@ -93,6 +91,22 @@ declare global {
|
||||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||||
eventCreatedAt: number
|
eventCreatedAt: number
|
||||||
) => Promise<{ applied: true } | { applied: false; reason: string }>
|
) => 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 }[]>
|
getSupportPages: () => Promise<{ id: string; title: string; content: string }[]>
|
||||||
// HAL hardware IPC
|
// HAL hardware IPC
|
||||||
halInit: (config: any) => Promise<{ success: boolean; error?: string }>
|
halInit: (config: any) => Promise<{ success: boolean; error?: string }>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue