Add QR code display and CLINK offer request handler

- Add qrcode.vue dependency for QR code rendering
- Create QRCode.vue component for displaying payment codes
- Update CashInView and CashOutView to use real QR codes
- Add CLINK offer request handler to respond when wallets scan noffers
- Wire payment received callback to state machine
- Auto-complete transaction when payment is detected

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-01-24 13:18:11 -05:00
commit 36e326a27e
7 changed files with 117 additions and 26 deletions

View file

@ -23,6 +23,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-vue-next": "^0.563.0", "lucide-vue-next": "^0.563.0",
"pinia": "^2.2.0", "pinia": "^2.2.0",
"qrcode.vue": "^3.6.0",
"reka-ui": "^2.7.0", "reka-ui": "^2.7.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"vue": "^3.5.0", "vue": "^3.5.0",

View file

@ -0,0 +1,20 @@
<script setup lang="ts">
import { computed } from 'vue'
import QrcodeVue from 'qrcode.vue'
const props = defineProps<{
value: string
size?: number
}>()
const qrSize = computed(() => props.size ?? 200)
</script>
<template>
<div class="flex flex-col items-center justify-center rounded-lg bg-white p-4">
<QrcodeVue :value="value" :size="qrSize" level="M" render-as="svg" />
<code class="mt-2 block max-w-full break-all px-2 text-center text-xs text-zinc-500">
{{ value.slice(0, 32) }}...
</code>
</div>
</template>

View file

@ -6,7 +6,8 @@
import { NostrClient, generateIdentity, type MachineIdentity } from '@lamassu/nostr-client' import { NostrClient, generateIdentity, type MachineIdentity } from '@lamassu/nostr-client'
import { LightningPubClient } from '@lamassu/lightning' import { LightningPubClient } from '@lamassu/lightning'
import { CLINKClient } from '@lamassu/clink' import { CLINKClient, createOfferSuccess, createOfferError, CLINKErrorCode } from '@lamassu/clink'
import type { OfferRequest } from '@lamassu/clink'
import type { ATMServices, ATMContext } from '@lamassu/state-machine' import type { ATMServices, ATMContext } from '@lamassu/state-machine'
// Development infrastructure configuration // Development infrastructure configuration
@ -28,8 +29,18 @@ interface LightningServices {
clink: CLINKClient clink: CLINKClient
identity: MachineIdentity identity: MachineIdentity
atmServices: ATMServices atmServices: ATMServices
/** Set callback for when offer requests are received */
onOfferRequest: (callback: OfferRequestCallback) => void
/** Set callback for when payments are received */
onPaymentReceived: (callback: PaymentReceivedCallback) => void
} }
/** Callback for incoming offer requests */
type OfferRequestCallback = (request: OfferRequest, senderPubkey: string) => void
/** Callback for payment received */
type PaymentReceivedCallback = (preimage: string) => void
/** /**
* Initialize Lightning services * Initialize Lightning services
*/ */
@ -67,8 +78,57 @@ export async function initializeLightningServices(): Promise<LightningServices>
relays: [DEV_CONFIG.relayUrl], relays: [DEV_CONFIG.relayUrl],
}) })
// Callbacks for events
let offerRequestCallback: OfferRequestCallback | null = null
let paymentReceivedCallback: PaymentReceivedCallback | null = null
// Set up CLINK offer request handler
// When someone scans our noffer and requests an invoice, we respond
clink.onOfferRequest(async (request, senderPubkey) => {
console.log('[CLINK] Received offer request from', senderPubkey.slice(0, 16) + '...')
console.log('[CLINK] Request:', request)
// Notify the ATM about the incoming request
if (offerRequestCallback) {
offerRequestCallback(request, senderPubkey)
}
// Generate an invoice for the requested amount
try {
const amountSats = request.amount_sats
if (!amountSats || amountSats <= 0) {
console.log('[CLINK] Invalid amount requested')
return createOfferError(CLINKErrorCode.InvalidAmount, 'Invalid amount')
}
console.log('[CLINK] Creating invoice for', amountSats, 'sats')
const invoice = await lightningPub.createInvoice({
amountSats,
description: request.description || 'Lamassu ATM Payment',
})
console.log('[CLINK] Invoice created:', invoice.paymentRequest.slice(0, 32) + '...')
// Watch for payment
lightningPub.watchInvoice(invoice.paymentHash, (paidInvoice) => {
console.log('[CLINK] Payment received! Preimage:', paidInvoice.preimage)
if (paidInvoice.preimage && paymentReceivedCallback) {
paymentReceivedCallback(paidInvoice.preimage)
}
})
return createOfferSuccess(invoice.paymentRequest)
} catch (error) {
console.error('[CLINK] Failed to create invoice:', error)
return createOfferError(
CLINKErrorCode.TemporaryFailure,
error instanceof Error ? error.message : 'Failed to create invoice'
)
}
})
clink.startListening() clink.startListening()
console.log('[Lightning] CLINK client initialized') console.log('[Lightning] CLINK client initialized with offer handler')
// Create ATM services // Create ATM services
const atmServices = createATMServices(lightningPub, clink, identity) const atmServices = createATMServices(lightningPub, clink, identity)
@ -79,6 +139,12 @@ export async function initializeLightningServices(): Promise<LightningServices>
clink, clink,
identity, identity,
atmServices, atmServices,
onOfferRequest: (callback: OfferRequestCallback) => {
offerRequestCallback = callback
},
onPaymentReceived: (callback: PaymentReceivedCallback) => {
paymentReceivedCallback = callback
},
} }
} }

View file

@ -112,6 +112,18 @@ export const useAtmStore = defineStore('atm', () => {
connectionStatus.value = 'connected' connectionStatus.value = 'connected'
console.log('[ATM] Connected to Lightning.Pub!') console.log('[ATM] Connected to Lightning.Pub!')
// Wire up payment received callback
services.onPaymentReceived((preimage) => {
console.log('[ATM] Payment received via CLINK, preimage:', preimage.slice(0, 16) + '...')
paymentReceived(preimage)
})
// Wire up offer request callback (for logging/debugging)
services.onOfferRequest((request, sender) => {
console.log('[ATM] Offer request from:', sender.slice(0, 16) + '...')
console.log('[ATM] Amount requested:', request.amount_sats, 'sats')
})
// Initialize state machine with real services // Initialize state machine with real services
initialize(services.atmServices) initialize(services.atmServices)
} catch (error) { } catch (error) {

View file

@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import QRCode from '@/components/QRCode.vue'
const router = useRouter() const router = useRouter()
const atmStore = useAtmStore() const atmStore = useAtmStore()
@ -143,18 +144,7 @@ function formatSats(sats: number): string {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<div <QRCode v-if="context?.clinkOffer" :value="context.clinkOffer" :size="200" />
class="mx-auto flex h-52 w-52 flex-col items-center justify-center rounded-lg bg-white"
>
<p class="text-3xl">📱</p>
<p class="text-sm text-black">QR Code</p>
<code
v-if="context?.clinkOffer"
class="mt-2 block max-w-full break-all px-2 text-xs text-zinc-500"
>
{{ context.clinkOffer.slice(0, 24) }}...
</code>
</div>
<p class="text-center text-sm text-muted-foreground"> <p class="text-center text-sm text-muted-foreground">
Open your Lightning wallet and scan the code Open your Lightning wallet and scan the code
</p> </p>

View file

@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import QRCode from '@/components/QRCode.vue'
const router = useRouter() const router = useRouter()
const atmStore = useAtmStore() const atmStore = useAtmStore()
@ -115,18 +116,7 @@ function formatSats(sats: number): string {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<div <QRCode v-if="context?.invoice" :value="context.invoice" :size="200" />
class="mx-auto flex h-52 w-52 flex-col items-center justify-center rounded-lg bg-white"
>
<p class="text-3xl">📱</p>
<p class="text-sm text-black">QR Code</p>
<code
v-if="context?.invoice"
class="mt-2 block max-w-full break-all px-2 text-xs text-zinc-500"
>
{{ context.invoice.slice(0, 24) }}...
</code>
</div>
<p class="text-center text-sm text-muted-foreground">Waiting for payment...</p> <p class="text-center text-sm text-muted-foreground">Waiting for payment...</p>
<!-- Dev controls --> <!-- Dev controls -->

View file

@ -50,6 +50,9 @@ importers:
pinia: pinia:
specifier: ^2.2.0 specifier: ^2.2.0
version: 2.3.1(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)) version: 2.3.1(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3))
qrcode.vue:
specifier: ^3.6.0
version: 3.6.0(vue@3.5.27(typescript@5.9.3))
reka-ui: reka-ui:
specifier: ^2.7.0 specifier: ^2.7.0
version: 2.7.0(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)) version: 2.7.0(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3))
@ -1459,6 +1462,11 @@ packages:
engines: {node: '>=14'} engines: {node: '>=14'}
hasBin: true hasBin: true
qrcode.vue@3.6.0:
resolution: {integrity: sha512-vQcl2fyHYHMjDO1GguCldJxepq2izQjBkDEEu9NENgfVKP6mv/e2SU62WbqYHGwTgWXLhxZ1NCD1dAZKHQq1fg==}
peerDependencies:
vue: ^3.0.0
reka-ui@2.7.0: reka-ui@2.7.0:
resolution: {integrity: sha512-m+XmxQN2xtFzBP3OAdIafKq7C8OETo2fqfxcIIxYmNN2Ch3r5oAf6yEYCIJg5tL/yJU2mHqF70dCCekUkrAnXA==} resolution: {integrity: sha512-m+XmxQN2xtFzBP3OAdIafKq7C8OETo2fqfxcIIxYmNN2Ch3r5oAf6yEYCIJg5tL/yJU2mHqF70dCCekUkrAnXA==}
peerDependencies: peerDependencies:
@ -2701,6 +2709,10 @@ snapshots:
prettier@3.8.1: {} prettier@3.8.1: {}
qrcode.vue@3.6.0(vue@3.5.27(typescript@5.9.3)):
dependencies:
vue: 3.5.27(typescript@5.9.3)
reka-ui@2.7.0(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)): reka-ui@2.7.0(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)):
dependencies: dependencies:
'@floating-ui/dom': 1.7.4 '@floating-ui/dom': 1.7.4