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

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