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,
|
||||
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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue