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(