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:
parent
30a2eb2199
commit
c98f126ba7
180 changed files with 2695 additions and 9587 deletions
277
packages/state-machine/src/__tests__/machine.test.ts
Normal file
277
packages/state-machine/src/__tests__/machine.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
57
packages/state-machine/src/index.ts
Normal file
57
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'
|
||||
651
packages/state-machine/src/machine.ts
Normal file
651
packages/state-machine/src/machine.ts
Normal 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
|
||||
191
packages/state-machine/src/types.ts
Normal file
191
packages/state-machine/src/types.ts
Normal 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>>
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue