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:
parent
6ddb7c7a06
commit
f35d0330b9
6 changed files with 821 additions and 0 deletions
|
|
@ -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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
57
lamassu-next/packages/state-machine/src/index.ts
Normal file
57
lamassu-next/packages/state-machine/src/index.ts
Normal 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'
|
||||||
446
lamassu-next/packages/state-machine/src/machine.ts
Normal file
446
lamassu-next/packages/state-machine/src/machine.ts
Normal 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
|
||||||
122
lamassu-next/packages/state-machine/src/types.ts
Normal file
122
lamassu-next/packages/state-machine/src/types.ts
Normal 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>
|
||||||
|
}
|
||||||
22
lamassu-next/packages/state-machine/tsconfig.json
Normal file
22
lamassu-next/packages/state-machine/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
8
lamassu-next/packages/state-machine/vitest.config.ts
Normal file
8
lamassu-next/packages/state-machine/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
globals: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue