feat(docker): add dev.sh with auto-funding and ATM app setup

- Add dev.sh script for managing regtest development environment
- Implement cmd_fund to fund ATM app owner via Lightning.Pub API
- Add --fund flag to cmd_up for automatic funding on startup
- Update setup_atm_app to write VITE_APP_ID to machine .env
- Fix Electron IPC to pass appId and extensionApiUrl to renderer
- Restructure repo from nested lamassu-next/ to root

The dev.sh script now supports:
- ./dev.sh up --fund  # Start regtest and auto-fund ATM
- ./dev.sh fund       # Fund existing ATM app
- ./dev.sh status     # Show environment status
- ./dev.sh reset      # Clean restart

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-02-15 14:19:16 -05:00
commit c98f126ba7
180 changed files with 2695 additions and 9587 deletions

49
apps/machine/.env.example Normal file
View file

@ -0,0 +1,49 @@
# Lamassu ATM Configuration
# Copy this file to .env and fill in your values
# =============================================================================
# Hardware Configuration
# =============================================================================
# Machine model preset (sintra, gaia, or custom)
VITE_LAMASSU_MACHINE_MODEL=sintra
# Fiat currency code (ISO 4217)
VITE_LAMASSU_FIAT_CODE=USD
# Custom device paths (optional - uses preset defaults if not set)
# VITE_LAMASSU_VALIDATOR_DEVICE=/dev/ttyJ5
# VITE_LAMASSU_DISPENSER_DEVICE=/dev/ttyJ7
# Cassette configuration (optional - JSON array)
# VITE_LAMASSU_CASSETTES='[{"denomination":20,"count":100}]'
# =============================================================================
# Lightning.Pub Connection (Required)
# =============================================================================
# Nostr relay WebSocket URL
VITE_RELAY_URL=ws://localhost:7777
# Lightning.Pub's Nostr public key (required!)
# Get from: docker logs lamassu-lightning-pub | grep pubkey
VITE_LIGHTNING_PUB_PUBKEY=
# Lightning.Pub HTTP API URL (optional)
VITE_LIGHTNING_PUB_API_URL=http://localhost:1776
# =============================================================================
# ATM Identity
# =============================================================================
# ATM's Nostr private key (hex format, 64 characters)
# Generate with: npx @lamassu/nostr-client generate-keypair
# If not set, generates ephemeral identity on each restart
VITE_ATM_PRIVATE_KEY=
# =============================================================================
# Development Only
# =============================================================================
# Lightning.Pub admin token (dev/testing only)
VITE_ADMIN_TOKEN=lamassu-dev-admin-token

View file

@ -0,0 +1,21 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "",
"css": "src/style.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"composables": "@/composables"
},
"registries": {}
}

View file

@ -0,0 +1,132 @@
/**
* Electron Main Process
*
* Creates the ATM kiosk window and handles IPC with the renderer.
* In production, runs in kiosk mode (fullscreen, no decorations).
* HAL hardware drivers run here (Node.js environment).
*/
import { app, BrowserWindow, ipcMain } from 'electron'
import path from 'node:path'
import fs from 'node:fs'
import { fileURLToPath } from 'node:url'
// ESM equivalent of __dirname
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// Load .env file manually (Electron main process doesn't have Vite's env loading)
function loadEnvFile() {
const envPath = path.join(__dirname, '..', '.env')
try {
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, 'utf-8')
for (const line of envContent.split('\n')) {
const trimmed = line.trim()
if (trimmed && !trimmed.startsWith('#')) {
const [key, ...valueParts] = trimmed.split('=')
if (key && valueParts.length > 0) {
process.env[key] = valueParts.join('=')
}
}
}
console.log('[Electron] Loaded .env file from:', envPath)
}
} catch (e) {
console.warn('[Electron] Failed to load .env file:', e)
}
}
loadEnvFile()
// Determine if we're in development
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
// Window dimensions (vertical ATM display)
const WINDOW_WIDTH = 1080
const WINDOW_HEIGHT = 1920
let mainWindow: BrowserWindow | null = null
function createWindow() {
mainWindow = new BrowserWindow({
width: WINDOW_WIDTH,
height: WINDOW_HEIGHT,
resizable: isDev, // Allow resize in dev for debugging
frame: isDev, // Show window frame in dev
kiosk: !isDev, // Kiosk mode in production
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
})
// Load the app
if (isDev) {
// Development: load from Vite dev server
const devServerUrl = process.env.VITE_DEV_SERVER_URL || 'http://localhost:1420'
mainWindow.loadURL(devServerUrl)
mainWindow.webContents.openDevTools()
} else {
// Production: load from built files
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'))
}
mainWindow.on('closed', () => {
mainWindow = null
})
}
// IPC Handlers
ipcMain.handle('get-version', () => {
return app.getVersion()
})
/**
* Get runtime configuration from environment variables
* This allows configuration to be set at runtime (not baked in at build time)
*/
ipcMain.handle('get-config', () => {
return {
// Lightning.Pub connection
relayUrl: process.env.VITE_RELAY_URL || 'ws://localhost:7777',
lightningPubPubkey: process.env.VITE_LIGHTNING_PUB_PUBKEY || '',
lightningPubApiUrl: process.env.VITE_LIGHTNING_PUB_API_URL || 'http://localhost:1776',
extensionApiUrl: process.env.VITE_EXTENSION_API_URL || 'http://localhost:1777',
atmPrivateKey: process.env.VITE_ATM_PRIVATE_KEY || '',
adminToken: process.env.VITE_ADMIN_TOKEN || '',
appId: process.env.VITE_APP_ID || '',
// Hardware configuration
machineModel: process.env.VITE_LAMASSU_MACHINE_MODEL || 'sintra',
fiatCode: process.env.VITE_LAMASSU_FIAT_CODE || 'USD',
validatorDevice: process.env.VITE_LAMASSU_VALIDATOR_DEVICE,
dispenserDevice: process.env.VITE_LAMASSU_DISPENSER_DEVICE,
cassettes: process.env.VITE_LAMASSU_CASSETTES,
}
})
// App lifecycle
app.whenReady().then(() => {
createWindow()
app.on('activate', () => {
// macOS: re-create window when dock icon clicked
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on('window-all-closed', () => {
// Quit on all platforms (ATM doesn't need macOS dock behavior)
app.quit()
})
// Security: prevent new window creation
app.on('web-contents-created', (_, contents) => {
contents.setWindowOpenHandler(() => {
return { action: 'deny' }
})
})

View file

@ -0,0 +1,50 @@
/**
* Electron Preload Script
*
* Exposes a secure API to the renderer process via contextBridge.
* This is the only way for the Vue app to communicate with the main process.
*/
import { contextBridge, ipcRenderer } from 'electron'
/**
* Runtime configuration interface
* These values are read from environment variables at runtime (not build time)
*/
export interface RuntimeConfig {
relayUrl: string
lightningPubPubkey: string
lightningPubApiUrl: string
extensionApiUrl: string
atmPrivateKey: string
adminToken: string
appId: string
machineModel: string
fiatCode: string
validatorDevice?: string
dispenserDevice?: string
cassettes?: string
}
// Expose protected methods to renderer
contextBridge.exposeInMainWorld('electronAPI', {
// Get app version
getVersion: (): Promise<string> => ipcRenderer.invoke('get-version'),
// Get runtime configuration (read from environment at runtime)
getConfig: (): Promise<RuntimeConfig> => ipcRenderer.invoke('get-config'),
// Platform info
platform: process.platform,
})
// Type declaration for the exposed API
declare global {
interface Window {
electronAPI: {
getVersion: () => Promise<string>
getConfig: () => Promise<RuntimeConfig>
platform: NodeJS.Platform
}
}
}

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "../dist-electron",
"rootDir": "."
},
"include": ["main.ts"],
"exclude": ["preload.ts"]
}

View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "../dist-electron",
"rootDir": "."
},
"include": ["preload.ts"]
}

27
apps/machine/index.html Normal file
View file

@ -0,0 +1,27 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lamassu ATM</title>
<style>
/* Prevent text selection and context menu on kiosk */
* {
user-select: none;
-webkit-user-select: none;
}
html,
body {
margin: 0;
padding: 0;
overflow: hidden;
background: #000;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

73
apps/machine/package.json Normal file
View file

@ -0,0 +1,73 @@
{
"name": "@lamassu/machine",
"version": "0.1.0",
"description": "Electron ATM kiosk application with Vue 3 UI",
"author": {
"name": "AIO Labs",
"email": "dev@aiolabs.dev"
},
"homepage": "https://aiolabs.dev",
"license": "AGPL-3.0",
"type": "module",
"main": "dist-electron/main.js",
"scripts": {
"dev": "concurrently -n vite,electron \"vite\" \"pnpm run electron:dev\"",
"dev:vite": "vite",
"electron:dev": "tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && electron dist-electron/main.js",
"build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json",
"build:electron": "pnpm build && electron-builder",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint src/"
},
"dependencies": {
"@lamassu/clink": "workspace:*",
"@lamassu/hal": "workspace:*",
"@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",
"express": "^5.2.1",
"lucide-vue-next": "^0.563.0",
"pinia": "^2.2.0",
"qrcode.vue": "^3.6.0",
"reka-ui": "^2.7.0",
"tailwind-merge": "^3.4.0",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/express": "^5.0.6",
"@types/node": "^22.0.0",
"@vitejs/plugin-vue": "^5.2.0",
"concurrently": "^9.0.0",
"electron": "^33.0.0",
"electron-builder": "^25.0.0",
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.0",
"vite": "^6.0.0",
"vue-tsc": "^2.1.0"
},
"build": {
"appId": "dev.lamassu.atm",
"productName": "Lamassu ATM",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"dist-electron/**/*"
],
"linux": {
"target": [
"AppImage"
],
"category": "Finance"
}
}
}

65
apps/machine/src/App.vue Normal file
View file

@ -0,0 +1,65 @@
<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(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>
<div
class="flex h-screen w-screen flex-col overflow-hidden bg-background font-sans text-foreground"
>
<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 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>
</template>

View file

@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import QrcodeVue from 'qrcode.vue'
const props = defineProps<{
value: string
size?: number
}>()
const qrSize = computed(() => props.size ?? 200)
// Read CSS variables for theme-aware colors
const qrBackground = ref('#f5f0e6')
const qrForeground = ref('#3c3836')
onMounted(() => {
const styles = getComputedStyle(document.documentElement)
qrBackground.value = styles.getPropertyValue('--qr').trim() || '#f5f0e6'
qrForeground.value = styles.getPropertyValue('--qr-foreground').trim() || '#3c3836'
})
</script>
<template>
<div class="flex flex-col items-center justify-center rounded-xl bg-qr p-5">
<QrcodeVue
:value="value"
:size="qrSize"
level="M"
render-as="svg"
:background="qrBackground"
:foreground="qrForeground"
/>
<code class="mt-3 block max-w-full break-all px-2 text-center text-xs text-qr-foreground">
{{ value.slice(0, 32) }}...
</code>
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { AlertVariants } from '.'
import { cn } from '@/lib/utils'
import { alertVariants } from '.'
const props = defineProps<{
class?: HTMLAttributes['class']
variant?: AlertVariants['variant']
}>()
</script>
<template>
<div data-slot="alert" :class="cn(alertVariants({ variant }), props.class)" role="alert">
<slot />
</div>
</template>

View file

@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="alert-description"
:class="
cn(
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
props.class
)
"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="alert-title"
:class="cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,24 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Alert } from './Alert.vue'
export { default as AlertDescription } from './AlertDescription.vue'
export { default as AlertTitle } from './AlertTitle.vue'
export const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export type AlertVariants = VariantProps<typeof alertVariants>

View file

@ -0,0 +1,28 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { BadgeVariants } from '.'
import { reactiveOmit } from '@vueuse/core'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { badgeVariants } from '.'
const props = defineProps<
PrimitiveProps & {
variant?: BadgeVariants['variant']
class?: HTMLAttributes['class']
}
>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Primitive
data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template>

View file

@ -0,0 +1,24 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Badge } from './Badge.vue'
export const badgeVariants = cva(
'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export type BadgeVariants = VariantProps<typeof badgeVariants>

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
})
</script>
<template>
<Primitive
data-slot="button"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>

View file

@ -0,0 +1,35 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export type ButtonVariants = VariantProps<typeof buttonVariants>

View file

@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card"
:class="
cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
props.class
)
"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-action"
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div data-slot="card-content" :class="cn('px-6', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<p data-slot="card-description" :class="cn('text-muted-foreground text-sm', props.class)">
<slot />
</p>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div data-slot="card-footer" :class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-header"
:class="
cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
props.class
)
"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<h3 data-slot="card-title" :class="cn('leading-none font-semibold', props.class)">
<slot />
</h3>
</template>

View file

@ -0,0 +1,7 @@
export { default as Card } from './Card.vue'
export { default as CardAction } from './CardAction.vue'
export { default as CardContent } from './CardContent.vue'
export { default as CardDescription } from './CardDescription.vue'
export { default as CardFooter } from './CardFooter.vue'
export { default as CardHeader } from './CardHeader.vue'
export { default as CardTitle } from './CardTitle.vue'

View file

@ -0,0 +1,35 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes['class']
}>()
const emits = defineEmits<{
(e: 'update:modelValue', payload: string | number): void
}>()
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="
cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class
)
"
/>
</template>

View file

@ -0,0 +1 @@
export { default as Input } from './Input.vue'

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface SkeletonProps {
class?: HTMLAttributes['class']
}
const props = defineProps<SkeletonProps>()
</script>
<template>
<div data-slot="skeleton" :class="cn('animate-pulse rounded-md bg-primary/10', props.class)" />
</template>

View file

@ -0,0 +1 @@
export { default as Skeleton } from './Skeleton.vue'

View file

@ -0,0 +1,217 @@
/**
* Device Configuration
*
* Defines hardware configuration for ATM devices (bill validator, dispenser).
* Supports multiple machine models with sensible defaults.
*
* Configuration priority (highest to lowest):
* 1. Runtime overrides (passed directly to functions)
* 2. Environment variables (LAMASSU_*)
* 3. Machine preset defaults
*/
import type { HalConfig, CassetteConfig } from '@/services/hal'
/**
* Supported machine models
*/
export type MachineModel = 'sintra' | 'gaia' | 'custom'
/**
* Full device configuration
*/
export interface DeviceConfig {
/** Machine model name */
model: MachineModel
/** Fiat currency code (ISO 4217) */
fiatCode: string
/** Bill validator configuration */
validator: {
/** Validator protocol type */
type: 'id003'
/** Serial device path(s) */
device: string | string[]
}
/** Bill dispenser configuration */
dispenser: {
/** Dispenser model */
type: 'f56'
/** Serial device path */
device: string
/** Cassette configuration */
cassettes: CassetteConfig[]
}
}
/**
* Preset configurations for known machine models
*/
export const MACHINE_PRESETS: Record<MachineModel, Omit<DeviceConfig, 'fiatCode'>> = {
/**
* Lamassu Sintra (Gen 2)
* - Platform: Aaeon UP Board (Intel Atom x5-Z8350)
* - Validator: JCM iVIZION (ID003 protocol)
* - Dispenser: Fujitsu F56
*/
sintra: {
model: 'sintra',
validator: {
type: 'id003',
device: '/dev/ttyJ5',
},
dispenser: {
type: 'f56',
device: '/dev/ttyJ7',
cassettes: [{ denomination: 20, count: 50 }],
},
},
/**
* Lamassu Gaia
* - Validator: JCM iVIZION (ID003 protocol)
* - Dispenser: Fujitsu F56
* Note: Device paths may vary - verify on hardware
*/
gaia: {
model: 'gaia',
validator: {
type: 'id003',
device: '/dev/ttyUSB0',
},
dispenser: {
type: 'f56',
device: '/dev/ttyUSB1',
cassettes: [{ denomination: 20, count: 50 }],
},
},
/**
* Custom configuration - all values must be provided via env/runtime
*/
custom: {
model: 'custom',
validator: {
type: 'id003',
device: '/dev/ttyUSB0',
},
dispenser: {
type: 'f56',
device: '/dev/ttyUSB1',
cassettes: [],
},
},
}
/**
* Get device configuration for a machine model
*
* @param model - Machine model or 'custom' for manual config
* @param fiatCode - Fiat currency code (default: 'USD')
* @param overrides - Optional overrides for specific values
*/
export function getDeviceConfig(
model: MachineModel = 'sintra',
fiatCode: string = 'USD',
overrides?: Partial<DeviceConfig>
): DeviceConfig {
const preset = MACHINE_PRESETS[model]
const config: DeviceConfig = {
...preset,
fiatCode,
validator: { ...preset.validator },
dispenser: { ...preset.dispenser, cassettes: [...preset.dispenser.cassettes] },
}
// Apply overrides
if (overrides) {
if (overrides.fiatCode) config.fiatCode = overrides.fiatCode
if (overrides.validator?.device) config.validator.device = overrides.validator.device
if (overrides.dispenser?.device) config.dispenser.device = overrides.dispenser.device
if (overrides.dispenser?.cassettes) config.dispenser.cassettes = overrides.dispenser.cassettes
}
return config
}
/**
* Load device configuration from environment variables
*
* Supported variables:
* - LAMASSU_MACHINE_MODEL: Machine model preset (sintra, gaia, custom)
* - LAMASSU_FIAT_CODE: Fiat currency code (USD, EUR, etc.)
* - LAMASSU_VALIDATOR_DEVICE: Bill validator serial device path
* - LAMASSU_DISPENSER_DEVICE: Bill dispenser serial device path
* - LAMASSU_CASSETTES: JSON array of cassette configs
*
* @example
* LAMASSU_MACHINE_MODEL=sintra
* LAMASSU_FIAT_CODE=USD
* LAMASSU_CASSETTES='[{"denomination":20,"count":100},{"denomination":50,"count":50}]'
*/
export function loadDeviceConfigFromEnv(): DeviceConfig {
const model = (import.meta.env.VITE_LAMASSU_MACHINE_MODEL as MachineModel) || 'sintra'
const fiatCode = import.meta.env.VITE_LAMASSU_FIAT_CODE || 'USD'
const overrides: Partial<DeviceConfig> = {}
// Validator device override
const validatorDevice = import.meta.env.VITE_LAMASSU_VALIDATOR_DEVICE
if (validatorDevice) {
overrides.validator = { type: 'id003', device: validatorDevice }
}
// Dispenser device override
const dispenserDevice = import.meta.env.VITE_LAMASSU_DISPENSER_DEVICE
if (dispenserDevice) {
overrides.dispenser = {
type: 'f56',
device: dispenserDevice,
cassettes: [],
}
}
// Cassettes override (JSON)
const cassettesJson = import.meta.env.VITE_LAMASSU_CASSETTES
if (cassettesJson) {
try {
const cassettes = JSON.parse(cassettesJson) as CassetteConfig[]
if (overrides.dispenser) {
overrides.dispenser.cassettes = cassettes
} else {
overrides.dispenser = {
type: 'f56',
device: MACHINE_PRESETS[model].dispenser.device,
cassettes,
}
}
} catch (e) {
console.error('[Config] Failed to parse LAMASSU_CASSETTES:', e)
}
}
return getDeviceConfig(model, fiatCode, overrides)
}
/**
* Convert DeviceConfig to HalConfig format
*/
export function toHalConfig(config: DeviceConfig): HalConfig {
return {
validator: {
type: config.validator.type,
device: config.validator.device,
fiatCode: config.fiatCode,
},
dispenser: {
type: config.dispenser.type,
device: config.dispenser.device,
cassettes: config.dispenser.cassettes,
},
}
}
/**
* Default export: Load config from environment with Sintra defaults
*/
export const deviceConfig = loadDeviceConfigFromEnv()

