Implement LNURL-withdraw for cash-in flow

Replace CLINK offer with LNURL-withdraw for the "buy bitcoin" flow.
LNURL-withdraw is the correct protocol for customers to receive sats:
- ATM displays LNURL-withdraw QR code
- Customer scans with their Lightning wallet
- Wallet automatically creates invoice and sends it to ATM
- ATM pays the invoice, sending sats to customer

This provides a much better UX than manual invoice entry, especially
for a kiosk where users can't paste text.

Changes:
- Add LnurlWithdrawServer class that implements LUD-03 protocol
- Add LNURL HTTP server (port 3333) for wallet callbacks
- Update state machine: generatingOffer → generatingLnurlWithdraw
- Add lnurlWithdraw context field and generateLnurlWithdraw service
- Update CashInView to display LNURL-withdraw QR
- Add @scure/base dependency for bech32 encoding

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-01-24 14:04:25 -05:00
commit 5d56ebb642
8 changed files with 427 additions and 19 deletions

View file

@ -18,6 +18,7 @@
"@lamassu/lightning": "workspace:*",
"@lamassu/nostr-client": "workspace:*",
"@lamassu/state-machine": "workspace:*",
"@scure/base": "^1.2.0",
"@vueuse/core": "^14.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View file

@ -2,6 +2,7 @@
* Lightning Services
*
* Connects to Lightning.Pub and provides real ATMServices implementation.
* Includes LNURL-withdraw server for cash-in flow.
*/
import { NostrClient, generateIdentity, type MachineIdentity } from '@lamassu/nostr-client'
@ -9,6 +10,7 @@ import { LightningPubClient } from '@lamassu/lightning'
import { CLINKClient, createOfferSuccess, createOfferError, CLINKErrorCode } from '@lamassu/clink'
import type { OfferRequest } from '@lamassu/clink'
import type { ATMServices, ATMContext } from '@lamassu/state-machine'
import { LnurlWithdrawServer } from './lnurl-withdraw'
// Development infrastructure configuration
// In production, these would come from environment or secure config
@ -21,17 +23,23 @@ const DEV_CONFIG = {
adminToken: 'lamassu-dev-admin-token',
// Lightning.Pub HTTP API
lightningPubApiUrl: 'http://localhost:1776',
// LNURL-withdraw server configuration
lnurlPort: 3333,
// External host - this needs to be reachable by the customer's wallet
// For local development, use localhost. In production, use the ATM's public IP/domain
lnurlExternalHost: 'localhost',
}
interface LightningServices {
nostrClient: NostrClient
lightningPub: LightningPubClient
clink: CLINKClient
lnurlServer: LnurlWithdrawServer
identity: MachineIdentity
atmServices: ATMServices
/** Set callback for when offer requests are received */
onOfferRequest: (callback: OfferRequestCallback) => void
/** Set callback for when payments are received */
/** Set callback for when payments are received (from CLINK or LNURL-withdraw) */
onPaymentReceived: (callback: PaymentReceivedCallback) => void
}
@ -130,13 +138,48 @@ export async function initializeLightningServices(): Promise<LightningServices>
clink.startListening()
console.log('[Lightning] CLINK client initialized with offer handler')
// Create pay invoice function for LNURL-withdraw
const payInvoice = async (invoice: string): Promise<{ preimage: string }> => {
console.log('[Lightning] Paying invoice:', invoice.slice(0, 32) + '...')
try {
const result = await lightningPub.payInvoice(invoice)
if (!result.success) {
throw new Error(result.error || 'Payment failed')
}
console.log(
'[Lightning] Payment successful, preimage:',
result.preimage?.slice(0, 16) + '...'
)
return { preimage: result.preimage! }
} catch (error) {
console.error('[Lightning] Failed to pay invoice:', error)
throw error
}
}
// Create LNURL-withdraw server
const lnurlServer = new LnurlWithdrawServer({
port: DEV_CONFIG.lnurlPort,
externalHost: DEV_CONFIG.lnurlExternalHost,
payInvoice,
})
// Start the LNURL server
await lnurlServer.start()
console.log('[Lightning] LNURL-withdraw server started on port', DEV_CONFIG.lnurlPort)
// Create ATM services
const atmServices = createATMServices(lightningPub, clink, identity)
const atmServices = createATMServices(lightningPub, clink, lnurlServer, identity, (preimage) => {
if (paymentReceivedCallback) {
paymentReceivedCallback(preimage)
}
})
return {
nostrClient,
lightningPub,
clink,
lnurlServer,
identity,
atmServices,
onOfferRequest: (callback: OfferRequestCallback) => {
@ -154,13 +197,15 @@ export async function initializeLightningServices(): Promise<LightningServices>
function createATMServices(
lightningPub: LightningPubClient,
clink: CLINKClient,
_identity: MachineIdentity
lnurlServer: LnurlWithdrawServer,
_identity: MachineIdentity,
onPaymentSuccess: (preimage: string) => void
): ATMServices {
return {
/**
* Generate a CLINK offer (noffer) for receiving payment
*
* For cash-in: customer scans QR to receive sats
* For cash-out display (not currently used for main flow)
*/
generateClinkOffer: async (context: ATMContext): Promise<string> => {
console.log('[ATM Service] Generating CLINK offer for', context.satsAmount, 'sats')
@ -178,6 +223,35 @@ function createATMServices(
return noffer
},
/**
* Generate an LNURL-withdraw link for cash-in
*
* Customer scans QR with their wallet, wallet automatically creates invoice
* and sends it to us, we pay it.
*/
generateLnurlWithdraw: async (context: ATMContext): Promise<string> => {
console.log('[ATM Service] Generating LNURL-withdraw for', context.satsAmount, 'sats')
const lnurl = lnurlServer.createWithdrawal(
context.satsAmount,
`Lamassu ATM - Buy ${context.satsAmount} sats`,
(preimage) => {
console.log(
'[ATM Service] LNURL-withdraw payment sent! Preimage:',
preimage.slice(0, 16) + '...'
)
onPaymentSuccess(preimage)
},
(error) => {
console.error('[ATM Service] LNURL-withdraw payment failed:', error)
// Note: error handling is done via the state machine's PAYMENT_FAILED event
}
)
console.log('[ATM Service] Generated LNURL-withdraw:', lnurl.slice(0, 32) + '...')
return lnurl
},
/**
* Generate a Lightning invoice for payment
*

View file

@ -0,0 +1,310 @@
/**
* LNURL-Withdraw Server
*
* Implements LUD-03 (LNURL-withdraw) protocol for cash-in flow.
* Customer scans a QR code with their Lightning wallet, which automatically
* creates an invoice and sends it to us for payment.
*
* Flow:
* 1. ATM generates withdrawal with unique k1 token
* 2. Customer scans LNURL-withdraw QR
* 3. Wallet fetches parameters from our callback URL
* 4. Wallet creates invoice and submits to callback
* 5. We validate k1, pay the invoice, notify the ATM
*
* @see https://github.com/lnurl/luds/blob/luds/03.md
*/
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http'
import { bech32 } from '@scure/base'
/** Pending withdrawal request */
interface PendingWithdrawal {
k1: string
amountMsats: number
description: string
createdAt: number
/** Callback to pay the invoice when received */
payInvoice: (invoice: string) => Promise<{ preimage: string }>
/** Callback when payment succeeds */
onSuccess: (preimage: string) => void
/** Callback when payment fails */
onError: (error: string) => void
}
/** LNURL-withdraw server configuration */
export interface LnurlWithdrawConfig {
/** Port to listen on */
port: number
/** External host/IP that wallets will connect to */
externalHost: string
/** Payment function */
payInvoice: (invoice: string) => Promise<{ preimage: string }>
}
/**
* LNURL-withdraw server
*/
export class LnurlWithdrawServer {
private server: Server | null = null
private pendingWithdrawals = new Map<string, PendingWithdrawal>()
private config: LnurlWithdrawConfig
constructor(config: LnurlWithdrawConfig) {
this.config = config
}
/**
* Start the HTTP server
*/
async start(): Promise<void> {
if (this.server) {
return // Already running
}
return new Promise((resolve, reject) => {
this.server = createServer((req, res) => this.handleRequest(req, res))
this.server.on('error', (err) => {
console.error('[LNURL] Server error:', err)
reject(err)
})
this.server.listen(this.config.port, () => {
console.log(`[LNURL] Server listening on port ${this.config.port}`)
resolve()
})
})
}
/**
* Stop the HTTP server
*/
async stop(): Promise<void> {
return new Promise((resolve) => {
if (this.server) {
this.server.close(() => {
this.server = null
console.log('[LNURL] Server stopped')
resolve()
})
} else {
resolve()
}
})
}
/**
* Create a new withdrawal request
*
* @param amountSats Amount in satoshis
* @param description Description for the invoice
* @param onSuccess Callback when payment succeeds
* @param onError Callback when payment fails
* @returns LNURL-withdraw string (bech32-encoded)
*/
createWithdrawal(
amountSats: number,
description: string,
onSuccess: (preimage: string) => void,
onError: (error: string) => void
): string {
// Generate random k1 token
const k1 = this.generateK1()
// Store the pending withdrawal
const withdrawal: PendingWithdrawal = {
k1,
amountMsats: amountSats * 1000,
description,
createdAt: Date.now(),
payInvoice: this.config.payInvoice,
onSuccess,
onError,
}
this.pendingWithdrawals.set(k1, withdrawal)
console.log(`[LNURL] Created withdrawal: ${k1.slice(0, 16)}... for ${amountSats} sats`)
// Build the callback URL
const callbackUrl = `http://${this.config.externalHost}:${this.config.port}/lnurl?k1=${k1}`
// Encode as LNURL (bech32 with "lnurl" prefix)
const lnurl = this.encodeLnurl(callbackUrl)
console.log(`[LNURL] Generated LNURL: ${lnurl.slice(0, 32)}...`)
return lnurl
}
/**
* Cancel a pending withdrawal
*/
cancelWithdrawal(k1: string): void {
if (this.pendingWithdrawals.has(k1)) {
this.pendingWithdrawals.delete(k1)
console.log(`[LNURL] Cancelled withdrawal: ${k1.slice(0, 16)}...`)
}
}
/**
* Handle incoming HTTP requests
*/
private handleRequest(req: IncomingMessage, res: ServerResponse): void {
// Enable CORS for wallet compatibility
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
res.setHeader('Content-Type', 'application/json')
if (req.method === 'OPTIONS') {
res.writeHead(200)
res.end()
return
}
const url = new URL(req.url || '/', `http://${req.headers.host}`)
const path = url.pathname
const k1 = url.searchParams.get('k1')
const pr = url.searchParams.get('pr') // Payment request (invoice)
console.log(
`[LNURL] Request: ${path}?k1=${k1?.slice(0, 16)}...${pr ? `&pr=${pr.slice(0, 20)}...` : ''}`
)
if (path !== '/lnurl') {
this.sendError(res, 404, 'Not found')
return
}
if (!k1) {
this.sendError(res, 400, 'Missing k1 parameter')
return
}
const withdrawal = this.pendingWithdrawals.get(k1)
if (!withdrawal) {
this.sendError(res, 400, 'Unknown or expired k1')
return
}
if (pr) {
// Step 2: Wallet is submitting an invoice
this.handleInvoiceSubmission(res, withdrawal, pr)
} else {
// Step 1: Wallet is requesting withdrawal parameters
this.handleWithdrawalRequest(res, withdrawal)
}
}
/**
* Handle initial LNURL-withdraw request (wallet fetching parameters)
*/
private handleWithdrawalRequest(res: ServerResponse, withdrawal: PendingWithdrawal): void {
const callbackUrl = `http://${this.config.externalHost}:${this.config.port}/lnurl?k1=${withdrawal.k1}`
const response = {
tag: 'withdrawRequest',
callback: callbackUrl,
k1: withdrawal.k1,
defaultDescription: withdrawal.description,
minWithdrawable: withdrawal.amountMsats,
maxWithdrawable: withdrawal.amountMsats,
}
console.log(`[LNURL] Sending withdrawal parameters:`, {
...response,
k1: response.k1.slice(0, 16) + '...',
})
res.writeHead(200)
res.end(JSON.stringify(response))
}
/**
* Handle invoice submission from wallet
*/
private async handleInvoiceSubmission(
res: ServerResponse,
withdrawal: PendingWithdrawal,
invoice: string
): Promise<void> {
console.log(`[LNURL] Received invoice: ${invoice.slice(0, 32)}...`)
try {
// Validate invoice starts with expected prefix
const lowerInvoice = invoice.toLowerCase()
if (
!lowerInvoice.startsWith('lnbc') &&
!lowerInvoice.startsWith('lntb') &&
!lowerInvoice.startsWith('lnbcrt')
) {
this.sendError(res, 400, 'Invalid invoice format')
return
}
// TODO: Decode and verify invoice amount matches withdrawal amount
// For now, trust the wallet created the correct invoice
console.log(`[LNURL] Paying invoice...`)
const result = await withdrawal.payInvoice(invoice)
// Payment successful
console.log(`[LNURL] Payment successful! Preimage: ${result.preimage.slice(0, 16)}...`)
// Remove from pending
this.pendingWithdrawals.delete(withdrawal.k1)
// Notify the ATM
withdrawal.onSuccess(result.preimage)
// Send success response to wallet
res.writeHead(200)
res.end(JSON.stringify({ status: 'OK' }))
} catch (error) {
console.error(`[LNURL] Payment failed:`, error)
// Notify the ATM
withdrawal.onError(error instanceof Error ? error.message : 'Payment failed')
// Send error to wallet
this.sendError(res, 500, error instanceof Error ? error.message : 'Payment failed')
}
}
/**
* Send JSON error response
*/
private sendError(res: ServerResponse, status: number, message: string): void {
res.writeHead(status)
res.end(JSON.stringify({ status: 'ERROR', reason: message }))
}
/**
* Generate a random k1 token (32 bytes hex)
*/
private generateK1(): string {
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
/**
* Encode a URL as LNURL (bech32 with "lnurl" prefix)
*/
private encodeLnurl(url: string): string {
const bytes = new TextEncoder().encode(url)
const words = bech32.toWords(bytes)
return bech32.encode('lnurl', words, 1023).toUpperCase()
}
}
/**
* Decode an LNURL back to a URL (for testing/debugging)
*/
export function decodeLnurl(lnurl: string): string {
// Cast to the expected bech32 format type
const decoded = bech32.decode(lnurl.toLowerCase() as `${string}1${string}`, 1023)
const bytes = bech32.fromWords(decoded.words)
return new TextDecoder().decode(new Uint8Array(bytes))
}

View file

@ -18,6 +18,12 @@ const mockServices: ATMServices = {
return `noffer1mock${Date.now().toString(36)}`
},
generateLnurlWithdraw: async (context) => {
console.log('[Mock] Generating LNURL-withdraw for', context.satsAmount, 'sats')
// This is a mock LNURL - in reality it would be a bech32 encoded URL
return `LNURL1MOCK${Date.now().toString(36).toUpperCase()}`
},
generateInvoice: async (amountMsat) => {
console.log('[Mock] Generating invoice for', amountMsat, 'msats')
return `lnbc${amountMsat}n1mock${Date.now().toString(36)}`

View file

@ -125,18 +125,18 @@ function formatSats(sats: number): string {
</CardContent>
</Card>
<!-- Generating Offer -->
<Card v-else-if="nestedState === 'generatingOffer'" class="w-80 border-0 bg-white/5">
<!-- Generating LNURL-withdraw -->
<Card v-else-if="nestedState === 'generatingLnurlWithdraw'" 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">Generating CLINK offer...</p>
<p class="text-muted-foreground">Preparing your withdrawal...</p>
</CardContent>
</Card>
<!-- Display QR -->
<!-- Display LNURL-withdraw QR -->
<Card v-else-if="nestedState === 'displayingQR'" class="w-96 border-0 bg-white/5">
<CardHeader class="text-center">
<CardTitle>Scan to Receive</CardTitle>
<CardTitle>Scan to Receive Sats</CardTitle>
<CardDescription v-if="context">
<Badge variant="secondary" class="text-lg">
{{ formatSats(context.satsAmount) }} sats
@ -144,9 +144,12 @@ function formatSats(sats: number): string {
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<QRCode v-if="context?.clinkOffer" :value="context.clinkOffer" :size="200" />
<QRCode v-if="context?.lnurlWithdraw" :value="context.lnurlWithdraw" :size="200" />
<p class="text-center text-sm text-muted-foreground">
Open your Lightning wallet and scan the code
Open your Lightning wallet and scan to receive your sats
</p>
<p class="text-center text-xs text-muted-foreground/70">
Your wallet will automatically create an invoice and we'll pay it instantly
</p>
<!-- Dev controls -->
@ -157,7 +160,7 @@ function formatSats(sats: number): string {
size="sm"
@click="atmStore.paymentReceived('mock-preimage-' + Date.now())"
>
Dev: Simulate Payment
Dev: Simulate Payment Sent
</Button>
</AlertDescription>
</Alert>

View file

@ -24,6 +24,12 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
}
return services.generateClinkOffer(input)
}),
generateLnurlWithdraw: fromPromise(async ({ input }: { input: ATMContext }) => {
if (!services.generateLnurlWithdraw) {
throw new Error('generateLnurlWithdraw service not provided')
}
return services.generateLnurlWithdraw(input)
}),
generateInvoice: fromPromise(async ({ input }: { input: number }) => {
if (!services.generateInvoice) {
throw new Error('generateInvoice service not provided')
@ -217,21 +223,21 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
},
FINISH_INSERTING: {
guard: 'hasInsertedBills',
target: 'generatingOffer',
target: 'generatingLnurlWithdraw',
},
CANCEL: '#atm.idle',
TIMEOUT: '#atm.idle',
},
},
generatingOffer: {
generatingLnurlWithdraw: {
invoke: {
src: 'generateClinkOffer',
src: 'generateLnurlWithdraw',
input: ({ context }) => context,
onDone: {
target: 'displayingQR',
actions: assign({
clinkOffer: ({ event }) => event.output,
paymentMethod: () => 'clink_offer' as const,
lnurlWithdraw: ({ event }) => event.output,
paymentMethod: () => 'lnurl_withdraw' as const,
}),
},
onError: {

View file

@ -25,8 +25,10 @@ export interface ATMContext {
// Payment
/** BOLT11 invoice for payment */
invoice: string | null
/** CLINK offer string (noffer) */
/** CLINK offer string (noffer) - for cash-out */
clinkOffer: string | null
/** LNURL-withdraw string - for cash-in (customer receives sats) */
lnurlWithdraw: string | null
/** Current payment status */
paymentStatus: PaymentStatus
/** Payment preimage (proof of payment) */
@ -94,6 +96,7 @@ export const initialContext: ATMContext = {
feePercent: 0.02,
invoice: null,
clinkOffer: null,
lnurlWithdraw: null,
paymentStatus: null,
preimage: null,
paymentMethod: null,
@ -109,8 +112,10 @@ export const initialContext: ATMContext = {
/** Service inputs for actors */
export interface ATMServices {
/** Generate a CLINK offer */
/** Generate a CLINK offer (for cash-out) */
generateClinkOffer: (context: ATMContext) => Promise<string>
/** Generate an LNURL-withdraw link (for cash-in - customer receives sats) */
generateLnurlWithdraw: (context: ATMContext) => Promise<string>
/** Generate a Lightning invoice */
generateInvoice: (amountMsat: number) => Promise<string>
/** Send receipt via Nostr */

View file

@ -35,6 +35,9 @@ importers:
'@lamassu/state-machine':
specifier: workspace:*
version: link:../../packages/state-machine
'@scure/base':
specifier: ^1.2.0
version: 1.2.6
'@vueuse/core':
specifier: ^14.1.0
version: 14.1.0(vue@3.5.27(typescript@5.9.3))