feat(machine): add SQLite persistence and remove npub linking
Add crash-safe persistence for cassette inventory, cashbox state, and transaction history using better-sqlite3 in the Electron main process. The state machine now loads inventory from the database at runtime instead of using hardcoded values, and transactions are automatically persisted on completion. Remove the unnecessary npub linking code — Lightning.Pub auto-creates and associates Nostr users when appId is included in RPC requests, making the HTTP-based user creation and token linking redundant. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
722635a5a6
commit
f37555565e
15 changed files with 739 additions and 171 deletions
101
apps/machine/src/composables/useAvailabilityBroadcast.ts
Normal file
101
apps/machine/src/composables/useAvailabilityBroadcast.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* Availability Broadcast Composable
|
||||
*
|
||||
* Watches ATM inventory and Lightning.Pub balance, then publishes a public
|
||||
* replaceable event (kind 30078) indicating whether the machine can accept
|
||||
* cash-in and cash-out transactions.
|
||||
*
|
||||
* Event format:
|
||||
* kind: 30078
|
||||
* tags: [["d", "atm-availability"]]
|
||||
* content: {"cash_in":true,"cash_out":true,"fiat":"USD","model":"sintra"}
|
||||
*
|
||||
* No exact amounts are revealed — just boolean per direction.
|
||||
*/
|
||||
|
||||
import { watch, type Ref } from 'vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import type { NostrClient, MachineIdentity } from '@lamassu/nostr-client'
|
||||
import { createSignedEvent } from '@lamassu/nostr-client'
|
||||
import type { ATMAvailability } from '@/types/state'
|
||||
|
||||
interface UseAvailabilityBroadcastOptions {
|
||||
nostrClient: NostrClient
|
||||
identity: MachineIdentity
|
||||
/** Reactive inventory: denomination -> count */
|
||||
inventory: Ref<Record<number, number>>
|
||||
/** Reactive Lightning.Pub balance in sats (null = unknown) */
|
||||
balanceSats: Ref<number | null>
|
||||
/** Fiat currency code */
|
||||
fiatCode: string
|
||||
/** Machine model */
|
||||
model: string
|
||||
}
|
||||
|
||||
export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOptions) {
|
||||
const { nostrClient, identity, inventory, balanceSats, fiatCode, model } = options
|
||||
|
||||
let lastPublished: ATMAvailability | null = null
|
||||
|
||||
function computeAvailability(): ATMAvailability {
|
||||
// cash_out: machine has at least one bill in any cassette
|
||||
const totalBills = Object.values(inventory.value).reduce((s, c) => s + c, 0)
|
||||
const cashOut = totalBills > 0
|
||||
|
||||
// cash_in: Lightning.Pub has sats balance to sell
|
||||
const cashIn = (balanceSats.value ?? 0) > 0
|
||||
|
||||
return { cashIn, cashOut }
|
||||
}
|
||||
|
||||
async function publish(avail: ATMAvailability) {
|
||||
const content = JSON.stringify({
|
||||
cash_in: avail.cashIn,
|
||||
cash_out: avail.cashOut,
|
||||
fiat: fiatCode,
|
||||
model,
|
||||
})
|
||||
|
||||
const event = createSignedEvent(identity, {
|
||||
kind: 30078,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['d', 'atm-availability']],
|
||||
content,
|
||||
})
|
||||
|
||||
try {
|
||||
await nostrClient.publish(event)
|
||||
lastPublished = avail
|
||||
console.log('[Availability] Published:', content)
|
||||
} catch (e) {
|
||||
console.warn('[Availability] Failed to publish:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedPublish = useDebounceFn(() => {
|
||||
const avail = computeAvailability()
|
||||
// Only publish if availability actually changed
|
||||
if (
|
||||
!lastPublished ||
|
||||
avail.cashIn !== lastPublished.cashIn ||
|
||||
avail.cashOut !== lastPublished.cashOut
|
||||
) {
|
||||
publish(avail)
|
||||
}
|
||||
}, 5000) // 5s debounce to avoid spamming
|
||||
|
||||
// Watch reactive sources
|
||||
watch(
|
||||
[inventory, balanceSats],
|
||||
() => {
|
||||
debouncedPublish()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Publish immediately on setup
|
||||
const initial = computeAvailability()
|
||||
publish(initial)
|
||||
|
||||
return { computeAvailability }
|
||||
}
|
||||
|
|
@ -61,8 +61,6 @@ interface LightningConfig {
|
|||
adminToken: string
|
||||
atmPrivateKey: string
|
||||
appId: string
|
||||
appToken: string // JWT token for app API calls (user management, linking)
|
||||
linkingToken: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -78,8 +76,6 @@ async function loadLightningConfig(): Promise<LightningConfig> {
|
|||
adminToken: 'lamassu-dev-admin-token',
|
||||
atmPrivateKey: '',
|
||||
appId: '152fd75c134226824e5183cd9c02a35b4972e995f39e7c0e4ec215ae8c1fae1d', // ATM app ID
|
||||
appToken: '', // JWT token from app creation
|
||||
linkingToken: '', // Token to link Nostr pubkey to app user balance
|
||||
}
|
||||
|
||||
// In Electron, get runtime config from main process
|
||||
|
|
@ -94,8 +90,6 @@ async function loadLightningConfig(): Promise<LightningConfig> {
|
|||
adminToken: runtimeConfig.adminToken || defaults.adminToken,
|
||||
atmPrivateKey: runtimeConfig.atmPrivateKey || defaults.atmPrivateKey,
|
||||
appId: runtimeConfig.appId || defaults.appId,
|
||||
appToken: runtimeConfig.appToken || defaults.appToken,
|
||||
linkingToken: runtimeConfig.linkingToken || defaults.linkingToken,
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Lightning] Failed to get runtime config from Electron:', e)
|
||||
|
|
@ -111,122 +105,12 @@ async function loadLightningConfig(): Promise<LightningConfig> {
|
|||
adminToken: import.meta.env.VITE_ADMIN_TOKEN || defaults.adminToken,
|
||||
atmPrivateKey: import.meta.env.VITE_ATM_PRIVATE_KEY || defaults.atmPrivateKey,
|
||||
appId: import.meta.env.VITE_APP_ID || defaults.appId,
|
||||
appToken: import.meta.env.VITE_APP_TOKEN || defaults.appToken,
|
||||
linkingToken: import.meta.env.VITE_LINKING_TOKEN || defaults.linkingToken,
|
||||
}
|
||||
}
|
||||
|
||||
// Config is loaded async now - will be set in initializeLightningServices
|
||||
let CONFIG: LightningConfig
|
||||
|
||||
// ATM user identifier - consistent across restarts
|
||||
const ATM_USER_IDENTIFIER = 'atm-primary-user'
|
||||
|
||||
/**
|
||||
* Link Nostr pubkey to App User
|
||||
*
|
||||
* This is critical for LNURL-withdraw to work correctly. The Extension API
|
||||
* uses App Users (created via HTTP API), but Nostr RPC creates separate
|
||||
* Nostr Users. By linking our npub to the App User BEFORE any operations,
|
||||
* all subsequent Nostr RPC calls will find and use the linked App User.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Create App User via admin API (if not exists)
|
||||
* 2. Request a fresh linking token via admin API
|
||||
* 3. Immediately link via Nostr RPC (before 2-min token expiry)
|
||||
*/
|
||||
async function linkNostrPubkeyToAppUser(
|
||||
config: LightningConfig,
|
||||
identity: MachineIdentity
|
||||
): Promise<void> {
|
||||
console.log('[Linking] Starting npub linking process...')
|
||||
|
||||
// Step 1: Create App User if needed
|
||||
console.log('[Linking] Ensuring App User exists:', ATM_USER_IDENTIFIER)
|
||||
const authToken = config.appToken || `app_${config.appId}`
|
||||
console.log(
|
||||
'[Linking] Using auth token:',
|
||||
authToken.startsWith('eyJ') ? 'JWT token' : 'app_id fallback'
|
||||
)
|
||||
const createUserResponse = await fetch(`${config.lightningPubApiUrl}/api/app/user/add`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
identifier: ATM_USER_IDENTIFIER,
|
||||
fail_if_exists: false,
|
||||
balance: 0,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!createUserResponse.ok) {
|
||||
const errorText = await createUserResponse.text()
|
||||
// User might already exist - that's OK
|
||||
if (!errorText.includes('already exists')) {
|
||||
console.log('[Linking] Create user response:', createUserResponse.status, errorText)
|
||||
}
|
||||
} else {
|
||||
console.log('[Linking] App User created/exists')
|
||||
}
|
||||
|
||||
// Step 2: Request fresh linking token
|
||||
console.log('[Linking] Requesting fresh linking token...')
|
||||
const linkingTokenResponse = await fetch(`${config.lightningPubApiUrl}/api/app/user/npub/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user_identifier: ATM_USER_IDENTIFIER,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!linkingTokenResponse.ok) {
|
||||
const errorText = await linkingTokenResponse.text()
|
||||
// May fail if already linked - check for that
|
||||
if (errorText.includes('already has an npub')) {
|
||||
console.log('[Linking] User already has npub linked')
|
||||
return
|
||||
}
|
||||
throw new Error(`Failed to get linking token: ${linkingTokenResponse.status} ${errorText}`)
|
||||
}
|
||||
|
||||
const linkingData = await linkingTokenResponse.json()
|
||||
const token = linkingData.token
|
||||
|
||||
if (!token) {
|
||||
throw new Error('No token in linking response')
|
||||
}
|
||||
|
||||
console.log('[Linking] Got fresh token, linking immediately...')
|
||||
|
||||
// Step 3: Link via Nostr RPC immediately
|
||||
// Create a temporary LightningPub client just for linking
|
||||
const tempClient = new NostrClient({
|
||||
relays: [{ url: config.relayUrl }],
|
||||
identity,
|
||||
})
|
||||
await tempClient.connect()
|
||||
|
||||
const tempLP = new LightningPubClient({
|
||||
accountPubkey: config.lightningPubPubkey,
|
||||
relays: [config.relayUrl],
|
||||
appId: config.appId,
|
||||
})
|
||||
tempLP.initialize(tempClient, identity)
|
||||
|
||||
try {
|
||||
await tempLP.linkNpub(token)
|
||||
console.log('[Linking] Successfully linked npub to App User')
|
||||
} finally {
|
||||
tempLP.disconnect()
|
||||
tempClient.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cash-in Session Management (for ndebit single-use protection)
|
||||
// ============================================================================
|
||||
|
|
@ -800,22 +684,6 @@ export async function initializeLightningServices(): Promise<LightningServices>
|
|||
lightningPub.initialize(nostrClient, identity)
|
||||
console.log('[Lightning] Lightning.Pub client initialized')
|
||||
|
||||
// Link Nostr pubkey to app user - CRITICAL for LNURL-withdraw
|
||||
// The Extension API uses App Users, but Nostr RPC creates separate Nostr Users.
|
||||
// We fetch a fresh linking token from the admin API and link immediately,
|
||||
// before the 2-minute token expiry. This ensures:
|
||||
// 1. Subsequent Nostr RPC calls find and use the linked App User
|
||||
// 2. Funding and LNURL-withdraw use the same user (same balance)
|
||||
if (CONFIG.appId && CONFIG.adminToken) {
|
||||
try {
|
||||
await linkNostrPubkeyToAppUser(CONFIG, identity)
|
||||
} catch (e) {
|
||||
console.log('[Lightning] Note: linking skipped or already linked:', e)
|
||||
}
|
||||
} else {
|
||||
console.log('[Lightning] Skipping npub linking (no appId or adminToken configured)')
|
||||
}
|
||||
|
||||
// Create CLINK client
|
||||
const clink = new CLINKClient({
|
||||
nostrClient,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,41 @@ import type { HalConfig, HalServices } from '@/services/hal'
|
|||
import { deviceConfig, toHalConfig } from '@/config'
|
||||
import type { LightningPubClient } from '@lamassu/lightning'
|
||||
import type { CLINKClient } from '@lamassu/clink'
|
||||
import type { TransactionRecord } from '@/types/state'
|
||||
|
||||
// Check if we're running in Electron
|
||||
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
|
||||
|
||||
/**
|
||||
* Load inventory from SQLite via IPC (Electron only).
|
||||
* Returns empty object in browser dev mode.
|
||||
*/
|
||||
async function loadInventoryFromDb(): Promise<Record<number, number>> {
|
||||
if (isElectron && window.electronAPI) {
|
||||
try {
|
||||
const inv = await window.electronAPI.getInventory()
|
||||
console.log('[ATM] Loaded inventory from DB:', inv)
|
||||
return inv
|
||||
} catch (e) {
|
||||
console.warn('[ATM] Failed to load inventory from DB:', e)
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a completed transaction to SQLite via IPC.
|
||||
*/
|
||||
async function persistTransaction(tx: TransactionRecord): Promise<void> {
|
||||
if (isElectron && window.electronAPI) {
|
||||
try {
|
||||
await window.electronAPI.recordTransaction(tx)
|
||||
console.log('[ATM] Transaction persisted:', tx.txid)
|
||||
} catch (e) {
|
||||
console.error('[ATM] Failed to persist transaction:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mock services for development/fallback
|
||||
const mockServices: ATMServices = {
|
||||
|
|
@ -156,10 +191,42 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
const machine = createATMMachine(services)
|
||||
actor.value = createActor(machine)
|
||||
|
||||
// Track previous state to detect transitions into 'complete'
|
||||
let prevNestedState: string | null = null
|
||||
|
||||
// Subscribe to state changes
|
||||
actor.value.subscribe((newSnapshot: SnapshotFrom<ATMMachine>) => {
|
||||
const prevSnapshot = snapshot.value
|
||||
snapshot.value = newSnapshot
|
||||
console.log('[ATM] State:', newSnapshot.value)
|
||||
|
||||
// Detect transition into a complete state
|
||||
const state = newSnapshot.value
|
||||
let currentNested: string | null = null
|
||||
if (typeof state === 'object') {
|
||||
if ('cashIn' in state) currentNested = state.cashIn as string
|
||||
if ('cashOut' in state) currentNested = state.cashOut as string
|
||||
}
|
||||
|
||||
if (currentNested === 'complete' && prevNestedState !== 'complete' && prevSnapshot) {
|
||||
const ctx = newSnapshot.context
|
||||
if (ctx.txid) {
|
||||
const isCashInTx = typeof state === 'object' && 'cashIn' in state
|
||||
const bills: { denomination: number; count: number }[] = isCashInTx
|
||||
? buildBillsFromInserted(ctx.billsInserted)
|
||||
: ctx.dispenseAmounts
|
||||
|
||||
persistTransaction({
|
||||
txid: ctx.txid,
|
||||
type: isCashInTx ? 'cash_in' : 'cash_out',
|
||||
fiatCents: ctx.fiatAmount,
|
||||
sats: ctx.satsAmount,
|
||||
bills,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
prevNestedState = currentNested
|
||||
})
|
||||
|
||||
// Start the machine
|
||||
|
|
@ -167,6 +234,17 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
console.log('[ATM] State machine initialized')
|
||||
}
|
||||
|
||||
/**
|
||||
* Group an array of inserted bill denominations into { denomination, count } pairs.
|
||||
*/
|
||||
function buildBillsFromInserted(bills: number[]): { denomination: number; count: number }[] {
|
||||
const counts = new Map<number, number>()
|
||||
for (const d of bills) {
|
||||
counts.set(d, (counts.get(d) || 0) + 1)
|
||||
}
|
||||
return Array.from(counts.entries()).map(([denomination, count]) => ({ denomination, count }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize with real Lightning.Pub services
|
||||
*/
|
||||
|
|
@ -235,8 +313,17 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
}
|
||||
})
|
||||
|
||||
// Inject DB-backed inventory into services
|
||||
const servicesWithInventory: ATMServices = {
|
||||
...services.atmServices,
|
||||
getInventory: async () => {
|
||||
const fresh = await loadInventoryFromDb()
|
||||
return Object.keys(fresh).length > 0 ? fresh : services.atmServices.getInventory()
|
||||
},
|
||||
}
|
||||
|
||||
// Initialize state machine with real services
|
||||
initialize(services.atmServices)
|
||||
initialize(servicesWithInventory)
|
||||
} catch (error) {
|
||||
console.error('[ATM] Failed to connect to Lightning.Pub:', error)
|
||||
connectionStatus.value = 'error'
|
||||
|
|
@ -443,6 +530,11 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
const mergedServices: ATMServices = {
|
||||
...lightning.atmServices,
|
||||
...hal.atmServices, // Override dispenseCash and getInventory with real hardware
|
||||
// If DB has inventory, use it; otherwise fall back to HAL
|
||||
getInventory: async () => {
|
||||
const fresh = await loadInventoryFromDb()
|
||||
return Object.keys(fresh).length > 0 ? fresh : hal.atmServices.getInventory()
|
||||
},
|
||||
}
|
||||
|
||||
// Initialize state machine with merged services
|
||||
|
|
@ -586,6 +678,20 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
send({ type: 'CONFIRM_AMOUNT' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh inventory from DB and update the state machine context.
|
||||
* Called after operator sets cassettes or after transactions.
|
||||
*/
|
||||
async function refreshInventory() {
|
||||
const inv = await loadInventoryFromDb()
|
||||
if (actor.value && Object.keys(inv).length > 0) {
|
||||
// Directly update the actor's context with new inventory
|
||||
// XState v5: send a custom event that the machine can handle
|
||||
// For now, we update via the next getInventory call on transition
|
||||
console.log('[ATM] Inventory refreshed:', inv)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDebug() {
|
||||
debugMode.value = !debugMode.value
|
||||
}
|
||||
|
|
@ -634,6 +740,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
removeDenomination,
|
||||
clearSelection,
|
||||
confirmAmount,
|
||||
refreshInventory,
|
||||
toggleDebug,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
18
apps/machine/src/types/electron.d.ts
vendored
18
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -10,8 +10,6 @@ export interface RuntimeConfig {
|
|||
atmPrivateKey: string
|
||||
adminToken: string
|
||||
appId: string
|
||||
appToken: string
|
||||
linkingToken: string
|
||||
machineModel: string
|
||||
fiatCode: string
|
||||
validatorDevice?: string
|
||||
|
|
@ -24,6 +22,22 @@ declare global {
|
|||
electronAPI?: {
|
||||
getVersion: () => Promise<string>
|
||||
getConfig: () => Promise<RuntimeConfig>
|
||||
loadCassettes: () => Promise<{ denomination: number; count: number }[]>
|
||||
setCassettes: (cassettes: { denomination: number; count: number }[]) => Promise<void>
|
||||
getInventory: () => Promise<Record<number, number>>
|
||||
getCashbox: () => Promise<{
|
||||
totalBills: number
|
||||
totalFiatCents: number
|
||||
lastEmptiedAt: number | null
|
||||
}>
|
||||
recordTransaction: (tx: {
|
||||
txid: string
|
||||
type: 'cash_in' | 'cash_out'
|
||||
fiatCents: number
|
||||
sats: number
|
||||
bills: { denomination: number; count: number }[]
|
||||
}) => Promise<void>
|
||||
emptyCashbox: () => Promise<void>
|
||||
platform: NodeJS.Platform
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
apps/machine/src/types/state.ts
Normal file
27
apps/machine/src/types/state.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Shared types for persistent ATM state (SQLite <-> IPC <-> Renderer)
|
||||
*/
|
||||
|
||||
export interface CassetteState {
|
||||
denomination: number
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface CashboxState {
|
||||
totalBills: number
|
||||
totalFiatCents: number
|
||||
lastEmptiedAt: number | null
|
||||
}
|
||||
|
||||
export interface TransactionRecord {
|
||||
txid: string
|
||||
type: 'cash_in' | 'cash_out'
|
||||
fiatCents: number
|
||||
sats: number
|
||||
bills: { denomination: number; count: number }[]
|
||||
}
|
||||
|
||||
export interface ATMAvailability {
|
||||
cashIn: boolean
|
||||
cashOut: boolean
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue