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:
parent
687717bfce
commit
36e326a27e
7 changed files with 117 additions and 26 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
20
lamassu-next/apps/machine/src/components/QRCode.vue
Normal file
20
lamassu-next/apps/machine/src/components/QRCode.vue
Normal 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>
|
||||||
|
|
@ -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
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
|
||||||
12
lamassu-next/pnpm-lock.yaml
generated
12
lamassu-next/pnpm-lock.yaml
generated
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue