refactor(machine): canonical sat-amount vocabulary + fix 100× fee bug

Aligns lamassu-next with the canonical sat-amount vocabulary agreed
across lnbits/bitspire/satmachineadmin (satmachineadmin@d717a6e,
coordination log 2026-05-26T17:10Z):

- `feePercent` / `cashInFeePercent` / `cashOutFeePercent`
  → `feeFraction` / `cashInFeeFraction` / `cashOutFeeFraction`
  (canonical: unit fraction in [0, 1], NEVER a percentage)
- `cashInFeeRate` / `cashOutFeeRate` (config option names)
  → `cashInFeeFraction` / `cashOutFeeFraction`
- `fee_percent` (wire field on Payment.extra + state.db column)
  → `fee_fraction`

Bug fix bundled with the rename:
`lightning.ts:780` previously stamped `Payment.extra.fee_percent =
context.feePercent * 100` (0.05 → 5.0). state.db stored the unit
fraction (0.05) but Payment.extra carried the percent (5.0) — 100×
divergence that any consumer reading Payment.extra computed fees
wrong by exactly 100×. Now stamps `fee_fraction` directly as unit
fraction. Display layers (atm-tui, view components) multiply by 100
themselves.

Defensive invariants added:
- `computeFeeSats` (atm store) throws if `feeFraction` outside [0, 1]
  or if cash-in `feeSats > principalSats` (would mean negative payout)
- `recordTransaction` (state-store) throws on the same range
- state-machine + electron + Vue views propagate the rename

state.db migration v6 → v7: `ALTER TABLE transactions RENAME COLUMN
fee_percent TO fee_fraction`. Historical migrations preserved
verbatim (they wrote `fee_percent`, future installs see the same
sequence followed by the v7 rename).

12/12 typecheck + 18/18 state-machine tests green. Coordinated with
~/dev/bitspire/atm-tui (separate commit) reading `fee_fraction`
from the new column.

refs: log:2026-05-26T17:10Z, log:2026-05-26T18:50Z,
satmachineadmin@d717a6e

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-27 21:53:53 +02:00
commit 6a627e5b4a
12 changed files with 92 additions and 56 deletions

View file

@ -20,7 +20,7 @@ import {
*/
export function createATMMachine(
services: Partial<ATMServices> = {},
options?: { currency?: string; cashInFeeRate?: number; cashOutFeeRate?: number }
options?: { currency?: string; cashInFeeFraction?: number; cashOutFeeFraction?: number }
) {
return setup({
types: {
@ -159,8 +159,8 @@ export function createATMMachine(
...initialContext,
currency: context.currency,
inventory: context.inventory,
cashInFeePercent: context.cashInFeePercent,
cashOutFeePercent: context.cashOutFeePercent,
cashInFeeFraction: context.cashInFeeFraction,
cashOutFeeFraction: context.cashOutFeeFraction,
cashInSessionId: null,
dispenseResult: null,
})),
@ -170,10 +170,10 @@ export function createATMMachine(
cashInSessionId: () => generateSessionId(),
}),
setCashInFee: assign({
feePercent: ({ context }) => context.cashInFeePercent,
feeFraction: ({ context }) => context.cashInFeeFraction,
}),
setCashOutFee: assign({
feePercent: ({ context }) => context.cashOutFeePercent,
feeFraction: ({ context }) => context.cashOutFeeFraction,
}),
addBill: assign({
billsInserted: ({ context, event }) => {
@ -190,7 +190,7 @@ export function createATMMachine(
if (context.exchangeRate === 0) return 0
const fiatDollars = context.fiatCents / 100
const principalSats = Math.floor(fiatDollars * context.exchangeRate)
const fee = Math.floor(principalSats * context.feePercent)
const fee = Math.floor(principalSats * context.feeFraction)
return principalSats - fee
},
}),
@ -326,7 +326,7 @@ export function createATMMachine(
const fiatDollars = context.fiatCents / 100
const principalSats = Math.floor(fiatDollars * context.exchangeRate)
// For cash-out, user pays the sats, so fee is added
const fee = Math.floor(principalSats * context.feePercent)
const fee = Math.floor(principalSats * context.feeFraction)
return principalSats + fee
},
}),
@ -373,7 +373,7 @@ export function createATMMachine(
if (context.availableBalance <= 0 || context.exchangeRate === 0) return false
const newFiatCents = context.fiatCents + event.denomination * 100
const principalSats = Math.floor((newFiatCents / 100) * context.exchangeRate)
const fee = Math.floor(principalSats * context.feePercent)
const fee = Math.floor(principalSats * context.feeFraction)
return principalSats - fee <= context.availableBalance
},
// Cash-out guards
@ -404,9 +404,9 @@ export function createATMMachine(
context: {
...initialContext,
...(options?.currency ? { currency: options.currency } : {}),
...(options?.cashInFeeRate !== undefined ? { cashInFeePercent: options.cashInFeeRate } : {}),
...(options?.cashOutFeeRate !== undefined
? { cashOutFeePercent: options.cashOutFeeRate }
...(options?.cashInFeeFraction !== undefined ? { cashInFeeFraction: options.cashInFeeFraction } : {}),
...(options?.cashOutFeeFraction !== undefined
? { cashOutFeeFraction: options.cashOutFeeFraction }
: {}),
},
states: {

View file

@ -59,12 +59,12 @@ export interface ATMContext {
currency: string
/** Exchange rate (sats per fiat unit) */
exchangeRate: number
/** Fee percentage (0.02 = 2%) — used at runtime for the active flow */
feePercent: number
/** Cash-in commission (0.02 = 2%) */
cashInFeePercent: number
/** Cash-out commission (0.02 = 2%) */
cashOutFeePercent: number
/** Fee as unit fraction in [0, 1] — 0.02 means 2%. Active flow's rate. */
feeFraction: number
/** Cash-in commission as unit fraction (0.02 = 2%) */
cashInFeeFraction: number
/** Cash-out commission as unit fraction (0.02 = 2%) */
cashOutFeeFraction: number
/** ATM's available balance in sats (for limiting cash-in transactions) */
availableBalance: number
@ -163,9 +163,9 @@ export const initialContext: ATMContext = {
satsAmount: 0,
currency: 'USD',
exchangeRate: 0,
feePercent: 0.0333,
cashInFeePercent: 0.0333,
cashOutFeePercent: 0.0777,
feeFraction: 0.0333,
cashInFeeFraction: 0.0333,
cashOutFeeFraction: 0.0777,
availableBalance: 0,
invoice: null,
clinkOffer: null,