Implement @lamassu/state-machine package

XState v5 state machine for ATM transaction flows:

States:
- idle: Waiting for user selection
- cashIn: Buy bitcoin (insert bills -> display QR -> payment received)
- cashOut: Sell bitcoin (select amount -> display invoice -> dispense cash)

Features:
- Full cash-in flow with bill accumulation
- Full cash-out flow with cash dispensing
- Exchange rate fetching
- Sats calculation with fee deduction
- Optional Nostr receipt sending
- Error handling with retry logic
- Transaction ID generation
- Timeout handling

Service injection for testability:
- generateClinkOffer
- generateInvoice
- sendNostrReceipt
- dispenseCash
- getExchangeRate

Includes comprehensive tests for state transitions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-01-23 05:51:42 -05:00
commit f35d0330b9
6 changed files with 821 additions and 0 deletions

View file

@ -0,0 +1,166 @@
import { describe, it, expect, vi } from 'vitest'
import { createActor } from 'xstate'
import { createATMMachine } from '../machine.js'
import type { ATMServices } from '../types.js'
describe('ATM State Machine', () => {
const mockServices: ATMServices = {
generateClinkOffer: vi.fn().mockResolvedValue('noffer1test'),
generateInvoice: vi.fn().mockResolvedValue('lnbc1test'),
sendNostrReceipt: vi.fn().mockResolvedValue(undefined),
dispenseCash: vi.fn().mockResolvedValue(undefined),
getExchangeRate: vi.fn().mockResolvedValue(2500), // 2500 sats per USD
}
describe('initial state', () => {
it('should start in idle state', () => {
const machine = createATMMachine()
const actor = createActor(machine)
actor.start()
expect(actor.getSnapshot().value).toBe('idle')
})
it('should have initial context values', () => {
const machine = createATMMachine()
const actor = createActor(machine)
actor.start()
const context = actor.getSnapshot().context
expect(context.fiatAmount).toBe(0)
expect(context.satsAmount).toBe(0)
expect(context.billsInserted).toEqual([])
expect(context.error).toBeNull()
})
})
describe('cash-in flow', () => {
it('should transition to cashIn on SELECT_CASH_IN', async () => {
const machine = createATMMachine(mockServices)
const actor = createActor(machine)
actor.start()
actor.send({ type: 'SELECT_CASH_IN' })
// Wait for state to settle
await new Promise((resolve) => setTimeout(resolve, 50))
const state = actor.getSnapshot()
expect(state.value).toMatchObject({ cashIn: expect.any(String) })
expect(state.context.txid).not.toBeNull()
expect(state.context.startedAt).not.toBeNull()
})
it('should accumulate bills and calculate sats', async () => {
const machine = createATMMachine(mockServices)
const actor = createActor(machine)
actor.start()
actor.send({ type: 'SELECT_CASH_IN' })
// Wait for rate fetch
await new Promise((resolve) => setTimeout(resolve, 50))
actor.send({ type: 'BILL_INSERTED', denomination: 20 })
actor.send({ type: 'BILL_INSERTED', denomination: 10 })
const context = actor.getSnapshot().context
expect(context.billsInserted).toEqual([20, 10])
expect(context.fiatAmount).toBe(30) // $30 in cents? No, as dollars
})
it('should return to idle on CANCEL', async () => {
const machine = createATMMachine(mockServices)
const actor = createActor(machine)
actor.start()
actor.send({ type: 'SELECT_CASH_IN' })
await new Promise((resolve) => setTimeout(resolve, 50))
actor.send({ type: 'CANCEL' })
expect(actor.getSnapshot().value).toBe('idle')
})
})
describe('cash-out flow', () => {
it('should transition to cashOut on SELECT_CASH_OUT', async () => {
const machine = createATMMachine(mockServices)
const actor = createActor(machine)
actor.start()
actor.send({ type: 'SELECT_CASH_OUT' })
await new Promise((resolve) => setTimeout(resolve, 50))
const state = actor.getSnapshot()
expect(state.value).toMatchObject({ cashOut: expect.any(String) })
})
it('should calculate dispense amounts for selected amount', async () => {
const machine = createATMMachine(mockServices)
const actor = createActor(machine)
actor.start()
actor.send({ type: 'SELECT_CASH_OUT' })
await new Promise((resolve) => setTimeout(resolve, 50))
actor.send({ type: 'SELECT_AMOUNT', amount: 75 })
await new Promise((resolve) => setTimeout(resolve, 50))
const context = actor.getSnapshot().context
expect(context.fiatAmount).toBe(7500) // $75 in cents
})
})
describe('error handling', () => {
it('should transition to error state on service failure', async () => {
const failingServices: ATMServices = {
...mockServices,
getExchangeRate: vi.fn().mockRejectedValue(new Error('Rate fetch failed')),
}
const machine = createATMMachine(failingServices)
const actor = createActor(machine)
actor.start()
actor.send({ type: 'SELECT_CASH_IN' })
await new Promise((resolve) => setTimeout(resolve, 50))
const state = actor.getSnapshot()
expect(state.value).toMatchObject({ cashIn: 'error' })
})
it('should allow retry on error', async () => {
let callCount = 0
const retryServices: ATMServices = {
...mockServices,
getExchangeRate: vi.fn().mockImplementation(() => {
callCount++
if (callCount === 1) {
return Promise.reject(new Error('First call fails'))
}
return Promise.resolve(2500)
}),
}
const machine = createATMMachine(retryServices)
const actor = createActor(machine)
actor.start()
actor.send({ type: 'SELECT_CASH_IN' })
await new Promise((resolve) => setTimeout(resolve, 50))
// Should be in error state
expect(actor.getSnapshot().value).toMatchObject({ cashIn: 'error' })
// Retry
actor.send({ type: 'RETRY' })
await new Promise((resolve) => setTimeout(resolve, 50))
// Should have retried and succeeded
const state = actor.getSnapshot()
expect(state.value).toMatchObject({ cashIn: 'insertingBills' })
})
})
})

