Wire Lightning.Pub services to ATM state machine

- Add lightning.ts services module with real LightningPubClient and CLINKClient
- Connect to local relay (ws://localhost:7777) and Lightning.Pub on init
- Generate real CLINK noffers for cash-in flow
- Generate real Lightning invoices for cash-out flow
- Add connection status indicator (Live/Connecting/Offline badge)
- Add toggle between live and mock services in debug panel
- Fall back to mock services on connection failure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-01-24 13:12:15 -05:00
commit 687717bfce
3 changed files with 283 additions and 16 deletions

View file

@ -1,13 +1,25 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useAtmStore } from '@/stores/atm'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
const atmStore = useAtmStore()
onMounted(() => {
// Initialize the ATM state machine
atmStore.initialize()
onMounted(async () => {
// Initialize the ATM state machine with Lightning.Pub
await atmStore.initializeWithLightning()
})
function toggleLiveServices() {
if (atmStore.useLiveServices) {
// Switch to mock
atmStore.initialize()
} else {
// Switch to live
atmStore.initializeWithLightning()
}
}
</script>
<template>
@ -16,12 +28,37 @@ onMounted(() => {
>
<router-view />
<!-- Connection status (top right) -->
<div class="fixed right-4 top-4 z-50 flex items-center gap-2">
<Badge
v-if="atmStore.connectionStatus === 'connected'"
class="bg-success text-success-foreground"
>
Live
</Badge>
<Badge
v-else-if="atmStore.connectionStatus === 'connecting'"
class="bg-warning text-warning-foreground"
>
Connecting...
</Badge>
<Badge v-else-if="atmStore.connectionStatus === 'error'" class="bg-destructive">
Offline
</Badge>
<Badge v-else class="bg-muted text-muted-foreground">Mock</Badge>
</div>
<!-- Debug overlay (dev only) -->
<div
v-if="atmStore.debugMode"
class="fixed inset-x-0 bottom-0 z-50 max-h-52 overflow-auto bg-popover p-4 font-mono text-xs text-popover-foreground"
>
<div class="mb-2 text-success">State: {{ atmStore.currentState }}</div>
<div class="mb-2 flex items-center gap-4">
<span class="text-success">State: {{ atmStore.currentState }}</span>
<Button variant="outline" size="sm" class="h-6 text-xs" @click="toggleLiveServices">
{{ atmStore.useLiveServices ? 'Switch to Mock' : 'Connect Live' }}
</Button>
</div>
<pre class="text-muted-foreground">{{ JSON.stringify(atmStore.context, null, 2) }}</pre>
</div>
</div>

View file

@ -0,0 +1,201 @@
/**
* Lightning Services
*
* Connects to Lightning.Pub and provides real ATMServices implementation.
*/
import { NostrClient, generateIdentity, type MachineIdentity } from '@lamassu/nostr-client'
import { LightningPubClient } from '@lamassu/lightning'
import { CLINKClient } from '@lamassu/clink'
import type { ATMServices, ATMContext } from '@lamassu/state-machine'
// Development infrastructure configuration
// In production, these would come from environment or secure config
const DEV_CONFIG = {
// Lightning.Pub Nostr pubkey (from docker logs)
lightningPubPubkey: '0943fe710a0f48c77a0bdd11bd186d320cd1b01615cf9ec36fa2dc45bd86e92f',
// Local strfry relay
relayUrl: 'ws://localhost:7777',
// Admin token for HTTP API (development only)
adminToken: 'lamassu-dev-admin-token',
// Lightning.Pub HTTP API
lightningPubApiUrl: 'http://localhost:1776',
}
interface LightningServices {
nostrClient: NostrClient
lightningPub: LightningPubClient
clink: CLINKClient
identity: MachineIdentity
atmServices: ATMServices
}
/**
* Initialize Lightning services
*/
export async function initializeLightningServices(): Promise<LightningServices> {
console.log('[Lightning] Initializing services...')
// Generate a machine identity (in production, load from secure storage)
const identity = generateIdentity()
console.log('[Lightning] Machine identity:', identity.publicKey.slice(0, 16) + '...')
// Create Nostr client
const nostrClient = new NostrClient({
relays: [{ url: DEV_CONFIG.relayUrl }],
identity,
})
// Connect to relay
await nostrClient.connect()
console.log('[Lightning] Connected to relay:', DEV_CONFIG.relayUrl)
// Create Lightning.Pub client
const lightningPub = new LightningPubClient({
accountPubkey: DEV_CONFIG.lightningPubPubkey,
relays: [DEV_CONFIG.relayUrl],
})
lightningPub.initialize(nostrClient, identity)
console.log('[Lightning] Lightning.Pub client initialized')
// Create CLINK client
const clink = new CLINKClient({
nostrClient,
identity,
operatorPubkey: DEV_CONFIG.lightningPubPubkey, // Use Lightning.Pub as operator for dev
relays: [DEV_CONFIG.relayUrl],
})
clink.startListening()
console.log('[Lightning] CLINK client initialized')
// Create ATM services
const atmServices = createATMServices(lightningPub, clink, identity)
return {
nostrClient,
lightningPub,
clink,
identity,
atmServices,
}
}
/**
* Create ATMServices implementation using real Lightning clients
*/
function createATMServices(
lightningPub: LightningPubClient,
clink: CLINKClient,
_identity: MachineIdentity
): ATMServices {
return {
/**
* Generate a CLINK offer (noffer) for receiving payment
*
* For cash-in: customer scans QR to receive sats
*/
generateClinkOffer: async (context: ATMContext): Promise<string> => {
console.log('[ATM Service] Generating CLINK offer for', context.satsAmount, 'sats')
// Create a noffer for this specific amount
const noffer = clink.createOffer({
priceType: 'fixed',
amountSats: context.satsAmount,
description: `Lamassu ATM - ${context.satsAmount} sats`,
minAmountSats: context.satsAmount,
maxAmountSats: context.satsAmount,
})
console.log('[ATM Service] Generated noffer:', noffer.slice(0, 32) + '...')
return noffer
},
/**
* Generate a Lightning invoice for payment
*
* For cash-out: customer pays this invoice to receive cash
*/
generateInvoice: async (amountMsat: number): Promise<string> => {
console.log('[ATM Service] Generating invoice for', amountMsat, 'msats')
const amountSats = Math.floor(amountMsat / 1000)
try {
const invoice = await lightningPub.createInvoice({
amountSats,
description: 'Lamassu ATM - Cash Out',
})
console.log('[ATM Service] Generated invoice:', invoice.paymentRequest.slice(0, 32) + '...')
return invoice.paymentRequest
} catch (error) {
console.error('[ATM Service] Failed to generate invoice:', error)
throw error
}
},
/**
* Get current exchange rate
*/
getExchangeRate: async (currency: string): Promise<number> => {
console.log('[ATM Service] Fetching exchange rate for', currency)
try {
const rate = await lightningPub.getExchangeRate(currency)
console.log('[ATM Service] Exchange rate:', rate.satsPerUnit, 'sats/' + currency)
return rate.satsPerUnit
} catch (error) {
console.warn('[ATM Service] Failed to fetch rate, using fallback:', error)
// Fallback rate for development
return 2500 // sats per USD (~$40k BTC)
}
},
/**
* Send receipt via Nostr DM
*/
sendNostrReceipt: async (context: ATMContext): Promise<void> => {
console.log('[ATM Service] Sending receipt to', context.userNpub)
if (!context.userNpub) {
console.log('[ATM Service] No user npub, skipping receipt')
return
}
// TODO: Implement NIP-04/NIP-44 encrypted DM with receipt details
console.log('[ATM Service] Receipt sending not yet implemented')
},
/**
* Dispense cash (mock for development)
*/
dispenseCash: async (amounts: { denomination: number; count: number }[]): Promise<void> => {
console.log('[ATM Service] Dispensing cash:', amounts)
// In production, this would interface with the Rust HAL
// For now, simulate dispense delay
await new Promise((resolve) => setTimeout(resolve, 2000))
console.log('[ATM Service] Cash dispensed successfully')
},
}
}
/**
* Watch for invoice payment
*/
export function watchInvoice(
lightningPub: LightningPubClient,
paymentHash: string,
onPaid: (preimage: string) => void
): void {
lightningPub.watchInvoice(paymentHash, (invoice) => {
if (invoice.preimage) {
onPaid(invoice.preimage)
}
})
}
export { DEV_CONFIG }

View file

@ -5,48 +5,48 @@ import {
createActor,
type ATMContext,
type ATMServices,
type ActorRefFrom,
type SnapshotFrom,
type ATMMachine,
} from '@lamassu/state-machine'
import type { Actor, SnapshotFrom } from 'xstate'
import { initializeLightningServices } from '@/services/lightning'
// Service implementations will be injected
// For now, use mock implementations for development
// Mock services for development/fallback
const mockServices: ATMServices = {
generateClinkOffer: async (context) => {
console.log('[Mock] Generating CLINK offer for', context.satsAmount, 'sats')
// Return a fake noffer for development
return `noffer1mock${Date.now().toString(36)}`
},
generateInvoice: async (amountMsat) => {
console.log('[Mock] Generating invoice for', amountMsat, 'msats')
// Return a fake invoice for development
return `lnbc${amountMsat}n1mock${Date.now().toString(36)}`
},
getExchangeRate: async (currency) => {
console.log('[Mock] Fetching exchange rate for', currency)
// Return mock rate: 2500 sats per USD (40k USD/BTC)
return 2500
},
sendNostrReceipt: async (context) => {
console.log('[Mock] Sending Nostr receipt to', context.userNpub)
return true
},
dispenseCash: async (amounts) => {
console.log('[Mock] Dispensing cash:', amounts)
// Simulate dispense delay
await new Promise((resolve) => setTimeout(resolve, 2000))
return true
},
}
export const useAtmStore = defineStore('atm', () => {
// State
const actor = ref<Actor<ReturnType<typeof createATMMachine>> | null>(null)
const snapshot = ref<SnapshotFrom<ReturnType<typeof createATMMachine>> | null>(null)
const actor = ref<ActorRefFrom<ATMMachine> | null>(null)
const snapshot = ref<SnapshotFrom<ATMMachine> | null>(null)
const debugMode = ref(import.meta.env.DEV)
const useLiveServices = ref(false)
const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>(
'disconnected'
)
// Computed
const currentState = computed(() => {
@ -89,7 +89,7 @@ export const useAtmStore = defineStore('atm', () => {
actor.value = createActor(machine)
// Subscribe to state changes
actor.value.subscribe((newSnapshot) => {
actor.value.subscribe((newSnapshot: SnapshotFrom<ATMMachine>) => {
snapshot.value = newSnapshot
console.log('[ATM] State:', newSnapshot.value)
})
@ -99,6 +99,32 @@ export const useAtmStore = defineStore('atm', () => {
console.log('[ATM] State machine initialized')
}
/**
* Initialize with real Lightning.Pub services
*/
async function initializeWithLightning() {
connectionStatus.value = 'connecting'
console.log('[ATM] Connecting to Lightning.Pub...')
try {
const services = await initializeLightningServices()
useLiveServices.value = true
connectionStatus.value = 'connected'
console.log('[ATM] Connected to Lightning.Pub!')
// Initialize state machine with real services
initialize(services.atmServices)
} catch (error) {
console.error('[ATM] Failed to connect to Lightning.Pub:', error)
connectionStatus.value = 'error'
// Fall back to mock services
console.log('[ATM] Falling back to mock services')
useLiveServices.value = false
initialize(mockServices)
}
}
function send(event: Parameters<NonNullable<typeof actor.value>['send']>[0]) {
if (!actor.value) {
console.error('[ATM] Cannot send event: machine not initialized')
@ -154,6 +180,8 @@ export const useAtmStore = defineStore('atm', () => {
actor,
snapshot,
debugMode,
useLiveServices,
connectionStatus,
// Computed
currentState,
@ -165,6 +193,7 @@ export const useAtmStore = defineStore('atm', () => {
// Actions
initialize,
initializeWithLightning,
send,
selectCashIn,
selectCashOut,