fix: use GetLiveUserOperations for incoming invoice payment detection

Lightning.Pub's GetPaymentState RPC is for checking OUTGOING payments
(invoices you've paid), not incoming payments (invoices you've created).

This caused invoice payment detection to fail with "invoice not found"
even when payments succeeded.

Solution: Subscribe to GetLiveUserOperations events which Lightning.Pub
sends in real-time when invoices are paid. These events have:
- requestId: "GetLiveUserOperations"
- operation.type: "INCOMING_INVOICE"
- operation.identifier: the full invoice string

Key changes:
- packages/lightning/src/client.ts: Rewrote watchInvoice() to use
  subscription-based detection instead of GetPaymentState polling
- apps/machine/src/services/lightning.ts: Updated to use full invoice
  string instead of payment hash
- packages/lightning/TROUBLESHOOTING.md: Added Issue #9 documenting
  this gotcha (GetPaymentState vs GetLiveUserOperations)

Also includes ATM-driven cash-out flow implementation with:
- Invoice generation via NewInvoice RPC
- Real-time payment detection
- State machine updates for cash-out states

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-01-26 10:39:55 -05:00
commit 7defc10444
9 changed files with 879 additions and 271 deletions

View file

@ -140,8 +140,9 @@ export async function initializeLightningServices(): Promise<LightningServices>
console.log('[CLINK] Invoice created:', invoice.paymentRequest.slice(0, 32) + '...')
// Watch for payment
lightningPub.watchInvoice(invoice.paymentHash, (paidInvoice) => {
// Watch for payment using the full invoice string (not payment hash)
// Lightning.Pub sends LiveUserOperation events when invoices are paid
lightningPub.watchInvoice(invoice.paymentRequest, (paidInvoice) => {
console.log('[CLINK] Payment received! Preimage:', paidInvoice.preimage)
if (paidInvoice.preimage && paymentReceivedCallback) {
paymentReceivedCallback(paidInvoice.preimage)
@ -285,6 +286,7 @@ function createATMServices(
console.log('[ATM Service] Generating invoice for', amountMsat, 'msats')
const amountSats = Math.floor(amountMsat / 1000)
console.log('[ATM Service] Amount in sats:', amountSats)
try {
const invoice = await lightningPub.createInvoice({
@ -292,6 +294,12 @@ function createATMServices(
description: 'Lamassu ATM - Cash Out',
})
console.log('[ATM Service] createInvoice response:', invoice)
if (!invoice || !invoice.paymentRequest) {
throw new Error('Invoice creation returned empty response')
}
console.log('[ATM Service] Generated invoice:', invoice.paymentRequest.slice(0, 32) + '...')
return invoice.paymentRequest
} catch (error) {
@ -438,6 +446,56 @@ function createATMServices(
console.log('[ATM Service] Validated amount:', fiatCents, 'cents')
return fiatCents
},
/**
* Watch an invoice for payment (cash-out flow)
*
* Uses Lightning.Pub's GetLiveUserOperations subscription to detect payment.
* Returns a cleanup function to stop watching.
*
* Note: Lightning.Pub sends LiveUserOperation events when invoices are paid.
* We subscribe to kind 21000 events and filter for INCOMING_INVOICE operations.
*/
watchInvoice: (invoice: string, callback: (preimage: string) => void): (() => void) => {
console.log('[ATM Service] Watching invoice for payment:', invoice.slice(0, 32) + '...')
// Validate it looks like a BOLT11 invoice
if (!invoice.toLowerCase().startsWith('ln')) {
console.error('[ATM Service] Invalid invoice format - expected BOLT11')
return () => {}
}
// Lightning.Pub's watchInvoice subscribes to LiveUserOperation events
// and returns a cleanup function
const cleanup = lightningPub.watchInvoice(invoice, (paidInvoice) => {
console.log('[ATM Service] Invoice paid!')
// LiveUserOperation doesn't include preimage, so we use a placeholder
// In a real scenario, we'd need to get preimage from another source
callback(paidInvoice.preimage || 'payment-confirmed')
})
// Return the cleanup function from Lightning.Pub
return cleanup
},
/**
* Get available inventory (denominations and counts)
*
* In production, this would query the actual dispenser hardware.
* For Phase 1, returns mock inventory.
*/
getInventory: async (): Promise<Record<number, number>> => {
console.log('[ATM Service] Getting inventory')
// Mock inventory for Phase 1: only $20 bills
// In production, this would interface with the Rust HAL
const inventory: Record<number, number> = {
20: 50, // 50 x $20 bills = $1000 capacity
}
console.log('[ATM Service] Inventory:', inventory)
return inventory
},
}
}

View file

@ -71,6 +71,19 @@ const mockServices: ATMServices = {
}
return fiatCents
},
watchInvoice: (paymentHash, _callback) => {
console.log('[Mock] Watching invoice for payment:', paymentHash.slice(0, 20) + '...')
// Note: Auto-pay disabled for testing real invoice monitoring
// Use the "Simulate Payment Now" button in the UI to manually trigger payment
return () => {}
},
getInventory: async () => {
console.log('[Mock] Getting inventory')
// Mock inventory: only $20 bills for Phase 1
return { 20: 50 }
},
}
export const useAtmStore = defineStore('atm', () => {
@ -343,6 +356,23 @@ export const useAtmStore = defineStore('atm', () => {
send({ type: 'CASH_DISPENSED' })
}
// Cash-out selection methods
function addDenomination(denomination: number) {
send({ type: 'ADD_DENOMINATION', denomination })
}
function removeDenomination(denomination: number) {
send({ type: 'REMOVE_DENOMINATION', denomination })
}
function clearSelection() {
send({ type: 'CLEAR_SELECTION' })
}
function confirmAmount() {
send({ type: 'CONFIRM_AMOUNT' })
}
function toggleDebug() {
debugMode.value = !debugMode.value
}
@ -381,6 +411,10 @@ export const useAtmStore = defineStore('atm', () => {
requestDebit,
skipReceipt,
cashTaken,
addDenomination,
removeDenomination,
clearSelection,
confirmAmount,
toggleDebug,
}
})

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { watch, computed } from 'vue'
import { watch, computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAtmStore } from '@/stores/atm'
import { Button } from '@/components/ui/button'
@ -12,6 +12,23 @@ import QRCode from '@/components/QRCode.vue'
const router = useRouter()
const atmStore = useAtmStore()
// Copy invoice functionality
const copyButtonText = ref('Copy Invoice')
async function copyInvoice() {
if (!atmStore.context?.invoice) return
try {
await navigator.clipboard.writeText(atmStore.context.invoice)
copyButtonText.value = 'Copied!'
setTimeout(() => {
copyButtonText.value = 'Copy Invoice'
}, 2000)
} catch (err) {
console.error('Failed to copy:', err)
copyButtonText.value = 'Copy failed'
}
}
// Navigate back to idle when transaction completes
watch(
() => atmStore.isIdle,
@ -25,6 +42,38 @@ watch(
const nestedState = computed(() => atmStore.nestedState)
const context = computed(() => atmStore.context)
// Available denominations from inventory
const availableDenominations = computed(() => {
if (!context.value?.inventory) return []
return Object.keys(context.value.inventory)
.map(Number)
.filter((d) => (context.value?.inventory[d] || 0) > 0)
.sort((a, b) => b - a) // Largest first
})
// Count of each denomination selected
const selectionCounts = computed(() => {
if (!context.value?.cashOutSelection) return {}
const counts: Record<number, number> = {}
for (const denom of context.value.cashOutSelection) {
counts[denom] = (counts[denom] || 0) + 1
}
return counts
})
// Check if we can add more of a denomination
function canAdd(denom: number): boolean {
if (!context.value) return false
const available = context.value.inventory[denom] || 0
const selected = selectionCounts.value[denom] || 0
return selected < available
}
// Check if we can remove a denomination
function canRemove(denom: number): boolean {
return (selectionCounts.value[denom] || 0) > 0
}
function cancel() {
atmStore.cancel()
}
@ -65,90 +114,89 @@ function formatFiat(cents: number): string {
</CardContent>
</Card>
<!-- Generating Noffer -->
<Card v-else-if="nestedState === 'generatingNoffer'" class="w-80 border-0 bg-white/5">
<CardContent class="flex flex-col items-center gap-4 p-8">
<Skeleton class="h-10 w-10 rounded-full" />
<p class="text-muted-foreground">Preparing payment code...</p>
</CardContent>
</Card>
<!-- Display Noffer QR - Main cash-out screen -->
<Card v-else-if="nestedState === 'displayingNoffer'" class="w-[480px] border-0 bg-white/5">
<!-- Selecting Amount - Main cash-out screen -->
<Card v-else-if="nestedState === 'selectingAmount'" class="w-[520px] border-0 bg-white/5">
<CardHeader class="text-center">
<CardTitle>Scan to Sell Bitcoin</CardTitle>
<CardTitle>Select Cash Amount</CardTitle>
<CardDescription v-if="context">
Current rate:
<Badge variant="secondary">{{ formatSats(context.exchangeRate) }} sats/$</Badge>
Rate: <Badge variant="secondary">{{ formatSats(context.exchangeRate) }} sats/$</Badge>
</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<!-- Noffer QR Code -->
<div class="flex flex-col items-center">
<QRCode v-if="context?.nofferString" :value="context.nofferString" :size="220" />
<p class="mt-4 text-center text-sm text-muted-foreground">
Scan with your Lightning wallet
<!-- Denomination buttons -->
<div class="grid grid-cols-2 gap-4">
<div
v-for="denom in availableDenominations"
:key="denom"
class="flex items-center justify-between rounded-lg bg-white/5 p-4"
>
<span class="text-2xl font-bold text-green-400">${{ denom }}</span>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
:disabled="!canRemove(denom)"
@click="atmStore.removeDenomination(denom)"
>
-
</Button>
<span class="w-8 text-center text-lg">{{ selectionCounts[denom] || 0 }}</span>
<Button
variant="outline"
size="sm"
:disabled="!canAdd(denom)"
@click="atmStore.addDenomination(denom)"
>
+
</Button>
</div>
</div>
</div>
<!-- Selected amount summary -->
<div class="rounded-lg bg-white/10 p-6 text-center">
<p class="text-sm text-muted-foreground">You will receive</p>
<p class="text-4xl font-bold text-green-400">
${{ context ? formatFiat(context.fiatAmount) : '0.00' }}
</p>
<p class="mt-2 text-sm text-muted-foreground">
You pay:
<Badge variant="secondary" class="ml-1">
{{ context ? formatSats(context.satsAmount) : 0 }} sats
</Badge>
</p>
</div>
<!-- Action buttons -->
<div class="flex gap-4">
<Button
variant="outline"
class="flex-1"
:disabled="!context?.cashOutSelection?.length"
@click="atmStore.clearSelection()"
>
Clear
</Button>
<Button
class="flex-1 bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"
:disabled="!context?.cashOutSelection?.length"
@click="atmStore.confirmAmount()"
>
Continue
</Button>
</div>
<!-- Instructions -->
<Alert class="border-orange-500/50 bg-orange-500/10">
<AlertDescription>
<p class="mb-2 text-sm font-medium text-orange-400">How it works:</p>
<ol class="space-y-1 text-xs text-muted-foreground">
<li>1. Scan the QR code with Shock Wallet or compatible wallet</li>
<li>2. Enter the amount you want to sell (in sats)</li>
<li>3. Confirm the payment in your wallet</li>
<li>4. Receive cash from the ATM</li>
</ol>
</AlertDescription>
</Alert>
<!-- Info about limits -->
<div class="rounded-lg bg-white/5 p-4 text-center">
<p class="text-sm text-muted-foreground">
Limits: <Badge variant="outline">$1 - $500</Badge>
Select the cash amount you want to receive. You'll pay the equivalent in Bitcoin.
</p>
<p class="mt-1 text-xs text-muted-foreground">Waiting for payment request...</p>
</div>
<!-- Debug: Simulate offer request -->
<Alert v-if="atmStore.debugMode" class="border-red-500/50 bg-red-500/10">
<AlertDescription class="text-center">
<p class="mb-2 text-xs text-red-400">Dev: Simulate offer request</p>
<Button
variant="outline"
size="sm"
@click="
atmStore.send({
type: 'OFFER_REQUEST_RECEIVED',
request: {
eventId: 'mock-event-' + Date.now(),
payerPubkey: 'mock-pubkey-' + Date.now(),
amountSats: 250000,
description: 'Mock cash out',
},
})
"
>
Simulate $100 Request (250k sats)
</Button>
</AlertDescription>
</Alert>
</CardContent>
</Card>
<!-- Validating Request -->
<Card v-else-if="nestedState === 'validatingRequest'" class="w-80 border-0 bg-white/5">
<CardContent class="flex flex-col items-center gap-4 p-8">
<Skeleton class="h-10 w-10 rounded-full" />
<p class="text-muted-foreground">Validating amount...</p>
<p v-if="context" class="text-sm text-orange-400">
{{ formatSats(context.satsAmount) }} sats requested
</p>
</CardContent>
</Card>
<!-- Generating Invoice -->
<Card v-else-if="nestedState === 'generatingInvoice'" class="w-80 border-0 bg-white/5">
<CardContent class="flex flex-col items-center gap-4 p-8">
@ -157,49 +205,54 @@ function formatFiat(cents: number): string {
</CardContent>
</Card>
<!-- Sending Invoice -->
<Card v-else-if="nestedState === 'sendingInvoice'" class="w-80 border-0 bg-white/5">
<CardContent class="flex flex-col items-center gap-4 p-8">
<Skeleton class="h-10 w-10 rounded-full" />
<p class="text-muted-foreground">Sending invoice to wallet...</p>
</CardContent>
</Card>
<!-- Awaiting Payment -->
<Card v-else-if="nestedState === 'awaitingPayment'" class="w-96 border-0 bg-white/5">
<!-- Displaying Invoice - Show QR for payment -->
<Card v-else-if="nestedState === 'displayingInvoice'" class="w-[480px] border-0 bg-white/5">
<CardHeader class="text-center">
<CardTitle>Waiting for Payment</CardTitle>
<CardTitle>Scan to Pay</CardTitle>
<CardDescription v-if="context">
Pay <Badge variant="secondary">{{ formatSats(context.satsAmount) }} sats</Badge> to
receive <Badge class="bg-green-600">${{ formatFiat(context.fiatAmount) }}</Badge>
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex flex-col items-center gap-4 py-8">
<div class="h-16 w-16 animate-pulse rounded-full bg-orange-500/20">
<div class="flex h-full items-center justify-center text-3xl">⏳</div>
</div>
<p class="text-center text-muted-foreground">
Please confirm the payment in your wallet
<CardContent class="space-y-6">
<!-- Invoice QR Code -->
<div class="flex flex-col items-center">
<QRCode v-if="context?.invoice" :value="context.invoice" :size="220" />
<p class="mt-4 text-center text-sm text-muted-foreground">
Scan with any Lightning wallet
</p>
</div>
<!-- Invoice info (collapsed) -->
<div v-if="context?.invoice" class="rounded-lg bg-white/5 p-3 text-center">
<p class="truncate font-mono text-xs text-muted-foreground">
{{ context.invoice.slice(0, 40) }}...
<!-- Payment status -->
<div class="flex flex-col items-center gap-4 py-4">
<div class="h-12 w-12 animate-pulse rounded-full bg-orange-500/20">
<div class="flex h-full items-center justify-center text-2xl">⏳</div>
</div>
<p class="text-center text-muted-foreground">Waiting for payment...</p>
</div>
<!-- Invoice info with copy button -->
<div v-if="context?.invoice" class="rounded-lg bg-white/5 p-3">
<p class="mb-2 truncate text-center font-mono text-xs text-muted-foreground">
{{ context.invoice.slice(0, 50) }}...
</p>
<div class="flex justify-center">
<Button variant="outline" size="sm" @click="copyInvoice">
{{ copyButtonText }}
</Button>
</div>
</div>
<!-- Dev controls -->
<Alert v-if="atmStore.debugMode" class="border-red-500/50 bg-red-500/10">
<AlertDescription class="text-center">
<p class="mb-2 text-xs text-red-400">Dev: Use alice-pay or button below</p>
<Button
variant="outline"
size="sm"
@click="atmStore.paymentReceived('mock-preimage-' + Date.now())"
>
Dev: Simulate Payment
Simulate Payment
</Button>
</AlertDescription>
</Alert>

View file

@ -323,17 +323,191 @@ async sendRPC<T>(rpcName: string, body: unknown): Promise<T> {
---
## Issue #7: NewInvoice Response Field Name
### Symptom
```
TypeError: Cannot read properties of undefined (reading 'slice')
```
When accessing `response.payment_request` after calling `NewInvoice`.
### Cause
Lightning.Pub's `NewInvoice` RPC returns `invoice`, not `payment_request`:
```json
{ "invoice": "lnbcrt510u1p5hw7vz..." }
```
Not:
```json
{ "payment_request": "lnbcrt510u1p5hw7vz..." }
```
### Solution
Use `response.invoice` instead of `response.payment_request`:
```typescript
const response = await this.sendRPC<CreateInvoiceResponse>('NewInvoice', {...})
// WRONG
return { paymentRequest: response.payment_request }
// RIGHT
return { paymentRequest: response.invoice }
```
---
## Issue #8: No LookupInvoice RPC
### Symptom
- Lightning.Pub logs show: `ERROR unknown rpc call name from nostr event:LookupInvoice`
- Trying to check payment status using standard LND-style lookup
### Cause
Lightning.Pub doesn't have a `LookupInvoice` RPC method. The available methods for checking payment state are:
- `GetPaymentState` - For **outgoing** payments (invoices you've paid) - see Issue #9
- `GetLiveUserOperations` - For **incoming** payments (invoices you've created) - see Issue #9
### Solution (for outgoing payments only)
Use `GetPaymentState` with the full BOLT11 invoice to check if YOU paid an invoice:
> ⚠️ **Warning:** If you're trying to detect when someone PAYS an invoice you created,
> `GetPaymentState` won't work! See **Issue #9** for the correct approach.
```typescript
// WRONG - LookupInvoice doesn't exist
const response = await this.sendRPC('LookupInvoice', {
payment_hash: paymentHash,
})
// RIGHT - Use GetPaymentState with full invoice
const response = await this.sendRPC('GetPaymentState', {
invoice: fullBolt11Invoice,
})
// Response format:
// {
// amount: number,
// internal: boolean,
// network_fee: number,
// operation_id: string,
// paid_at_unix: number, // > 0 means paid
// service_fee: number
// }
```
**Important:** `GetPaymentState` doesn't return preimage. If you need the preimage, you'll need to get it from another source.
---
## Issue #9: GetPaymentState is for OUTGOING Payments Only
### Symptom
- `GetPaymentState` returns "invoice not found"
- Invoice was created successfully with `NewInvoice`
- Invoice was paid successfully (verified in LND logs)
- Client never detects the payment
### Cause
`GetPaymentState` is for checking **outgoing payments** (invoices you've PAID to others), NOT incoming payments (invoices you've CREATED that others pay).
Looking at Lightning.Pub's code:
```typescript
// paymentManager.ts - GetPaymentState
const invoice = await this.storage.paymentStorage.GetPaymentOwner(req.invoice)
// GetPaymentOwner looks in the PAYMENT storage, not the INVOICE storage!
```
### Solution
For detecting when an invoice you created has been paid, use the `GetLiveUserOperations` subscription. Lightning.Pub sends real-time notifications via Nostr kind 21000 events with `requestId: "GetLiveUserOperations"` when payments are received.
```typescript
// Subscribe to kind 21000 events from Lightning.Pub
const sub = relay.subscribe(
[
{
kinds: [21000],
authors: [lightningPubPubkey],
since: Math.floor(Date.now() / 1000) - 5,
},
],
{
onevent: (event) => {
// Check p-tags to ensure it's for us
const pTags = event.tags.filter((t) => t[0] === 'p')
if (!pTags.some((t) => t[1] === myPubkey)) return
const response = decrypt(event.content)
// Check if this is a LiveUserOperation
if (response.requestId !== 'GetLiveUserOperations') return
const op = response.operation
if (op.type !== 'INCOMING_INVOICE') return
// Match by invoice string
if (op.identifier === ourInvoice) {
console.log('Invoice paid! Amount:', op.amount)
}
},
}
)
```
**LiveUserOperation format:**
```json
{
"requestId": "GetLiveUserOperations",
"status": "OK",
"operation": {
"type": "INCOMING_INVOICE",
"identifier": "lnbcrt510u1p5hw7vz...", // The full invoice
"amount": 51000,
"paidAtUnix": 1706300000,
"inbound": true
},
"latest_balance": 150000
}
```
**Key insight:** Lightning.Pub has two different storages:
- `paymentStorage.GetPaymentOwner()` - For outgoing payments you've made
- `paymentStorage.GetInvoiceOwner()` - For incoming invoices you've created
`GetPaymentState` uses the wrong one for invoice monitoring!
---
## Time Spent on Each Issue
| Issue | Time to Diagnose | Root Cause |
| -------------- | ---------------- | ---------------------------------------- |
| ------------------ | ---------------- | --------------------------------------------- |
| RPC format | ~30 min | Missing `params`, `query` fields |
| Amount field | ~45 min | Confusing `0` vs actual amount semantics |
| SimplePool | ~60 min | Pool doesn't deliver real-time events |
| Race condition | ~15 min | Subscribe before publish |
| Tag filters | ~30 min | Relay doesn't support #p filter properly |
| #e matching | ~20 min | Lightning.Pub doesn't use e-tags |
| Invoice response | ~10 min | Field named `invoice` not `payment_request` |
| GetPaymentState | ~45 min | No LookupInvoice RPC |
| Incoming detection | ~60 min | GetPaymentState is for outgoing payments only |
**Total debugging time: ~3+ hours**
**Total debugging time: ~5+ hours**
Following this document should reduce that to ~15 minutes.

View file

@ -93,6 +93,8 @@ export class LightningPubClient {
* Create a Lightning invoice
*/
async createInvoice(params: CreateInvoiceParams): Promise<Invoice> {
console.log('[LightningPub] Creating invoice for', params.amountSats, 'sats')
const response = await this.sendRPC<CreateInvoiceResponse>('NewInvoice', {
amountSats: params.amountSats,
memo: params.description || 'ATM Payment',
@ -100,13 +102,23 @@ export class LightningPubClient {
private: params.privateHints ?? false,
})
console.log('[LightningPub] NewInvoice response:', JSON.stringify(response))
if (!response || !response.invoice) {
console.error('[LightningPub] Invalid response - missing invoice')
throw new Error('Invalid invoice response from Lightning.Pub')
}
// Extract payment hash from the invoice if not provided
const decoded = this.decodeInvoice(response.invoice)
return {
paymentRequest: response.payment_request,
paymentHash: response.payment_hash,
paymentRequest: response.invoice,
paymentHash: response.payment_hash || decoded.paymentHash,
amountSats: params.amountSats,
description: params.description,
createdAt: Math.floor(Date.now() / 1000),
expiresAt: response.expires_at,
expiresAt: response.expires_at || decoded.expiresAt,
status: 'pending',
}
}
@ -189,53 +201,182 @@ export class LightningPubClient {
}
/**
* Look up an invoice by payment hash
* Get payment state for an invoice
*
* Lightning.Pub uses GetPaymentState with the full invoice string,
* not LookupInvoice with a payment hash.
*/
async lookupInvoice(paymentHash: string): Promise<Invoice | null> {
async getPaymentState(invoice: string): Promise<{ paid: boolean; paidAt?: number } | null> {
try {
const response = await this.sendRPC<LookupInvoiceResponse>('LookupInvoice', {
payment_hash: paymentHash,
console.log('[LightningPub] Getting payment state for invoice:', invoice.slice(0, 32) + '...')
const response = await this.sendRPC<{
amount: number
internal: boolean
network_fee: number
operation_id: string
paid_at_unix: number
service_fee: number
}>('GetPaymentState', {
invoice,
})
console.log('[LightningPub] GetPaymentState response:', JSON.stringify(response))
return {
paymentRequest: response.payment_request,
paymentHash,
amountSats: response.amount,
description: response.description,
createdAt: response.created_at,
expiresAt: response.expires_at,
status: response.settled ? 'paid' : 'pending',
preimage: response.preimage,
paid: response.paid_at_unix > 0,
paidAt: response.paid_at_unix > 0 ? response.paid_at_unix : undefined,
}
} catch {
} catch (error) {
console.error('[LightningPub] GetPaymentState error:', error)
return null
}
}
/**
* Watch for invoice payment
* Look up an invoice by payment hash
* @deprecated Use getPaymentState(invoice) instead - Lightning.Pub doesn't support LookupInvoice
*/
watchInvoice(paymentHash: string, callback: InvoiceCallback): void {
this.invoiceCallbacks.set(paymentHash, callback)
// Poll for payment status
const pollInterval = setInterval(async () => {
const invoice = await this.lookupInvoice(paymentHash)
if (invoice && invoice.status === 'paid') {
clearInterval(pollInterval)
this.invoiceCallbacks.delete(paymentHash)
callback(invoice)
async lookupInvoice(paymentHash: string): Promise<Invoice | null> {
console.warn('[LightningPub] lookupInvoice is deprecated - use getPaymentState instead')
// This method is kept for backwards compatibility but won't work with Lightning.Pub
return null
}
}, 2000)
// Clean up after expiry
setTimeout(
/**
* Watch for invoice payment using GetLiveUserOperations subscription
*
* Lightning.Pub sends LiveUserOperation events via Nostr when payments
* are received. This is more reliable than polling GetPaymentState
* (which is for outgoing payments, not incoming invoices).
*
* @param invoice - The full BOLT11 invoice string (not payment hash)
* @param callback - Called when payment is detected
* @returns Cleanup function to stop watching
*/
watchInvoice(invoice: string, callback: InvoiceCallback): () => void {
if (!this.nostrClient || !this.identity) {
console.error('[LightningPub] Cannot watch invoice: client not initialized')
return () => {}
}
console.log('[LightningPub] Starting invoice watch for:', invoice.slice(0, 32) + '...')
// Use invoice as key
const invoiceKey = invoice.slice(0, 64)
this.invoiceCallbacks.set(invoiceKey, callback)
// Subscribe to kind 21000 events from Lightning.Pub
// LiveUserOperation events have requestId: "GetLiveUserOperations"
const subId = this.nostrClient.subscribe(
[
{
kinds: [LightningPubEventKind.RPC],
authors: [this.config.accountPubkey],
since: Math.floor(Date.now() / 1000) - 5,
},
],
{
onEvent: (event) => {
// Check if this event is for us (has our pubkey in p tags)
const pTags = event.tags.filter((t: string[]) => t[0] === 'p')
if (!pTags.some((t: string[]) => t[1] === this.identity!.publicKey)) {
return
}
try {
const response = decryptJSON<{
requestId: string
status: string
operation?: {
type: string
identifier: string
amount: number
paidAtUnix: number
inbound: boolean
}
latest_balance?: number
}>(this.identity!, this.config.accountPubkey, event.content)
// Check if this is a LiveUserOperation for incoming invoice
if (response.requestId !== 'GetLiveUserOperations') {
return
}
console.log('[LightningPub] Received LiveUserOperation:', JSON.stringify(response))
if (!response.operation) {
return
}
const op = response.operation
// Check if this is an incoming invoice payment matching our invoice
if (op.type !== 'INCOMING_INVOICE') {
console.log('[LightningPub] Operation type:', op.type, '(not INCOMING_INVOICE)')
return
}
// Match invoice - the identifier is the full invoice string
if (!op.identifier || !op.identifier.toLowerCase().startsWith('ln')) {
console.log('[LightningPub] Invalid identifier:', op.identifier?.slice(0, 20))
return
}
// Compare invoices (case-insensitive, matching prefix)
const opInvoiceKey = op.identifier.slice(0, 64).toLowerCase()
const watchedInvoiceKey = invoice.slice(0, 64).toLowerCase()
if (opInvoiceKey !== watchedInvoiceKey) {
console.log(
'[LightningPub] Invoice mismatch, waiting for:',
watchedInvoiceKey.slice(0, 20)
)
return
}
console.log('[LightningPub] Invoice payment detected! Amount:', op.amount, 'sats')
// Clean up subscription
this.nostrClient!.unsubscribe(subId)
this.invoiceCallbacks.delete(invoiceKey)
// Extract payment hash from invoice for the callback
const decoded = this.decodeInvoice(invoice)
callback({
paymentRequest: invoice,
paymentHash: decoded.paymentHash,
amountSats: op.amount,
description: decoded.description,
createdAt: op.paidAtUnix - 60, // approximate creation time
expiresAt: decoded.expiresAt,
status: 'paid',
preimage: 'paid-via-live-operation', // LiveUserOperation doesn't include preimage
})
} catch {
// Decryption failed - might not be for us, ignore
}
},
}
)
// Clean up after expiry (10 minutes)
const timeoutId = setTimeout(
() => {
clearInterval(pollInterval)
this.invoiceCallbacks.delete(paymentHash)
console.log('[LightningPub] Invoice watch timeout, cleaning up')
this.nostrClient?.unsubscribe(subId)
this.invoiceCallbacks.delete(invoiceKey)
},
10 * 60 * 1000
) // 10 minutes max
)
// Return cleanup function
return () => {
console.log('[LightningPub] Stopping invoice watch')
clearTimeout(timeoutId)
this.nostrClient?.unsubscribe(subId)
this.invoiceCallbacks.delete(invoiceKey)
}
}
/**
@ -260,9 +401,11 @@ export class LightningPubClient {
description: string
expiresAt: number
} {
const invoice = paymentRequest.toLowerCase()
// Match amount BEFORE the '1' separator
// Group 1: amount (optional), Group 2: multiplier (optional)
const amountMatch = paymentRequest.toLowerCase().match(/ln(?:bc|tb|bcrt)(\d+)?([munp])?1/)
const amountMatch = invoice.match(/ln(?:bc|tb|bcrt)(\d+)?([munp])?1/)
let amountSats: number | null = null
if (amountMatch && amountMatch[1]) {
@ -285,16 +428,67 @@ export class LightningPubClient {
amountSats = baseAmount * 100_000_000 // BTC to sats
}
}
// If no amount captured or match[1] is undefined, amountSats remains null (amountless)
// Extract payment hash from tagged data
// Data section starts after '1', first 7 chars are timestamp
// Then tagged fields: type (1 char) + length (2 chars) + data
// Payment hash tag type is 'p' (value 1), length is typically 'p5' (52 5-bit chars = 32 bytes)
let paymentHash = ''
const separatorIdx = invoice.lastIndexOf('1')
if (separatorIdx > 0) {
const data = invoice.slice(separatorIdx + 1)
// Skip timestamp (7 chars), look for payment hash tag
const afterTimestamp = data.slice(7)
// Find 'p' tag (payment hash) - it's usually the first tag
if (afterTimestamp.startsWith('pp')) {
// pp = type 'p' + length starts with 'p'
// Length is 2 chars after type: positions 1-2
// Data starts at position 3
const hashData = afterTimestamp.slice(3, 3 + 52) // 52 5-bit chars = 260 bits for 256-bit hash
paymentHash = this.bech32ToHex(hashData)
}
}
return {
amountSats,
paymentHash: '', // Would need proper parsing
paymentHash,
description: '',
expiresAt: Math.floor(Date.now() / 1000) + 3600,
}
}
/**
* Convert bech32-encoded 5-bit values to hex string
*/
private bech32ToHex(data: string): string {
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
// Convert each char to 5-bit value
const bits: number[] = []
for (const char of data) {
const idx = BECH32_CHARSET.indexOf(char)
if (idx === -1) return ''
bits.push(idx)
}
// Combine 5-bit values into 8-bit bytes
let buffer = 0
let bufferBits = 0
const bytes: number[] = []
for (const value of bits) {
buffer = (buffer << 5) | value
bufferBits += 5
if (bufferBits >= 8) {
bufferBits -= 8
bytes.push((buffer >> bufferBits) & 0xff)
}
}
// Convert to hex
return bytes.map((b) => b.toString(16).padStart(2, '0')).join('')
}
/**
* Get exchange rate (mock implementation)
*

View file

@ -125,12 +125,12 @@ export interface CreateInvoiceParams {
/** Create invoice response (from RPC) */
export interface CreateInvoiceResponse {
/** BOLT-11 payment request */
payment_request: string
/** Payment hash (hex) */
payment_hash: string
/** Expiry timestamp */
expires_at: number
/** BOLT-11 invoice string */
invoice: string
/** Payment hash (hex) - may not be returned, extract from invoice if needed */
payment_hash?: string
/** Expiry timestamp - may not be returned */
expires_at?: number
}
/** Lookup invoice response (from RPC) */

View file

@ -8,13 +8,17 @@ describe('ATM State Machine', () => {
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
// 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', () => {
@ -88,7 +92,7 @@ describe('ATM State Machine', () => {
})
})
describe('cash-out flow (noffer-based)', () => {
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)
@ -102,23 +106,24 @@ describe('ATM State Machine', () => {
expect(state.value).toMatchObject({ cashOut: expect.any(String) })
})
it('should generate noffer and display it', async () => {
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 and noffer generation
// Wait for rate fetch
await new Promise((resolve) => setTimeout(resolve, 100))
const state = actor.getSnapshot()
expect(state.value).toMatchObject({ cashOut: 'displayingNoffer' })
expect(state.context.nofferString).toBe('noffer1atmtest')
expect(state.context.paymentMethod).toBe('clink_offer')
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 process offer request from wallet', async () => {
it('should add and remove denominations', async () => {
const machine = createATMMachine(mockServices)
const actor = createActor(machine)
actor.start()
@ -126,21 +131,56 @@ describe('ATM State Machine', () => {
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',
}
// Add denominations
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
actor.send({ type: 'OFFER_REQUEST_RECEIVED', request: offerRequest })
await new Promise((resolve) => setTimeout(resolve, 150))
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()
// Should be awaiting payment after validation, invoice gen, and send
expect(state.context.pendingOfferRequest).toEqual(offerRequest)
expect(state.context.satsAmount).toBe(250000)
// $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 () => {
@ -151,14 +191,9 @@ describe('ATM State Machine', () => {
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))
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' })
@ -167,34 +202,25 @@ describe('ATM State Machine', () => {
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 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)
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))
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))
// Try to confirm with no selection
actor.send({ type: 'CONFIRM_AMOUNT' })
await new Promise((resolve) => setTimeout(resolve, 50))
const state = actor.getSnapshot()
// Should return to displaying noffer for new request
expect(state.value).toMatchObject({ cashOut: 'displayingNoffer' })
expect(state.context.pendingOfferRequest).toBeNull()
// Should still be in selectingAmount
expect(state.value).toMatchObject({ cashOut: 'selectingAmount' })
})
})

View file

@ -90,6 +90,29 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
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
@ -217,6 +240,60 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
},
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
@ -240,6 +317,19 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
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
@ -369,12 +459,12 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
},
// === 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
// 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: {
@ -383,7 +473,7 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
src: 'getExchangeRate',
input: ({ context }) => context.currency,
onDone: {
target: 'generatingNoffer',
target: 'selectingAmount',
actions: assign({
exchangeRate: ({ event }) => event.output,
}),
@ -394,59 +484,29 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
},
},
},
generatingNoffer: {
invoke: {
src: 'generateNoffer',
onDone: {
target: 'displayingNoffer',
actions: assign({
nofferString: ({ event }) => event.output,
paymentMethod: () => 'clink_offer' as const,
}),
},
onError: {
target: 'error',
actions: 'setError',
},
},
},
displayingNoffer: {
// Display noffer QR and subscribe to Kind 21001 requests
invoke: {
src: 'subscribeToOfferRequests',
input: ({ context }) => ({ pubkey: context.nofferString ?? '' }),
},
selectingAmount: {
// User selects denomination buttons to build up the cash amount
// UI shows: available denominations, running total, sats equivalent
entry: 'clearCashOutSelection',
on: {
OFFER_REQUEST_RECEIVED: {
target: 'validatingRequest',
actions: ['setOfferRequest', 'setAmountFromOfferRequest'],
ADD_DENOMINATION: {
guard: 'canAddDenomination',
actions: ['addDenomination', 'calculateSatsFromFiat'],
},
TIMEOUT: '#atm.idle',
CANCEL: '#atm.idle',
REMOVE_DENOMINATION: {
guard: 'canRemoveDenomination',
actions: ['removeDenomination', 'calculateSatsFromFiat'],
},
CLEAR_SELECTION: {
actions: 'clearCashOutSelection',
},
validatingRequest: {
// Validate that the requested sats amount can be dispensed
invoke: {
src: 'validateDispenseAmount',
input: ({ context }) => ({
amountSats: context.satsAmount,
exchangeRate: context.exchangeRate,
}),
onDone: {
CONFIRM_AMOUNT: {
guard: 'hasSelectedAmount',
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',
actions: 'calculateDispenseFromSelection',
},
CANCEL: '#atm.idle',
TIMEOUT: '#atm.idle',
},
},
generatingInvoice: {
@ -454,47 +514,35 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
src: 'generateInvoice',
input: ({ context }) => context.satsAmount * 1000, // sats to msats
onDone: {
target: 'sendingInvoice',
target: 'displayingInvoice',
actions: assign({
invoice: ({ event }) => event.output,
paymentMethod: () => 'invoice' as const,
}),
},
onError: {
target: 'displayingNoffer',
actions: ['setError', 'clearOfferRequest'],
target: 'selectingAmount',
actions: 'setError',
},
},
},
sendingInvoice: {
// Send Kind 21001 response with invoice to payer's wallet
displayingInvoice: {
// Show QR code with BOLT11 invoice, watch for payment
invoke: {
src: 'sendOfferResponse',
input: ({ context }) => ({
request: context.pendingOfferRequest!,
invoice: context.invoice!,
}),
onDone: 'awaitingPayment',
onError: {
target: 'displayingNoffer',
actions: ['setError', 'clearOfferRequest'],
src: 'watchInvoicePayment',
input: ({ context }) => ({ invoice: context.invoice! }),
},
},
},
awaitingPayment: {
// Wait for payment confirmation
// UI shows: "Waiting for payment..." with amount info
on: {
PAYMENT_RECEIVED: {
target: 'dispensingCash',
actions: 'setPaymentReceived',
},
PAYMENT_FAILED: {
target: 'displayingNoffer',
actions: ['setError', 'setPaymentFailed', 'clearOfferRequest'],
target: 'selectingAmount',
actions: ['setError', 'setPaymentFailed'],
},
TIMEOUT: {
target: 'displayingNoffer',
actions: 'clearOfferRequest',
target: 'selectingAmount',
},
CANCEL: '#atm.idle',
},
@ -544,7 +592,7 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
},
},
dispenseError: {
// Critical error - cash already paid but not dispensed
// Critical error - payment received but cash not dispensed
// Requires manual intervention
on: {
RETRY: {

View file

@ -61,6 +61,10 @@ export interface ATMContext {
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 */
@ -90,6 +94,11 @@ export type ATMEvent =
| { 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 }
@ -127,6 +136,9 @@ export const initialContext: ATMContext = {
billsInserted: [],
cashDispensed: false,
dispenseAmounts: [],
cashOutSelection: [],
// Mock inventory: $20 bills only for Phase 1
inventory: { 20: 50 },
userNpub: null,
error: null,
retryCount: 0,
@ -162,4 +174,13 @@ export interface ATMServices {
* 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>>
}