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

@ -298,8 +298,8 @@ ipcMain.handle('get-config', () => {
maintenanceMode: process.env.VITE_MAINTENANCE_MODE === 'true',
// Fee rates — accepts percentage (5.55) or decimal (0.0555), auto-detected
cashInFeeRate: parseFee(process.env.VITE_CASH_IN_FEE || '0.0333'),
cashOutFeeRate: parseFee(process.env.VITE_CASH_OUT_FEE || '0.0777'),
cashInFeeFraction: parseFee(process.env.VITE_CASH_IN_FEE || '0.0333'),
cashOutFeeFraction: parseFee(process.env.VITE_CASH_OUT_FEE || '0.0777'),
// Operator branding (logo/title/theme) — null when no override
branding: loadBranding(),
@ -560,7 +560,7 @@ function startCommandPoller(): void {
fiatCents: totalFiatCents,
sats: 0,
feeSats: 0,
feePercent: 0,
feeFraction: 0,
exchangeRate: 0,
currency: fiatCode,
bills: parsed.bills,

View file

@ -77,7 +77,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
fiatCents: number
sats: number
feeSats: number
feePercent: number
feeFraction: number
exchangeRate: number
currency: string
bills: { denomination: number; count: number }[]
@ -156,7 +156,7 @@ declare global {
fiatCents: number
sats: number
feeSats: number
feePercent: number
feeFraction: number
exchangeRate: number
currency: string
bills: { denomination: number; count: number }[]

View file

@ -15,7 +15,7 @@ import fs from 'node:fs'
let db: Database.Database | null = null
const SCHEMA_VERSION = '6'
const SCHEMA_VERSION = '7'
function getDbPath(): string {
const prodDir = '/var/lib/bitspire'
@ -70,7 +70,7 @@ export function initDatabase(dbPath?: string): void {
fiat_cents INTEGER NOT NULL,
sats INTEGER NOT NULL,
fee_sats INTEGER NOT NULL DEFAULT 0,
fee_percent REAL NOT NULL DEFAULT 0,
fee_fraction REAL NOT NULL DEFAULT 0,
exchange_rate REAL NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'GTQ',
status TEXT NOT NULL DEFAULT 'complete',
@ -215,6 +215,17 @@ export function initDatabase(dbPath?: string): void {
}
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('6', 'schema_version')
console.log('[StateStore] Migrated schema v5 → v6 (added cassette position)')
existing.value = '6'
}
if (existing && existing.value === '6') {
// Migration v6 → v7: rename fee_percent → fee_fraction. Same semantics
// (unit fraction in [0, 1]); the rename closes the 100× misinterpretation
// risk between satmachineadmin's consumer and bitspire's stamp.
// Coordinated with satmachineadmin commit d717a6e (v2-bitspire branch).
db.exec(`ALTER TABLE transactions RENAME COLUMN fee_percent TO fee_fraction`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('7', 'schema_version')
console.log('[StateStore] Migrated schema v6 → v7 (renamed fee_percent → fee_fraction)')
}
const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get()
@ -352,7 +363,7 @@ interface TransactionInput {
fiatCents: number
sats: number
feeSats: number
feePercent: number
feeFraction: number
exchangeRate: number
currency: string
bills: { denomination: number; count: number }[]
@ -373,8 +384,15 @@ interface TransactionInput {
export function recordTransaction(tx: TransactionInput): void {
if (!db) throw new Error('Database not initialized')
if (tx.feeFraction < 0 || tx.feeFraction > 1) {
throw new Error(
`[StateStore] feeFraction out of range [0, 1]: ${tx.feeFraction}. ` +
`Unit fraction expected (0.05 = 5%), not a percentage.`
)
}
const insertTx = db.prepare(
'INSERT INTO transactions (txid, type, status, error, fiat_cents, sats, fee_sats, fee_percent, exchange_rate, currency, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO transactions (txid, type, status, error, fiat_cents, sats, fee_sats, fee_fraction, exchange_rate, currency, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
)
const insertBill = db.prepare(
'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)'
@ -398,7 +416,7 @@ export function recordTransaction(tx: TransactionInput): void {
t.fiatCents,
t.sats,
t.feeSats,
t.feePercent,
t.feeFraction,
t.exchangeRate,
t.currency,
Date.now()

View file

@ -759,7 +759,10 @@ function createATMServices(
* Field-name vocabulary follows the comment thread on #44:
* - `principal_sats` (was `net_sats`) — the LP/DCA share
* - `fee_sats` — the commission
* - `fee_percent` (was `fee_pct`) — for display + auditing
* - `fee_fraction` — unit fraction in [0, 1].
* NEVER a percentage. 0.05
* means 5%. Display layers
* multiply by 100 themselves.
* - `exchange_rate` — sats per 1 fiat unit
* (RAW market rate, no
* commission baked in)
@ -777,7 +780,6 @@ function createATMServices(
? Math.floor((context.fiatCents / 100) * context.exchangeRate)
: 0
const feeSats = Math.max(0, amountSats - principalSats)
const feePercent = +(context.feePercent * 100).toFixed(4) // 0.05 -> 5.0
console.log(
'[ATM Service] Generating invoice — gross',
amountSats,
@ -785,7 +787,7 @@ function createATMServices(
principalSats,
'+ fee',
feeSats,
`≈ ${feePercent}% @ ${context.exchangeRate} sats/${context.currency})`
`≈ ${(context.feeFraction * 100).toFixed(2)}% @ ${context.exchangeRate} sats/${context.currency})`
)
const payment = await lnbits.createInvoice(lnbitsWalletId, {
amount: amountSats,
@ -806,7 +808,7 @@ function createATMServices(
currency: context.currency,
principal_sats: principalSats,
fee_sats: feeSats,
fee_percent: feePercent,
fee_fraction: context.feeFraction,
exchange_rate: context.exchangeRate,
// bills/cassettes deferred — they're meaningful for cash-in
// and for partial-dispense reconciliation, neither of which

View file

@ -34,10 +34,26 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
* Cash-out: on-wire satsAmount = principalSats + feeSats (customer pays more than principal)
*/
function computeFeeSats(ctx: ATMContext, isCashIn: boolean): number {
// Defensive: feeFraction is unit fraction in [0, 1]. Anything outside means
// a config bug or 100× misinterpretation. Fail loudly rather than computing
// a silently-wrong fee from a percentage-shaped value.
if (ctx.feeFraction < 0 || ctx.feeFraction > 1) {
throw new Error(
`[ATM] feeFraction out of range [0, 1]: ${ctx.feeFraction}. ` +
`Unit fraction expected (0.05 = 5%), not a percentage.`
)
}
const principalSats = ctx.exchangeRate > 0 ? Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate) : 0
const feeSats = isCashIn
? principalSats - ctx.satsAmount // cash-in: customer receives less than principal
: ctx.satsAmount - principalSats // cash-out: customer pays more than principal
// Cash-in invariant: fee can't exceed principal (customer must receive positive sats).
if (isCashIn && feeSats > principalSats) {
throw new Error(
`[ATM] Cash-in feeSats (${feeSats}) > principalSats (${principalSats}) — ` +
`customer would receive negative sats. Check satsAmount derivation.`
)
}
return Math.max(0, feeSats)
}
@ -100,7 +116,7 @@ async function handleManagementCommand(
fiatCents: totalFiatCents,
sats: 0,
feeSats: 0,
feePercent: 0,
feeFraction: 0,
exchangeRate: 0,
currency,
bills: request.bills,
@ -267,8 +283,8 @@ export const useAtmStore = defineStore('atm', () => {
const allowMockFallback = ref(true) // default true for browser dev
const initError = ref<string | null>(null) // fatal error → maintenance screen
const fiatCode = ref('USD')
const cashInFeeRate = ref(0.0333)
const cashOutFeeRate = ref(0.0777)
const cashInFeeFraction = ref(0.0333)
const cashOutFeeFraction = ref(0.0777)
const machineModel = ref('atm')
const useLiveServices = ref(false)
const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>(
@ -370,8 +386,8 @@ export const useAtmStore = defineStore('atm', () => {
atmServicesRef = services
const machine = createATMMachine(services, {
currency: fiatCode.value,
cashInFeeRate: cashInFeeRate.value,
cashOutFeeRate: cashOutFeeRate.value,
cashInFeeFraction: cashInFeeFraction.value,
cashOutFeeFraction: cashOutFeeFraction.value,
})
actor.value = createActor(machine)
@ -439,7 +455,7 @@ export const useAtmStore = defineStore('atm', () => {
fiatCents: ctx.fiatCents,
sats: ctx.satsAmount,
feeSats: computeFeeSats(ctx, false),
feePercent: ctx.feePercent,
feeFraction: ctx.feeFraction,
exchangeRate: ctx.exchangeRate,
currency: ctx.currency,
bills,
@ -469,7 +485,7 @@ export const useAtmStore = defineStore('atm', () => {
fiatCents: ctx.fiatCents,
sats: ctx.satsAmount,
feeSats: computeFeeSats(ctx, isCashInTx),
feePercent: ctx.feePercent,
feeFraction: ctx.feeFraction,
exchangeRate: ctx.exchangeRate,
currency: ctx.currency,
bills,
@ -858,7 +874,7 @@ export const useAtmStore = defineStore('atm', () => {
const newFiatCents = ctx.fiatCents + denomination * 100
const newFiatUnits = newFiatCents / 100
const principalSats = Math.floor(newFiatUnits * ctx.exchangeRate)
const fee = Math.floor(principalSats * ctx.feePercent)
const fee = Math.floor(principalSats * ctx.feeFraction)
const newSatsAmount = principalSats - fee
// Check against available balance
@ -938,9 +954,9 @@ export const useAtmStore = defineStore('atm', () => {
machineModel.value = model
const runtimeFiatCode = runtimeConfig.fiatCode || 'USD'
fiatCode.value = runtimeFiatCode
if (runtimeConfig.cashInFeeRate !== undefined) cashInFeeRate.value = runtimeConfig.cashInFeeRate
if (runtimeConfig.cashOutFeeRate !== undefined)
cashOutFeeRate.value = runtimeConfig.cashOutFeeRate
if (runtimeConfig.cashInFeeFraction !== undefined) cashInFeeFraction.value = runtimeConfig.cashInFeeFraction
if (runtimeConfig.cashOutFeeFraction !== undefined)
cashOutFeeFraction.value = runtimeConfig.cashOutFeeFraction
// Build device config from runtime values
const { getDeviceConfig, toHalConfig, MACHINE_PRESETS } = await import('@/config')
@ -1114,7 +1130,7 @@ export const useAtmStore = defineStore('atm', () => {
const newFiatCents = ctx.fiatCents + denomination * 100
const newFiatUnits = newFiatCents / 100
const principalSats = Math.floor(newFiatUnits * ctx.exchangeRate)
const fee = Math.floor(principalSats * ctx.feePercent)
const fee = Math.floor(principalSats * ctx.feeFraction)
const newSatsAmount = principalSats - fee
if (newSatsAmount > ctx.availableBalance) {

View file

@ -21,8 +21,8 @@ export interface RuntimeConfig {
allowMockFallback: boolean
operatorPubkeys: string
maintenanceMode: boolean
cashInFeeRate: number
cashOutFeeRate: number
cashInFeeFraction: number
cashOutFeeFraction: number
/** Operator branding override loaded from /var/lib/bitspire/branding/. Null when no override. */
branding: BrandingConfig | null
}
@ -70,7 +70,7 @@ declare global {
fiatCents: number
sats: number
feeSats: number
feePercent: number
feeFraction: number
exchangeRate: number
currency: string
bills: { denomination: number; count: number }[]

View file

@ -20,7 +20,7 @@ export interface TransactionRecord {
fiatCents: number
sats: number
feeSats: number
feePercent: number
feeFraction: number
exchangeRate: number
currency: string
bills: { denomination: number; count: number }[]

View file

@ -121,7 +121,7 @@ function canAcceptBill(denomination: number): boolean {
if (ctx.availableBalance <= 0 || ctx.exchangeRate === 0) return true
const newFiatCents = ctx.fiatCents + denomination * 100
const principalSats = Math.floor((newFiatCents / 100) * ctx.exchangeRate)
const fee = Math.floor(principalSats * ctx.feePercent)
const fee = Math.floor(principalSats * ctx.feeFraction)
return principalSats - fee <= ctx.availableBalance
}
@ -439,14 +439,14 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
</div>
<div class="flex justify-between text-base lg:text-2xl">
<span class="text-muted-foreground"
>Commission ({{ (context.feePercent * 100).toFixed(1) }}%)</span
>Commission ({{ (context.feeFraction * 100).toFixed(1) }}%)</span
>
<span
>{{
formatSats(
Math.floor(
Math.floor((context.fiatCents / 100) * context.exchangeRate) *
context.feePercent
context.feeFraction
)
)
}}

View file

@ -404,7 +404,7 @@ function formatFiat(cents: number): string {
</div>
<div class="flex justify-between text-base lg:text-2xl">
<span class="text-muted-foreground"
>Commission ({{ (context.feePercent * 100).toFixed(1) }}%)</span
>Commission ({{ (context.feeFraction * 100).toFixed(1) }}%)</span
>
<span
>{{

View file

@ -99,7 +99,7 @@ function handleCashOut() {
<span class="font-bold text-bitcoin"
>{{
(
(atmStore.context?.cashInFeePercent ?? initialContext.cashInFeePercent) * 100
(atmStore.context?.cashInFeeFraction ?? initialContext.cashInFeeFraction) * 100
).toFixed(2)
}}%</span
>
@ -109,7 +109,7 @@ function handleCashOut() {
<span class="font-bold text-success"
>{{
(
(atmStore.context?.cashOutFeePercent ?? initialContext.cashOutFeePercent) * 100
(atmStore.context?.cashOutFeeFraction ?? initialContext.cashOutFeeFraction) * 100
).toFixed(2)
}}%</span
>

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,