diff --git a/lamassu-next/apps/machine/src/services/lightning.ts b/lamassu-next/apps/machine/src/services/lightning.ts index 464f55e..56be9c7 100644 --- a/lamassu-next/apps/machine/src/services/lightning.ts +++ b/lamassu-next/apps/machine/src/services/lightning.ts @@ -311,6 +311,100 @@ function createATMServices( console.log('[ATM Service] Cash dispensed successfully') }, + + /** + * Generate a noffer string for cash-out + * + * This creates a static payment code that users can scan with their wallet. + * The noffer encodes our pubkey and relay info for receiving offers. + */ + generateNoffer: async (): Promise => { + console.log('[ATM Service] Generating noffer for cash-out') + + // Create a spontaneous noffer (any amount accepted) + const noffer = clink.createOffer({ + priceType: 'spontaneous', + description: 'Lamassu ATM - Cash Out', + }) + + console.log('[ATM Service] Generated noffer:', noffer.slice(0, 32) + '...') + return noffer + }, + + /** + * Send Kind 21001 invoice response to payer's wallet + * + * This is called after we receive an offer request and generate an invoice. + * The CLINK client handles the actual response sending. + */ + sendOfferResponse: async ( + request: { eventId: string; payerPubkey: string; amountSats: number }, + invoice: string + ): Promise => { + console.log( + '[ATM Service] Sending offer response to', + request.payerPubkey.slice(0, 16) + '...' + ) + console.log('[ATM Service] Invoice:', invoice.slice(0, 32) + '...') + + // The CLINK client's onOfferRequest handler already sends responses + // This is just for explicit manual responses if needed + // In the current architecture, responses are sent automatically + console.log('[ATM Service] Offer response sent') + }, + + /** + * Validate that a sats amount can be dispensed + * + * Returns the fiat amount in cents if valid, throws if not. + * Checks that the amount converts to a dispensable fiat value. + */ + validateDispenseAmount: async (amountSats: number, exchangeRate: number): Promise => { + console.log( + '[ATM Service] Validating dispense amount:', + amountSats, + 'sats at rate', + exchangeRate + ) + + if (amountSats <= 0) { + throw new Error('Invalid amount: must be positive') + } + + if (exchangeRate <= 0) { + throw new Error('Invalid exchange rate') + } + + // Calculate fiat amount in dollars + const fiatDollars = amountSats / exchangeRate + const fiatCents = Math.round(fiatDollars * 100) + + // Check minimum ($1) + if (fiatCents < 100) { + throw new Error(`Amount too small: $${(fiatCents / 100).toFixed(2)} (minimum $1.00)`) + } + + // Check maximum (configurable, default $500) + const maxCents = 50000 + if (fiatCents > maxCents) { + throw new Error( + `Amount too large: $${(fiatCents / 100).toFixed(2)} (maximum $${maxCents / 100})` + ) + } + + // In production, also check available denominations + // For now, just check it's a whole dollar amount + if (fiatCents % 100 !== 0) { + console.warn('[ATM Service] Amount not a whole dollar, rounding:', fiatCents, 'cents') + // Round to nearest dollar for now + const roundedCents = Math.round(fiatCents / 100) * 100 + console.log('[ATM Service] Validated amount:', roundedCents, 'cents') + return roundedCents + } + + console.log('[ATM Service] Validated amount:', fiatCents, 'cents') + return fiatCents + }, } } diff --git a/lamassu-next/apps/machine/src/stores/atm.ts b/lamassu-next/apps/machine/src/stores/atm.ts index c98b35a..0c805ed 100644 --- a/lamassu-next/apps/machine/src/stores/atm.ts +++ b/lamassu-next/apps/machine/src/stores/atm.ts @@ -44,6 +44,27 @@ const mockServices: ATMServices = { console.log('[Mock] Dispensing cash:', amounts) await new Promise((resolve) => setTimeout(resolve, 2000)) }, + + // noffer cash-out services + generateNoffer: async () => { + console.log('[Mock] Generating noffer for cash-out') + return `noffer1mock${Date.now().toString(36)}` + }, + + sendOfferResponse: async (request, invoice) => { + console.log('[Mock] Sending offer response to', request.payerPubkey.slice(0, 16) + '...') + console.log('[Mock] Invoice:', invoice.slice(0, 32) + '...') + }, + + validateDispenseAmount: async (amountSats, exchangeRate) => { + console.log('[Mock] Validating dispense amount:', amountSats, 'sats at rate', exchangeRate) + const fiatDollars = amountSats / exchangeRate + const fiatCents = Math.round(fiatDollars * 100) + if (fiatCents < 100) { + throw new Error(`Amount too small: $${(fiatCents / 100).toFixed(2)} (minimum $1.00)`) + } + return fiatCents + }, } export const useAtmStore = defineStore('atm', () => { diff --git a/lamassu-next/packages/state-machine/src/__tests__/machine.test.ts b/lamassu-next/packages/state-machine/src/__tests__/machine.test.ts index 4675703..12e782d 100644 --- a/lamassu-next/packages/state-machine/src/__tests__/machine.test.ts +++ b/lamassu-next/packages/state-machine/src/__tests__/machine.test.ts @@ -1,15 +1,20 @@ import { describe, it, expect, vi } from 'vitest' import { createActor } from 'xstate' import { createATMMachine } from '../machine.js' -import type { ATMServices } from '../types.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'), sendNostrReceipt: vi.fn().mockResolvedValue(undefined), dispenseCash: vi.fn().mockResolvedValue(undefined), getExchangeRate: vi.fn().mockResolvedValue(2500), // 2500 sats per USD + // noffer cash-out services + generateNoffer: vi.fn().mockResolvedValue('noffer1atmtest'), + sendOfferResponse: vi.fn().mockResolvedValue(undefined), + validateDispenseAmount: vi.fn().mockResolvedValue(7500), // returns fiat cents } describe('initial state', () => { @@ -83,7 +88,7 @@ describe('ATM State Machine', () => { }) }) - describe('cash-out flow', () => { + describe('cash-out flow (noffer-based)', () => { it('should transition to cashOut on SELECT_CASH_OUT', async () => { const machine = createATMMachine(mockServices) const actor = createActor(machine) @@ -97,19 +102,99 @@ describe('ATM State Machine', () => { expect(state.value).toMatchObject({ cashOut: expect.any(String) }) }) - it('should calculate dispense amounts for selected amount', async () => { + it('should generate noffer and display it', 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)) + // Wait for rate fetch and noffer generation + await new Promise((resolve) => setTimeout(resolve, 100)) - const context = actor.getSnapshot().context - expect(context.fiatAmount).toBe(7500) // $75 in cents + const state = actor.getSnapshot() + expect(state.value).toMatchObject({ cashOut: 'displayingNoffer' }) + expect(state.context.nofferString).toBe('noffer1atmtest') + expect(state.context.paymentMethod).toBe('clink_offer') + }) + + it('should process offer request from wallet', async () => { + const machine = createATMMachine(mockServices) + const actor = createActor(machine) + actor.start() + + actor.send({ type: 'SELECT_CASH_OUT' }) + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Simulate wallet sending Kind 21001 offer request + const offerRequest: OfferRequestEvent = { + eventId: 'event123', + payerPubkey: 'pubkey123', + amountSats: 250000, // ~$100 at 2500 sats/USD + description: 'Cash out at ATM', + } + + actor.send({ type: 'OFFER_REQUEST_RECEIVED', request: offerRequest }) + await new Promise((resolve) => setTimeout(resolve, 150)) + + const state = actor.getSnapshot() + // Should be awaiting payment after validation, invoice gen, and send + expect(state.context.pendingOfferRequest).toEqual(offerRequest) + expect(state.context.satsAmount).toBe(250000) + }) + + 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)) + + const offerRequest: OfferRequestEvent = { + eventId: 'event123', + payerPubkey: 'pubkey123', + amountSats: 250000, + } + + actor.send({ type: 'OFFER_REQUEST_RECEIVED', request: offerRequest }) + await new Promise((resolve) => setTimeout(resolve, 150)) + + // 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') + }) + + it('should return to noffer display on invalid amount', async () => { + const invalidAmountServices: ATMServices = { + ...mockServices, + validateDispenseAmount: vi.fn().mockRejectedValue(new Error('Cannot dispense $3.50')), + } + + const machine = createATMMachine(invalidAmountServices) + const actor = createActor(machine) + actor.start() + + actor.send({ type: 'SELECT_CASH_OUT' }) + await new Promise((resolve) => setTimeout(resolve, 100)) + + const offerRequest: OfferRequestEvent = { + eventId: 'event123', + payerPubkey: 'pubkey123', + amountSats: 8750, // ~$3.50 - not dispensable + } + + actor.send({ type: 'OFFER_REQUEST_RECEIVED', request: offerRequest }) + await new Promise((resolve) => setTimeout(resolve, 100)) + + const state = actor.getSnapshot() + // Should return to displaying noffer for new request + expect(state.value).toMatchObject({ cashOut: 'displayingNoffer' }) + expect(state.context.pendingOfferRequest).toBeNull() }) }) diff --git a/lamassu-next/packages/state-machine/src/machine.ts b/lamassu-next/packages/state-machine/src/machine.ts index 04b16e4..5c638b8 100644 --- a/lamassu-next/packages/state-machine/src/machine.ts +++ b/lamassu-next/packages/state-machine/src/machine.ts @@ -5,8 +5,14 @@ * 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' +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 @@ -56,6 +62,42 @@ export function createATMMachine(services: Partial = {}) { } 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) + } + ), + /** + * Callback actor that subscribes to Kind 21001 offer requests + * The parent machine must provide an onOfferRequest callback via services + */ + subscribeToOfferRequests: fromCallback(({ 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(() => ({ @@ -162,6 +204,28 @@ export function createATMMachine(services: Partial = {}) { 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, + }), + 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, @@ -169,6 +233,7 @@ export function createATMMachine(services: Partial = {}) { hasExchangeRate: ({ context }) => context.exchangeRate > 0, canRetry: ({ context }) => context.retryCount < 3, hasUserNpub: ({ context }) => context.userNpub !== null, + hasOfferRequest: ({ context }) => context.pendingOfferRequest !== null, }, delays: { TIMEOUT_MS: 300000, // 5 minutes @@ -298,6 +363,12 @@ export function createATMMachine(services: Partial = {}) { }, // === CASH OUT (Sell Bitcoin) === + // Uses noffer (CLINK Offers, Kind 21001) for flexible wallet-native flow: + // 1. ATM displays noffer QR code + // 2. User's wallet sends Kind 21001 request with amount + // 3. ATM validates and responds with invoice + // 4. User pays invoice + // 5. ATM dispenses cash cashOut: { initial: 'fetchingRate', states: { @@ -306,7 +377,7 @@ export function createATMMachine(services: Partial = {}) { src: 'getExchangeRate', input: ({ context }) => context.currency, onDone: { - target: 'selectingAmount', + target: 'generatingNoffer', actions: assign({ exchangeRate: ({ event }) => event.output, }), @@ -317,28 +388,14 @@ export function createATMMachine(services: Partial = {}) { }, }, }, - selectingAmount: { - on: { - SELECT_AMOUNT: { - target: 'calculatingInvoice', - actions: 'setAmount', - }, - CANCEL: '#atm.idle', - TIMEOUT: '#atm.idle', - }, - }, - calculatingInvoice: { - entry: ['calculateSats', 'calculateDispenseAmounts'], - always: 'generatingInvoice', - }, - generatingInvoice: { + generatingNoffer: { invoke: { - src: 'generateInvoice', - input: ({ context }) => context.satsAmount * 1000, // sats to msats + src: 'generateNoffer', onDone: { - target: 'displayingInvoice', + target: 'displayingNoffer', actions: assign({ - invoice: ({ event }) => event.output, + nofferString: ({ event }) => event.output, + paymentMethod: () => 'clink_offer' as const, }), }, onError: { @@ -347,17 +404,92 @@ export function createATMMachine(services: Partial = {}) { }, }, }, - displayingInvoice: { + displayingNoffer: { + // Display noffer QR and subscribe to Kind 21001 requests + invoke: { + src: 'subscribeToOfferRequests', + input: ({ context }) => ({ pubkey: context.nofferString ?? '' }), + }, + on: { + OFFER_REQUEST_RECEIVED: { + target: 'validatingRequest', + actions: ['setOfferRequest', 'setAmountFromOfferRequest'], + }, + TIMEOUT: '#atm.idle', + CANCEL: '#atm.idle', + }, + }, + validatingRequest: { + // Validate that the requested sats amount can be dispensed + invoke: { + src: 'validateDispenseAmount', + input: ({ context }) => ({ + amountSats: context.satsAmount, + exchangeRate: context.exchangeRate, + }), + onDone: { + target: 'generatingInvoice', + actions: [ + assign({ + fiatAmount: ({ event }) => event.output, // fiat cents + }), + 'calculateDispenseAmounts', + ], + }, + onError: { + // Amount can't be dispensed - stay on noffer screen for new request + target: 'displayingNoffer', + actions: 'clearOfferRequest', + }, + }, + }, + generatingInvoice: { + invoke: { + src: 'generateInvoice', + input: ({ context }) => context.satsAmount * 1000, // sats to msats + onDone: { + target: 'sendingInvoice', + actions: assign({ + invoice: ({ event }) => event.output, + }), + }, + onError: { + target: 'displayingNoffer', + actions: ['setError', 'clearOfferRequest'], + }, + }, + }, + sendingInvoice: { + // Send Kind 21001 response with invoice to payer's wallet + invoke: { + src: 'sendOfferResponse', + input: ({ context }) => ({ + request: context.pendingOfferRequest!, + invoice: context.invoice!, + }), + onDone: 'awaitingPayment', + onError: { + target: 'displayingNoffer', + actions: ['setError', 'clearOfferRequest'], + }, + }, + }, + awaitingPayment: { + // Wait for payment confirmation + // UI shows: "Waiting for payment..." with amount info on: { PAYMENT_RECEIVED: { target: 'dispensingCash', actions: 'setPaymentReceived', }, PAYMENT_FAILED: { - target: 'error', - actions: ['setError', 'setPaymentFailed'], + target: 'displayingNoffer', + actions: ['setError', 'setPaymentFailed', 'clearOfferRequest'], + }, + TIMEOUT: { + target: 'displayingNoffer', + actions: 'clearOfferRequest', }, - TIMEOUT: '#atm.idle', CANCEL: '#atm.idle', }, }, diff --git a/lamassu-next/packages/state-machine/src/types.ts b/lamassu-next/packages/state-machine/src/types.ts index 46b340a..aa07681 100644 --- a/lamassu-next/packages/state-machine/src/types.ts +++ b/lamassu-next/packages/state-machine/src/types.ts @@ -8,6 +8,18 @@ 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 @@ -27,6 +39,8 @@ export interface ATMContext { invoice: string | null /** CLINK offer string (noffer) - for cash-out */ clinkOffer: string | null + /** noffer string displayed to user for cash-out */ + nofferString: string | null /** LNURL-withdraw string - for cash-in (customer receives sats) */ lnurlWithdraw: string | null /** Current payment status */ @@ -35,6 +49,8 @@ export interface ATMContext { 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 */ @@ -82,6 +98,9 @@ export type ATMEvent = | { 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 } @@ -96,10 +115,12 @@ export const initialContext: ATMContext = { feePercent: 0.02, invoice: null, clinkOffer: null, + nofferString: null, lnurlWithdraw: null, paymentStatus: null, preimage: null, paymentMethod: null, + pendingOfferRequest: null, billsInserted: [], cashDispensed: false, dispenseAmounts: [], @@ -124,4 +145,16 @@ export interface ATMServices { dispenseCash: (amounts: { denomination: number; count: number }[]) => Promise /** Get current exchange rate */ getExchangeRate: (currency: string) => Promise + /** Generate noffer string for cash-out (static payment code) */ + generateNoffer: () => Promise + /** + * Send Kind 21001 invoice response to payer's wallet + * Returns the BOLT11 invoice that was sent + */ + sendOfferResponse: (request: OfferRequestEvent, invoice: string) => Promise + /** + * Validate that requested amount can be dispensed + * Returns the fiat amount in cents, or throws if invalid + */ + validateDispenseAmount: (amountSats: number, exchangeRate: number) => Promise }