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:
parent
f068cdf01d
commit
687717bfce
3 changed files with 283 additions and 16 deletions
|
|
@ -1,13 +1,25 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { useAtmStore } from '@/stores/atm'
|
import { useAtmStore } from '@/stores/atm'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
const atmStore = useAtmStore()
|
const atmStore = useAtmStore()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
// Initialize the ATM state machine
|
// Initialize the ATM state machine with Lightning.Pub
|
||||||
atmStore.initialize()
|
await atmStore.initializeWithLightning()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function toggleLiveServices() {
|
||||||
|
if (atmStore.useLiveServices) {
|
||||||
|
// Switch to mock
|
||||||
|
atmStore.initialize()
|
||||||
|
} else {
|
||||||
|
// Switch to live
|
||||||
|
atmStore.initializeWithLightning()
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -16,12 +28,37 @@ onMounted(() => {
|
||||||
>
|
>
|
||||||
<router-view />
|
<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) -->
|
<!-- Debug overlay (dev only) -->
|
||||||
<div
|
<div
|
||||||
v-if="atmStore.debugMode"
|
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"
|
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>
|
<pre class="text-muted-foreground">{{ JSON.stringify(atmStore.context, null, 2) }}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
201
lamassu-next/apps/machine/src/services/lightning.ts
Normal file
201
lamassu-next/apps/machine/src/services/lightning.ts
Normal 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 }
|
||||||
|
|
@ -5,48 +5,48 @@ import {
|
||||||
createActor,
|
createActor,
|
||||||
type ATMContext,
|
type ATMContext,
|
||||||
type ATMServices,
|
type ATMServices,
|
||||||
|
type ActorRefFrom,
|
||||||
|
type SnapshotFrom,
|
||||||
|
type ATMMachine,
|
||||||
} from '@lamassu/state-machine'
|
} from '@lamassu/state-machine'
|
||||||
import type { Actor, SnapshotFrom } from 'xstate'
|
import { initializeLightningServices } from '@/services/lightning'
|
||||||
|
|
||||||
// Service implementations will be injected
|
// Mock services for development/fallback
|
||||||
// For now, use mock implementations for development
|
|
||||||
const mockServices: ATMServices = {
|
const mockServices: ATMServices = {
|
||||||
generateClinkOffer: async (context) => {
|
generateClinkOffer: async (context) => {
|
||||||
console.log('[Mock] Generating CLINK offer for', context.satsAmount, 'sats')
|
console.log('[Mock] Generating CLINK offer for', context.satsAmount, 'sats')
|
||||||
// Return a fake noffer for development
|
|
||||||
return `noffer1mock${Date.now().toString(36)}`
|
return `noffer1mock${Date.now().toString(36)}`
|
||||||
},
|
},
|
||||||
|
|
||||||
generateInvoice: async (amountMsat) => {
|
generateInvoice: async (amountMsat) => {
|
||||||
console.log('[Mock] Generating invoice for', amountMsat, 'msats')
|
console.log('[Mock] Generating invoice for', amountMsat, 'msats')
|
||||||
// Return a fake invoice for development
|
|
||||||
return `lnbc${amountMsat}n1mock${Date.now().toString(36)}`
|
return `lnbc${amountMsat}n1mock${Date.now().toString(36)}`
|
||||||
},
|
},
|
||||||
|
|
||||||
getExchangeRate: async (currency) => {
|
getExchangeRate: async (currency) => {
|
||||||
console.log('[Mock] Fetching exchange rate for', currency)
|
console.log('[Mock] Fetching exchange rate for', currency)
|
||||||
// Return mock rate: 2500 sats per USD (40k USD/BTC)
|
|
||||||
return 2500
|
return 2500
|
||||||
},
|
},
|
||||||
|
|
||||||
sendNostrReceipt: async (context) => {
|
sendNostrReceipt: async (context) => {
|
||||||
console.log('[Mock] Sending Nostr receipt to', context.userNpub)
|
console.log('[Mock] Sending Nostr receipt to', context.userNpub)
|
||||||
return true
|
|
||||||
},
|
},
|
||||||
|
|
||||||
dispenseCash: async (amounts) => {
|
dispenseCash: async (amounts) => {
|
||||||
console.log('[Mock] Dispensing cash:', amounts)
|
console.log('[Mock] Dispensing cash:', amounts)
|
||||||
// Simulate dispense delay
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||||
return true
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAtmStore = defineStore('atm', () => {
|
export const useAtmStore = defineStore('atm', () => {
|
||||||
// State
|
// State
|
||||||
const actor = ref<Actor<ReturnType<typeof createATMMachine>> | null>(null)
|
const actor = ref<ActorRefFrom<ATMMachine> | null>(null)
|
||||||
const snapshot = ref<SnapshotFrom<ReturnType<typeof createATMMachine>> | null>(null)
|
const snapshot = ref<SnapshotFrom<ATMMachine> | null>(null)
|
||||||
const debugMode = ref(import.meta.env.DEV)
|
const debugMode = ref(import.meta.env.DEV)
|
||||||
|
const useLiveServices = ref(false)
|
||||||
|
const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>(
|
||||||
|
'disconnected'
|
||||||
|
)
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const currentState = computed(() => {
|
const currentState = computed(() => {
|
||||||
|
|
@ -89,7 +89,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
actor.value = createActor(machine)
|
actor.value = createActor(machine)
|
||||||
|
|
||||||
// Subscribe to state changes
|
// Subscribe to state changes
|
||||||
actor.value.subscribe((newSnapshot) => {
|
actor.value.subscribe((newSnapshot: SnapshotFrom<ATMMachine>) => {
|
||||||
snapshot.value = newSnapshot
|
snapshot.value = newSnapshot
|
||||||
console.log('[ATM] State:', newSnapshot.value)
|
console.log('[ATM] State:', newSnapshot.value)
|
||||||
})
|
})
|
||||||
|
|
@ -99,6 +99,32 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
console.log('[ATM] State machine initialized')
|
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]) {
|
function send(event: Parameters<NonNullable<typeof actor.value>['send']>[0]) {
|
||||||
if (!actor.value) {
|
if (!actor.value) {
|
||||||
console.error('[ATM] Cannot send event: machine not initialized')
|
console.error('[ATM] Cannot send event: machine not initialized')
|
||||||
|
|
@ -154,6 +180,8 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
actor,
|
actor,
|
||||||
snapshot,
|
snapshot,
|
||||||
debugMode,
|
debugMode,
|
||||||
|
useLiveServices,
|
||||||
|
connectionStatus,
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
currentState,
|
currentState,
|
||||||
|
|
@ -165,6 +193,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
initialize,
|
initialize,
|
||||||
|
initializeWithLightning,
|
||||||
send,
|
send,
|
||||||
selectCashIn,
|
selectCashIn,
|
||||||
selectCashOut,
|
selectCashOut,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue