diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 70370c9..56b916c 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -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, diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index 600023a..068846c 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -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 }[] diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index 511c6d7..96186b8 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -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() diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index c4f4d51..982fd64 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -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 diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index f3b71b8..945bbb4 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -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(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) { diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 3be9e14..fec9664 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -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 }[] diff --git a/apps/machine/src/types/state.ts b/apps/machine/src/types/state.ts index 75813d6..7d0d9c3 100644 --- a/apps/machine/src/types/state.ts +++ b/apps/machine/src/types/state.ts @@ -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 }[] diff --git a/apps/machine/src/views/CashInView.vue b/apps/machine/src/views/CashInView.vue index 2a150b2..16ee6be 100644 --- a/apps/machine/src/views/CashInView.vue +++ b/apps/machine/src/views/CashInView.vue @@ -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)
Commission ({{ (context.feePercent * 100).toFixed(1) }}%)Commission ({{ (context.feeFraction * 100).toFixed(1) }}%) {{ formatSats( Math.floor( Math.floor((context.fiatCents / 100) * context.exchangeRate) * - context.feePercent + context.feeFraction ) ) }} diff --git a/apps/machine/src/views/CashOutView.vue b/apps/machine/src/views/CashOutView.vue index 4881346..4b4a4d2 100644 --- a/apps/machine/src/views/CashOutView.vue +++ b/apps/machine/src/views/CashOutView.vue @@ -404,7 +404,7 @@ function formatFiat(cents: number): string {
Commission ({{ (context.feePercent * 100).toFixed(1) }}%)Commission ({{ (context.feeFraction * 100).toFixed(1) }}%) {{ diff --git a/apps/machine/src/views/IdleView.vue b/apps/machine/src/views/IdleView.vue index e866ee3..f05d9a2 100644 --- a/apps/machine/src/views/IdleView.vue +++ b/apps/machine/src/views/IdleView.vue @@ -99,7 +99,7 @@ function handleCashOut() { {{ ( - (atmStore.context?.cashInFeePercent ?? initialContext.cashInFeePercent) * 100 + (atmStore.context?.cashInFeeFraction ?? initialContext.cashInFeeFraction) * 100 ).toFixed(2) }}% @@ -109,7 +109,7 @@ function handleCashOut() { {{ ( - (atmStore.context?.cashOutFeePercent ?? initialContext.cashOutFeePercent) * 100 + (atmStore.context?.cashOutFeeFraction ?? initialContext.cashOutFeeFraction) * 100 ).toFixed(2) }}% diff --git a/packages/state-machine/src/machine.ts b/packages/state-machine/src/machine.ts index 848ac4d..b7dbce1 100644 --- a/packages/state-machine/src/machine.ts +++ b/packages/state-machine/src/machine.ts @@ -20,7 +20,7 @@ import { */ export function createATMMachine( services: Partial = {}, - 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: { diff --git a/packages/state-machine/src/types.ts b/packages/state-machine/src/types.ts index f24d0e8..2fc6fde 100644 --- a/packages/state-machine/src/types.ts +++ b/packages/state-machine/src/types.ts @@ -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,