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:
parent
0af2a9bb29
commit
6a627e5b4a
12 changed files with 92 additions and 56 deletions
|
|
@ -298,8 +298,8 @@ ipcMain.handle('get-config', () => {
|
||||||
maintenanceMode: process.env.VITE_MAINTENANCE_MODE === 'true',
|
maintenanceMode: process.env.VITE_MAINTENANCE_MODE === 'true',
|
||||||
|
|
||||||
// Fee rates — accepts percentage (5.55) or decimal (0.0555), auto-detected
|
// Fee rates — accepts percentage (5.55) or decimal (0.0555), auto-detected
|
||||||
cashInFeeRate: parseFee(process.env.VITE_CASH_IN_FEE || '0.0333'),
|
cashInFeeFraction: parseFee(process.env.VITE_CASH_IN_FEE || '0.0333'),
|
||||||
cashOutFeeRate: parseFee(process.env.VITE_CASH_OUT_FEE || '0.0777'),
|
cashOutFeeFraction: parseFee(process.env.VITE_CASH_OUT_FEE || '0.0777'),
|
||||||
|
|
||||||
// Operator branding (logo/title/theme) — null when no override
|
// Operator branding (logo/title/theme) — null when no override
|
||||||
branding: loadBranding(),
|
branding: loadBranding(),
|
||||||
|
|
@ -560,7 +560,7 @@ function startCommandPoller(): void {
|
||||||
fiatCents: totalFiatCents,
|
fiatCents: totalFiatCents,
|
||||||
sats: 0,
|
sats: 0,
|
||||||
feeSats: 0,
|
feeSats: 0,
|
||||||
feePercent: 0,
|
feeFraction: 0,
|
||||||
exchangeRate: 0,
|
exchangeRate: 0,
|
||||||
currency: fiatCode,
|
currency: fiatCode,
|
||||||
bills: parsed.bills,
|
bills: parsed.bills,
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
fiatCents: number
|
fiatCents: number
|
||||||
sats: number
|
sats: number
|
||||||
feeSats: number
|
feeSats: number
|
||||||
feePercent: number
|
feeFraction: number
|
||||||
exchangeRate: number
|
exchangeRate: number
|
||||||
currency: string
|
currency: string
|
||||||
bills: { denomination: number; count: number }[]
|
bills: { denomination: number; count: number }[]
|
||||||
|
|
@ -156,7 +156,7 @@ declare global {
|
||||||
fiatCents: number
|
fiatCents: number
|
||||||
sats: number
|
sats: number
|
||||||
feeSats: number
|
feeSats: number
|
||||||
feePercent: number
|
feeFraction: number
|
||||||
exchangeRate: number
|
exchangeRate: number
|
||||||
currency: string
|
currency: string
|
||||||
bills: { denomination: number; count: number }[]
|
bills: { denomination: number; count: number }[]
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import fs from 'node:fs'
|
||||||
|
|
||||||
let db: Database.Database | null = null
|
let db: Database.Database | null = null
|
||||||
|
|
||||||
const SCHEMA_VERSION = '6'
|
const SCHEMA_VERSION = '7'
|
||||||
|
|
||||||
function getDbPath(): string {
|
function getDbPath(): string {
|
||||||
const prodDir = '/var/lib/bitspire'
|
const prodDir = '/var/lib/bitspire'
|
||||||
|
|
@ -70,7 +70,7 @@ export function initDatabase(dbPath?: string): void {
|
||||||
fiat_cents INTEGER NOT NULL,
|
fiat_cents INTEGER NOT NULL,
|
||||||
sats INTEGER NOT NULL,
|
sats INTEGER NOT NULL,
|
||||||
fee_sats INTEGER NOT NULL DEFAULT 0,
|
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,
|
exchange_rate REAL NOT NULL DEFAULT 0,
|
||||||
currency TEXT NOT NULL DEFAULT 'GTQ',
|
currency TEXT NOT NULL DEFAULT 'GTQ',
|
||||||
status TEXT NOT NULL DEFAULT 'complete',
|
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')
|
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('6', 'schema_version')
|
||||||
console.log('[StateStore] Migrated schema v5 → v6 (added cassette position)')
|
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()
|
const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get()
|
||||||
|
|
@ -352,7 +363,7 @@ interface TransactionInput {
|
||||||
fiatCents: number
|
fiatCents: number
|
||||||
sats: number
|
sats: number
|
||||||
feeSats: number
|
feeSats: number
|
||||||
feePercent: number
|
feeFraction: number
|
||||||
exchangeRate: number
|
exchangeRate: number
|
||||||
currency: string
|
currency: string
|
||||||
bills: { denomination: number; count: number }[]
|
bills: { denomination: number; count: number }[]
|
||||||
|
|
@ -373,8 +384,15 @@ interface TransactionInput {
|
||||||
export function recordTransaction(tx: TransactionInput): void {
|
export function recordTransaction(tx: TransactionInput): void {
|
||||||
if (!db) throw new Error('Database not initialized')
|
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(
|
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(
|
const insertBill = db.prepare(
|
||||||
'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)'
|
'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)'
|
||||||
|
|
@ -398,7 +416,7 @@ export function recordTransaction(tx: TransactionInput): void {
|
||||||
t.fiatCents,
|
t.fiatCents,
|
||||||
t.sats,
|
t.sats,
|
||||||
t.feeSats,
|
t.feeSats,
|
||||||
t.feePercent,
|
t.feeFraction,
|
||||||
t.exchangeRate,
|
t.exchangeRate,
|
||||||
t.currency,
|
t.currency,
|
||||||
Date.now()
|
Date.now()
|
||||||
|
|
|
||||||
|
|
@ -759,7 +759,10 @@ function createATMServices(
|
||||||
* Field-name vocabulary follows the comment thread on #44:
|
* Field-name vocabulary follows the comment thread on #44:
|
||||||
* - `principal_sats` (was `net_sats`) — the LP/DCA share
|
* - `principal_sats` (was `net_sats`) — the LP/DCA share
|
||||||
* - `fee_sats` — the commission
|
* - `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
|
* - `exchange_rate` — sats per 1 fiat unit
|
||||||
* (RAW market rate, no
|
* (RAW market rate, no
|
||||||
* commission baked in)
|
* commission baked in)
|
||||||
|
|
@ -777,7 +780,6 @@ function createATMServices(
|
||||||
? Math.floor((context.fiatCents / 100) * context.exchangeRate)
|
? Math.floor((context.fiatCents / 100) * context.exchangeRate)
|
||||||
: 0
|
: 0
|
||||||
const feeSats = Math.max(0, amountSats - principalSats)
|
const feeSats = Math.max(0, amountSats - principalSats)
|
||||||
const feePercent = +(context.feePercent * 100).toFixed(4) // 0.05 -> 5.0
|
|
||||||
console.log(
|
console.log(
|
||||||
'[ATM Service] Generating invoice — gross',
|
'[ATM Service] Generating invoice — gross',
|
||||||
amountSats,
|
amountSats,
|
||||||
|
|
@ -785,7 +787,7 @@ function createATMServices(
|
||||||
principalSats,
|
principalSats,
|
||||||
'+ fee',
|
'+ fee',
|
||||||
feeSats,
|
feeSats,
|
||||||
`≈ ${feePercent}% @ ${context.exchangeRate} sats/${context.currency})`
|
`≈ ${(context.feeFraction * 100).toFixed(2)}% @ ${context.exchangeRate} sats/${context.currency})`
|
||||||
)
|
)
|
||||||
const payment = await lnbits.createInvoice(lnbitsWalletId, {
|
const payment = await lnbits.createInvoice(lnbitsWalletId, {
|
||||||
amount: amountSats,
|
amount: amountSats,
|
||||||
|
|
@ -806,7 +808,7 @@ function createATMServices(
|
||||||
currency: context.currency,
|
currency: context.currency,
|
||||||
principal_sats: principalSats,
|
principal_sats: principalSats,
|
||||||
fee_sats: feeSats,
|
fee_sats: feeSats,
|
||||||
fee_percent: feePercent,
|
fee_fraction: context.feeFraction,
|
||||||
exchange_rate: context.exchangeRate,
|
exchange_rate: context.exchangeRate,
|
||||||
// bills/cassettes deferred — they're meaningful for cash-in
|
// bills/cassettes deferred — they're meaningful for cash-in
|
||||||
// and for partial-dispense reconciliation, neither of which
|
// and for partial-dispense reconciliation, neither of which
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,26 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
|
||||||
* Cash-out: on-wire satsAmount = principalSats + feeSats (customer pays more than principal)
|
* Cash-out: on-wire satsAmount = principalSats + feeSats (customer pays more than principal)
|
||||||
*/
|
*/
|
||||||
function computeFeeSats(ctx: ATMContext, isCashIn: boolean): number {
|
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 principalSats = ctx.exchangeRate > 0 ? Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate) : 0
|
||||||
const feeSats = isCashIn
|
const feeSats = isCashIn
|
||||||
? principalSats - ctx.satsAmount // cash-in: customer receives less than principal
|
? principalSats - ctx.satsAmount // cash-in: customer receives less than principal
|
||||||
: ctx.satsAmount - principalSats // cash-out: customer pays more 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)
|
return Math.max(0, feeSats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,7 +116,7 @@ async function handleManagementCommand(
|
||||||
fiatCents: totalFiatCents,
|
fiatCents: totalFiatCents,
|
||||||
sats: 0,
|
sats: 0,
|
||||||
feeSats: 0,
|
feeSats: 0,
|
||||||
feePercent: 0,
|
feeFraction: 0,
|
||||||
exchangeRate: 0,
|
exchangeRate: 0,
|
||||||
currency,
|
currency,
|
||||||
bills: request.bills,
|
bills: request.bills,
|
||||||
|
|
@ -267,8 +283,8 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
const allowMockFallback = ref(true) // default true for browser dev
|
const allowMockFallback = ref(true) // default true for browser dev
|
||||||
const initError = ref<string | null>(null) // fatal error → maintenance screen
|
const initError = ref<string | null>(null) // fatal error → maintenance screen
|
||||||
const fiatCode = ref('USD')
|
const fiatCode = ref('USD')
|
||||||
const cashInFeeRate = ref(0.0333)
|
const cashInFeeFraction = ref(0.0333)
|
||||||
const cashOutFeeRate = ref(0.0777)
|
const cashOutFeeFraction = ref(0.0777)
|
||||||
const machineModel = ref('atm')
|
const machineModel = ref('atm')
|
||||||
const useLiveServices = ref(false)
|
const useLiveServices = ref(false)
|
||||||
const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>(
|
const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>(
|
||||||
|
|
@ -370,8 +386,8 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
atmServicesRef = services
|
atmServicesRef = services
|
||||||
const machine = createATMMachine(services, {
|
const machine = createATMMachine(services, {
|
||||||
currency: fiatCode.value,
|
currency: fiatCode.value,
|
||||||
cashInFeeRate: cashInFeeRate.value,
|
cashInFeeFraction: cashInFeeFraction.value,
|
||||||
cashOutFeeRate: cashOutFeeRate.value,
|
cashOutFeeFraction: cashOutFeeFraction.value,
|
||||||
})
|
})
|
||||||
actor.value = createActor(machine)
|
actor.value = createActor(machine)
|
||||||
|
|
||||||
|
|
@ -439,7 +455,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
fiatCents: ctx.fiatCents,
|
fiatCents: ctx.fiatCents,
|
||||||
sats: ctx.satsAmount,
|
sats: ctx.satsAmount,
|
||||||
feeSats: computeFeeSats(ctx, false),
|
feeSats: computeFeeSats(ctx, false),
|
||||||
feePercent: ctx.feePercent,
|
feeFraction: ctx.feeFraction,
|
||||||
exchangeRate: ctx.exchangeRate,
|
exchangeRate: ctx.exchangeRate,
|
||||||
currency: ctx.currency,
|
currency: ctx.currency,
|
||||||
bills,
|
bills,
|
||||||
|
|
@ -469,7 +485,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
fiatCents: ctx.fiatCents,
|
fiatCents: ctx.fiatCents,
|
||||||
sats: ctx.satsAmount,
|
sats: ctx.satsAmount,
|
||||||
feeSats: computeFeeSats(ctx, isCashInTx),
|
feeSats: computeFeeSats(ctx, isCashInTx),
|
||||||
feePercent: ctx.feePercent,
|
feeFraction: ctx.feeFraction,
|
||||||
exchangeRate: ctx.exchangeRate,
|
exchangeRate: ctx.exchangeRate,
|
||||||
currency: ctx.currency,
|
currency: ctx.currency,
|
||||||
bills,
|
bills,
|
||||||
|
|
@ -858,7 +874,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
const newFiatCents = ctx.fiatCents + denomination * 100
|
const newFiatCents = ctx.fiatCents + denomination * 100
|
||||||
const newFiatUnits = newFiatCents / 100
|
const newFiatUnits = newFiatCents / 100
|
||||||
const principalSats = Math.floor(newFiatUnits * ctx.exchangeRate)
|
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
|
const newSatsAmount = principalSats - fee
|
||||||
|
|
||||||
// Check against available balance
|
// Check against available balance
|
||||||
|
|
@ -938,9 +954,9 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
machineModel.value = model
|
machineModel.value = model
|
||||||
const runtimeFiatCode = runtimeConfig.fiatCode || 'USD'
|
const runtimeFiatCode = runtimeConfig.fiatCode || 'USD'
|
||||||
fiatCode.value = runtimeFiatCode
|
fiatCode.value = runtimeFiatCode
|
||||||
if (runtimeConfig.cashInFeeRate !== undefined) cashInFeeRate.value = runtimeConfig.cashInFeeRate
|
if (runtimeConfig.cashInFeeFraction !== undefined) cashInFeeFraction.value = runtimeConfig.cashInFeeFraction
|
||||||
if (runtimeConfig.cashOutFeeRate !== undefined)
|
if (runtimeConfig.cashOutFeeFraction !== undefined)
|
||||||
cashOutFeeRate.value = runtimeConfig.cashOutFeeRate
|
cashOutFeeFraction.value = runtimeConfig.cashOutFeeFraction
|
||||||
|
|
||||||
// Build device config from runtime values
|
// Build device config from runtime values
|
||||||
const { getDeviceConfig, toHalConfig, MACHINE_PRESETS } = await import('@/config')
|
const { getDeviceConfig, toHalConfig, MACHINE_PRESETS } = await import('@/config')
|
||||||
|
|
@ -1114,7 +1130,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
const newFiatCents = ctx.fiatCents + denomination * 100
|
const newFiatCents = ctx.fiatCents + denomination * 100
|
||||||
const newFiatUnits = newFiatCents / 100
|
const newFiatUnits = newFiatCents / 100
|
||||||
const principalSats = Math.floor(newFiatUnits * ctx.exchangeRate)
|
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
|
const newSatsAmount = principalSats - fee
|
||||||
|
|
||||||
if (newSatsAmount > ctx.availableBalance) {
|
if (newSatsAmount > ctx.availableBalance) {
|
||||||
|
|
|
||||||
6
apps/machine/src/types/electron.d.ts
vendored
6
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -21,8 +21,8 @@ export interface RuntimeConfig {
|
||||||
allowMockFallback: boolean
|
allowMockFallback: boolean
|
||||||
operatorPubkeys: string
|
operatorPubkeys: string
|
||||||
maintenanceMode: boolean
|
maintenanceMode: boolean
|
||||||
cashInFeeRate: number
|
cashInFeeFraction: number
|
||||||
cashOutFeeRate: number
|
cashOutFeeFraction: number
|
||||||
/** Operator branding override loaded from /var/lib/bitspire/branding/. Null when no override. */
|
/** Operator branding override loaded from /var/lib/bitspire/branding/. Null when no override. */
|
||||||
branding: BrandingConfig | null
|
branding: BrandingConfig | null
|
||||||
}
|
}
|
||||||
|
|
@ -70,7 +70,7 @@ declare global {
|
||||||
fiatCents: number
|
fiatCents: number
|
||||||
sats: number
|
sats: number
|
||||||
feeSats: number
|
feeSats: number
|
||||||
feePercent: number
|
feeFraction: number
|
||||||
exchangeRate: number
|
exchangeRate: number
|
||||||
currency: string
|
currency: string
|
||||||
bills: { denomination: number; count: number }[]
|
bills: { denomination: number; count: number }[]
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export interface TransactionRecord {
|
||||||
fiatCents: number
|
fiatCents: number
|
||||||
sats: number
|
sats: number
|
||||||
feeSats: number
|
feeSats: number
|
||||||
feePercent: number
|
feeFraction: number
|
||||||
exchangeRate: number
|
exchangeRate: number
|
||||||
currency: string
|
currency: string
|
||||||
bills: { denomination: number; count: number }[]
|
bills: { denomination: number; count: number }[]
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ function canAcceptBill(denomination: number): boolean {
|
||||||
if (ctx.availableBalance <= 0 || ctx.exchangeRate === 0) return true
|
if (ctx.availableBalance <= 0 || ctx.exchangeRate === 0) return true
|
||||||
const newFiatCents = ctx.fiatCents + denomination * 100
|
const newFiatCents = ctx.fiatCents + denomination * 100
|
||||||
const principalSats = Math.floor((newFiatCents / 100) * ctx.exchangeRate)
|
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
|
return principalSats - fee <= ctx.availableBalance
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -439,14 +439,14 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between text-base lg:text-2xl">
|
<div class="flex justify-between text-base lg:text-2xl">
|
||||||
<span class="text-muted-foreground"
|
<span class="text-muted-foreground"
|
||||||
>Commission ({{ (context.feePercent * 100).toFixed(1) }}%)</span
|
>Commission ({{ (context.feeFraction * 100).toFixed(1) }}%)</span
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
>{{
|
>{{
|
||||||
formatSats(
|
formatSats(
|
||||||
Math.floor(
|
Math.floor(
|
||||||
Math.floor((context.fiatCents / 100) * context.exchangeRate) *
|
Math.floor((context.fiatCents / 100) * context.exchangeRate) *
|
||||||
context.feePercent
|
context.feeFraction
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -404,7 +404,7 @@ function formatFiat(cents: number): string {
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between text-base lg:text-2xl">
|
<div class="flex justify-between text-base lg:text-2xl">
|
||||||
<span class="text-muted-foreground"
|
<span class="text-muted-foreground"
|
||||||
>Commission ({{ (context.feePercent * 100).toFixed(1) }}%)</span
|
>Commission ({{ (context.feeFraction * 100).toFixed(1) }}%)</span
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
>{{
|
>{{
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ function handleCashOut() {
|
||||||
<span class="font-bold text-bitcoin"
|
<span class="font-bold text-bitcoin"
|
||||||
>{{
|
>{{
|
||||||
(
|
(
|
||||||
(atmStore.context?.cashInFeePercent ?? initialContext.cashInFeePercent) * 100
|
(atmStore.context?.cashInFeeFraction ?? initialContext.cashInFeeFraction) * 100
|
||||||
).toFixed(2)
|
).toFixed(2)
|
||||||
}}%</span
|
}}%</span
|
||||||
>
|
>
|
||||||
|
|
@ -109,7 +109,7 @@ function handleCashOut() {
|
||||||
<span class="font-bold text-success"
|
<span class="font-bold text-success"
|
||||||
>{{
|
>{{
|
||||||
(
|
(
|
||||||
(atmStore.context?.cashOutFeePercent ?? initialContext.cashOutFeePercent) * 100
|
(atmStore.context?.cashOutFeeFraction ?? initialContext.cashOutFeeFraction) * 100
|
||||||
).toFixed(2)
|
).toFixed(2)
|
||||||
}}%</span
|
}}%</span
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import {
|
||||||
*/
|
*/
|
||||||
export function createATMMachine(
|
export function createATMMachine(
|
||||||
services: Partial<ATMServices> = {},
|
services: Partial<ATMServices> = {},
|
||||||
options?: { currency?: string; cashInFeeRate?: number; cashOutFeeRate?: number }
|
options?: { currency?: string; cashInFeeFraction?: number; cashOutFeeFraction?: number }
|
||||||
) {
|
) {
|
||||||
return setup({
|
return setup({
|
||||||
types: {
|
types: {
|
||||||
|
|
@ -159,8 +159,8 @@ export function createATMMachine(
|
||||||
...initialContext,
|
...initialContext,
|
||||||
currency: context.currency,
|
currency: context.currency,
|
||||||
inventory: context.inventory,
|
inventory: context.inventory,
|
||||||
cashInFeePercent: context.cashInFeePercent,
|
cashInFeeFraction: context.cashInFeeFraction,
|
||||||
cashOutFeePercent: context.cashOutFeePercent,
|
cashOutFeeFraction: context.cashOutFeeFraction,
|
||||||
cashInSessionId: null,
|
cashInSessionId: null,
|
||||||
dispenseResult: null,
|
dispenseResult: null,
|
||||||
})),
|
})),
|
||||||
|
|
@ -170,10 +170,10 @@ export function createATMMachine(
|
||||||
cashInSessionId: () => generateSessionId(),
|
cashInSessionId: () => generateSessionId(),
|
||||||
}),
|
}),
|
||||||
setCashInFee: assign({
|
setCashInFee: assign({
|
||||||
feePercent: ({ context }) => context.cashInFeePercent,
|
feeFraction: ({ context }) => context.cashInFeeFraction,
|
||||||
}),
|
}),
|
||||||
setCashOutFee: assign({
|
setCashOutFee: assign({
|
||||||
feePercent: ({ context }) => context.cashOutFeePercent,
|
feeFraction: ({ context }) => context.cashOutFeeFraction,
|
||||||
}),
|
}),
|
||||||
addBill: assign({
|
addBill: assign({
|
||||||
billsInserted: ({ context, event }) => {
|
billsInserted: ({ context, event }) => {
|
||||||
|
|
@ -190,7 +190,7 @@ export function createATMMachine(
|
||||||
if (context.exchangeRate === 0) return 0
|
if (context.exchangeRate === 0) return 0
|
||||||
const fiatDollars = context.fiatCents / 100
|
const fiatDollars = context.fiatCents / 100
|
||||||
const principalSats = Math.floor(fiatDollars * context.exchangeRate)
|
const principalSats = Math.floor(fiatDollars * context.exchangeRate)
|
||||||
const fee = Math.floor(principalSats * context.feePercent)
|
const fee = Math.floor(principalSats * context.feeFraction)
|
||||||
return principalSats - fee
|
return principalSats - fee
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
@ -326,7 +326,7 @@ export function createATMMachine(
|
||||||
const fiatDollars = context.fiatCents / 100
|
const fiatDollars = context.fiatCents / 100
|
||||||
const principalSats = Math.floor(fiatDollars * context.exchangeRate)
|
const principalSats = Math.floor(fiatDollars * context.exchangeRate)
|
||||||
// For cash-out, user pays the sats, so fee is added
|
// 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
|
return principalSats + fee
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
@ -373,7 +373,7 @@ export function createATMMachine(
|
||||||
if (context.availableBalance <= 0 || context.exchangeRate === 0) return false
|
if (context.availableBalance <= 0 || context.exchangeRate === 0) return false
|
||||||
const newFiatCents = context.fiatCents + event.denomination * 100
|
const newFiatCents = context.fiatCents + event.denomination * 100
|
||||||
const principalSats = Math.floor((newFiatCents / 100) * context.exchangeRate)
|
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
|
return principalSats - fee <= context.availableBalance
|
||||||
},
|
},
|
||||||
// Cash-out guards
|
// Cash-out guards
|
||||||
|
|
@ -404,9 +404,9 @@ export function createATMMachine(
|
||||||
context: {
|
context: {
|
||||||
...initialContext,
|
...initialContext,
|
||||||
...(options?.currency ? { currency: options.currency } : {}),
|
...(options?.currency ? { currency: options.currency } : {}),
|
||||||
...(options?.cashInFeeRate !== undefined ? { cashInFeePercent: options.cashInFeeRate } : {}),
|
...(options?.cashInFeeFraction !== undefined ? { cashInFeeFraction: options.cashInFeeFraction } : {}),
|
||||||
...(options?.cashOutFeeRate !== undefined
|
...(options?.cashOutFeeFraction !== undefined
|
||||||
? { cashOutFeePercent: options.cashOutFeeRate }
|
? { cashOutFeeFraction: options.cashOutFeeFraction }
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
states: {
|
states: {
|
||||||
|
|
|
||||||
|
|
@ -59,12 +59,12 @@ export interface ATMContext {
|
||||||
currency: string
|
currency: string
|
||||||
/** Exchange rate (sats per fiat unit) */
|
/** Exchange rate (sats per fiat unit) */
|
||||||
exchangeRate: number
|
exchangeRate: number
|
||||||
/** Fee percentage (0.02 = 2%) — used at runtime for the active flow */
|
/** Fee as unit fraction in [0, 1] — 0.02 means 2%. Active flow's rate. */
|
||||||
feePercent: number
|
feeFraction: number
|
||||||
/** Cash-in commission (0.02 = 2%) */
|
/** Cash-in commission as unit fraction (0.02 = 2%) */
|
||||||
cashInFeePercent: number
|
cashInFeeFraction: number
|
||||||
/** Cash-out commission (0.02 = 2%) */
|
/** Cash-out commission as unit fraction (0.02 = 2%) */
|
||||||
cashOutFeePercent: number
|
cashOutFeeFraction: number
|
||||||
/** ATM's available balance in sats (for limiting cash-in transactions) */
|
/** ATM's available balance in sats (for limiting cash-in transactions) */
|
||||||
availableBalance: number
|
availableBalance: number
|
||||||
|
|
||||||
|
|
@ -163,9 +163,9 @@ export const initialContext: ATMContext = {
|
||||||
satsAmount: 0,
|
satsAmount: 0,
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
exchangeRate: 0,
|
exchangeRate: 0,
|
||||||
feePercent: 0.0333,
|
feeFraction: 0.0333,
|
||||||
cashInFeePercent: 0.0333,
|
cashInFeeFraction: 0.0333,
|
||||||
cashOutFeePercent: 0.0777,
|
cashOutFeeFraction: 0.0777,
|
||||||
availableBalance: 0,
|
availableBalance: 0,
|
||||||
invoice: null,
|
invoice: null,
|
||||||
clinkOffer: null,
|
clinkOffer: null,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue