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:
parent
0f50401c8b
commit
7defc10444
9 changed files with 879 additions and 271 deletions
|
|
@ -140,8 +140,9 @@ export async function initializeLightningServices(): Promise<LightningServices>
|
||||||
|
|
||||||
console.log('[CLINK] Invoice created:', invoice.paymentRequest.slice(0, 32) + '...')
|
console.log('[CLINK] Invoice created:', invoice.paymentRequest.slice(0, 32) + '...')
|
||||||
|
|
||||||
// Watch for payment
|
// Watch for payment using the full invoice string (not payment hash)
|
||||||
lightningPub.watchInvoice(invoice.paymentHash, (paidInvoice) => {
|
// Lightning.Pub sends LiveUserOperation events when invoices are paid
|
||||||
|
lightningPub.watchInvoice(invoice.paymentRequest, (paidInvoice) => {
|
||||||
console.log('[CLINK] Payment received! Preimage:', paidInvoice.preimage)
|
console.log('[CLINK] Payment received! Preimage:', paidInvoice.preimage)
|
||||||
if (paidInvoice.preimage && paymentReceivedCallback) {
|
if (paidInvoice.preimage && paymentReceivedCallback) {
|
||||||
paymentReceivedCallback(paidInvoice.preimage)
|
paymentReceivedCallback(paidInvoice.preimage)
|
||||||
|
|
@ -285,6 +286,7 @@ function createATMServices(
|
||||||
console.log('[ATM Service] Generating invoice for', amountMsat, 'msats')
|
console.log('[ATM Service] Generating invoice for', amountMsat, 'msats')
|
||||||
|
|
||||||
const amountSats = Math.floor(amountMsat / 1000)
|
const amountSats = Math.floor(amountMsat / 1000)
|
||||||
|
console.log('[ATM Service] Amount in sats:', amountSats)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const invoice = await lightningPub.createInvoice({
|
const invoice = await lightningPub.createInvoice({
|
||||||
|
|
@ -292,6 +294,12 @@ function createATMServices(
|
||||||
description: 'Lamassu ATM - Cash Out',
|
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) + '...')
|
console.log('[ATM Service] Generated invoice:', invoice.paymentRequest.slice(0, 32) + '...')
|
||||||
return invoice.paymentRequest
|
return invoice.paymentRequest
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -438,6 +446,56 @@ function createATMServices(
|
||||||
console.log('[ATM Service] Validated amount:', fiatCents, 'cents')
|
console.log('[ATM Service] Validated amount:', fiatCents, 'cents')
|
||||||
return fiatCents
|
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
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,19 @@ const mockServices: ATMServices = {
|
||||||
}
|
}
|
||||||
return fiatCents
|
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', () => {
|
export const useAtmStore = defineStore('atm', () => {
|
||||||
|
|
@ -343,6 +356,23 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
send({ type: 'CASH_DISPENSED' })
|
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() {
|
function toggleDebug() {
|
||||||
debugMode.value = !debugMode.value
|
debugMode.value = !debugMode.value
|
||||||
}
|
}
|
||||||
|
|
@ -381,6 +411,10 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
requestDebit,
|
requestDebit,
|
||||||
skipReceipt,
|
skipReceipt,
|
||||||
cashTaken,
|
cashTaken,
|
||||||
|
addDenomination,
|
||||||
|
removeDenomination,
|
||||||
|
clearSelection,
|
||||||
|
confirmAmount,
|
||||||
toggleDebug,
|
toggleDebug,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watch, computed } from 'vue'
|
import { watch, computed, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAtmStore } from '@/stores/atm'
|
import { useAtmStore } from '@/stores/atm'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
@ -12,6 +12,23 @@ import QRCode from '@/components/QRCode.vue'
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const atmStore = useAtmStore()
|
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
|
// Navigate back to idle when transaction completes
|
||||||
watch(
|
watch(
|
||||||
() => atmStore.isIdle,
|
() => atmStore.isIdle,
|
||||||
|
|
@ -25,6 +42,38 @@ watch(
|
||||||
const nestedState = computed(() => atmStore.nestedState)
|
const nestedState = computed(() => atmStore.nestedState)
|
||||||
const context = computed(() => atmStore.context)
|
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() {
|
function cancel() {
|
||||||
atmStore.cancel()
|
atmStore.cancel()
|
||||||
}
|
}
|
||||||
|
|
@ -65,90 +114,89 @@ function formatFiat(cents: number): string {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Generating Noffer -->
|
<!-- Selecting Amount - Main cash-out screen -->
|
||||||
<Card v-else-if="nestedState === 'generatingNoffer'" class="w-80 border-0 bg-white/5">
|
<Card v-else-if="nestedState === 'selectingAmount'" class="w-[520px] 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">
|
|
||||||
<CardHeader class="text-center">
|
<CardHeader class="text-center">
|
||||||
<CardTitle>Scan to Sell Bitcoin</CardTitle>
|
<CardTitle>Select Cash Amount</CardTitle>
|
||||||
<CardDescription v-if="context">
|
<CardDescription v-if="context">
|
||||||
Current rate:
|
Rate: <Badge variant="secondary">{{ formatSats(context.exchangeRate) }} sats/$</Badge>
|
||||||
<Badge variant="secondary">{{ formatSats(context.exchangeRate) }} sats/$</Badge>
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-6">
|
<CardContent class="space-y-6">
|
||||||
<!-- Noffer QR Code -->
|
<!-- Denomination buttons -->
|
||||||
<div class="flex flex-col items-center">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<QRCode v-if="context?.nofferString" :value="context.nofferString" :size="220" />
|
<div
|
||||||
<p class="mt-4 text-center text-sm text-muted-foreground">
|
v-for="denom in availableDenominations"
|
||||||
Scan with your Lightning wallet
|
: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>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Instructions -->
|
<!-- Instructions -->
|
||||||
<Alert class="border-orange-500/50 bg-orange-500/10">
|
<Alert class="border-orange-500/50 bg-orange-500/10">
|
||||||
<AlertDescription>
|
<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">
|
<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>
|
||||||
<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>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 -->
|
<!-- Generating Invoice -->
|
||||||
<Card v-else-if="nestedState === 'generatingInvoice'" class="w-80 border-0 bg-white/5">
|
<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">
|
<CardContent class="flex flex-col items-center gap-4 p-8">
|
||||||
|
|
@ -157,49 +205,54 @@ function formatFiat(cents: number): string {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Sending Invoice -->
|
<!-- Displaying Invoice - Show QR for payment -->
|
||||||
<Card v-else-if="nestedState === 'sendingInvoice'" class="w-80 border-0 bg-white/5">
|
<Card v-else-if="nestedState === 'displayingInvoice'" class="w-[480px] 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">
|
|
||||||
<CardHeader class="text-center">
|
<CardHeader class="text-center">
|
||||||
<CardTitle>Waiting for Payment</CardTitle>
|
<CardTitle>Scan to Pay</CardTitle>
|
||||||
<CardDescription v-if="context">
|
<CardDescription v-if="context">
|
||||||
Pay <Badge variant="secondary">{{ formatSats(context.satsAmount) }} sats</Badge> to
|
Pay <Badge variant="secondary">{{ formatSats(context.satsAmount) }} sats</Badge> to
|
||||||
receive <Badge class="bg-green-600">${{ formatFiat(context.fiatAmount) }}</Badge>
|
receive <Badge class="bg-green-600">${{ formatFiat(context.fiatAmount) }}</Badge>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-6">
|
||||||
<div class="flex flex-col items-center gap-4 py-8">
|
<!-- Invoice QR Code -->
|
||||||
<div class="h-16 w-16 animate-pulse rounded-full bg-orange-500/20">
|
<div class="flex flex-col items-center">
|
||||||
<div class="flex h-full items-center justify-center text-3xl">⏳</div>
|
<QRCode v-if="context?.invoice" :value="context.invoice" :size="220" />
|
||||||
</div>
|
<p class="mt-4 text-center text-sm text-muted-foreground">
|
||||||
<p class="text-center text-muted-foreground">
|
Scan with any Lightning wallet
|
||||||
Please confirm the payment in your wallet
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Invoice info (collapsed) -->
|
<!-- Payment status -->
|
||||||
<div v-if="context?.invoice" class="rounded-lg bg-white/5 p-3 text-center">
|
<div class="flex flex-col items-center gap-4 py-4">
|
||||||
<p class="truncate font-mono text-xs text-muted-foreground">
|
<div class="h-12 w-12 animate-pulse rounded-full bg-orange-500/20">
|
||||||
{{ context.invoice.slice(0, 40) }}...
|
<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>
|
</p>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<Button variant="outline" size="sm" @click="copyInvoice">
|
||||||
|
{{ copyButtonText }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dev controls -->
|
<!-- Dev controls -->
|
||||||
<Alert v-if="atmStore.debugMode" class="border-red-500/50 bg-red-500/10">
|
<Alert v-if="atmStore.debugMode" class="border-red-500/50 bg-red-500/10">
|
||||||
<AlertDescription class="text-center">
|
<AlertDescription class="text-center">
|
||||||
|
<p class="mb-2 text-xs text-red-400">Dev: Use alice-pay or button below</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@click="atmStore.paymentReceived('mock-preimage-' + Date.now())"
|
@click="atmStore.paymentReceived('mock-preimage-' + Date.now())"
|
||||||
>
|
>
|
||||||
Dev: Simulate Payment
|
Simulate Payment
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
||||||
|
|
@ -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
|
## Time Spent on Each Issue
|
||||||
|
|
||||||
| Issue | Time to Diagnose | Root Cause |
|
| Issue | Time to Diagnose | Root Cause |
|
||||||
| -------------- | ---------------- | ---------------------------------------- |
|
| ------------------ | ---------------- | --------------------------------------------- |
|
||||||
| RPC format | ~30 min | Missing `params`, `query` fields |
|
| RPC format | ~30 min | Missing `params`, `query` fields |
|
||||||
| Amount field | ~45 min | Confusing `0` vs actual amount semantics |
|
| Amount field | ~45 min | Confusing `0` vs actual amount semantics |
|
||||||
| SimplePool | ~60 min | Pool doesn't deliver real-time events |
|
| SimplePool | ~60 min | Pool doesn't deliver real-time events |
|
||||||
| Race condition | ~15 min | Subscribe before publish |
|
| Race condition | ~15 min | Subscribe before publish |
|
||||||
| Tag filters | ~30 min | Relay doesn't support #p filter properly |
|
| Tag filters | ~30 min | Relay doesn't support #p filter properly |
|
||||||
| #e matching | ~20 min | Lightning.Pub doesn't use e-tags |
|
| #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.
|
Following this document should reduce that to ~15 minutes.
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,8 @@ export class LightningPubClient {
|
||||||
* Create a Lightning invoice
|
* Create a Lightning invoice
|
||||||
*/
|
*/
|
||||||
async createInvoice(params: CreateInvoiceParams): Promise<Invoice> {
|
async createInvoice(params: CreateInvoiceParams): Promise<Invoice> {
|
||||||
|
console.log('[LightningPub] Creating invoice for', params.amountSats, 'sats')
|
||||||
|
|
||||||
const response = await this.sendRPC<CreateInvoiceResponse>('NewInvoice', {
|
const response = await this.sendRPC<CreateInvoiceResponse>('NewInvoice', {
|
||||||
amountSats: params.amountSats,
|
amountSats: params.amountSats,
|
||||||
memo: params.description || 'ATM Payment',
|
memo: params.description || 'ATM Payment',
|
||||||
|
|
@ -100,13 +102,23 @@ export class LightningPubClient {
|
||||||
private: params.privateHints ?? false,
|
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 {
|
return {
|
||||||
paymentRequest: response.payment_request,
|
paymentRequest: response.invoice,
|
||||||
paymentHash: response.payment_hash,
|
paymentHash: response.payment_hash || decoded.paymentHash,
|
||||||
amountSats: params.amountSats,
|
amountSats: params.amountSats,
|
||||||
description: params.description,
|
description: params.description,
|
||||||
createdAt: Math.floor(Date.now() / 1000),
|
createdAt: Math.floor(Date.now() / 1000),
|
||||||
expiresAt: response.expires_at,
|
expiresAt: response.expires_at || decoded.expiresAt,
|
||||||
status: 'pending',
|
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 {
|
try {
|
||||||
const response = await this.sendRPC<LookupInvoiceResponse>('LookupInvoice', {
|
console.log('[LightningPub] Getting payment state for invoice:', invoice.slice(0, 32) + '...')
|
||||||
payment_hash: paymentHash,
|
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 {
|
return {
|
||||||
paymentRequest: response.payment_request,
|
paid: response.paid_at_unix > 0,
|
||||||
paymentHash,
|
paidAt: response.paid_at_unix > 0 ? response.paid_at_unix : undefined,
|
||||||
amountSats: response.amount,
|
|
||||||
description: response.description,
|
|
||||||
createdAt: response.created_at,
|
|
||||||
expiresAt: response.expires_at,
|
|
||||||
status: response.settled ? 'paid' : 'pending',
|
|
||||||
preimage: response.preimage,
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
console.error('[LightningPub] GetPaymentState error:', error)
|
||||||
return null
|
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 {
|
async lookupInvoice(paymentHash: string): Promise<Invoice | null> {
|
||||||
this.invoiceCallbacks.set(paymentHash, callback)
|
console.warn('[LightningPub] lookupInvoice is deprecated - use getPaymentState instead')
|
||||||
|
// This method is kept for backwards compatibility but won't work with Lightning.Pub
|
||||||
// Poll for payment status
|
return null
|
||||||
const pollInterval = setInterval(async () => {
|
|
||||||
const invoice = await this.lookupInvoice(paymentHash)
|
|
||||||
if (invoice && invoice.status === 'paid') {
|
|
||||||
clearInterval(pollInterval)
|
|
||||||
this.invoiceCallbacks.delete(paymentHash)
|
|
||||||
callback(invoice)
|
|
||||||
}
|
}
|
||||||
}, 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)
|
console.log('[LightningPub] Invoice watch timeout, cleaning up')
|
||||||
this.invoiceCallbacks.delete(paymentHash)
|
this.nostrClient?.unsubscribe(subId)
|
||||||
|
this.invoiceCallbacks.delete(invoiceKey)
|
||||||
},
|
},
|
||||||
10 * 60 * 1000
|
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
|
description: string
|
||||||
expiresAt: number
|
expiresAt: number
|
||||||
} {
|
} {
|
||||||
|
const invoice = paymentRequest.toLowerCase()
|
||||||
|
|
||||||
// Match amount BEFORE the '1' separator
|
// Match amount BEFORE the '1' separator
|
||||||
// Group 1: amount (optional), Group 2: multiplier (optional)
|
// 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
|
let amountSats: number | null = null
|
||||||
|
|
||||||
if (amountMatch && amountMatch[1]) {
|
if (amountMatch && amountMatch[1]) {
|
||||||
|
|
@ -285,16 +428,67 @@ export class LightningPubClient {
|
||||||
amountSats = baseAmount * 100_000_000 // BTC to sats
|
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 {
|
return {
|
||||||
amountSats,
|
amountSats,
|
||||||
paymentHash: '', // Would need proper parsing
|
paymentHash,
|
||||||
description: '',
|
description: '',
|
||||||
expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
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)
|
* Get exchange rate (mock implementation)
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -125,12 +125,12 @@ export interface CreateInvoiceParams {
|
||||||
|
|
||||||
/** Create invoice response (from RPC) */
|
/** Create invoice response (from RPC) */
|
||||||
export interface CreateInvoiceResponse {
|
export interface CreateInvoiceResponse {
|
||||||
/** BOLT-11 payment request */
|
/** BOLT-11 invoice string */
|
||||||
payment_request: string
|
invoice: string
|
||||||
/** Payment hash (hex) */
|
/** Payment hash (hex) - may not be returned, extract from invoice if needed */
|
||||||
payment_hash: string
|
payment_hash?: string
|
||||||
/** Expiry timestamp */
|
/** Expiry timestamp - may not be returned */
|
||||||
expires_at: number
|
expires_at?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lookup invoice response (from RPC) */
|
/** Lookup invoice response (from RPC) */
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,17 @@ describe('ATM State Machine', () => {
|
||||||
generateClinkOffer: vi.fn().mockResolvedValue('noffer1test'),
|
generateClinkOffer: vi.fn().mockResolvedValue('noffer1test'),
|
||||||
generateInvoice: vi.fn().mockResolvedValue('lnbc1test'),
|
generateInvoice: vi.fn().mockResolvedValue('lnbc1test'),
|
||||||
generateLnurlWithdraw: vi.fn().mockResolvedValue('lnurl1test'),
|
generateLnurlWithdraw: vi.fn().mockResolvedValue('lnurl1test'),
|
||||||
|
generateNdebit: vi.fn().mockResolvedValue('clink:ndebit1test?amount=1000'),
|
||||||
sendNostrReceipt: vi.fn().mockResolvedValue(undefined),
|
sendNostrReceipt: vi.fn().mockResolvedValue(undefined),
|
||||||
dispenseCash: vi.fn().mockResolvedValue(undefined),
|
dispenseCash: vi.fn().mockResolvedValue(undefined),
|
||||||
getExchangeRate: vi.fn().mockResolvedValue(2500), // 2500 sats per USD
|
getExchangeRate: vi.fn().mockResolvedValue(2500), // 2500 sats per USD
|
||||||
// noffer cash-out services
|
// noffer cash-out services (legacy)
|
||||||
generateNoffer: vi.fn().mockResolvedValue('noffer1atmtest'),
|
generateNoffer: vi.fn().mockResolvedValue('noffer1atmtest'),
|
||||||
sendOfferResponse: vi.fn().mockResolvedValue(undefined),
|
sendOfferResponse: vi.fn().mockResolvedValue(undefined),
|
||||||
validateDispenseAmount: vi.fn().mockResolvedValue(7500), // returns fiat cents
|
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', () => {
|
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 () => {
|
it('should transition to cashOut on SELECT_CASH_OUT', async () => {
|
||||||
const machine = createATMMachine(mockServices)
|
const machine = createATMMachine(mockServices)
|
||||||
const actor = createActor(machine)
|
const actor = createActor(machine)
|
||||||
|
|
@ -102,23 +106,24 @@ describe('ATM State Machine', () => {
|
||||||
expect(state.value).toMatchObject({ cashOut: expect.any(String) })
|
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 machine = createATMMachine(mockServices)
|
||||||
const actor = createActor(machine)
|
const actor = createActor(machine)
|
||||||
actor.start()
|
actor.start()
|
||||||
|
|
||||||
actor.send({ type: 'SELECT_CASH_OUT' })
|
actor.send({ type: 'SELECT_CASH_OUT' })
|
||||||
|
|
||||||
// Wait for rate fetch and noffer generation
|
// Wait for rate fetch
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
|
||||||
const state = actor.getSnapshot()
|
const state = actor.getSnapshot()
|
||||||
expect(state.value).toMatchObject({ cashOut: 'displayingNoffer' })
|
expect(state.value).toMatchObject({ cashOut: 'selectingAmount' })
|
||||||
expect(state.context.nofferString).toBe('noffer1atmtest')
|
expect(state.context.exchangeRate).toBe(2500)
|
||||||
expect(state.context.paymentMethod).toBe('clink_offer')
|
// 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 machine = createATMMachine(mockServices)
|
||||||
const actor = createActor(machine)
|
const actor = createActor(machine)
|
||||||
actor.start()
|
actor.start()
|
||||||
|
|
@ -126,21 +131,56 @@ describe('ATM State Machine', () => {
|
||||||
actor.send({ type: 'SELECT_CASH_OUT' })
|
actor.send({ type: 'SELECT_CASH_OUT' })
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
|
||||||
// Simulate wallet sending Kind 21001 offer request
|
// Add denominations
|
||||||
const offerRequest: OfferRequestEvent = {
|
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
|
||||||
eventId: 'event123',
|
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
|
||||||
payerPubkey: 'pubkey123',
|
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
|
||||||
amountSats: 250000, // ~$100 at 2500 sats/USD
|
|
||||||
description: 'Cash out at ATM',
|
|
||||||
}
|
|
||||||
|
|
||||||
actor.send({ type: 'OFFER_REQUEST_RECEIVED', request: offerRequest })
|
let state = actor.getSnapshot()
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
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()
|
const state = actor.getSnapshot()
|
||||||
// Should be awaiting payment after validation, invoice gen, and send
|
// $20 at 2500 sats/USD = 50,000 sats
|
||||||
expect(state.context.pendingOfferRequest).toEqual(offerRequest)
|
// Plus 2% fee = 51,000 sats
|
||||||
expect(state.context.satsAmount).toBe(250000)
|
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 () => {
|
it('should dispense cash after payment received', async () => {
|
||||||
|
|
@ -151,14 +191,9 @@ describe('ATM State Machine', () => {
|
||||||
actor.send({ type: 'SELECT_CASH_OUT' })
|
actor.send({ type: 'SELECT_CASH_OUT' })
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
|
||||||
const offerRequest: OfferRequestEvent = {
|
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
|
||||||
eventId: 'event123',
|
actor.send({ type: 'CONFIRM_AMOUNT' })
|
||||||
payerPubkey: 'pubkey123',
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
amountSats: 250000,
|
|
||||||
}
|
|
||||||
|
|
||||||
actor.send({ type: 'OFFER_REQUEST_RECEIVED', request: offerRequest })
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
|
||||||
|
|
||||||
// Simulate payment
|
// Simulate payment
|
||||||
actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' })
|
actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' })
|
||||||
|
|
@ -167,34 +202,25 @@ describe('ATM State Machine', () => {
|
||||||
const state = actor.getSnapshot()
|
const state = actor.getSnapshot()
|
||||||
expect(state.context.paymentStatus).toBe('paid')
|
expect(state.context.paymentStatus).toBe('paid')
|
||||||
expect(state.context.preimage).toBe('preimage123')
|
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 () => {
|
it('should not allow confirm without selection', async () => {
|
||||||
const invalidAmountServices: ATMServices = {
|
const machine = createATMMachine(mockServices)
|
||||||
...mockServices,
|
|
||||||
validateDispenseAmount: vi.fn().mockRejectedValue(new Error('Cannot dispense $3.50')),
|
|
||||||
}
|
|
||||||
|
|
||||||
const machine = createATMMachine(invalidAmountServices)
|
|
||||||
const actor = createActor(machine)
|
const actor = createActor(machine)
|
||||||
actor.start()
|
actor.start()
|
||||||
|
|
||||||
actor.send({ type: 'SELECT_CASH_OUT' })
|
actor.send({ type: 'SELECT_CASH_OUT' })
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
|
||||||
const offerRequest: OfferRequestEvent = {
|
// Try to confirm with no selection
|
||||||
eventId: 'event123',
|
actor.send({ type: 'CONFIRM_AMOUNT' })
|
||||||
payerPubkey: 'pubkey123',
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
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()
|
const state = actor.getSnapshot()
|
||||||
// Should return to displaying noffer for new request
|
// Should still be in selectingAmount
|
||||||
expect(state.value).toMatchObject({ cashOut: 'displayingNoffer' })
|
expect(state.value).toMatchObject({ cashOut: 'selectingAmount' })
|
||||||
expect(state.context.pendingOfferRequest).toBeNull()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,29 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
|
||||||
return services.validateDispenseAmount(input.amountSats, input.exchangeRate)
|
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
|
* Callback actor that subscribes to Kind 21001 offer requests
|
||||||
* The parent machine must provide an onOfferRequest callback via services
|
* 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,
|
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({
|
setOfferRequest: assign({
|
||||||
pendingOfferRequest: ({ event }) => {
|
pendingOfferRequest: ({ event }) => {
|
||||||
if (event.type !== 'OFFER_REQUEST_RECEIVED') return null
|
if (event.type !== 'OFFER_REQUEST_RECEIVED') return null
|
||||||
|
|
@ -240,6 +317,19 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
|
||||||
canRetry: ({ context }) => context.retryCount < 3,
|
canRetry: ({ context }) => context.retryCount < 3,
|
||||||
hasUserNpub: ({ context }) => context.userNpub !== null,
|
hasUserNpub: ({ context }) => context.userNpub !== null,
|
||||||
hasOfferRequest: ({ context }) => context.pendingOfferRequest !== 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: {
|
delays: {
|
||||||
TIMEOUT_MS: 300000, // 5 minutes
|
TIMEOUT_MS: 300000, // 5 minutes
|
||||||
|
|
@ -369,12 +459,12 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
|
||||||
},
|
},
|
||||||
|
|
||||||
// === CASH OUT (Sell Bitcoin) ===
|
// === CASH OUT (Sell Bitcoin) ===
|
||||||
// Uses noffer (CLINK Offers, Kind 21001) for flexible wallet-native flow:
|
// ATM-driven flow: user selects fiat amount on ATM, pays invoice, receives cash
|
||||||
// 1. ATM displays noffer QR code
|
// 1. ATM fetches exchange rate
|
||||||
// 2. User's wallet sends Kind 21001 request with amount
|
// 2. User selects denominations on ATM screen
|
||||||
// 3. ATM validates and responds with invoice
|
// 3. ATM generates BOLT11 invoice for calculated sats
|
||||||
// 4. User pays invoice
|
// 4. User scans QR and pays from any Lightning wallet
|
||||||
// 5. ATM dispenses cash
|
// 5. ATM watches invoice, dispenses cash when paid
|
||||||
cashOut: {
|
cashOut: {
|
||||||
initial: 'fetchingRate',
|
initial: 'fetchingRate',
|
||||||
states: {
|
states: {
|
||||||
|
|
@ -383,7 +473,7 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
|
||||||
src: 'getExchangeRate',
|
src: 'getExchangeRate',
|
||||||
input: ({ context }) => context.currency,
|
input: ({ context }) => context.currency,
|
||||||
onDone: {
|
onDone: {
|
||||||
target: 'generatingNoffer',
|
target: 'selectingAmount',
|
||||||
actions: assign({
|
actions: assign({
|
||||||
exchangeRate: ({ event }) => event.output,
|
exchangeRate: ({ event }) => event.output,
|
||||||
}),
|
}),
|
||||||
|
|
@ -394,59 +484,29 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
generatingNoffer: {
|
selectingAmount: {
|
||||||
invoke: {
|
// User selects denomination buttons to build up the cash amount
|
||||||
src: 'generateNoffer',
|
// UI shows: available denominations, running total, sats equivalent
|
||||||
onDone: {
|
entry: 'clearCashOutSelection',
|
||||||
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 ?? '' }),
|
|
||||||
},
|
|
||||||
on: {
|
on: {
|
||||||
OFFER_REQUEST_RECEIVED: {
|
ADD_DENOMINATION: {
|
||||||
target: 'validatingRequest',
|
guard: 'canAddDenomination',
|
||||||
actions: ['setOfferRequest', 'setAmountFromOfferRequest'],
|
actions: ['addDenomination', 'calculateSatsFromFiat'],
|
||||||
},
|
},
|
||||||
TIMEOUT: '#atm.idle',
|
REMOVE_DENOMINATION: {
|
||||||
CANCEL: '#atm.idle',
|
guard: 'canRemoveDenomination',
|
||||||
|
actions: ['removeDenomination', 'calculateSatsFromFiat'],
|
||||||
},
|
},
|
||||||
|
CLEAR_SELECTION: {
|
||||||
|
actions: 'clearCashOutSelection',
|
||||||
},
|
},
|
||||||
validatingRequest: {
|
CONFIRM_AMOUNT: {
|
||||||
// Validate that the requested sats amount can be dispensed
|
guard: 'hasSelectedAmount',
|
||||||
invoke: {
|
|
||||||
src: 'validateDispenseAmount',
|
|
||||||
input: ({ context }) => ({
|
|
||||||
amountSats: context.satsAmount,
|
|
||||||
exchangeRate: context.exchangeRate,
|
|
||||||
}),
|
|
||||||
onDone: {
|
|
||||||
target: 'generatingInvoice',
|
target: 'generatingInvoice',
|
||||||
actions: [
|
actions: 'calculateDispenseFromSelection',
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
|
CANCEL: '#atm.idle',
|
||||||
|
TIMEOUT: '#atm.idle',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
generatingInvoice: {
|
generatingInvoice: {
|
||||||
|
|
@ -454,47 +514,35 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
|
||||||
src: 'generateInvoice',
|
src: 'generateInvoice',
|
||||||
input: ({ context }) => context.satsAmount * 1000, // sats to msats
|
input: ({ context }) => context.satsAmount * 1000, // sats to msats
|
||||||
onDone: {
|
onDone: {
|
||||||
target: 'sendingInvoice',
|
target: 'displayingInvoice',
|
||||||
actions: assign({
|
actions: assign({
|
||||||
invoice: ({ event }) => event.output,
|
invoice: ({ event }) => event.output,
|
||||||
|
paymentMethod: () => 'invoice' as const,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
onError: {
|
onError: {
|
||||||
target: 'displayingNoffer',
|
target: 'selectingAmount',
|
||||||
actions: ['setError', 'clearOfferRequest'],
|
actions: 'setError',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
sendingInvoice: {
|
displayingInvoice: {
|
||||||
// Send Kind 21001 response with invoice to payer's wallet
|
// Show QR code with BOLT11 invoice, watch for payment
|
||||||
invoke: {
|
invoke: {
|
||||||
src: 'sendOfferResponse',
|
src: 'watchInvoicePayment',
|
||||||
input: ({ context }) => ({
|
input: ({ context }) => ({ invoice: context.invoice! }),
|
||||||
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: {
|
on: {
|
||||||
PAYMENT_RECEIVED: {
|
PAYMENT_RECEIVED: {
|
||||||
target: 'dispensingCash',
|
target: 'dispensingCash',
|
||||||
actions: 'setPaymentReceived',
|
actions: 'setPaymentReceived',
|
||||||
},
|
},
|
||||||
PAYMENT_FAILED: {
|
PAYMENT_FAILED: {
|
||||||
target: 'displayingNoffer',
|
target: 'selectingAmount',
|
||||||
actions: ['setError', 'setPaymentFailed', 'clearOfferRequest'],
|
actions: ['setError', 'setPaymentFailed'],
|
||||||
},
|
},
|
||||||
TIMEOUT: {
|
TIMEOUT: {
|
||||||
target: 'displayingNoffer',
|
target: 'selectingAmount',
|
||||||
actions: 'clearOfferRequest',
|
|
||||||
},
|
},
|
||||||
CANCEL: '#atm.idle',
|
CANCEL: '#atm.idle',
|
||||||
},
|
},
|
||||||
|
|
@ -544,7 +592,7 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dispenseError: {
|
dispenseError: {
|
||||||
// Critical error - cash already paid but not dispensed
|
// Critical error - payment received but cash not dispensed
|
||||||
// Requires manual intervention
|
// Requires manual intervention
|
||||||
on: {
|
on: {
|
||||||
RETRY: {
|
RETRY: {
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,10 @@ export interface ATMContext {
|
||||||
cashDispensed: boolean
|
cashDispensed: boolean
|
||||||
/** Dispense amounts for cash-out */
|
/** Dispense amounts for cash-out */
|
||||||
dispenseAmounts: { denomination: number; count: number }[]
|
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
|
||||||
/** User's npub for receipt */
|
/** User's npub for receipt */
|
||||||
|
|
@ -90,6 +94,11 @@ export type ATMEvent =
|
||||||
| { type: 'USER_SCANNED_NPUB'; npub: string }
|
| { type: 'USER_SCANNED_NPUB'; npub: string }
|
||||||
| { type: 'SKIP_RECEIPT' }
|
| { type: 'SKIP_RECEIPT' }
|
||||||
| { type: 'RETRY' }
|
| { type: 'RETRY' }
|
||||||
|
// Cash-out amount selection
|
||||||
|
| { type: 'ADD_DENOMINATION'; denomination: number }
|
||||||
|
| { type: 'REMOVE_DENOMINATION'; denomination: number }
|
||||||
|
| { type: 'CLEAR_SELECTION' }
|
||||||
|
| { type: 'CONFIRM_AMOUNT' }
|
||||||
// Hardware events
|
// Hardware events
|
||||||
| { type: 'BILL_INSERTED'; denomination: number }
|
| { type: 'BILL_INSERTED'; denomination: number }
|
||||||
| { type: 'BILL_REJECTED'; reason: string }
|
| { type: 'BILL_REJECTED'; reason: string }
|
||||||
|
|
@ -127,6 +136,9 @@ export const initialContext: ATMContext = {
|
||||||
billsInserted: [],
|
billsInserted: [],
|
||||||
cashDispensed: false,
|
cashDispensed: false,
|
||||||
dispenseAmounts: [],
|
dispenseAmounts: [],
|
||||||
|
cashOutSelection: [],
|
||||||
|
// Mock inventory: $20 bills only for Phase 1
|
||||||
|
inventory: { 20: 50 },
|
||||||
userNpub: null,
|
userNpub: null,
|
||||||
error: null,
|
error: null,
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
|
|
@ -162,4 +174,13 @@ export interface ATMServices {
|
||||||
* Returns the fiat amount in cents, or throws if invalid
|
* Returns the fiat amount in cents, or throws if invalid
|
||||||
*/
|
*/
|
||||||
validateDispenseAmount: (amountSats: number, exchangeRate: number) => Promise<number>
|
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