View file

@ -0,0 +1,15 @@
/**
* Configuration module
*
* Exports device configuration and utilities.
*/
export {
deviceConfig,
getDeviceConfig,
loadDeviceConfigFromEnv,
toHalConfig,
MACHINE_PRESETS,
type DeviceConfig,
type MachineModel,
} from './device'

View file

@ -0,0 +1,7 @@
import type { ClassValue } from 'clsx'
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

29
apps/machine/src/main.ts Normal file
View file

@ -0,0 +1,29 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createRouter, createWebHistory } from 'vue-router'
import App from './App.vue'
import './style.css'
// Import views
import IdleView from './views/IdleView.vue'
import CashOutView from './views/CashOutView.vue'
import CashInView from './views/CashInView.vue'
// Create router
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'idle', component: IdleView },
{ path: '/cash-out', name: 'cash-out', component: CashOutView },
{ path: '/cash-in', name: 'cash-in', component: CashInView },
],
})
// Create Pinia store
const pinia = createPinia()
// Create and mount app
const app = createApp(App)
app.use(pinia)
app.use(router)
app.mount('#app')

View file

@ -0,0 +1,211 @@
/**
* HAL Service Wrapper
*
* Bridges @lamassu/hal hardware drivers (EventEmitter-based) to the
* ATMServices interface expected by the XState state machine.
*
* Validator events are pushed into the state machine via callbacks.
* Dispenser operations are exposed as Promise-based ATMServices methods.
*
* NOTE: This module uses dynamic imports for @lamassu/hal because it
* requires Node.js APIs (serialport, node:events) that are unavailable
* in the browser. It will only work in Electron's main process or
* Node.js environments. In the browser, initializeHalServices() will
* throw with a clear error message.
*/
import type { ATMServices } from '@lamassu/state-machine'
export interface CassetteConfig {
denomination: number
count?: number
}
export interface HalConfig {
validator: {
type: 'id003'
device: string | string[]
fiatCode: string
}
dispenser: {
type: 'f56'
device: string
cassettes: CassetteConfig[]
}
}
export interface ValidatorCallbacks {
onBillInserted: (denomination: number) => void
onBillRejected: (reason: string) => void
onError: (error: string) => void
}
export interface HalServices {
/** Partial ATMServices for hardware operations (dispenseCash, getInventory) */
atmServices: Pick<ATMServices, 'dispenseCash' | 'getInventory'>
/** Wire validator events to state machine. Call after actor is started. */
connectValidator: (callbacks: ValidatorCallbacks) => void
/** Enable bill acceptance */
enableValidator: () => void
/** Disable bill acceptance */
disableValidator: () => void
/** Stack the bill currently in escrow (accept it) */
stackBill: () => void
/** Reject the bill currently in escrow (return it) */
rejectBill: () => void
/** Clean up all hardware connections */
cleanup: () => Promise<void>
}
/**
* Initialize HAL hardware and return services for state machine integration.
*
* The validator runs as an EventEmitter - call `connectValidator()` to wire
* its events to the state machine after the actor is started.
*
* The dispenser is Promise-based and exposed via `atmServices.dispenseCash`.
*
* Requires Node.js environment (Electron main process).
*/
export async function initializeHalServices(config: HalConfig): Promise<HalServices> {
// Dynamic import - only works in Node.js (Electron main process)
const hal = await import('@lamassu/hal').catch(() => {
throw new Error(
'[HAL] @lamassu/hal requires Node.js (serialport, node:events). ' +
'Run in Electron main process, not the browser.'
)
})
const { validator: valConfig, dispenser: dispConfig } = config
// Create hardware instances
const validator = hal.createValidator(valConfig.type, {
rs232: { device: valConfig.device, fiatCode: valConfig.fiatCode },
fiatCode: valConfig.fiatCode,
})
const dispenser = hal.createDispenser(dispConfig.type, {
device: dispConfig.device,
})
// Initialize dispenser
await dispenser.init({
fiatCode: valConfig.fiatCode,
cassettes: dispConfig.cassettes,
})
console.log('[HAL] Dispenser initialized')
// Start validator
await new Promise<void>((resolve, reject) => {
validator.run((err?: Error) => {
if (err) return reject(err)
resolve()
})
})
console.log('[HAL] Validator started')
// Track inventory (decremented on dispense)
const inventory: Record<number, number> = {}
for (const cassette of dispConfig.cassettes) {
inventory[cassette.denomination] = cassette.count ?? 0
}
// Map cassette index to denomination for dispense translation
const cassetteDenominations = dispConfig.cassettes.map((c) => c.denomination)
const atmServices: Pick<ATMServices, 'dispenseCash' | 'getInventory'> = {
dispenseCash: async (amounts) => {
console.log('[HAL] Dispensing:', amounts)
// Translate { denomination, count }[] to number[] by cassette position
const notes: number[] = new Array(cassetteDenominations.length).fill(0)
for (const { denomination, count } of amounts) {
const idx = cassetteDenominations.indexOf(denomination)
if (idx === -1) {
throw new Error(`No cassette loaded with denomination: ${denomination}`)
}
notes[idx] = count
}
const result = await dispenser.dispense(notes)
// Update inventory
for (let i = 0; i < result.value.length; i++) {
const denom = cassetteDenominations[i]
if (denom !== undefined && inventory[denom] !== undefined) {
inventory[denom] -= result.value[i]?.dispensed ?? 0
}
}
if (result.error) {
throw result.error
}
// Wait for customer to take the bills
await dispenser.waitForBillsRemoved()
console.log('[HAL] Bills removed by customer')
},
getInventory: async () => {
return { ...inventory }
},
}
return {
atmServices,
connectValidator: (callbacks: ValidatorCallbacks) => {
validator.on('billsRead', (data: { denomination: number | null; code: number }) => {
if (data.denomination !== null) {
// Auto-stack the bill (accept it into the cash box)
validator.stack()
callbacks.onBillInserted(data.denomination)
} else {
console.log('[HAL] Unknown denomination, rejecting. Code: 0x' + data.code.toString(16))
validator.reject()
}
})
validator.on('billsRejected', (data?: { reason: string; code: number | null }) => {
callbacks.onBillRejected(data?.reason ?? 'unknown')
})
validator.on('stackerOpen', () => {
callbacks.onError('Stacker open')
})
validator.on('error', (err: Error) => {
callbacks.onError(err.message)
})
validator.on('disconnected', () => {
callbacks.onError('Validator disconnected')
})
},
enableValidator: () => {
validator.enable()
validator.lightOn()
},
disableValidator: () => {
validator.disable()
validator.lightOff()
},
stackBill: () => validator.stack(),
rejectBill: () => validator.reject(),
cleanup: async () => {
return new Promise<void>((resolve) => {
validator.disable()
validator.lightOff()
dispenser.close()
validator.close((err?: Error) => {
if (err) console.error('[HAL] Validator close error:', err)
resolve()
})
})
},
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,580 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import {
createATMMachine,
createActor,
type ATMContext,
type ATMServices,
type ActorRefFrom,
type SnapshotFrom,
type ATMMachine,
} from '@lamassu/state-machine'
import { initializeLightningServices } from '@/services/lightning'
import type { HalConfig, HalServices } from '@/services/hal'
import { deviceConfig, toHalConfig } from '@/config'
import type { LightningPubClient } from '@lamassu/lightning'
import type { CLINKClient } from '@lamassu/clink'
// Mock services for development/fallback
const mockServices: ATMServices = {
generateClinkOffer: async (context) => {
console.log('[Mock] Generating CLINK offer for', context.satsAmount, 'sats')
return `noffer1mock${Date.now().toString(36)}`
},
generateNdebit: async (context) => {
console.log('[Mock] Generating ndebit for', context.satsAmount, 'sats')
// Mock ndebit URI with amount
return `clink:ndebit1mock${Date.now().toString(36)}?amount=${context.satsAmount}`
},
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)}`
},
getExchangeRate: async (currency) => {
console.log('[Mock] Fetching exchange rate for', currency)
return 1000 // 1000 sats per USD (~$100k BTC)
},
sendNostrReceipt: async (context) => {
console.log('[Mock] Sending Nostr receipt to', context.userNpub)
},
dispenseCash: async (amounts) => {
console.log('[Mock] Dispensing cash:', amounts)
await new Promise((resolve) => setTimeout(resolve, 2000))
},
// noffer cash-out services
generateNoffer: async () => {
console.log('[Mock] Generating noffer for cash-out')
return `noffer1mock${Date.now().toString(36)}`
},
sendOfferResponse: async (request, invoice) => {
console.log('[Mock] Sending offer response to', request.payerPubkey.slice(0, 16) + '...')
console.log('[Mock] Invoice:', invoice.slice(0, 32) + '...')
},
validateDispenseAmount: async (amountSats, exchangeRate) => {
console.log('[Mock] Validating dispense amount:', amountSats, 'sats at rate', exchangeRate)
const fiatDollars = amountSats / exchangeRate
const fiatCents = Math.round(fiatDollars * 100)
if (fiatCents < 100) {
throw new Error(`Amount too small: $${(fiatCents / 100).toFixed(2)} (minimum $1.00)`)
}
return fiatCents
},
watchInvoice: (paymentHash, _callback) => {
console.log('[Mock] Watching invoice for payment:', paymentHash.slice(0, 20) + '...')
// Note: Auto-pay disabled for testing real invoice monitoring
// Use the "Simulate Payment Now" button in the UI to manually trigger payment
return () => {}
},
getInventory: async () => {
console.log('[Mock] Getting inventory')
// Mock inventory: only $20 bills for Phase 1
return { 20: 50 }
},
}
export const useAtmStore = defineStore('atm', () => {
// State
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'
)
const lightningPub = ref<LightningPubClient | null>(null)
const clinkClient = ref<CLINKClient | null>(null)
const halServices = ref<HalServices | null>(null)
const isPayingInvoice = ref(false)
const isRequestingDebit = ref(false)
const paymentError = ref<string | null>(null)
const lnurlWithdrawUri = ref<string | null>(null)
// Store reference to ATM services for direct calls
let atmServicesRef: ATMServices | null = null
// Computed
const currentState = computed(() => {
if (!snapshot.value) return 'uninitialized'
const value = snapshot.value.value
if (typeof value === 'string') return value
// Handle nested states
return JSON.stringify(value)
})
const context = computed((): ATMContext | null => {
return snapshot.value?.context ?? null
})
const isIdle = computed(() => currentState.value === 'idle')
const isCashIn = computed(() => {
const state = snapshot.value?.value
return typeof state === 'object' && 'cashIn' in state
})
const isCashOut = computed(() => {
const state = snapshot.value?.value
return typeof state === 'object' && 'cashOut' in state
})
// Get the nested state for cashIn or cashOut
const nestedState = computed(() => {
const state = snapshot.value?.value
if (typeof state === 'object') {
if ('cashIn' in state) return state.cashIn as string
if ('cashOut' in state) return state.cashOut as string
}
return null
})
// Actions
function initialize(services: ATMServices = mockServices) {
atmServicesRef = services
const machine = createATMMachine(services)
actor.value = createActor(machine)
// Subscribe to state changes
actor.value.subscribe((newSnapshot: SnapshotFrom<ATMMachine>) => {
snapshot.value = newSnapshot
console.log('[ATM] State:', newSnapshot.value)
})
// Start the machine
actor.value.start()
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!')
// Store references to clients for direct operations
lightningPub.value = services.lightningPub
clinkClient.value = services.clink
// 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 - send to state machine for cash-out flow
services.onOfferRequest((request, sender) => {
console.log('[ATM] Offer request from:', sender.slice(0, 16) + '...')
console.log('[ATM] Amount requested:', request.amount_sats, 'sats')
// Send to state machine if we're in the displayingNoffer state
if (nestedState.value === 'displayingNoffer') {
send({
type: 'OFFER_REQUEST_RECEIVED',
request: {
eventId: request.offer || 'unknown',
payerPubkey: sender,
amountSats: request.amount_sats,
description: request.description,
},
})
}
})
// 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)
}
}
/**
* Pay a Lightning invoice (for cash-in flow)
* Customer provides their invoice, we pay it with the calculated sats amount
*/
async function payInvoice(invoice: string): Promise<boolean> {
if (!lightningPub.value) {
paymentError.value = 'Lightning services not initialized'
console.error('[ATM] Cannot pay invoice: Lightning.Pub not initialized')
return false
}
// Get the calculated sats amount from context (based on inserted cash)
const amountSats = context.value?.satsAmount
if (!amountSats || amountSats <= 0) {
paymentError.value = 'No amount calculated - insert cash first'
console.error('[ATM] Cannot pay invoice: no sats amount in context')
return false
}
isPayingInvoice.value = true
paymentError.value = null
try {
console.log(
'[ATM] Paying invoice:',
invoice.slice(0, 40) + '...',
'amount:',
amountSats,
'sats'
)
const result = await lightningPub.value.payInvoice(invoice, amountSats)
if (result.success && result.preimage) {
console.log('[ATM] Payment successful! Preimage:', result.preimage.slice(0, 16) + '...')
// Trigger the state machine transition
paymentReceived(result.preimage)
return true
} else {
paymentError.value = result.error || 'Payment failed'
console.error('[ATM] Payment failed:', result.error)
return false
}
} catch (error) {
paymentError.value = error instanceof Error ? error.message : 'Payment failed'
console.error('[ATM] Payment error:', error)
return false
} finally {
isPayingInvoice.value = false
}
}
/**
* Request a CLINK Debit from customer's wallet (for cash-in flow)
* Customer provides their Nostr pubkey, we request payment authorization
*
* Flow:
* 1. ATM generates an invoice for the amount
* 2. ATM sends DebitPaymentRequest with that invoice to customer's wallet
* 3. Customer's wallet pays the invoice and returns preimage
*/
async function requestDebit(customerPubkey: string, amountSats?: number): Promise<boolean> {
if (!clinkClient.value || !lightningPub.value) {
paymentError.value = 'Lightning services not initialized'
console.error('[ATM] Cannot request debit: Lightning services not initialized')
return false
}
// Use the amount from context if not specified
const amount = amountSats ?? context.value?.satsAmount
if (!amount) {
paymentError.value = 'No amount specified'
return false
}
isRequestingDebit.value = true
paymentError.value = null
try {
console.log('[ATM] Requesting CLINK Debit from', customerPubkey.slice(0, 16) + '...')
console.log('[ATM] Amount:', amount, 'sats')
// Step 1: Generate an invoice for the amount
console.log('[ATM] Generating invoice for debit request...')
const invoice = await lightningPub.value.createInvoice({
amountSats: amount,
description: `Lamassu ATM - Buy ${amount} sats`,
})
if (!invoice.paymentRequest) {
throw new Error('Failed to generate invoice')
}
// Step 2: Send debit payment request with the invoice
const response = await clinkClient.value.requestDebitPayment(
customerPubkey,
invoice.paymentRequest,
{
amountSats: amount,
description: `Lamassu ATM - Buy ${amount} sats`,
}
)
if (response.res === 'ok' && response.preimage) {
console.log(
'[ATM] CLINK Debit successful! Preimage:',
response.preimage.slice(0, 16) + '...'
)
// Trigger the state machine transition
paymentReceived(response.preimage)
return true
} else {
const errorResponse = response as { res: 'GFY'; error: string }
paymentError.value = errorResponse.error || 'Debit request denied'
console.error('[ATM] CLINK Debit failed:', errorResponse.error)
return false
}
} catch (error) {
paymentError.value = error instanceof Error ? error.message : 'Debit request failed'
console.error('[ATM] CLINK Debit error:', error)
return false
} finally {
isRequestingDebit.value = false
}
}
/**
* Generate an LNURL-withdraw link for the current cash-in amount
* This allows any Lightning wallet to claim the sats
*/
async function generateLnurlWithdraw(): Promise<string> {
if (!atmServicesRef) {
throw new Error('ATM services not initialized')
}
const ctx = context.value
if (!ctx?.satsAmount) {
throw new Error('No amount available - insert cash first')
}
console.log('[ATM] Generating LNURL-withdraw for', ctx.satsAmount, 'sats')
const lnurl = await atmServicesRef.generateLnurlWithdraw(ctx)
lnurlWithdrawUri.value = lnurl
console.log('[ATM] LNURL-withdraw generated:', lnurl.slice(0, 40) + '...')
return lnurl
}
/**
* Initialize with real HAL hardware + Lightning services
*
* Connects to physical bill validator and dispenser, then merges
* hardware services with Lightning payment services.
*/
async function initializeWithHal(halConfig: HalConfig) {
connectionStatus.value = 'connecting'
console.log('[ATM] Initializing HAL hardware...')
try {
// Dynamic import - hal.ts uses Node.js APIs only available in Electron
const { initializeHalServices } = await import('@/services/hal')
const hal = await initializeHalServices(halConfig)
halServices.value = hal
console.log('[ATM] HAL hardware initialized')
// Initialize Lightning services
const lightning = await initializeLightningServices()
useLiveServices.value = true
connectionStatus.value = 'connected'
lightningPub.value = lightning.lightningPub
clinkClient.value = lightning.clink
// Wire payment callbacks
lightning.onPaymentReceived((preimage) => {
paymentReceived(preimage)
})
lightning.onOfferRequest((request, sender) => {
if (nestedState.value === 'displayingNoffer') {
send({
type: 'OFFER_REQUEST_RECEIVED',
request: {
eventId: request.offer || 'unknown',
payerPubkey: sender,
amountSats: request.amount_sats,
description: request.description,
},
})
}
})
// Merge HAL hardware services with Lightning payment services
const mergedServices: ATMServices = {
...lightning.atmServices,
...hal.atmServices, // Override dispenseCash and getInventory with real hardware
}
// Initialize state machine with merged services
initialize(mergedServices)
// Wire validator events to state machine
hal.connectValidator({
onBillInserted: (denomination) => {
send({ type: 'BILL_INSERTED', denomination })
},
onBillRejected: (reason) => {
send({ type: 'BILL_REJECTED', reason })
},
onError: (error) => {
send({ type: 'ERROR', error })
},
})
console.log('[ATM] Fully initialized with HAL + Lightning')
} catch (error) {
console.error('[ATM] HAL initialization failed:', error)
connectionStatus.value = 'error'
// Fall back to mock services
console.log('[ATM] Falling back to mock services')
useLiveServices.value = false
initialize(mockServices)
}
}
/**
* Initialize for production using device configuration
*
* Loads device config from environment variables (with Sintra defaults)
* and initializes HAL hardware + Lightning services.
*
* Environment variables (optional, have sensible defaults):
* - VITE_LAMASSU_MACHINE_MODEL: sintra | gaia | custom
* - VITE_LAMASSU_FIAT_CODE: USD, EUR, etc.
* - VITE_LAMASSU_VALIDATOR_DEVICE: /dev/ttyJ5
* - VITE_LAMASSU_DISPENSER_DEVICE: /dev/ttyJ7
* - VITE_LAMASSU_CASSETTES: JSON array of cassette configs
*/
async function initializeForProduction() {
console.log('[ATM] Initializing for production...')
console.log('[ATM] Machine model:', deviceConfig.model)
console.log('[ATM] Fiat currency:', deviceConfig.fiatCode)
console.log('[ATM] Validator device:', deviceConfig.validator.device)
console.log('[ATM] Dispenser device:', deviceConfig.dispenser.device)
console.log('[ATM] Cassettes:', deviceConfig.dispenser.cassettes)
const halConfig = toHalConfig(deviceConfig)
await initializeWithHal(halConfig)
}
function send(event: Parameters<NonNullable<typeof actor.value>['send']>[0]) {
if (!actor.value) {
console.error('[ATM] Cannot send event: machine not initialized')
return
}
console.log('[ATM] Sending event:', event)
actor.value.send(event)
}
// Convenience methods for common events
function selectCashIn() {
send({ type: 'SELECT_CASH_IN' })
}
function selectCashOut() {
send({ type: 'SELECT_CASH_OUT' })
}
function cancel() {
send({ type: 'CANCEL' })
}
function insertBill(denomination: number) {
send({ type: 'BILL_INSERTED', denomination })
}
function finishInserting() {
send({ type: 'FINISH_INSERTING' })
}
function selectAmount(amount: number) {
send({ type: 'SELECT_AMOUNT', amount })
}
function paymentReceived(preimage: string) {
send({ type: 'PAYMENT_RECEIVED', preimage })
}
function skipReceipt() {
send({ type: 'SKIP_RECEIPT' })
}
function cashTaken() {
send({ type: 'CASH_DISPENSED' })
}
// Cash-out selection methods
function addDenomination(denomination: number) {
send({ type: 'ADD_DENOMINATION', denomination })
}
function removeDenomination(denomination: number) {
send({ type: 'REMOVE_DENOMINATION', denomination })
}
function clearSelection() {
send({ type: 'CLEAR_SELECTION' })
}
function confirmAmount() {
send({ type: 'CONFIRM_AMOUNT' })
}
function toggleDebug() {
debugMode.value = !debugMode.value
}
return {
// State
actor,
snapshot,
debugMode,
useLiveServices,
connectionStatus,
halServices,
isPayingInvoice,
isRequestingDebit,
paymentError,
// Computed
currentState,
context,
isIdle,
isCashIn,
isCashOut,
nestedState,
// Actions
initialize,
initializeWithLightning,
initializeWithHal,
initializeForProduction,
send,
selectCashIn,
selectCashOut,
cancel,
insertBill,
finishInserting,
selectAmount,
paymentReceived,
payInvoice,
requestDebit,
generateLnurlWithdraw,
lnurlWithdrawUri,
skipReceipt,
cashTaken,
addDenomination,
removeDenomination,
clearSelection,
confirmAmount,
toggleDebug,
}
})

123
apps/machine/src/style.css Normal file
View file

@ -0,0 +1,123 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* ATM-specific semantic colors */
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-bitcoin: var(--bitcoin);
--color-bitcoin-foreground: var(--bitcoin-foreground);
/* QR code colors - high contrast for scanning */
--color-qr: var(--qr);
--color-qr-foreground: var(--qr-foreground);
}
/*
* Gruvbox Color Palette
* https://github.com/morhetz/gruvbox
*/
:root {
--radius: 0.625rem;
/* Gruvbox Light */
--background: #fbf1c7; /* bg0 hard */
--foreground: #3c3836; /* fg1 */
--card: #ebdbb2; /* bg1 */
--card-foreground: #3c3836;
--popover: #ebdbb2;
--popover-foreground: #3c3836;
--primary: #d65d0e; /* orange */
--primary-foreground: #fbf1c7;
--secondary: #d5c4a1; /* bg2 */
--secondary-foreground: #3c3836;
--muted: #d5c4a1;
--muted-foreground: #665c54; /* bg3 */
--accent: #458588; /* blue */
--accent-foreground: #fbf1c7;
--destructive: #cc241d; /* red */
--border: #bdae93; /* fg3 */
--input: #bdae93;
--ring: #d65d0e;
/* ATM semantic colors - light */
--success: #98971a; /* green */
--success-foreground: #fbf1c7;
--warning: #d79921; /* yellow */
--warning-foreground: #3c3836;
--bitcoin: #d65d0e; /* orange */
--bitcoin-foreground: #fbf1c7;
/* QR code - high contrast for scanning */
--qr: #fbf1c7; /* bg0 - light background */
--qr-foreground: #282828; /* dark0 - dark modules */
}
.dark {
/* Gruvbox Dark */
--background: #282828; /* bg0 */
--foreground: #ebdbb2; /* fg1 */
--card: #3c3836; /* bg1 */
--card-foreground: #ebdbb2;
--popover: #3c3836;
--popover-foreground: #ebdbb2;
--primary: #fe8019; /* bright orange */
--primary-foreground: #282828;
--secondary: #504945; /* bg2 */
--secondary-foreground: #ebdbb2;
--muted: #504945;
--muted-foreground: #a89984; /* fg4 */
--accent: #83a598; /* bright blue */
--accent-foreground: #282828;
--destructive: #fb4934; /* bright red */
--border: #504945;
--input: #504945;
--ring: #fe8019;
/* ATM semantic colors - dark */
--success: #b8bb26; /* bright green */
--success-foreground: #282828;
--warning: #fabd2f; /* bright yellow */
--warning-foreground: #282828;
--bitcoin: #fe8019; /* bright orange */
--bitcoin-foreground: #282828;
/* QR code - warm tones easier on eyes in dark environments */
--qr: #f5f0e6; /* warm cream - softer than pure white */
--qr-foreground: #3c3836; /* gruvbox fg - maintains contrast */
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

28
apps/machine/src/types/electron.d.ts vendored Normal file
View file

@ -0,0 +1,28 @@
/**
* Type declarations for Electron API exposed via preload
*/
export interface RuntimeConfig {
relayUrl: string
lightningPubPubkey: string
lightningPubApiUrl: string
atmPrivateKey: string
adminToken: string
machineModel: string
fiatCode: string
validatorDevice?: string
dispenserDevice?: string
cassettes?: string
}
declare global {
interface Window {
electronAPI?: {
getVersion: () => Promise<string>
getConfig: () => Promise<RuntimeConfig>
platform: NodeJS.Platform
}
}
}
export {}

View file

@ -0,0 +1,476 @@
<script setup lang="ts">
import { watch, computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAtmStore } from '@/stores/atm'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import QRCode from '@/components/QRCode.vue'
const router = useRouter()
const atmStore = useAtmStore()
// CLINK Debit: customer's npub/pubkey
const pubkeyInput = ref('')
// Invoice input for manual payment
const invoiceInput = ref('')
// Copy state for ndebit URI
const copied = ref(false)
// QR display mode: unified (default), lnurl-only, or clink-only
const qrMode = ref<'unified' | 'lnurl' | 'clink'>('unified')
const lnurlWithdraw = ref<string | null>(null)
const isGeneratingLnurl = ref(false)
// Generate LNURL-withdraw when entering displayingQR state
watch(
() => atmStore.nestedState,
async (state) => {
if (state === 'displayingQR' && !lnurlWithdraw.value && !isGeneratingLnurl.value) {
isGeneratingLnurl.value = true
try {
lnurlWithdraw.value = await atmStore.generateLnurlWithdraw()
} catch (err) {
console.error('[CashIn] Failed to generate LNURL-withdraw:', err)
} finally {
isGeneratingLnurl.value = false
}
}
},
{ immediate: true }
)
// Compute the unified QR value (lightning + clink on separate lines)
const unifiedQrValue = computed(() => {
const ndebit = atmStore.context?.ndebitUri
const lnurl = lnurlWithdraw.value
if (lnurl && ndebit) {
// Unified: lightning scheme first (more universal), then clink
return `lightning:${lnurl}\n${ndebit}`
} else if (lnurl) {
return `lightning:${lnurl}`
} else if (ndebit) {
return ndebit
}
return null
})
// Get the appropriate QR value based on mode
const currentQrValue = computed(() => {
switch (qrMode.value) {
case 'unified':
return unifiedQrValue.value
case 'lnurl':
return lnurlWithdraw.value ? `lightning:${lnurlWithdraw.value}` : null
case 'clink':
return atmStore.context?.ndebitUri || null
}
})
// Copy current QR value to clipboard
async function copyQrValue() {
const uri = currentQrValue.value
if (!uri) return
try {
await navigator.clipboard.writeText(uri)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (err) {
console.error('[CashIn] Failed to copy:', err)
}
}
// Navigate back to idle when transaction completes
watch(
() => atmStore.isIdle,
(isIdle) => {
if (isIdle) {
router.push('/')
}
}
)
const nestedState = computed(() => atmStore.nestedState)
const context = computed(() => atmStore.context)
function cancel() {
atmStore.cancel()
}
function finishInserting() {
atmStore.finishInserting()
}
function skipReceipt() {
atmStore.skipReceipt()
}
// Simulate bill insertion (for development)
function simulateBill(denomination: number) {
atmStore.insertBill(denomination)
}
// Convert npub to hex pubkey if needed
function normalizePubkey(input: string): string {
const trimmed = input.trim()
// If it's an npub, we'd need to decode it - for now just pass through
// In production, use nostr-tools nip19.decode()
if (trimmed.startsWith('npub1')) {
// Simple check - in production decode properly
console.log('[CashIn] npub detected, would decode in production')
// For now, just return as-is and let the CLINK client handle it
// TODO: Properly decode npub using nostr-tools
}
return trimmed
}
// Request CLINK Debit (Nostr-native payment)
async function submitDebitRequest() {
const pubkey = normalizePubkey(pubkeyInput.value)
if (!pubkey) return
// Basic validation - should be 64 hex chars or npub
if (!pubkey.startsWith('npub1') && !/^[0-9a-fA-F]{64}$/.test(pubkey)) {
alert('Please enter a valid Nostr pubkey (64 hex chars) or npub')
return
}
await atmStore.requestDebit(pubkey)
}
// Pay invoice (manual BOLT-11 payment)
async function submitInvoice() {
const invoice = invoiceInput.value.trim()
if (!invoice) return
// Basic validation - should start with lnbc or lntb
if (!invoice.toLowerCase().startsWith('lnbc') && !invoice.toLowerCase().startsWith('lntb')) {
alert('Please enter a valid Lightning invoice (starts with lnbc or lntb)')
return
}
await atmStore.payInvoice(invoice)
}
function formatSats(sats: number): string {
return sats.toLocaleString()
}
const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isRequestingDebit)
</script>
<template>
<div class="flex flex-1 flex-col">
<!-- Header -->
<header class="flex items-center bg-black/30 px-8 py-4">
<Button variant="ghost" class="text-muted-foreground" @click="cancel">← Cancel</Button>
<h2 class="flex-1 text-center text-xl font-semibold">Buy Bitcoin</h2>
<div class="w-20"></div>
</header>
<!-- Content -->
<main class="flex flex-1 items-center justify-center p-8">
<!-- Fetching Rate -->
<Card v-if="nestedState === 'fetchingRate'" 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">Fetching exchange rate...</p>
</CardContent>
</Card>
<!-- Inserting Bills -->
<Card v-else-if="nestedState === 'insertingBills'" class="w-96 border-0 bg-white/5">
<CardHeader class="text-center">
<CardTitle>Insert Cash</CardTitle>
<CardDescription v-if="context">
Rate: {{ formatSats(context.exchangeRate) }} sats/$
</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<!-- Bill Status -->
<div v-if="context" class="rounded-lg bg-white/5 p-4">
<div class="mb-2 flex justify-between">
<span class="text-muted-foreground">Inserted:</span>
<span class="text-2xl font-bold text-green-400">${{ context.fiatAmount / 100 }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">You'll receive:</span>
<span class="font-semibold text-orange-400"
>{{ formatSats(context.satsAmount) }} sats</span
>
</div>
</div>
<!-- Bill Acceptor Visual -->
<div class="rounded-lg border-2 border-dashed border-white/20 p-6 text-center">
<div class="mb-2 text-4xl">💵 ➡️ 🏧</div>
<p class="text-sm text-muted-foreground">Insert bills into the acceptor</p>
</div>
<!-- Dev: Simulate bill insertion -->
<Alert v-if="atmStore.debugMode" class="border-red-500/50 bg-red-500/10">
<AlertDescription>
<p class="mb-2 text-xs text-red-400">Dev: Simulate bills</p>
<div class="flex flex-wrap justify-center gap-2">
<Button
v-for="denom in [1, 5, 10, 20, 50, 100]"
:key="denom"
variant="outline"
size="sm"
class="h-8"
@click="simulateBill(denom)"
>
${{ denom }}
</Button>
</div>
</AlertDescription>
</Alert>
<Button
class="w-full bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"
size="lg"
:disabled="!context || context.billsInserted.length === 0"
@click="finishInserting"
>
Done Inserting
</Button>
</CardContent>
</Card>
<!-- Generating NDebit -->
<Card v-else-if="nestedState === 'generatingNdebit'" 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">Preparing payment code...</p>
</CardContent>
</Card>
<!-- Display QR (unified lightning + clink) -->
<Card v-else-if="nestedState === 'displayingQR'" class="w-[420px] border-0 bg-white/5">
<CardContent class="pt-6 space-y-5">
<!-- Amount Display -->
<div class="text-center">
<p class="text-sm text-muted-foreground">Withdraw</p>
<p class="text-3xl font-bold text-orange-400">
{{ formatSats(context?.satsAmount || 0) }} sats
</p>
<p class="text-sm text-muted-foreground">
${{ ((context?.fiatAmount || 0) / 100).toFixed(2) }}
</p>
</div>
<!-- Scan with selector -->
<div class="text-center">
<p class="text-xs text-muted-foreground mb-2">Scan with</p>
<div class="flex flex-wrap gap-2 justify-center">
<Button
:variant="qrMode === 'unified' ? 'default' : 'outline'"
size="sm"
class="rounded-full px-4"
@click="qrMode = 'unified'"
>
Universal
</Button>
<Button
:variant="qrMode === 'lnurl' ? 'default' : 'outline'"
size="sm"
class="rounded-full px-4"
@click="qrMode = 'lnurl'"
>
LNURL
</Button>
<Button
:variant="qrMode === 'clink' ? 'default' : 'outline'"
size="sm"
class="rounded-full px-4"
@click="qrMode = 'clink'"
>
CLINK
</Button>
</div>
</div>
<!-- QR Code -->
<div class="flex flex-col items-center">
<div class="relative">
<Skeleton
v-if="isGeneratingLnurl && !context?.ndebitUri"
class="h-[240px] w-[240px] rounded-xl"
/>
<div v-else-if="currentQrValue" class="rounded-xl overflow-hidden bg-white p-3">
<QRCode :value="currentQrValue" :size="216" />
</div>
<div
v-else
class="flex h-[240px] w-[240px] items-center justify-center rounded-xl bg-white/10"
>
<p class="text-sm text-muted-foreground">Generating...</p>
</div>
<!-- Lightning bolt overlay -->
<div
v-if="currentQrValue"
class="absolute inset-0 flex items-center justify-center pointer-events-none"
>
<div
class="w-12 h-12 rounded-full bg-orange-500 flex items-center justify-center shadow-lg"
>
<span class="text-2xl">⚡</span>
</div>
</div>
</div>
</div>
<!-- Mode label + truncated URI -->
<div class="text-center space-y-1">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{{
qrMode === 'unified'
? 'Universal'
: qrMode === 'lnurl'
? 'LNURL-withdraw'
: 'CLINK ndebit'
}}
</p>
<div v-if="currentQrValue" class="flex items-center justify-center gap-2">
<code class="text-xs text-muted-foreground truncate max-w-[280px]">
{{ currentQrValue.split('\n')[0].slice(0, 32) }}...
</code>
<Button variant="ghost" size="sm" class="h-6 w-6 p-0" @click="copyQrValue">
<span v-if="copied">✓</span>
<span v-else class="text-muted-foreground">📋</span>
</Button>
</div>
</div>
<!-- Alternative: Manual Options (collapsed) -->
<details class="rounded-lg bg-white/5 p-3">
<summary class="cursor-pointer text-sm text-muted-foreground">
Alternative: Manual payment options
</summary>
<div class="mt-4 space-y-4">
<!-- CLINK Debit by pubkey -->
<div class="space-y-2">
<p class="text-xs font-medium text-blue-400">Enter your npub/pubkey:</p>
<div class="flex gap-2">
<Input
v-model="pubkeyInput"
placeholder="npub1... or hex pubkey"
class="flex-1 font-mono text-xs"
:disabled="isProcessing"
/>
<Button
variant="outline"
size="sm"
:disabled="!pubkeyInput.trim() || isProcessing"
@click="submitDebitRequest"
>
{{ atmStore.isRequestingDebit ? '...' : 'Send' }}
</Button>
</div>
</div>
<!-- Invoice paste -->
<div class="space-y-2">
<p class="text-xs font-medium text-orange-400">Or paste your invoice:</p>
<div class="flex gap-2">
<Input
v-model="invoiceInput"
placeholder="lnbc... or lntb..."
class="flex-1 font-mono text-xs"
:disabled="isProcessing"
/>
<Button
variant="outline"
size="sm"
:disabled="!invoiceInput.trim() || isProcessing"
@click="submitInvoice"
>
{{ atmStore.isPayingInvoice ? '...' : 'Pay' }}
</Button>
</div>
</div>
<p v-if="atmStore.paymentError" class="text-xs text-red-400">
Error: {{ atmStore.paymentError }}
</p>
</div>
</details>
<!-- Debug: Simulate payment button -->
<div v-if="atmStore.debugMode" class="pt-2 text-center">
<Button
variant="ghost"
size="sm"
class="text-xs text-muted-foreground"
@click="atmStore.paymentReceived('mock-preimage-' + Date.now())"
>
Dev: Skip to Success
</Button>
</div>
</CardContent>
</Card>
<!-- Ask for Receipt -->
<Card v-else-if="nestedState === 'askForReceipt'" class="w-80 border-0 bg-white/5">
<CardContent class="flex flex-col items-center gap-4 p-8">
<div class="text-6xl text-green-400">✅</div>
<CardTitle>Payment Sent!</CardTitle>
<p class="text-muted-foreground">Would you like a receipt?</p>
<div class="flex h-32 w-32 items-center justify-center rounded-lg bg-white">
<p class="text-black">📱 Scan npub</p>
</div>
<Button variant="outline" @click="skipReceipt">No Thanks</Button>
</CardContent>
</Card>
<!-- Sending Receipt -->
<Card v-else-if="nestedState === 'sendingReceipt'" 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">Sending receipt...</p>
</CardContent>
</Card>
<!-- Complete -->
<Card v-else-if="nestedState === 'complete'" class="w-80 border-0 bg-green-900/30">
<CardContent class="flex flex-col items-center gap-4 p-8 text-center">
<div class="text-6xl">🎉</div>
<CardTitle class="text-green-400">Transaction Complete!</CardTitle>
<p v-if="context">
You received
<Badge variant="secondary">{{ formatSats(context.satsAmount) }} sats</Badge>
</p>
<p class="text-muted-foreground">Thank you!</p>
</CardContent>
</Card>
<!-- Error -->
<Card v-else-if="nestedState === 'error'" class="w-80 border-0 bg-red-900/30">
<CardContent class="flex flex-col items-center gap-4 p-8 text-center">
<div class="text-6xl">⚠️</div>
<CardTitle class="text-red-400">Error</CardTitle>
<p class="text-muted-foreground">{{ context?.error || 'An error occurred' }}</p>
<Button class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black" @click="cancel">
Return to Start
</Button>
</CardContent>
</Card>
<!-- Fallback -->
<Card v-else class="w-80 border-0 bg-white/5">
<CardContent class="p-8 text-center">
<p class="text-muted-foreground">State: {{ nestedState }}</p>
</CardContent>
</Card>
</main>
</div>
</template>

View file

@ -0,0 +1,334 @@
<script setup lang="ts">
import { watch, computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAtmStore } from '@/stores/atm'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Skeleton } from '@/components/ui/skeleton'
import QRCode from '@/components/QRCode.vue'
const router = useRouter()
const atmStore = useAtmStore()
// Copy invoice functionality
const copyButtonText = ref('Copy Invoice')
async function copyInvoice() {
if (!atmStore.context?.invoice) return
try {
await navigator.clipboard.writeText(atmStore.context.invoice)
copyButtonText.value = 'Copied!'
setTimeout(() => {
copyButtonText.value = 'Copy Invoice'
}, 2000)
} catch (err) {
console.error('Failed to copy:', err)
copyButtonText.value = 'Copy failed'
}
}
// Navigate back to idle when transaction completes
watch(
() => atmStore.isIdle,
(isIdle) => {
if (isIdle) {
router.push('/')
}
}
)
const nestedState = computed(() => atmStore.nestedState)
const context = computed(() => atmStore.context)
// Available denominations from inventory
const availableDenominations = computed(() => {
if (!context.value?.inventory) return []
return Object.keys(context.value.inventory)
.map(Number)
.filter((d) => (context.value?.inventory[d] || 0) > 0)
.sort((a, b) => b - a) // Largest first
})
// Count of each denomination selected
const selectionCounts = computed(() => {
if (!context.value?.cashOutSelection) return {}
const counts: Record<number, number> = {}
for (const denom of context.value.cashOutSelection) {
counts[denom] = (counts[denom] || 0) + 1
}
return counts
})
// Check if we can add more of a denomination
function canAdd(denom: number): boolean {
if (!context.value) return false
const available = context.value.inventory[denom] || 0
const selected = selectionCounts.value[denom] || 0
return selected < available
}
// Check if we can remove a denomination
function canRemove(denom: number): boolean {
return (selectionCounts.value[denom] || 0) > 0
}
function cancel() {
atmStore.cancel()
}
function confirmCashTaken() {
atmStore.cashTaken()
}
function skipReceipt() {
atmStore.skipReceipt()
}
function formatSats(sats: number): string {
return sats.toLocaleString()
}
function formatFiat(cents: number): string {
return (cents / 100).toFixed(2)
}
</script>
<template>
<div class="flex flex-1 flex-col">
<!-- Header -->
<header class="flex items-center bg-black/30 px-8 py-4">
<Button variant="ghost" class="text-muted-foreground" @click="cancel">← Cancel</Button>
<h2 class="flex-1 text-center text-xl font-semibold">Sell Bitcoin</h2>
<div class="w-20"></div>
</header>
<!-- Content -->
<main class="flex flex-1 items-center justify-center p-8">
<!-- Fetching Rate -->
<Card v-if="nestedState === 'fetchingRate'" 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">Fetching exchange rate...</p>
</CardContent>
</Card>
<!-- Selecting Amount - Main cash-out screen -->
<Card v-else-if="nestedState === 'selectingAmount'" class="w-[520px] border-0 bg-white/5">
<CardHeader class="text-center">
<CardTitle>Select Cash Amount</CardTitle>
<CardDescription v-if="context">
Rate: <Badge variant="secondary">{{ formatSats(context.exchangeRate) }} sats/$</Badge>
</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<!-- Denomination buttons -->
<div class="grid grid-cols-2 gap-4">
<div
v-for="denom in availableDenominations"
:key="denom"
class="flex items-center justify-between rounded-lg bg-white/5 p-4"
>
<span class="text-2xl font-bold text-green-400">${{ denom }}</span>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
:disabled="!canRemove(denom)"
@click="atmStore.removeDenomination(denom)"
>
-
</Button>
<span class="w-8 text-center text-lg">{{ selectionCounts[denom] || 0 }}</span>
<Button
variant="outline"
size="sm"
:disabled="!canAdd(denom)"
@click="atmStore.addDenomination(denom)"
>
+
</Button>
</div>
</div>
</div>
<!-- Selected amount summary -->
<div class="rounded-lg bg-white/10 p-6 text-center">
<p class="text-sm text-muted-foreground">You will receive</p>
<p class="text-4xl font-bold text-green-400">
${{ context ? formatFiat(context.fiatAmount) : '0.00' }}
</p>
<p class="mt-2 text-sm text-muted-foreground">
You pay:
<Badge variant="secondary" class="ml-1">
{{ context ? formatSats(context.satsAmount) : 0 }} sats
</Badge>
</p>
</div>
<!-- Action buttons -->
<div class="flex gap-4">
<Button
variant="outline"
class="flex-1"
:disabled="!context?.cashOutSelection?.length"
@click="atmStore.clearSelection()"
>
Clear
</Button>
<Button
class="flex-1 bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"
:disabled="!context?.cashOutSelection?.length"
@click="atmStore.confirmAmount()"
>
Continue
</Button>
</div>
<!-- Instructions -->
<Alert class="border-orange-500/50 bg-orange-500/10">
<AlertDescription>
<p class="text-sm text-muted-foreground">
Select the cash amount you want to receive. You'll pay the equivalent in Bitcoin.
</p>
</AlertDescription>
</Alert>
</CardContent>
</Card>
<!-- Generating Invoice -->
<Card v-else-if="nestedState === 'generatingInvoice'" 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">Creating invoice...</p>
</CardContent>
</Card>
<!-- Displaying Invoice - Show QR for payment -->
<Card v-else-if="nestedState === 'displayingInvoice'" class="w-[480px] border-0 bg-white/5">
<CardHeader class="text-center">
<CardTitle>Scan to Pay</CardTitle>
<CardDescription v-if="context">
Pay <Badge variant="secondary">{{ formatSats(context.satsAmount) }} sats</Badge> to
receive <Badge class="bg-green-600">${{ formatFiat(context.fiatAmount) }}</Badge>
</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<!-- Invoice QR Code -->
<div class="flex flex-col items-center">
<QRCode v-if="context?.invoice" :value="context.invoice" :size="220" />
<p class="mt-4 text-center text-sm text-muted-foreground">
Scan with any Lightning wallet
</p>
</div>
<!-- Payment status -->
<div class="flex flex-col items-center gap-4 py-4">
<div class="h-12 w-12 animate-pulse rounded-full bg-orange-500/20">
<div class="flex h-full items-center justify-center text-2xl">⏳</div>
</div>
<p class="text-center text-muted-foreground">Waiting for payment...</p>
</div>
<!-- Invoice info with copy button -->
<div v-if="context?.invoice" class="rounded-lg bg-white/5 p-3">
<p class="mb-2 truncate text-center font-mono text-xs text-muted-foreground">
{{ context.invoice.slice(0, 50) }}...
</p>
<div class="flex justify-center">
<Button variant="outline" size="sm" @click="copyInvoice">
{{ copyButtonText }}
</Button>
</div>
</div>
<!-- Dev controls -->
<Alert v-if="atmStore.debugMode" class="border-red-500/50 bg-red-500/10">
<AlertDescription class="text-center">
<p class="mb-2 text-xs text-red-400">Dev: Use alice-pay or button below</p>
<Button
variant="outline"
size="sm"
@click="atmStore.paymentReceived('mock-preimage-' + Date.now())"
>
Simulate Payment
</Button>
</AlertDescription>
</Alert>
</CardContent>
</Card>
<!-- Dispensing Cash -->
<Card v-else-if="nestedState === 'dispensingCash'" 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">Dispensing cash...</p>
<p class="text-sm text-muted-foreground">Please wait</p>
</CardContent>
</Card>
<!-- Cash Ready -->
<Card v-else-if="nestedState === 'waitingForCashTaken'" class="w-80 border-0 bg-green-900/30">
<CardContent class="flex flex-col items-center gap-4 p-8 text-center">
<div class="text-6xl text-green-400">✅</div>
<CardTitle class="text-green-400">Cash Ready!</CardTitle>
<p v-if="context">
Please take your
<Badge class="bg-green-600 text-lg">${{ formatFiat(context.fiatAmount) }}</Badge>
</p>
<Button
class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"
size="lg"
@click="confirmCashTaken"
>
I've Taken the Cash
</Button>
</CardContent>
</Card>
<!-- Ask for Receipt -->
<Card v-else-if="nestedState === 'askForReceipt'" class="w-80 border-0 bg-white/5">
<CardContent class="flex flex-col items-center gap-4 p-8">
<CardTitle>Would you like a receipt?</CardTitle>
<p class="text-sm text-muted-foreground">Scan your npub to receive a Nostr DM receipt</p>
<div class="flex h-32 w-32 items-center justify-center rounded-lg bg-white">
<p class="text-black">📱 Scan npub</p>
</div>
<Button variant="outline" @click="skipReceipt">No Thanks</Button>
</CardContent>
</Card>
<!-- Complete -->
<Card v-else-if="nestedState === 'complete'" class="w-80 border-0 bg-green-900/30">
<CardContent class="flex flex-col items-center gap-4 p-8 text-center">
<div class="text-6xl">🎉</div>
<CardTitle class="text-green-400">Transaction Complete!</CardTitle>
<p class="text-muted-foreground">Thank you!</p>
</CardContent>
</Card>
<!-- Error -->
<Card
v-else-if="nestedState === 'error' || nestedState === 'dispenseError'"
class="w-80 border-0 bg-red-900/30"
>
<CardContent class="flex flex-col items-center gap-4 p-8 text-center">
<div class="text-6xl">⚠️</div>
<CardTitle class="text-red-400">Error</CardTitle>
<p class="text-muted-foreground">{{ context?.error || 'An error occurred' }}</p>
<Button class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black" @click="cancel">
Return to Start
</Button>
</CardContent>
</Card>
<!-- Fallback -->
<Card v-else class="w-80 border-0 bg-white/5">
<CardContent class="p-8 text-center">
<p class="text-muted-foreground">State: {{ nestedState }}</p>
</CardContent>
</Card>
</main>
</div>
</template>

View file

@ -0,0 +1,84 @@
<script setup lang="ts">
import { watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAtmStore } from '@/stores/atm'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
const router = useRouter()
const atmStore = useAtmStore()
// Watch for state changes and navigate accordingly
watch(
() => atmStore.currentState,
(state) => {
if (state.includes('cashIn')) {
router.push('/cash-in')
} else if (state.includes('cashOut')) {
router.push('/cash-out')
}
}
)
function handleCashIn() {
atmStore.selectCashIn()
}
function handleCashOut() {
atmStore.selectCashOut()
}
</script>
<template>
<div class="flex flex-1 flex-col items-center justify-center gap-12 p-8">
<!-- Logo -->
<div class="text-center">
<h1 class="text-5xl font-bold text-primary">⚡ Bitcoin ATM</h1>
</div>
<!-- Action Buttons -->
<div class="flex gap-8">
<!-- Buy Bitcoin -->
<Card
class="h-52 w-52 cursor-pointer border-2 border-success/50 bg-success/20 transition-all hover:scale-105 hover:border-success hover:bg-success/30 active:scale-95"
@click="handleCashIn"
>
<CardContent class="flex h-full flex-col items-center justify-center gap-2 p-6">
<span class="text-5xl">💵</span>
<span class="text-xl font-bold text-success">Buy Bitcoin</span>
<span class="text-sm text-foreground/70">Insert cash, receive sats</span>
</CardContent>
</Card>
<!-- Sell Bitcoin -->
<Card
class="h-52 w-52 cursor-pointer border-2 border-warning/50 bg-warning/20 transition-all hover:scale-105 hover:border-warning hover:bg-warning/30 active:scale-95"
@click="handleCashOut"
>
<CardContent class="flex h-full flex-col items-center justify-center gap-2 p-6">
<span class="text-5xl">🏧</span>
<span class="text-xl font-bold text-warning">Sell Bitcoin</span>
<span class="text-sm text-foreground/70">Pay invoice, receive cash</span>
</CardContent>
</Card>
</div>
<!-- Footer -->
<div class="flex items-center gap-4">
<Badge variant="outline" class="border-success text-success">No KYC</Badge>
<Badge variant="outline" class="border-primary text-primary">No Registration</Badge>
<Badge variant="outline" class="border-bitcoin text-bitcoin">Just Bitcoin</Badge>
</div>
<!-- Debug toggle -->
<Button
variant="ghost"
size="sm"
class="absolute bottom-4 right-4 text-xs text-muted-foreground"
@click="atmStore.toggleDebug"
>
{{ atmStore.debugMode ? 'Hide Debug' : 'Show Debug' }}
</Button>
</div>
</template>

7
apps/machine/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}

View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
},
"baseUrl": "."
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View file

@ -0,0 +1,33 @@
import path from 'node:path'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
// Dev server configuration
server: {
port: 1420,
strictPort: true,
},
// Build configuration for Electron renderer
build: {
// Target modern Chrome (Electron uses Chromium)
target: 'chrome120',
// Sourcemaps for debugging
sourcemap: process.env.NODE_ENV === 'development',
rollupOptions: {
// @lamassu/hal uses Node.js APIs (serialport, node:events) and will only
// run in Electron's main process. Exclude from renderer bundle.
external: ['@lamassu/hal'],
},
},
// Prevent Vite from clearing the screen (useful when running with Electron)
clearScreen: false,
})