feat(docker): add dev.sh with auto-funding and ATM app setup

- Add dev.sh script for managing regtest development environment
- Implement cmd_fund to fund ATM app owner via Lightning.Pub API
- Add --fund flag to cmd_up for automatic funding on startup
- Update setup_atm_app to write VITE_APP_ID to machine .env
- Fix Electron IPC to pass appId and extensionApiUrl to renderer
- Restructure repo from nested lamassu-next/ to root

The dev.sh script now supports:
- ./dev.sh up --fund  # Start regtest and auto-fund ATM
- ./dev.sh fund       # Fund existing ATM app
- ./dev.sh status     # Show environment status
- ./dev.sh reset      # Clean restart

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-02-15 14:19:16 -05:00
commit c98f126ba7
180 changed files with 2695 additions and 9587 deletions

View file

@ -0,0 +1,277 @@
import { describe, it, expect, vi } from 'vitest'
import { createActor } from 'xstate'
import { createATMMachine } from '../machine.js'
import type { ATMServices, OfferRequestEvent } from '../types.js'
describe('ATM State Machine', () => {
const mockServices: ATMServices = {
generateClinkOffer: vi.fn().mockResolvedValue('noffer1test'),
generateInvoice: vi.fn().mockResolvedValue('lnbc1test'),
generateLnurlWithdraw: vi.fn().mockResolvedValue('lnurl1test'),
generateNdebit: vi.fn().mockResolvedValue('clink:ndebit1test?amount=1000'),
sendNostrReceipt: vi.fn().mockResolvedValue(undefined),
dispenseCash: vi.fn().mockResolvedValue(undefined),
getExchangeRate: vi.fn().mockResolvedValue(2500), // 2500 sats per USD
// noffer cash-out services (legacy)
generateNoffer: vi.fn().mockResolvedValue('noffer1atmtest'),
sendOfferResponse: vi.fn().mockResolvedValue(undefined),
validateDispenseAmount: vi.fn().mockResolvedValue(7500), // returns fiat cents
// New cash-out services
watchInvoice: vi.fn().mockReturnValue(() => {}),
getInventory: vi.fn().mockResolvedValue({ 20: 50 }), // 50 x $20 bills
}
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(3000) // $30 in cents
})
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 (ATM-driven amount selection)', () => {
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 go to selectingAmount after fetching rate', async () => {
const machine = createATMMachine(mockServices)
const actor = createActor(machine)
actor.start()
actor.send({ type: 'SELECT_CASH_OUT' })
// Wait for rate fetch
await new Promise((resolve) => setTimeout(resolve, 100))
const state = actor.getSnapshot()
expect(state.value).toMatchObject({ cashOut: 'selectingAmount' })
expect(state.context.exchangeRate).toBe(2500)
// Should have mock inventory
expect(state.context.inventory).toEqual({ 20: 50 })
})
it('should add and remove denominations', async () => {
const machine = createATMMachine(mockServices)
const actor = createActor(machine)
actor.start()
actor.send({ type: 'SELECT_CASH_OUT' })
await new Promise((resolve) => setTimeout(resolve, 100))
// Add denominations
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
let state = actor.getSnapshot()
expect(state.context.cashOutSelection).toEqual([20, 20, 20])
expect(state.context.fiatAmount).toBe(6000) // $60 in cents
// Remove one
actor.send({ type: 'REMOVE_DENOMINATION', denomination: 20 })
state = actor.getSnapshot()
expect(state.context.cashOutSelection).toEqual([20, 20])
expect(state.context.fiatAmount).toBe(4000) // $40 in cents
})
it('should calculate sats amount from fiat with fee', async () => {
const machine = createATMMachine(mockServices)
const actor = createActor(machine)
actor.start()
actor.send({ type: 'SELECT_CASH_OUT' })
await new Promise((resolve) => setTimeout(resolve, 100))
// Add $20
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
const state = actor.getSnapshot()
// $20 at 2500 sats/USD = 50,000 sats
// Plus 2% fee = 51,000 sats
expect(state.context.satsAmount).toBe(51000)
})
it('should generate invoice after confirming amount', async () => {
const machine = createATMMachine(mockServices)
const actor = createActor(machine)
actor.start()
actor.send({ type: 'SELECT_CASH_OUT' })
await new Promise((resolve) => setTimeout(resolve, 100))
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
actor.send({ type: 'CONFIRM_AMOUNT' })
await new Promise((resolve) => setTimeout(resolve, 100))
const state = actor.getSnapshot()
expect(state.value).toMatchObject({ cashOut: 'displayingInvoice' })
expect(state.context.invoice).toBe('lnbc1test')
expect(state.context.paymentMethod).toBe('invoice')
})
it('should dispense cash after payment received', async () => {
const machine = createATMMachine(mockServices)
const actor = createActor(machine)
actor.start()
actor.send({ type: 'SELECT_CASH_OUT' })
await new Promise((resolve) => setTimeout(resolve, 100))
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
actor.send({ type: 'CONFIRM_AMOUNT' })
await new Promise((resolve) => setTimeout(resolve, 100))
// Simulate payment
actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' })
await new Promise((resolve) => setTimeout(resolve, 100))
const state = actor.getSnapshot()
expect(state.context.paymentStatus).toBe('paid')
expect(state.context.preimage).toBe('preimage123')
// Should be in dispensing or later state
expect(state.context.dispenseAmounts).toContainEqual({ denomination: 20, count: 1 })
})
it('should not allow confirm without selection', async () => {
const machine = createATMMachine(mockServices)
const actor = createActor(machine)
actor.start()
actor.send({ type: 'SELECT_CASH_OUT' })
await new Promise((resolve) => setTimeout(resolve, 100))
// Try to confirm with no selection
actor.send({ type: 'CONFIRM_AMOUNT' })
await new Promise((resolve) => setTimeout(resolve, 50))
const state = actor.getSnapshot()
// Should still be in selectingAmount
expect(state.value).toMatchObject({ cashOut: 'selectingAmount' })
})
})
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,651 @@
/**
* 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, fromCallback } from 'xstate'
import {
type ATMContext,
type ATMEvent,
initialContext,
type ATMServices,
type OfferRequestEvent,
} 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)
}),
generateNdebit: fromPromise(async ({ input }: { input: ATMContext }) => {
if (!services.generateNdebit) {
throw new Error('generateNdebit service not provided')
}
return services.generateNdebit(input)
}),
generateLnurlWithdraw: fromPromise(async ({ input }: { input: ATMContext }) => {
if (!services.generateLnurlWithdraw) {
throw new Error('generateLnurlWithdraw service not provided')
}
return services.generateLnurlWithdraw(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)
}),
generateNoffer: fromPromise(async () => {
if (!services.generateNoffer) {
throw new Error('generateNoffer service not provided')
}
return services.generateNoffer()
}),
sendOfferResponse: fromPromise(
async ({ input }: { input: { request: OfferRequestEvent; invoice: string } }) => {
if (!services.sendOfferResponse) {
throw new Error('sendOfferResponse service not provided')
}
return services.sendOfferResponse(input.request, input.invoice)
}
),
validateDispenseAmount: fromPromise(
async ({ input }: { input: { amountSats: number; exchangeRate: number } }) => {
if (!services.validateDispenseAmount) {
throw new Error('validateDispenseAmount service not provided')
}
return services.validateDispenseAmount(input.amountSats, input.exchangeRate)
}
),
getInventory: fromPromise(async () => {
if (!services.getInventory) {
// Return mock inventory if service not provided
return { 20: 50 } as Record<number, number>
}
return services.getInventory()
}),
/**
* Callback actor that watches an invoice for payment
* Sends PAYMENT_RECEIVED when the invoice is paid
*/
watchInvoicePayment: fromCallback<ATMEvent, { invoice: string }>(({ sendBack, input }) => {
if (!services.watchInvoice) {
console.warn('watchInvoice service not provided')
return () => {}
}
// Extract payment hash from invoice (simplified - real impl would decode BOLT11)
// The service handles the actual extraction
const cleanup = services.watchInvoice(input.invoice, (preimage: string) => {
sendBack({ type: 'PAYMENT_RECEIVED', preimage })
})
return cleanup
}),
/**
* Callback actor that subscribes to Kind 21001 offer requests
* The parent machine must provide an onOfferRequest callback via services
*/
subscribeToOfferRequests: fromCallback<ATMEvent, { pubkey: string }>(({ sendBack }) => {
// This is a placeholder - actual implementation is injected via services
// The callback will receive OFFER_REQUEST_RECEIVED events
// In production, this subscribes to Nostr Kind 21001 events tagged with ATM pubkey
// Return cleanup function
return () => {
// Unsubscribe from Nostr events
}
}),
},
actions: {
resetContext: assign(() => ({
...initialContext,
cashInSessionId: null,
})),
setStartTime: assign({
startedAt: () => Date.now(),
txid: () => generateTxId(),
cashInSessionId: () => generateSessionId(),
}),
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
// denomination is in dollars, fiatAmount is in cents
return context.fiatAmount + event.denomination * 100
},
}),
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
},
}),
setNoffer: assign({
nofferString: ({ event }) => {
if (event.type !== 'NOFFER_GENERATED') return null
return event.noffer
},
paymentMethod: () => 'clink_offer' as const,
}),
// Cash-out selection actions
addDenomination: assign({
cashOutSelection: ({ context, event }) => {
if (event.type !== 'ADD_DENOMINATION') return context.cashOutSelection
return [...context.cashOutSelection, event.denomination]
},
fiatAmount: ({ context, event }) => {
if (event.type !== 'ADD_DENOMINATION') return context.fiatAmount
return context.fiatAmount + event.denomination * 100 // dollars to cents
},
}),
removeDenomination: assign({
cashOutSelection: ({ context, event }) => {
if (event.type !== 'REMOVE_DENOMINATION') return context.cashOutSelection
const idx = context.cashOutSelection.lastIndexOf(event.denomination)
if (idx === -1) return context.cashOutSelection
const newSelection = [...context.cashOutSelection]
newSelection.splice(idx, 1)
return newSelection
},
fiatAmount: ({ context, event }) => {
if (event.type !== 'REMOVE_DENOMINATION') return context.fiatAmount
if (!context.cashOutSelection.includes(event.denomination)) return context.fiatAmount
return context.fiatAmount - event.denomination * 100 // dollars to cents
},
}),
clearCashOutSelection: assign({
cashOutSelection: () => [],
fiatAmount: () => 0,
satsAmount: () => 0,
}),
calculateSatsFromFiat: assign({
satsAmount: ({ context }) => {
if (context.exchangeRate === 0) return 0
const fiatDollars = context.fiatAmount / 100 // cents to dollars
const grossSats = Math.floor(fiatDollars * context.exchangeRate)
// For cash-out, user pays the sats, so fee is added
const fee = Math.floor(grossSats * context.feePercent)
return grossSats + fee
},
}),
calculateDispenseFromSelection: assign({
dispenseAmounts: ({ context }) => {
// Group selected denominations by value
const counts = new Map<number, number>()
for (const denom of context.cashOutSelection) {
counts.set(denom, (counts.get(denom) || 0) + 1)
}
return Array.from(counts.entries()).map(([denomination, count]) => ({
denomination,
count,
}))
},
}),
setOfferRequest: assign({
pendingOfferRequest: ({ event }) => {
if (event.type !== 'OFFER_REQUEST_RECEIVED') return null
return event.request
},
}),
setAmountFromOfferRequest: assign({
satsAmount: ({ event }) => {
if (event.type !== 'OFFER_REQUEST_RECEIVED') return 0
return event.request.amountSats
},
}),
clearOfferRequest: assign({
pendingOfferRequest: () => null,
}),
},
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,
hasOfferRequest: ({ context }) => context.pendingOfferRequest !== null,
// Cash-out guards
hasSelectedAmount: ({ context }) => context.cashOutSelection.length > 0,
canAddDenomination: ({ context, event }) => {
if (event.type !== 'ADD_DENOMINATION') return false
const denom = event.denomination
const available = context.inventory[denom] || 0
const alreadySelected = context.cashOutSelection.filter((d) => d === denom).length
return alreadySelected < available
},
canRemoveDenomination: ({ context, event }) => {
if (event.type !== 'REMOVE_DENOMINATION') return false
return context.cashOutSelection.includes(event.denomination)
},
},
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: 'generatingNdebit',
},
CANCEL: '#atm.idle',
TIMEOUT: '#atm.idle',
},
},
generatingNdebit: {
invoke: {
src: 'generateNdebit',
input: ({ context }) => context,
onDone: {
target: 'displayingQR',
actions: assign({
ndebitUri: ({ 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) ===
// ATM-driven flow: user selects fiat amount on ATM, pays invoice, receives cash
// 1. ATM fetches exchange rate
// 2. User selects denominations on ATM screen
// 3. ATM generates BOLT11 invoice for calculated sats
// 4. User scans QR and pays from any Lightning wallet
// 5. ATM watches invoice, dispenses cash when paid
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: {
// User selects denomination buttons to build up the cash amount
// UI shows: available denominations, running total, sats equivalent
entry: 'clearCashOutSelection',
on: {
ADD_DENOMINATION: {
guard: 'canAddDenomination',
actions: ['addDenomination', 'calculateSatsFromFiat'],
},
REMOVE_DENOMINATION: {
guard: 'canRemoveDenomination',
actions: ['removeDenomination', 'calculateSatsFromFiat'],
},
CLEAR_SELECTION: {
actions: 'clearCashOutSelection',
},
CONFIRM_AMOUNT: {
guard: 'hasSelectedAmount',
target: 'generatingInvoice',
actions: 'calculateDispenseFromSelection',
},
CANCEL: '#atm.idle',
TIMEOUT: '#atm.idle',
},
},
generatingInvoice: {
invoke: {
src: 'generateInvoice',
input: ({ context }) => context.satsAmount * 1000, // sats to msats
onDone: {
target: 'displayingInvoice',
actions: assign({
invoice: ({ event }) => event.output,
paymentMethod: () => 'invoice' as const,
}),
},
onError: {
target: 'selectingAmount',
actions: 'setError',
},
},
},
displayingInvoice: {
// Show QR code with BOLT11 invoice, watch for payment
invoke: {
src: 'watchInvoicePayment',
input: ({ context }) => ({ invoice: context.invoice! }),
},
on: {
PAYMENT_RECEIVED: {
target: 'dispensingCash',
actions: 'setPaymentReceived',
},
PAYMENT_FAILED: {
target: 'selectingAmount',
actions: ['setError', 'setPaymentFailed'],
},
TIMEOUT: {
target: 'selectingAmount',
},
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 - payment received but cash 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}`
}
/**
* Generate a unique session ID for cash-in ndebit single-use protection
* This is used in the ndebit pointer field to link debit requests to specific transactions
*/
function generateSessionId(): string {
const timestamp = Date.now().toString(36)
const random = Math.random().toString(36).substring(2, 12)
return `${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,191 @@
/**
* 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'
/** Incoming offer request from a user's wallet (Kind 21001) */
export interface OfferRequestEvent {
/** Event ID for referencing in response */
eventId: string
/** Payer's pubkey */
payerPubkey: string
/** Requested amount in satoshis */
amountSats: number
/** Optional payer-provided description */
description?: string
}
/** 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) - for cash-out */
clinkOffer: string | null
/** noffer string displayed to user for cash-out */
nofferString: string | null
/** ndebit URI for cash-in (clink:ndebit1...?amount=X) */
ndebitUri: string | null
/** LNURL-withdraw string - for cash-in (customer receives sats) - legacy */
lnurlWithdraw: string | null
/** Current payment status */
paymentStatus: PaymentStatus
/** Payment preimage (proof of payment) */
preimage: string | null
/** Payment method used */
paymentMethod: PaymentMethod | null
/** Pending offer request (Kind 21001 from user's wallet) */
pendingOfferRequest: OfferRequestEvent | 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 }[]
/** Cash-out: selected denominations (each entry is one bill) */
cashOutSelection: number[]
/** Available inventory: denomination -> count available */
inventory: Record<number, 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
// Cash-in session (for ndebit single-use protection)
/** Unique session ID for this cash-in transaction (used in ndebit pointer) */
cashInSessionId: string | 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' }
// Cash-out amount selection
| { type: 'ADD_DENOMINATION'; denomination: number }
| { type: 'REMOVE_DENOMINATION'; denomination: number }
| { type: 'CLEAR_SELECTION' }
| { type: 'CONFIRM_AMOUNT' }
// 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 }
| { type: 'NOFFER_GENERATED'; noffer: string }
| { type: 'OFFER_REQUEST_RECEIVED'; request: OfferRequestEvent }
| { type: 'INVOICE_SENT'; invoice: 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,
nofferString: null,
ndebitUri: null,
lnurlWithdraw: null,
paymentStatus: null,
preimage: null,
paymentMethod: null,
pendingOfferRequest: null,
billsInserted: [],
cashDispensed: false,
dispenseAmounts: [],
cashOutSelection: [],
// Mock inventory: $20 bills only for Phase 1
inventory: { 20: 50 },
userNpub: null,
error: null,
retryCount: 0,
txid: null,
startedAt: null,
cashInSessionId: null,
}
/** Service inputs for actors */
export interface ATMServices {
/** Generate a CLINK offer (for cash-out) */
generateClinkOffer: (context: ATMContext) => Promise<string>
/** Generate an ndebit URI for cash-in (clink:ndebit1...?amount=X) */
generateNdebit: (context: ATMContext) => Promise<string>
/** Generate an LNURL-withdraw link (for cash-in - customer receives sats) - legacy */
generateLnurlWithdraw: (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>
/** Generate noffer string for cash-out (static payment code) */
generateNoffer: () => Promise<string>
/**
* Send Kind 21001 invoice response to payer's wallet
* Returns the BOLT11 invoice that was sent
*/
sendOfferResponse: (request: OfferRequestEvent, invoice: string) => Promise<void>
/**
* Validate that requested amount can be dispensed
* Returns the fiat amount in cents, or throws if invalid
*/
validateDispenseAmount: (amountSats: number, exchangeRate: number) => Promise<number>
/**
* Watch an invoice for payment (polling-based)
* Calls the callback when paid, returns cleanup function
*/
watchInvoice: (paymentHash: string, callback: (preimage: string) => void) => () => void
/**
* Get available inventory: denomination -> count
*/
getInventory: () => Promise<Record<number, number>>
}