View file

@ -0,0 +1,57 @@
/**
* @lamassu/state-machine
*
* XState v5 state machine for ATM transaction flows.
*
* @example
* ```typescript
* import { createATMMachine, createActor } from '@lamassu/state-machine'
* import { createActor } from 'xstate'
*
* const machine = createATMMachine({
* generateClinkOffer: async (context) => {
* // Generate offer for the current amount
* return 'noffer1...'
* },
* generateInvoice: async (amountMsat) => {
* // Generate Lightning invoice
* return 'lnbc...'
* },
* getExchangeRate: async (currency) => {
* // Get current rate in sats per fiat unit
* return 2500 // Example: 2500 sats per USD
* },
* })
*
* const actor = createActor(machine)
* actor.start()
*
* // User selects cash-in
* actor.send({ type: 'SELECT_CASH_IN' })
*
* // Bill inserted
* actor.send({ type: 'BILL_INSERTED', denomination: 20 })
*
* // Subscribe to state changes
* actor.subscribe((snapshot) => {
* console.log('State:', snapshot.value)
* console.log('Context:', snapshot.context)
* })
* ```
*/
// Machine factory and default machine
export { createATMMachine, atmMachine, type ATMMachine } from './machine.js'
// Types
export {
type ATMContext,
type ATMEvent,
type ATMServices,
type PaymentStatus,
type PaymentMethod,
initialContext,
} from './types.js'
// Re-export useful xstate utilities
export { createActor, type ActorRefFrom, type SnapshotFrom } from 'xstate'

View file

@ -0,0 +1,446 @@
/**
* ATM State Machine
*
* XState v5 machine for ATM transaction flows.
* Handles both cash-in (buy bitcoin) and cash-out (sell bitcoin) flows.
*/
import { setup, assign, fromPromise } from 'xstate'
import { type ATMContext, type ATMEvent, initialContext, type ATMServices } from './types.js'
/**
* Create the ATM state machine with injected services
*/
export function createATMMachine(services: Partial<ATMServices> = {}) {
return setup({
types: {
context: {} as ATMContext,
events: {} as ATMEvent,
},
actors: {
generateClinkOffer: fromPromise(async ({ input }: { input: ATMContext }) => {
if (!services.generateClinkOffer) {
throw new Error('generateClinkOffer service not provided')
}
return services.generateClinkOffer(input)
}),
generateInvoice: fromPromise(async ({ input }: { input: number }) => {
if (!services.generateInvoice) {
throw new Error('generateInvoice service not provided')
}
return services.generateInvoice(input)
}),
sendNostrReceipt: fromPromise(async ({ input }: { input: ATMContext }) => {
if (!services.sendNostrReceipt) {
throw new Error('sendNostrReceipt service not provided')
}
return services.sendNostrReceipt(input)
}),
dispenseCash: fromPromise(
async ({ input }: { input: { denomination: number; count: number }[] }) => {
if (!services.dispenseCash) {
throw new Error('dispenseCash service not provided')
}
return services.dispenseCash(input)
}
),
getExchangeRate: fromPromise(async ({ input }: { input: string }) => {
if (!services.getExchangeRate) {
throw new Error('getExchangeRate service not provided')
}
return services.getExchangeRate(input)
}),
},
actions: {
resetContext: assign(() => ({
...initialContext,
})),
setStartTime: assign({
startedAt: () => Date.now(),
txid: () => generateTxId(),
}),
addBill: assign({
billsInserted: ({ context, event }) => {
if (event.type !== 'BILL_INSERTED') return context.billsInserted
return [...context.billsInserted, event.denomination]
},
fiatAmount: ({ context, event }) => {
if (event.type !== 'BILL_INSERTED') return context.fiatAmount
return context.fiatAmount + event.denomination
},
}),
calculateSats: assign({
satsAmount: ({ context }) => {
if (context.exchangeRate === 0) return 0
const fiatUnits = context.fiatAmount / 100 // cents to dollars
const grossSats = Math.floor(fiatUnits * context.exchangeRate)
const fee = Math.floor(grossSats * context.feePercent)
return grossSats - fee
},
}),
calculateDispenseAmounts: assign({
dispenseAmounts: ({ context }) => {
// Calculate bills to dispense for cash-out
// Simple algorithm: use largest denominations first
const denominations = [100, 50, 20, 10, 5, 1]
let remaining = context.fiatAmount / 100 // cents to dollars
const amounts: { denomination: number; count: number }[] = []
for (const denom of denominations) {
if (remaining >= denom) {
const count = Math.floor(remaining / denom)
amounts.push({ denomination: denom, count })
remaining -= count * denom
}
}
return amounts
},
}),
setUserNpub: assign({
userNpub: ({ event }) => {
if (event.type !== 'USER_SCANNED_NPUB') return null
return event.npub
},
}),
setError: assign({
error: ({ event }) => {
if (
event.type === 'ERROR' ||
event.type === 'PAYMENT_FAILED' ||
event.type === 'DISPENSE_ERROR'
) {
return event.error
}
return null
},
}),
incrementRetry: assign({
retryCount: ({ context }) => context.retryCount + 1,
}),
setExchangeRate: assign({
exchangeRate: ({ event }) => {
if (event.type !== 'EXCHANGE_RATE_UPDATED') return 0
return event.rate
},
}),
setOffer: assign({
clinkOffer: ({ event }) => {
if (event.type !== 'OFFER_GENERATED') return null
return event.offer
},
paymentMethod: () => 'clink_offer' as const,
}),
setInvoice: assign({
invoice: ({ event }) => {
if (event.type !== 'INVOICE_GENERATED') return null
return event.invoice
},
}),
setPaymentReceived: assign({
paymentStatus: () => 'paid' as const,
preimage: ({ event }) => {
if (event.type !== 'PAYMENT_RECEIVED') return null
return event.preimage
},
}),
setPaymentFailed: assign({
paymentStatus: () => 'failed' as const,
}),
setCashDispensed: assign({
cashDispensed: () => true,
}),
setAmount: assign({
fiatAmount: ({ event }) => {
if (event.type !== 'SELECT_AMOUNT') return 0
return event.amount * 100 // dollars to cents
},
}),
},
guards: {
hasInsertedBills: ({ context }) => context.billsInserted.length > 0,
hasSufficientAmount: ({ context }) => context.fiatAmount >= 100, // $1 minimum
hasExchangeRate: ({ context }) => context.exchangeRate > 0,
canRetry: ({ context }) => context.retryCount < 3,
hasUserNpub: ({ context }) => context.userNpub !== null,
},
delays: {
TIMEOUT_MS: 300000, // 5 minutes
COMPLETE_DELAY: 3000,
},
}).createMachine({
id: 'atm',
initial: 'idle',
context: initialContext,
states: {
idle: {
entry: 'resetContext',
on: {
SELECT_CASH_IN: {
target: 'cashIn',
actions: 'setStartTime',
},
SELECT_CASH_OUT: {
target: 'cashOut',
actions: 'setStartTime',
},
},
},
// === CASH IN (Buy Bitcoin) ===
cashIn: {
initial: 'fetchingRate',
states: {
fetchingRate: {
invoke: {
src: 'getExchangeRate',
input: ({ context }) => context.currency,
onDone: {
target: 'insertingBills',
actions: assign({
exchangeRate: ({ event }) => event.output,
}),
},
onError: {
target: 'error',
actions: 'setError',
},
},
},
insertingBills: {
on: {
BILL_INSERTED: {
actions: ['addBill', 'calculateSats'],
},
BILL_REJECTED: {
// Stay in state, maybe show message
},
FINISH_INSERTING: {
guard: 'hasInsertedBills',
target: 'generatingOffer',
},
CANCEL: '#atm.idle',
TIMEOUT: '#atm.idle',
},
},
generatingOffer: {
invoke: {
src: 'generateClinkOffer',
input: ({ context }) => context,
onDone: {
target: 'displayingQR',
actions: assign({
clinkOffer: ({ event }) => event.output,
paymentMethod: () => 'clink_offer' as const,
}),
},
onError: {
target: 'error',
actions: 'setError',
},
},
},
displayingQR: {
on: {
PAYMENT_RECEIVED: {
target: 'askForReceipt',
actions: 'setPaymentReceived',
},
PAYMENT_FAILED: {
target: 'error',
actions: ['setError', 'setPaymentFailed'],
},
TIMEOUT: '#atm.idle',
CANCEL: '#atm.idle',
},
},
askForReceipt: {
on: {
USER_SCANNED_NPUB: {
target: 'sendingReceipt',
actions: 'setUserNpub',
},
SKIP_RECEIPT: 'complete',
CANCEL: 'complete',
TIMEOUT: 'complete',
},
},
sendingReceipt: {
invoke: {
src: 'sendNostrReceipt',
input: ({ context }) => context,
onDone: 'complete',
onError: 'complete', // Don't fail transaction for receipt
},
},
complete: {
after: {
COMPLETE_DELAY: '#atm.idle',
},
},
error: {
on: {
RETRY: {
guard: 'canRetry',
target: 'fetchingRate',
actions: 'incrementRetry',
},
CANCEL: '#atm.idle',
},
},
},
},
// === CASH OUT (Sell Bitcoin) ===
cashOut: {
initial: 'fetchingRate',
states: {
fetchingRate: {
invoke: {
src: 'getExchangeRate',
input: ({ context }) => context.currency,
onDone: {
target: 'selectingAmount',
actions: assign({
exchangeRate: ({ event }) => event.output,
}),
},
onError: {
target: 'error',
actions: 'setError',
},
},
},
selectingAmount: {
on: {
SELECT_AMOUNT: {
target: 'calculatingInvoice',
actions: 'setAmount',
},
CANCEL: '#atm.idle',
TIMEOUT: '#atm.idle',
},
},
calculatingInvoice: {
entry: ['calculateSats', 'calculateDispenseAmounts'],
always: 'generatingInvoice',
},
generatingInvoice: {
invoke: {
src: 'generateInvoice',
input: ({ context }) => context.satsAmount * 1000, // sats to msats
onDone: {
target: 'displayingInvoice',
actions: assign({
invoice: ({ event }) => event.output,
}),
},
onError: {
target: 'error',
actions: 'setError',
},
},
},
displayingInvoice: {
on: {
PAYMENT_RECEIVED: {
target: 'dispensingCash',
actions: 'setPaymentReceived',
},
PAYMENT_FAILED: {
target: 'error',
actions: ['setError', 'setPaymentFailed'],
},
TIMEOUT: '#atm.idle',
CANCEL: '#atm.idle',
},
},
dispensingCash: {
invoke: {
src: 'dispenseCash',
input: ({ context }) => context.dispenseAmounts,
onDone: {
target: 'waitingForCashTaken',
actions: 'setCashDispensed',
},
onError: {
target: 'dispenseError',
actions: 'setError',
},
},
},
waitingForCashTaken: {
on: {
CASH_DISPENSED: 'askForReceipt',
TIMEOUT: 'askForReceipt', // Assume taken
},
},
askForReceipt: {
on: {
USER_SCANNED_NPUB: {
target: 'sendingReceipt',
actions: 'setUserNpub',
},
SKIP_RECEIPT: 'complete',
CANCEL: 'complete',
TIMEOUT: 'complete',
},
},
sendingReceipt: {
invoke: {
src: 'sendNostrReceipt',
input: ({ context }) => context,
onDone: 'complete',
onError: 'complete',
},
},
complete: {
after: {
COMPLETE_DELAY: '#atm.idle',
},
},
dispenseError: {
// Critical error - cash already paid but not dispensed
// Requires manual intervention
on: {
RETRY: {
guard: 'canRetry',
target: 'dispensingCash',
actions: 'incrementRetry',
},
},
},
error: {
on: {
RETRY: {
guard: 'canRetry',
target: 'fetchingRate',
actions: 'incrementRetry',
},
CANCEL: '#atm.idle',
},
},
},
},
},
})
}
/**
* Generate a unique transaction ID
*/
function generateTxId(): string {
const timestamp = Date.now().toString(36)
const random = Math.random().toString(36).substring(2, 10)
return `tx_${timestamp}_${random}`
}
/**
* Default ATM machine (no services - for type checking)
*/
export const atmMachine = createATMMachine()
/**
* Type of the ATM machine
*/
export type ATMMachine = typeof atmMachine

View file

@ -0,0 +1,122 @@
/**
* ATM State Machine type definitions
*/
/** Payment status */
export type PaymentStatus = 'pending' | 'paid' | 'failed' | null
/** Payment methods supported */
export type PaymentMethod = 'clink_offer' | 'lnurl_withdraw' | 'invoice' | 'cashu'
/** ATM machine context */
export interface ATMContext {
// Transaction details
/** Fiat amount in cents */
fiatAmount: number
/** Satoshi amount */
satsAmount: number
/** Fiat currency code */
currency: string
/** Exchange rate (sats per fiat unit) */
exchangeRate: number
/** Fee percentage (0.02 = 2%) */
feePercent: number
// Payment
/** BOLT11 invoice for payment */
invoice: string | null
/** CLINK offer string (noffer) */
clinkOffer: string | null
/** Current payment status */
paymentStatus: PaymentStatus
/** Payment preimage (proof of payment) */
preimage: string | null
/** Payment method used */
paymentMethod: PaymentMethod | null
// Hardware state
/** Bills inserted during cash-in */
billsInserted: number[]
/** Whether cash has been dispensed */
cashDispensed: boolean
/** Dispense amounts for cash-out */
dispenseAmounts: { denomination: number; count: number }[]
// User
/** User's npub for receipt */
userNpub: string | null
// Error handling
/** Current error message */
error: string | null
/** Retry count for recoverable errors */
retryCount: number
// Transaction metadata
/** Unique transaction ID */
txid: string | null
/** Transaction start time */
startedAt: number | null
}
/** ATM events */
export type ATMEvent =
// User actions
| { type: 'SELECT_CASH_IN' }
| { type: 'SELECT_CASH_OUT' }
| { type: 'CANCEL' }
| { type: 'SELECT_AMOUNT'; amount: number }
| { type: 'FINISH_INSERTING' }
| { type: 'USER_SCANNED_NPUB'; npub: string }
| { type: 'SKIP_RECEIPT' }
| { type: 'RETRY' }
// Hardware events
| { type: 'BILL_INSERTED'; denomination: number }
| { type: 'BILL_REJECTED'; reason: string }
| { type: 'CASH_DISPENSED' }
| { type: 'DISPENSE_ERROR'; error: string }
// Payment events
| { type: 'PAYMENT_RECEIVED'; preimage: string }
| { type: 'PAYMENT_FAILED'; error: string }
| { type: 'INVOICE_GENERATED'; invoice: string }
| { type: 'OFFER_GENERATED'; offer: string }
// System events
| { type: 'TIMEOUT' }
| { type: 'ERROR'; error: string }
| { type: 'EXCHANGE_RATE_UPDATED'; rate: number }
/** Initial context values */
export const initialContext: ATMContext = {
fiatAmount: 0,
satsAmount: 0,
currency: 'USD',
exchangeRate: 0,
feePercent: 0.02,
invoice: null,
clinkOffer: null,
paymentStatus: null,
preimage: null,
paymentMethod: null,
billsInserted: [],
cashDispensed: false,
dispenseAmounts: [],
userNpub: null,
error: null,
retryCount: 0,
txid: null,
startedAt: null,
}
/** Service inputs for actors */
export interface ATMServices {
/** Generate a CLINK offer */
generateClinkOffer: (context: ATMContext) => Promise<string>
/** Generate a Lightning invoice */
generateInvoice: (amountMsat: number) => Promise<string>
/** Send receipt via Nostr */
sendNostrReceipt: (context: ATMContext) => Promise<void>
/** Dispense cash */
dispenseCash: (amounts: { denomination: number; count: number }[]) => Promise<void>
/** Get current exchange rate */
getExchangeRate: (currency: string) => Promise<number>
}

View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
globals: false,
},
})