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

513
.devenv.flake.nix Normal file
View file

@ -0,0 +1,513 @@
{
inputs =
let
vars = {
version = "1.11.2";
system = "x86_64-linux";
devenv_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next";
project_input_ref = "path:/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next";
devenv_dotfile = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next/.devenv";
devenv_dotfile_path = ./.devenv;
devenv_tmpdir = "/run/user/1000";
devenv_runtime = "/run/user/1000/devenv-f4ba770";
devenv_istesting = false;
devenv_direnvrc_latest_version = 1;
container_name = null;
active_profiles = [
];
hostname = "gizmo";
username = "padreug";
git_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages";
secretspec = null;
};
in
{
git-hooks.url = "github:cachix/git-hooks.nix";
git-hooks.inputs.nixpkgs.follows = "nixpkgs";
pre-commit-hooks.follows = "git-hooks";
nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling";
devenv.url = "github:cachix/devenv?dir=src/modules";
}
// (
if builtins.pathExists (vars.devenv_dotfile_path + "/flake.json") then
builtins.fromJSON (builtins.readFile (vars.devenv_dotfile_path + "/flake.json"))
else
{ }
);
outputs =
{ nixpkgs, ... }@inputs:
let
vars = {
version = "1.11.2";
system = "x86_64-linux";
devenv_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next";
project_input_ref = "path:/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next";
devenv_dotfile = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next/.devenv";
devenv_dotfile_path = ./.devenv;
devenv_tmpdir = "/run/user/1000";
devenv_runtime = "/run/user/1000/devenv-f4ba770";
devenv_istesting = false;
devenv_direnvrc_latest_version = 1;
container_name = null;
active_profiles = [
];
hostname = "gizmo";
username = "padreug";
git_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages";
secretspec = null;
};
devenv =
if builtins.pathExists (vars.devenv_dotfile_path + "/devenv.json") then
builtins.fromJSON (builtins.readFile (vars.devenv_dotfile_path + "/devenv.json"))
else
{ };
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
# Function to create devenv configuration for a specific system with profiles support
mkDevenvForSystem =
targetSystem:
let
getOverlays =
inputName: inputAttrs:
map (
overlay:
let
input =
inputs.${inputName} or (throw "No such input `${inputName}` while trying to configure overlays.");
in
input.overlays.${overlay}
or (throw "Input `${inputName}` has no overlay called `${overlay}`. Supported overlays: ${nixpkgs.lib.concatStringsSep ", " (builtins.attrNames input.overlays)}")
) inputAttrs.overlays or [ ];
overlays = nixpkgs.lib.flatten (nixpkgs.lib.mapAttrsToList getOverlays (devenv.inputs or { }));
permittedUnfreePackages =
devenv.nixpkgs.per-platform."${targetSystem}".permittedUnfreePackages
or devenv.nixpkgs.permittedUnfreePackages or [ ];
pkgs = import nixpkgs {
system = targetSystem;
config = {
allowUnfree =
devenv.nixpkgs.per-platform."${targetSystem}".allowUnfree or devenv.nixpkgs.allowUnfree
or devenv.allowUnfree or false;
allowBroken =
devenv.nixpkgs.per-platform."${targetSystem}".allowBroken or devenv.nixpkgs.allowBroken
or devenv.allowBroken or false;
cudaSupport =
devenv.nixpkgs.per-platform."${targetSystem}".cudaSupport or devenv.nixpkgs.cudaSupport or false;
cudaCapabilities =
devenv.nixpkgs.per-platform."${targetSystem}".cudaCapabilities or devenv.nixpkgs.cudaCapabilities
or [ ];
permittedInsecurePackages =
devenv.nixpkgs.per-platform."${targetSystem}".permittedInsecurePackages
or devenv.nixpkgs.permittedInsecurePackages or devenv.permittedInsecurePackages or [ ];
allowUnfreePredicate =
if (permittedUnfreePackages != [ ]) then
(pkg: builtins.elem (nixpkgs.lib.getName pkg) permittedUnfreePackages)
else
(_: false);
};
inherit overlays;
};
inherit (pkgs) lib;
importModule =
path:
if lib.hasPrefix "./" path then
if lib.hasSuffix ".nix" path then
./. + (builtins.substring 1 255 path)
else
./. + (builtins.substring 1 255 path) + "/devenv.nix"
else if lib.hasPrefix "../" path then
# For parent directory paths, concatenate with /.
# ./. refers to the directory containing this file (project root)
# So ./. + "/../shared" = <project-root>/../shared
if lib.hasSuffix ".nix" path then ./. + "/${path}" else ./. + "/${path}/devenv.nix"
else
let
paths = lib.splitString "/" path;
name = builtins.head paths;
input = inputs.${name} or (throw "Unknown input ${name}");
subpath = "/${lib.concatStringsSep "/" (builtins.tail paths)}";
devenvpath = "${input}" + subpath;
devenvdefaultpath = devenvpath + "/devenv.nix";
in
if lib.hasSuffix ".nix" devenvpath then
devenvpath
else if builtins.pathExists devenvdefaultpath then
devenvdefaultpath
else
throw (devenvdefaultpath + " file does not exist for input ${name}.");
# Phase 1: Base evaluation to extract profile definitions
baseProject = pkgs.lib.evalModules {
specialArgs = inputs // {
inherit inputs;
};
modules = [
(
{ config, ... }:
{
_module.args.pkgs = pkgs.appendOverlays (config.overlays or [ ]);
}
)
(inputs.devenv.modules + /top-level.nix)
(
{ options, ... }:
{
config.devenv = lib.mkMerge [
{
cliVersion = vars.version;
root = vars.devenv_root;
dotfile = vars.devenv_dotfile;
}
(pkgs.lib.optionalAttrs (builtins.hasAttr "tmpdir" options.devenv) {
tmpdir = vars.devenv_tmpdir;
})
(pkgs.lib.optionalAttrs (builtins.hasAttr "isTesting" options.devenv) {
isTesting = vars.devenv_istesting;
})
(pkgs.lib.optionalAttrs (builtins.hasAttr "runtime" options.devenv) {
runtime = vars.devenv_runtime;
})
(pkgs.lib.optionalAttrs (builtins.hasAttr "direnvrcLatestVersion" options.devenv) {
direnvrcLatestVersion = vars.devenv_direnvrc_latest_version;
})
];
}
)
(
{ options, ... }:
{
config = lib.mkMerge [
(pkgs.lib.optionalAttrs (builtins.hasAttr "git" options) {
git.root = vars.git_root;
})
];
}
)
(pkgs.lib.optionalAttrs (vars.container_name != null) {
container.isBuilding = pkgs.lib.mkForce true;
containers.${vars.container_name}.isBuilding = true;
})
]
++ (map importModule (devenv.imports or [ ]))
++ [
(if builtins.pathExists ./devenv.nix then ./devenv.nix else { })
(devenv.devenv or { })
(if builtins.pathExists ./devenv.local.nix then ./devenv.local.nix else { })
(
if builtins.pathExists (vars.devenv_dotfile_path + "/cli-options.nix") then
import (vars.devenv_dotfile_path + "/cli-options.nix")
else
{ }
)
];
};
# Phase 2: Extract and apply profiles using extendModules with priority overrides
project =
let
# Build ordered list of profile names: hostname -> user -> manual
manualProfiles = vars.active_profiles;
currentHostname = vars.hostname;
currentUsername = vars.username;
hostnameProfiles = lib.optional (
currentHostname != ""
&& builtins.hasAttr currentHostname (baseProject.config.profiles.hostname or { })
) "hostname.${currentHostname}";
userProfiles = lib.optional (
currentUsername != "" && builtins.hasAttr currentUsername (baseProject.config.profiles.user or { })
) "user.${currentUsername}";
# Ordered list of profiles to activate
orderedProfiles = hostnameProfiles ++ userProfiles ++ manualProfiles;
# Resolve profile extends with cycle detection
resolveProfileExtends =
profileName: visited:
if builtins.elem profileName visited then
throw "Circular dependency detected in profile extends: ${lib.concatStringsSep " -> " visited} -> ${profileName}"
else
let
profile = getProfileConfig profileName;
extends = profile.extends or [ ];
newVisited = visited ++ [ profileName ];
extendedProfiles = lib.flatten (map (name: resolveProfileExtends name newVisited) extends);
in
extendedProfiles ++ [ profileName ];
# Get profile configuration by name from baseProject
getProfileConfig =
profileName:
if lib.hasPrefix "hostname." profileName then
let
name = lib.removePrefix "hostname." profileName;
in
baseProject.config.profiles.hostname.${name}
else if lib.hasPrefix "user." profileName then
let
name = lib.removePrefix "user." profileName;
in
baseProject.config.profiles.user.${name}
else
let
availableProfiles = builtins.attrNames (baseProject.config.profiles or { });
hostnameProfiles = map (n: "hostname.${n}") (
builtins.attrNames (baseProject.config.profiles.hostname or { })
);
userProfiles = map (n: "user.${n}") (builtins.attrNames (baseProject.config.profiles.user or { }));
allAvailableProfiles = availableProfiles ++ hostnameProfiles ++ userProfiles;
in
baseProject.config.profiles.${profileName}
or (throw "Profile '${profileName}' not found. Available profiles: ${lib.concatStringsSep ", " allAvailableProfiles}");
# Fold over ordered profiles to build final list with extends
expandedProfiles = lib.foldl' (
acc: profileName:
let
allProfileNames = resolveProfileExtends profileName [ ];
in
acc ++ allProfileNames
) [ ] orderedProfiles;
# Map over expanded profiles and apply priorities
allPrioritizedModules = lib.imap0 (
index: profileName:
let
# Decrement priority for each profile (lower = higher precedence)
# Start with the next lowest priority after the default priority for values (100)
profilePriority = (lib.modules.defaultOverridePriority - 1) - index;
profileConfig = getProfileConfig profileName;
# Check if an option type needs explicit override to resolve conflicts
# Only apply overrides to LEAF values (scalars), not collection types that can merge
typeNeedsOverride =
type:
if type == null then
false
else
let
typeName = type.name or type._type or "";
# True leaf types that need priority resolution when they conflict
isLeafType = builtins.elem typeName [
"str"
"int"
"bool"
"enum"
"path"
"package"
"float"
"anything"
];
in
if isLeafType then
true
else if typeName == "nullOr" then
# For nullOr, check the wrapped type recursively
let
innerType =
type.elemType
or (if type ? nestedTypes && type.nestedTypes ? elemType then type.nestedTypes.elemType else null);
in
if innerType != null then typeNeedsOverride innerType else false
else
# Everything else (collections, submodules, etc.) should merge naturally
false;
# Check if a config path needs explicit override
pathNeedsOverride =
optionPath:
let
# Try direct option first
directOption = lib.attrByPath optionPath null baseProject.options;
in
if directOption != null && lib.isOption directOption then
typeNeedsOverride directOption.type
else if optionPath != [ ] then
# Check parent for freeform type
let
parentPath = lib.init optionPath;
parentOption = lib.attrByPath parentPath null baseProject.options;
in
if parentOption != null && lib.isOption parentOption then
let
# Look for freeform type:
# 1. Standard location: type.freeformType (primary)
# 2. Nested location: type.nestedTypes.freeformType (evaluated form)
freeformType = parentOption.type.freeformType or parentOption.type.nestedTypes.freeformType or null;
elementType =
if freeformType ? elemType then
freeformType.elemType
else if freeformType ? nestedTypes && freeformType.nestedTypes ? elemType then
freeformType.nestedTypes.elemType
else
freeformType;
in
typeNeedsOverride elementType
else
false
else
false;
# Support overriding both plain attrset modules and functions
applyModuleOverride =
config:
if builtins.isFunction config then
let
wrapper = args: applyOverrideRecursive (config args) [ ];
in
lib.mirrorFunctionArgs config wrapper
else
applyOverrideRecursive config [ ];
# Apply overrides recursively based on option types
applyOverrideRecursive =
config: optionPath:
if lib.isAttrs config && config ? _type then
config # Don't touch values with existing type metadata
else if lib.isAttrs config then
lib.mapAttrs (name: value: applyOverrideRecursive value (optionPath ++ [ name ])) config
else if pathNeedsOverride optionPath then
lib.mkOverride profilePriority config
else
config;
# Apply priority overrides recursively to the deferredModule imports structure
prioritizedConfig = (
profileConfig.module
// {
imports = lib.map (
importItem:
importItem
// {
imports = lib.map (nestedImport: applyModuleOverride nestedImport) (importItem.imports or [ ]);
}
) (profileConfig.module.imports or [ ]);
}
);
in
prioritizedConfig
) expandedProfiles;
in
if allPrioritizedModules == [ ] then
baseProject
else
baseProject.extendModules { modules = allPrioritizedModules; };
config = project.config;
options = pkgs.nixosOptionsDoc {
options = builtins.removeAttrs project.options [ "_module" ];
warningsAreErrors = false;
# Unpack Nix types, e.g. literalExpression, mDoc.
transformOptions =
let
isDocType =
v:
builtins.elem v [
"literalDocBook"
"literalExpression"
"literalMD"
"mdDoc"
];
in
lib.attrsets.mapAttrs (
_: v:
if v ? _type && isDocType v._type then
v.text
else if v ? _type && v._type == "derivation" then
v.name
else
v
);
};
# Recursively search for outputs in the config.
# This is used when not building a specific output by attrpath.
build =
options: config:
lib.concatMapAttrs (
name: option:
if lib.isOption option then
let
typeName = option.type.name or "";
in
if
builtins.elem typeName [
"output"
"outputOf"
]
then
{ ${name} = config.${name}; }
else
{ }
else if builtins.isAttrs option && !lib.isDerivation option then
let
v = build option config.${name};
in
if v != { } then
{
${name} = v;
}
else
{ }
else
{ }
) options;
in
{
inherit
config
options
build
project
;
shell = config.shell;
packages = {
optionsJSON = options.optionsJSON;
# deprecated
inherit (config)
info
procfileScript
procfileEnv
procfile
;
ci = config.ciDerivation;
};
};
# Generate per-system devenv configurations
perSystem = nixpkgs.lib.genAttrs systems mkDevenvForSystem;
# Default devenv for the current system
currentSystemDevenv = perSystem.${vars.system};
in
{
devShell = nixpkgs.lib.genAttrs systems (s: perSystem.${s}.shell);
packages = nixpkgs.lib.genAttrs systems (s: perSystem.${s}.packages);
# Per-system devenv configurations
devenv = {
# Default devenv for the current system
inherit (currentSystemDevenv)
config
options
build
shell
packages
project
;
# Per-system devenv configurations
inherit perSystem;
};
# Legacy build output
build = currentSystemDevenv.build currentSystemDevenv.options currentSystemDevenv.config;
};
}

78
.gitignore vendored
View file

@ -4,13 +4,11 @@ node_modules/
# Build outputs
dist/
build/
*.tsbuildinfo
# Environment files
.env
.env.*
!.env.example
.next/
.nuxt/
.output/
target/
*.node
# IDE
.idea/
@ -19,35 +17,51 @@ build/
*.swo
*~
# Environment
.env
.env.*
!.env.example
# Secrets
*.nsec
*.pem
*.key
.secrets.baseline
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
# Testing
coverage/
.nyc_output/
# Caches
.turbo/
.cache/
.parcel-cache/
.eslintcache
*.tsbuildinfo
# OS
.DS_Store
Thumbs.db
# Nix
result
result-*
.direnv/
# Electron
apps/machine/dist-electron/
apps/machine/release/
# devenv
.devenv/
.devenv.flake.nix
# Logs
*.log
logs/
# Test coverage
coverage/
# Lamassu specific
.lamassu/
*.pem
*.crt
*.key
# Claude Code
.claude/
# Pre-commit (auto-generated by devenv)
.direnv/
.pre-commit-config.yaml
# External dependencies (cloned for docker)
Lightning.Pub/
# Docker
docker/**/data/
# Temporary
tmp/
temp/
*.tmp
*.timestamp-*.mjs

12
.gitmodules vendored
View file

@ -1,12 +0,0 @@
[submodule "lamassu-server"]
path = lamassu-server
url = https://github.com/lamassu/lamassu-server.git
[submodule "lamassu-machine"]
path = lamassu-machine
url = https://github.com/lamassu/lamassu-machine.git
[submodule "lamassu-install"]
path = lamassu-install
url = https://github.com/lamassu/lamassu-install.git
[submodule "lnbits"]
path = lnbits
url = https://github.com/lnbits/lnbits.git

404
CLAUDE.md
View file

@ -1,121 +1,339 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance to Claude Code when working with the Lamassu Next codebase.
## Repository Overview
## Project Overview
This is the Lamassu Bitcoin ATM system, consisting of three components:
**Lamassu Next** is a Nostr-native Lightning ATM system. Key principles:
- **lamassu-server/** - Backend services and admin dashboard (pnpm monorepo with Turbo)
- **lamassu-machine/** - ATM kiosk software that runs on the physical machines
- **lamassu-install/** - Production installation and upgrade scripts
## Commands
### lamassu-server (monorepo)
```bash
cd lamassu-server
# Install dependencies
pnpm install
# Run all packages in development mode (server + admin-ui)
pnpm run dev
# Build all packages
pnpm run build
# Run tests across all packages
pnpm run test
# Run a single test file
cd packages/admin-ui && pnpm vitest run path/to/file.test.js
# Database migrations
node packages/server/bin/lamassu-migrate
# Generate SSL certificates (first-time setup)
bash packages/server/tools/cert-gen.sh
# Create admin user
node packages/server/bin/lamassu-register admin@example.com superuser
# Regenerate database types (requires running postgres)
cd packages/typesafe-db && pnpm run generate-types
```
### lamassu-machine
```bash
cd lamassu-machine
# Install and build
npm install
bash ./setup.sh
npm run build
# Run tests
npm test
# Development with mock hardware
node bin/fake-bills.js # In one terminal
node bin/lamassu-machine --mockBillValidator --mockBillDispenser --mockCam --mockPair '<totem>'
```
- **KYC-Free**: No identity collection, no compliance theater
- **Lightning-Native**: Security encapsulated in Lightning protocol
- **Nostr as Infrastructure**: Relay for communication, keypairs for identity
- **Open Source First**: Every component auditable and forkable
## Architecture
### lamassu-server Monorepo Structure
```
packages/
├── server/ # Express + Apollo GraphQL backend (CommonJS)
├── admin-ui/ # React 18 + Vite + MUI admin dashboard (ESM)
├── coins/ # @lamassu/coins - cryptocurrency constants (TypeScript)
└── typesafe-db/ # @lamassu/typesafe-db - Kysely database layer (TypeScript)
lamassu-next/
├── apps/
│ ├── machine/ # Electron + Vue 3 ATM kiosk application ✅
│ ├── dashboard/ # Vue 3 operator dashboard (planned)
│ └── relay/ # strfry relay configuration (planned)
├── packages/
│ ├── hal/ # TypeScript Hardware Abstraction Layer ✅
│ ├── nostr-client/ # Nostr client library ✅
│ ├── clink/ # CLINK protocol implementation ✅
│ ├── state-machine/ # XState v5 ATM state machine ✅
│ ├── lightning/ # Lightning.Pub RPC client ✅
│ ├── cashu/ # Cashu ecash (placeholder)
│ └── ui-shared/ # Shared Vue components (placeholder)
└── docker/ # Development infrastructure ✅
```
**Dependency flow**: `server` and `admin-ui` depend on `coins` and `typesafe-db`
## Implementation Status
**Key server entry points**:
- `bin/lamassu-server` - Main HTTPS server (port 3000, client cert auth)
- `bin/lamassu-admin-server` - Admin API server
- 20+ CLI utilities in `bin/` for operations tasks
### Completed Packages
**GraphQL**: Two implementations exist:
- `lib/graphql/` - Machine-facing API
- `lib/new-admin/graphql/` - Admin dashboard API
| Package | Description | Tests |
| ------------------------ | ------------------------------------------------------ | ----- |
| `@lamassu/nostr-client` | Nostr relay client with NIP-42 auth, NIP-44 encryption | 13 |
| `@lamassu/clink` | CLINK protocol (kinds 21001-21003), noffer encoding | 7 |
| `@lamassu/lightning` | Lightning.Pub RPC client (kind 21000) | 10 |
| `@lamassu/state-machine` | XState v5 ATM state machine (idle, cashIn, cashOut) | 14 |
| `@lamassu/hal` | Hardware drivers (ID003 validator, F56 dispenser) | - |
### lamassu-machine
### Placeholder Packages
The ATM kiosk uses a state machine architecture (`machina.js`) in `lib/brain.js` (134KB). The UI is vanilla JavaScript with Babel transpilation.
| Package | Description |
| -------------------- | --------------------------------- |
| `@lamassu/cashu` | Cashu ecash for offline operation |
| `@lamassu/ui-shared` | Shared Vue 3 components |
**Hardware drivers** in `lib/`: id003, mei, puloon, ccnet (bill validators), printer, leds, camera
### Completed Applications
- **apps/machine** - Electron ATM kiosk with Vue 3 UI (HAL integrated, ready for hardware testing)
### Planned Components
- **apps/dashboard** - Operator dashboard for fleet management
### Critical Documentation
- **`packages/lightning/TROUBLESHOOTING.md`** - Lightning.Pub integration gotchas. Read this BEFORE debugging payment issues. Contains solutions to 9 non-obvious issues that took 5+ hours to diagnose.
## Commands
```bash
# Enter development environment
devenv shell
# Start development
pnpm dev
# Build all packages
pnpm build
# Run tests
pnpm test
# Infrastructure management
infra-up # Start all Docker services
infra-down # Stop all Docker services
infra-status # Show service status
infra-logs # Follow service logs
# Bitcoin/Lightning (regtest)
btccli # Bitcoin CLI
lncli # LND CLI (Lightning.Pub's node)
lncli-alice # LND CLI (Alice's node for testing payments)
mine-blocks # Mine regtest blocks (default: 1)
setup-channel # Setup channel between Alice and LND
alice-pay # Pay invoice from Alice's node
relay-test # Test Nostr relay connection
# Testing (E2E)
test-setup # Validate test environment (services, channels, payments)
test-payment # Quick e2e payment test (ATM → customer)
fund-atm # Fund ATM account (default: 100k sats)
alice-invoice # Create invoice on Alice's node
node-info # Show node pubkeys and channel info
```
## Development Infrastructure
The `docker/` directory contains a complete development environment:
| Service | Container | Port(s) | Description |
| ------------- | --------------------- | ----------- | ------------------------------------- |
| strfry | lamassu-relay | 7777 | Private Nostr relay |
| bitcoind | lamassu-bitcoind | 18443 | Bitcoin Core (regtest) |
| LND | lamassu-lnd | 10009, 8080 | Lightning node (Lightning.Pub's node) |
| LND Alice | lamassu-lnd-alice | 10010, 8081 | Second LND for payment testing |
| Lightning.Pub | lamassu-lightning-pub | 1776 | Nostr-native account system |
| PostgreSQL | lamassu-postgres | 5432 | Database for server-side state |
### Quick Start
```bash
devenv shell # Enter dev environment
infra-up # Start all services (30-60s first run)
mine-blocks 101 # Fund the regtest wallet
setup-channel # Open channel between Alice and LND
```
### Testing Payments
The development setup includes two LND nodes to enable proper payment testing:
1. **LND** (`lamassu-lnd`) - Used by Lightning.Pub to create invoices
2. **Alice** (`lamassu-lnd-alice`) - Used to pay invoices (simulates external payers)
```bash
# Get Lightning.Pub admin token
curl -X POST "http://localhost:1776/api/admin/app/auth" \
-H "Authorization: Bearer lamassu-dev-admin-token" \
-d '{"name": "wallet"}'
# Create user and invoice
curl -X POST "http://localhost:1776/api/app/user/add" -H "Authorization: Bearer $APP_TOKEN" \
-d '{"identifier": "test-user", "balance": 0}'
curl -X POST "http://localhost:1776/api/app/user/add/invoice" -H "Authorization: Bearer $APP_TOKEN" \
-d '{"receiver_identifier": "test-user", "payer_identifier": "external", "http_callback_url": "", "invoice_req": {"amountSats": 1000, "memo": "Test"}}'
# Pay from Alice
alice-pay <invoice>
```
### Comprehensive Regtest Integration
For advanced testing with multiple Lightning implementations, use the regtest environment at `~/dev/local/docker/regtest`. This provides:
| Service | Description |
| ----------------- | ------------------------------------- |
| 4 LND nodes | lnd-1 (hub), lnd-2 (Boltz), lnd-3 (LNbits), lnd-4 (standalone) |
| 3 CLN nodes | Core Lightning with REST/gRPC |
| 1 Eclair node | ACINQ Eclair implementation |
| LNbits | Lightning wallet platform (port 5001) |
| Boltz | Submarine swaps (port 9001) |
| Electrs | Electrum server (port 3002) |
| Lightning Terminal| Web UI for lnd-1 (port 8443) |
| Elements/Liquid | Sidechain (port 18884) |
```bash
# Start regtest environment
cd ~/dev/local/docker/regtest && ./start-regtest
source docker-scripts.sh
# Start lamassu services connected to regtest
cd lamassu-next/docker && ./start-with-regtest.sh
# CLI helpers
bitcoin-cli-sim -generate 1 # Mine blocks
lncli-sim 4 getinfo # lnd-4 (Lightning.Pub's node)
lightning-cli-sim 1 getinfo # CLN node 1
```
The integration uses `lnd-4` as Lightning.Pub's backend, giving you access to test payments from multiple node types (LND, CLN, Eclair) and services (LNbits, Boltz).
### MCP Tools Available
Claude has access to these MCP servers for development:
| MCP Server | Purpose |
| ------------ | ----------------------------------------- |
| docker-mcp | Container management (logs, status, etc.) |
| nostr-mcp | Nostr operations (post notes, profiles) |
| postgres-mcp | Database queries and schema inspection |
| mcp-nixos | NixOS/Nix package queries |
| forgejo-mcp | Git operations on Forgejo |
Use these to interact with infrastructure directly during development.
## Key Technologies
| Component | Technology | Notes |
| ------------- | -------------- | --------------------------------------- |
| Runtime | Node.js 22 LTS | Strict TypeScript, ESM |
| ATM Shell | Electron | Node.js main process, Vue 3 renderer |
| State Machine | XState v5 | Actor model, service injection |
| Hardware | TypeScript | ID003, F56 drivers from lamassu-machine |
| Messaging | Nostr | NIP-01, NIP-42, NIP-44 |
| Payments | CLINK + RPC | Kind 21000 (RPC), 21001-21003 (CLINK) |
| Backend | Lightning.Pub | Nostr-native account system |
## Custom Skills
The following skills are available for development assistance:
### `/security` - Security Review
Audit code for Bitcoin/Lightning/ATM-specific vulnerabilities.
```
/security packages/lightning/src/
/security --staged
```
### `/nostr-check` - Nostr Conformity
Validate NIP compliance and Nostr protocol implementation.
```
/nostr-check packages/nostr-client/src/events.ts --nips NIP-01,NIP-44
```
### `/lightning-check` - Lightning.Pub Conformity
Validate CLINK protocol and Lightning.Pub integration.
```
/lightning-check packages/clink/src/ --clink
```
### `/test` - Testing Agent
Run tests, generate test cases, validate transaction flows.
```
/test coverage packages/state-machine/
/test flow cash-out
/test generate packages/lightning/src/client.ts
```
### `/docs` - Documentation Agent
Keep documentation synchronized with code.
```
/docs sync packages/clink/
/docs api packages/nostr-client/src/
```
### `/hal-check` - HAL Validation
Validate Rust HAL drivers against lamassu-machine implementations.
```
/hal-check port id003
/hal-check safety packages/hal/src/dispensers/
```
## Code Style
**Formatting** (enforced by husky pre-commit):
- 2-space indent, no semicolons, single quotes, trailing commas
- Prettier + ESLint with auto-fix on commit
### TypeScript
**TypeScript**: Only in `packages/coins/` and `packages/typesafe-db/`. Use `@typescript-eslint/consistent-type-imports` for imports.
- ESM only (`import`/`export`)
- Strict mode with `strictNullChecks` and `noUncheckedIndexedAccess`
- Zod for runtime validation
- No `any` types
**Server code**: CommonJS (`require`/`module.exports`)
**Admin UI**: ESM (`import`/`export`)
### Rust (HAL)
## Database
- Stable toolchain
- `#![deny(unsafe_code)]` unless justified
- Error handling with `thiserror`
- Async with `tokio`
PostgreSQL with Kysely ORM. Types are auto-generated from the schema.
### Formatting
**Environment**: Configure postgres connection in `packages/server/.env`
- Prettier for TypeScript (2 spaces, no semicolons, single quotes)
- rustfmt for Rust
- Pre-commit hooks enforce formatting
## Requirements
## Hardware Drivers
- Node.js 22+
- pnpm 10+
- PostgreSQL
- Python 3 (for native dependency builds)
Drivers are ported from `lamassu-machine/lib/`:
## Documentation
| Category | Drivers |
| ---------- | ------------------------------------------------------------ |
| Validators | id003, ccnet, cashflow_sc, bnr_advance, genmega, hcm2, gsr50 |
| Dispensers | puloon, f56, genmega, hcm2, gsr50 |
| Printers | nippon, zebra, genmega |
- `docs/modernization-plan.md` - 2026 modernization roadmap (Obsidian-compatible)
When porting:
1. Read JS driver thoroughly
2. Document protocol from JS code
3. Implement Rust version
4. Test against same hardware
5. Use `/hal-check port <driver>` to validate
## Nostr Event Kinds
| Kind | Description |
| ----- | ----------------------------------------------- |
| 21000 | Lightning.Pub RPC (generic request/response) |
| 21001 | CLINK Offer (invoice request/response) |
| 21002 | CLINK Debit (payment authorization) |
| 21003 | CLINK Manage (offer management) |
| 30078 | Service Beacon (replaceable, service discovery) |
| 30079 | Transaction Record (replaceable) |
## Security Priorities
1. **Private keys** - Never log nsec, protect with 0600 permissions
2. **Payments** - Validate invoices, verify preimages, prevent double-pay
3. **Hardware** - Validate dispense amounts, handle errors gracefully
4. **Encryption** - Use NIP-44 for all sensitive data
## Testing Requirements
- Unit tests for all packages
- Integration tests for cross-package interactions
- E2E tests for full transaction flows
- **Cash-out flow is critical path** (95%+ of activity)
## Related Documentation
- `docs/architecture-comparison.md` - Nostr-native vs traditional lamassu-server comparison
- `docs/ndebit-cash-in-flow.md` - Technical walkthrough of cash-in implementation
- `packages/lightning/TROUBLESHOOTING.md` - Lightning.Pub integration gotchas (must read!)
- `.claude/skills/*.md` - Custom skill documentation
## External Resources
- [CLINK Protocol Spec](https://github.com/shocknet/clink)
- [Lightning.Pub](https://github.com/shocknet/Lightning.Pub)
- [NIP-44 Encryption](https://github.com/nostr-protocol/nips/blob/master/44.md)
- [LND Hold Invoices](https://docs.lightning.engineering/lightning-network-tools/lnd/hold-invoices)

View file

@ -1,3 +0,0 @@
[workspace]
resolver = "2"
members = ["lamassu-next/apps/machine/src-tauri"]

View file

@ -93,8 +93,10 @@ ipcMain.handle('get-config', () => {
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',

View file

@ -15,8 +15,10 @@ export interface RuntimeConfig {
relayUrl: string
lightningPubPubkey: string
lightningPubApiUrl: string
extensionApiUrl: string
atmPrivateKey: string
adminToken: string
appId: string
machineModel: string
fiatCode: string
validatorDevice?: string

1
docker/.state/atm-app-id Normal file
View file

@ -0,0 +1 @@
02f5340554b29537f4b9c3bb6c131f69c08044b2114939849d3bec8c4df456e7

View file

@ -0,0 +1 @@
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBJZCI6IjAyZjUzNDA1NTRiMjk1MzdmNGI5YzNiYjZjMTMxZjY5YzA4MDQ0YjIxMTQ5Mzk4NDlkM2JlYzhjNGRmNDU2ZTciLCJpYXQiOjE3NzExODIwMTZ9.uE473GYJdB946daNCZ-5PqPe1JGihSM6KnL66n1AN7k

View file

@ -0,0 +1 @@
nprofile1qyg8wue69uhhxarjvee8jw3hxumnwqpq35fnkv4nguyhrutu4tspkjlfaqs95pnnegzqkg24h040vn3852jshankcx

View file

@ -0,0 +1 @@
8d133b32b3470971f17caae01b4be9e8205a0673ca040b2155bbeaf64e27a2a5

655
docker/dev.sh Executable file
View file

@ -0,0 +1,655 @@
#!/bin/bash
#
# Lamassu Next Development Environment
#
# Single command to manage the complete development stack:
# - Regtest Bitcoin/Lightning network (from ~/dev/local/docker/regtest)
# - Lightning.Pub with configurable image/worktree
# - Auto-configured ATM application
#
# Usage:
# ./dev.sh up # Start everything
# ./dev.sh up --fund # Start and auto-fund ATM with 100k sats
# ./dev.sh up --fund=50000 # Start and auto-fund with specific amount
# ./dev.sh up --worktree ~/path/to/lp # Build Lightning.Pub from worktree
# ./dev.sh up --image myimage:tag # Use specific Docker image
# ./dev.sh down # Stop everything
# ./dev.sh atm # Launch ATM application
# ./dev.sh status # Show connection info
# ./dev.sh logs [service] # Follow logs
# ./dev.sh fund [amount] # Fund ATM (default: 100000 sats)
# ./dev.sh mine [blocks] # Mine blocks manually
# ./dev.sh reset # Reset all state (fresh start)
#
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
REGTEST_DIR="${REGTEST_DIR:-$HOME/dev/local/docker/regtest}"
DEFAULT_IMAGE="lightning-pub-withdraw:latest"
DEFAULT_FUNDING_SATS=100000
# State files
STATE_DIR="$SCRIPT_DIR/.state"
PUBKEY_FILE="$STATE_DIR/lightning-pub-pubkey"
NPROFILE_FILE="$STATE_DIR/lightning-pub-nprofile"
APP_TOKEN_FILE="$STATE_DIR/atm-app-token"
APP_ID_FILE="$STATE_DIR/atm-app-id"
# Colors
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m'
log() { echo -e "${GREEN}►${NC} $1"; }
warn() { echo -e "${YELLOW}⚠${NC} $1"; }
error() { echo -e "${RED}✗${NC} $1"; }
success() { echo -e "${GREEN}✓${NC} $1"; }
# Ensure state directory exists
mkdir -p "$STATE_DIR"
#############################################################################
# Helper Functions
#############################################################################
get_local_ip() {
ip route get 1 2>/dev/null | awk '{print $7; exit}' || hostname -I | awk '{print $1}'
}
is_regtest_running() {
# Check if lnd-4 container is actually running (not just network exists)
docker ps --filter "name=lnbits-lnd-4-1" --format "{{.Names}}" 2>/dev/null | grep -q lnbits-lnd-4-1
}
is_lnd4_ready() {
# Use lnd-4 hostname (not localhost) since lnd binds to container IP
docker exec lnbits-lnd-4-1 lncli --network=regtest --rpcserver=lnd-4:10009 getinfo &>/dev/null 2>&1
}
is_lightning_pub_ready() {
docker logs lamassu-lightning-pub 2>&1 | grep -q "LightningPub listening"
}
get_lnd4_balance() {
docker exec lnbits-lnd-4-1 lncli --network=regtest walletbalance 2>/dev/null | grep -oP '"total_balance":\s*"\K[0-9]+' || echo "0"
}
#############################################################################
# Regtest Management
#############################################################################
start_regtest() {
if is_regtest_running; then
log "Regtest network already running"
return 0
fi
if [[ ! -d "$REGTEST_DIR" ]]; then
error "Regtest directory not found: $REGTEST_DIR"
echo " Clone it from: https://github.com/your-org/regtest-env"
exit 1
fi
log "Starting regtest environment..."
# Use project name "lnbits" to match the expected network name (lnbits_default)
(cd "$REGTEST_DIR" && docker compose -p lnbits up -d)
# Wait for lnd-4 to be ready
log "Waiting for lnd-4 to be ready..."
local attempts=0
while ! is_lnd4_ready && [[ $attempts -lt 30 ]]; do
sleep 2
attempts=$((attempts + 1))
done
if is_lnd4_ready; then
success "lnd-4 is ready"
else
error "lnd-4 failed to start"
return 1
fi
}
stop_regtest() {
if [[ -d "$REGTEST_DIR" ]]; then
log "Stopping regtest environment..."
(cd "$REGTEST_DIR" && docker compose down)
fi
}
#############################################################################
# Lightning.Pub Management
#############################################################################
build_from_worktree() {
local worktree="$1"
local image_name="lightning-pub-dev:latest"
if [[ ! -d "$worktree" ]]; then
error "Worktree not found: $worktree"
exit 1
fi
log "Building Lightning.Pub from $worktree..."
docker build -t "$image_name" "$worktree"
echo "$image_name"
}
wait_for_lightning_pub() {
log "Waiting for Lightning.Pub..."
local attempts=0
while ! is_lightning_pub_ready && [[ $attempts -lt 45 ]]; do
# Check for errors
if docker logs lamassu-lightning-pub 2>&1 | grep -q "Error:"; then
local err=$(docker logs lamassu-lightning-pub 2>&1 | grep "Error:" | tail -1)
error "Lightning.Pub error: $err"
return 1
fi
sleep 2
attempts=$((attempts + 1))
done
if is_lightning_pub_ready; then
sleep 2 # Extra time for Nostr middleware
success "Lightning.Pub is ready"
return 0
else
error "Lightning.Pub failed to start (timeout)"
docker logs lamassu-lightning-pub 2>&1 | tail -10
return 1
fi
}
extract_lightning_pub_info() {
local pubkey=$(docker logs lamassu-lightning-pub 2>&1 | grep -oP 'pubkey:\s*\K[a-f0-9]+' | tail -1)
local nprofile=$(docker logs lamassu-lightning-pub 2>&1 | grep -oP 'nprofile:\s*\K\S+' | tail -1)
echo "$pubkey" > "$PUBKEY_FILE"
echo "$nprofile" > "$NPROFILE_FILE"
echo "$pubkey"
}
#############################################################################
# ATM Configuration
#############################################################################
update_atm_env() {
local pubkey="$1"
local env_file="$PROJECT_DIR/apps/machine/.env"
if [[ ! -f "$env_file" ]]; then
warn "ATM .env not found, creating..."
cat > "$env_file" << EOF
# Lightning.Pub connection
VITE_RELAY_URL=ws://localhost:7777
VITE_LIGHTNING_PUB_PUBKEY=$pubkey
VITE_LIGHTNING_PUB_API_URL=http://localhost:1776
VITE_ADMIN_TOKEN=lamassu-dev-admin-token
VITE_ATM_PRIVATE_KEY=f391a2c3fc734f443b0f685688a0441b5fb9805853c0023f570c5a3c6412b136
# Extension API (for LNURL-withdraw)
VITE_EXTENSION_API_URL=http://localhost:1777
EOF
else
# Update existing pubkey
if grep -q "VITE_LIGHTNING_PUB_PUBKEY" "$env_file"; then
sed -i "s/VITE_LIGHTNING_PUB_PUBKEY=.*/VITE_LIGHTNING_PUB_PUBKEY=$pubkey/" "$env_file"
else
echo "VITE_LIGHTNING_PUB_PUBKEY=$pubkey" >> "$env_file"
fi
fi
success "Updated ATM .env with pubkey"
}
setup_atm_app() {
log "Creating ATM app..."
# Generate unique app name to avoid conflicts
local app_name="atm-$(date +%s)"
local response=$(curl -s -X POST http://localhost:1776/api/admin/app/add \
-H "Content-Type: application/json" \
-H "Authorization: Bearer lamassu-dev-admin-token" \
-d "{\"name\":\"$app_name\",\"allow_user_creation\":true}" 2>/dev/null)
if echo "$response" | grep -q '"status":"OK"'; then
local app_id=$(echo "$response" | grep -oP '"id":"\K[^"]+')
local app_token=$(echo "$response" | grep -oP '"auth_token":"\K[^"]+')
echo "$app_id" > "$APP_ID_FILE"
echo "$app_token" > "$APP_TOKEN_FILE"
# Update ATM .env with app ID
local env_file="$PROJECT_DIR/apps/machine/.env"
if [[ -f "$env_file" ]]; then
if grep -q "VITE_APP_ID" "$env_file"; then
sed -i "s/VITE_APP_ID=.*/VITE_APP_ID=$app_id/" "$env_file"
else
echo "" >> "$env_file"
echo "# ATM App ID for LNURL-withdraw" >> "$env_file"
echo "VITE_APP_ID=$app_id" >> "$env_file"
fi
fi
success "Created ATM app: ${app_id:0:16}..."
return 0
else
warn "Failed to create ATM app (may already exist)"
# Try to use existing app from state file
if [[ -f "$APP_ID_FILE" ]]; then
log "Using existing app ID from state"
return 0
fi
return 1
fi
}
#############################################################################
# Zeus Connection
#############################################################################
generate_lndconnect() {
local lnd_data="$REGTEST_DIR/data/lnd-3"
local local_ip=$(get_local_ip)
local rest_port=8082
local cert_path="$lnd_data/tls.cert"
local mac_path="$lnd_data/data/chain/bitcoin/regtest/admin.macaroon"
if [[ ! -f "$cert_path" ]] || [[ ! -f "$mac_path" ]]; then
return 1
fi
local cert_b64=$(base64 -w0 "$cert_path" | tr '+/' '-_' | tr -d '=')
local mac_b64=$(base64 -w0 "$mac_path" | tr '+/' '-_' | tr -d '=')
echo "lndconnect://${local_ip}:${rest_port}?cert=${cert_b64}&macaroon=${mac_b64}"
}
#############################################################################
# Display Functions
#############################################################################
get_atm_balance() {
# Get ATM app owner balance from Lightning.Pub logs
local app_id=$(cat "$APP_ID_FILE" 2>/dev/null)
if [[ -z "$app_id" ]]; then
echo "not configured"
return
fi
# Query the app balance via admin API (if available)
# For now, just indicate it's configured
local app_token=$(cat "$APP_TOKEN_FILE" 2>/dev/null)
if [[ -n "$app_token" ]]; then
echo "configured (use './dev.sh fund' to add sats)"
else
echo "not configured"
fi
}
show_status() {
local pubkey=$(cat "$PUBKEY_FILE" 2>/dev/null || docker logs lamassu-lightning-pub 2>&1 | grep -oP 'pubkey:\s*\K[a-f0-9]+' | tail -1)
local nprofile=$(cat "$NPROFILE_FILE" 2>/dev/null || docker logs lamassu-lightning-pub 2>&1 | grep -oP 'nprofile:\s*\K\S+' | tail -1)
local local_ip=$(get_local_ip)
local lndconnect=$(generate_lndconnect 2>/dev/null || echo "")
local lnd4_balance=$(get_lnd4_balance)
local app_id=$(cat "$APP_ID_FILE" 2>/dev/null || echo "not set")
echo ""
echo -e "${BOLD}╔═══════════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║ LAMASSU NEXT - DEVELOPMENT ENVIRONMENT ║${NC}"
echo -e "${BOLD}╚═══════════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${CYAN}Lightning.Pub${NC}"
echo -e " Pubkey: ${GREEN}$pubkey${NC}"
echo -e " nprofile: ${GREEN}$nprofile${NC}"
echo ""
echo -e "${CYAN}Service URLs (local)${NC}"
echo " Nostr Relay: ws://localhost:7777"
echo " Lightning.Pub: http://localhost:1776"
echo " Withdraw API: http://localhost:1777"
echo ""
echo -e "${CYAN}External Access (LAN: $local_ip)${NC}"
echo " Nostr Relay: ws://${local_ip}:7777"
echo " Withdraw API: http://${local_ip}:1777"
echo ""
echo -e "${CYAN}lnd-4 (Lightning.Pub backend)${NC}"
echo " Balance: ${lnd4_balance} sats"
echo ""
echo -e "${CYAN}ATM App${NC}"
if [[ "$app_id" != "not set" ]]; then
echo " App ID: ${app_id:0:16}..."
echo " Status: $(get_atm_balance)"
else
echo " Status: not configured"
fi
echo ""
if [[ -n "$lndconnect" ]]; then
echo -e "${CYAN}Zeus Wallet (connect to lnd-3 for testing)${NC}"
echo -e " ${DIM}$lndconnect${NC}"
echo ""
fi
echo -e "${CYAN}Quick Commands${NC}"
echo " ./dev.sh logs lightning-pub # View Lightning.Pub logs"
echo " ./dev.sh fund 100000 # Fund ATM with 100k sats"
echo " ./dev.sh status # Show this info"
echo ""
echo -e "${BOLD}═══════════════════════════════════════════════════════════════════${NC}"
}
#############################################################################
# Main Commands
#############################################################################
cmd_up() {
local image="$DEFAULT_IMAGE"
local worktree=""
local skip_regtest=false
local auto_fund=false
local fund_amount="$DEFAULT_FUNDING_SATS"
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--image) image="$2"; shift 2 ;;
--worktree) worktree="$2"; shift 2 ;;
--skip-regtest) skip_regtest=true; shift ;;
--fund) auto_fund=true; shift ;;
--fund=*) auto_fund=true; fund_amount="${1#*=}"; shift ;;
*) shift ;;
esac
done
echo ""
log "Starting Lamassu development environment..."
echo ""
# 1. Start regtest if needed
if [[ "$skip_regtest" != "true" ]]; then
start_regtest || exit 1
else
if ! is_regtest_running; then
error "Regtest not running. Remove --skip-regtest or start it manually."
exit 1
fi
fi
# 2. Build from worktree if specified
if [[ -n "$worktree" ]]; then
image=$(build_from_worktree "$worktree")
fi
# 3. Check image exists
if ! docker image inspect "$image" &>/dev/null; then
error "Docker image not found: $image"
echo ""
echo "Options:"
echo " 1. Build from worktree: ./dev.sh up --worktree ~/path/to/lightning-pub"
echo " 2. Build manually: docker build -t $image ~/path/to/lightning-pub"
exit 1
fi
log "Using Lightning.Pub image: $image"
# 4. Start lamassu services
export REGTEST_DATA_DIR="$REGTEST_DIR/data"
export LIGHTNING_PUB_IMAGE="$image"
export HOST_IP=$(get_local_ip)
log "Starting lamassu services..."
docker compose -f "$SCRIPT_DIR/docker-compose.regtest.yml" up -d
# 5. Wait for Lightning.Pub
if ! wait_for_lightning_pub; then
error "Failed to start. Check: ./dev.sh logs lightning-pub"
exit 1
fi
# 6. Extract and save Lightning.Pub info
local pubkey=$(extract_lightning_pub_info)
# 7. Update ATM .env
update_atm_env "$pubkey"
# 8. Setup ATM app
setup_atm_app || true
# 9. Auto-fund if requested
if [[ "$auto_fund" == "true" ]]; then
echo ""
log "Auto-funding ATM with $fund_amount sats..."
sleep 2 # Give Lightning.Pub a moment to settle
cmd_fund "$fund_amount" || warn "Auto-funding failed. Run './dev.sh fund' manually."
fi
# 10. Show status
show_status
}
cmd_down() {
log "Stopping lamassu services..."
docker compose -f "$SCRIPT_DIR/docker-compose.regtest.yml" down 2>/dev/null || true
if [[ "$1" == "--all" ]]; then
stop_regtest
fi
success "Services stopped"
}
cmd_reset() {
warn "This will delete all lamassu state (Lightning.Pub identity, ATM app, etc.)"
read -p "Continue? [y/N] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted"
exit 0
fi
log "Stopping services..."
docker compose -f "$SCRIPT_DIR/docker-compose.regtest.yml" down -v 2>/dev/null || true
log "Removing state files..."
rm -rf "$STATE_DIR"
mkdir -p "$STATE_DIR"
success "Reset complete. Run './dev.sh up' for fresh start."
}
cmd_logs() {
docker compose -f "$SCRIPT_DIR/docker-compose.regtest.yml" logs -f "$@"
}
cmd_status() {
if ! docker ps --format '{{.Names}}' | grep -q lamassu-lightning-pub; then
error "Services not running. Start with: ./dev.sh up"
exit 1
fi
show_status
}
cmd_fund() {
local amount="${1:-$DEFAULT_FUNDING_SATS}"
if ! is_regtest_running; then
error "Regtest not running"
exit 1
fi
# Check for app token
if [[ ! -f "$APP_TOKEN_FILE" ]]; then
error "ATM app not configured. Run './dev.sh up' first."
exit 1
fi
local app_token=$(cat "$APP_TOKEN_FILE")
log "Creating invoice for $amount sats..."
# Create invoice for app owner (using unique payer_identifier)
local payer_id="funder-$(date +%s)"
local response=$(curl -s -X POST "http://localhost:1776/api/app/add/invoice" \
-H "Authorization: Bearer $app_token" \
-H "Content-Type: application/json" \
-d "{\"payer_identifier\": \"$payer_id\", \"http_callback_url\": \"\", \"invoice_req\": {\"amountSats\": $amount, \"memo\": \"ATM funding\"}}")
local invoice=$(echo "$response" | grep -oP '"invoice":"\K[^"]+')
if [[ -z "$invoice" ]]; then
error "Failed to create invoice"
echo "Response: $response"
exit 1
fi
log "Paying invoice from lnd-3..."
# Source regtest helpers and pay
if [[ -f "$REGTEST_DIR/docker-scripts.sh" ]]; then
(
cd "$REGTEST_DIR"
source docker-scripts.sh 2>/dev/null
lncli-sim 3 payinvoice --force "$invoice"
)
if [[ $? -eq 0 ]]; then
success "Funded ATM with $amount sats"
else
error "Payment failed"
exit 1
fi
else
# Fallback: try direct docker exec
docker exec lnbits-lnd-3-1 lncli --network=regtest --rpcserver=lnd-3:10009 payinvoice --force "$invoice"
if [[ $? -eq 0 ]]; then
success "Funded ATM with $amount sats"
else
error "Payment failed. Make sure lnd-3 has funds and channels."
exit 1
fi
fi
}
cmd_mine() {
local blocks="${1:-1}"
if ! is_regtest_running; then
error "Regtest not running"
exit 1
fi
log "Mining $blocks block(s)..."
docker exec lnbits-bitcoind-1 bitcoin-cli -regtest -generate "$blocks" > /dev/null
local height=$(docker exec lnbits-bitcoind-1 bitcoin-cli -regtest getblockcount)
success "Mined $blocks block(s) (height: $height)"
}
cmd_atm() {
local atm_dir="$PROJECT_DIR/apps/machine"
if [[ ! -d "$atm_dir" ]]; then
error "ATM app not found at $atm_dir"
exit 1
fi
# Check if services are running
if ! docker ps --format '{{.Names}}' | grep -q lamassu-lightning-pub; then
warn "Services not running. Start with: ./dev.sh up"
read -p "Start services first? [Y/n] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
cmd_up
fi
fi
log "Starting ATM application..."
echo ""
echo -e "${CYAN}ATM Mock Mode:${NC}"
echo " - Press 'b' to insert a bill (simulates cash insertion)"
echo " - Use the UI to complete transactions"
echo ""
cd "$atm_dir" && pnpm dev
}
#############################################################################
# Entry Point
#############################################################################
case "${1:-help}" in
up|start)
shift
cmd_up "$@"
;;
down|stop)
shift
cmd_down "$@"
;;
reset)
cmd_reset
;;
logs)
shift
cmd_logs "$@"
;;
status|info)
cmd_status
;;
fund)
shift
cmd_fund "$@"
;;
mine)
shift
cmd_mine "$@"
;;
atm)
cmd_atm
;;
*)
echo "Lamassu Next Development Environment"
echo ""
echo "Usage: $0 <command> [options]"
echo ""
echo "Commands:"
echo " up [options] Start development environment"
echo " down [--all] Stop services (--all includes regtest)"
echo " atm Launch ATM application (Electron)"
echo " status Show connection info"
echo " logs [service] Follow service logs"
echo " fund [sats] Fund ATM account"
echo " mine [blocks] Mine blocks manually (default: 1)"
echo " reset Delete all state and start fresh"
echo ""
echo "Note: Auto-miner runs in background (1 block/2min). Check with:"
echo " ./dev.sh logs miner"
echo ""
echo "Options for 'up':"
echo " --worktree <path> Build Lightning.Pub from git worktree"
echo " --image <name> Use specific Docker image"
echo " --skip-regtest Don't auto-start regtest"
echo " --fund Auto-fund ATM with 100k sats after startup"
echo " --fund=<sats> Auto-fund ATM with specific amount"
echo ""
echo "Examples:"
echo " $0 up # Start with default image"
echo " $0 up --fund # Start and fund ATM"
echo " $0 up --fund=50000 # Start and fund with 50k sats"
echo " $0 up --worktree ~/dev/lightning-pub/withdraw"
echo " $0 atm # Launch ATM app"
echo " $0 fund 200000 # Add 200k more sats"
echo " $0 logs lightning-pub"
echo " $0 reset && $0 up --fund # Fresh start with funding"
;;
esac

View file

@ -0,0 +1,148 @@
# Lamassu Next - Regtest Integration
#
# This overlay connects lamassu-next services to the comprehensive regtest
# environment at ~/dev/local/docker/regtest
#
# Usage:
# 1. Start the regtest environment:
# cd ~/dev/local/docker/regtest && ./start-regtest
#
# 2. Start lamassu services:
# cd lamassu-next/docker && docker compose -f docker-compose.regtest.yml up -d
#
# 3. Configure apps/machine/.env:
# VITE_RELAY_URL=ws://localhost:7777
# VITE_EXTENSION_API_URL=http://localhost:1777
#
# Services:
# - strfry: Private Nostr relay (port 7777)
# - lightning-pub: Nostr-native Lightning account system (port 1776)
# - Uses lnd-4 from regtest as backend
# - Withdraw extension on port 1777
# - miner: Auto-mines blocks to keep Lightning channels active
#
services:
# Private Nostr relay for ATM communication
strfry:
image: ghcr.io/hoytech/strfry:latest
container_name: lamassu-relay
ports:
- '7777:7777'
volumes:
- ./strfry.conf:/etc/strfry.conf:ro
- strfry-data:/app/strfry-db
ulimits:
nofile:
soft: 524288
hard: 524288
healthcheck:
test: ['CMD', 'nc', '-z', 'localhost', '7777']
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- regtest
# Lightning.Pub - Nostr-native Lightning account system
# Connects to lnd-4 from the regtest environment
# Use LIGHTNING_PUB_IMAGE env var to specify image (default: lightning-pub-withdraw)
lightning-pub:
image: ${LIGHTNING_PUB_IMAGE:-lightning-pub-withdraw:latest}
container_name: lamassu-lightning-pub
extra_hosts:
- 'host.docker.internal:host-gateway'
ports:
- '1776:1776'
- '1777:1777' # Withdraw extension HTTP API
volumes:
- lightning-pub-data:/root/lightning_pub
# Override Dockerfile's anonymous /app/data volume with named volume
- lightning-pub-appdata:/app/data
# Mount lnd-4 data from regtest for macaroons/certs
- ${REGTEST_DATA_DIR:-/home/padreug/dev/local/docker/regtest/data}/lnd-4:/root/.lnd:ro
environment:
- NETWORK=regtest
# lnd-4 is accessible via Docker network
- LND_ADDRESS=lnd-4:10009
- LND_CERT_PATH=/root/.lnd/tls.cert
- LND_MACAROON_PATH=/root/.lnd/data/chain/bitcoin/regtest/admin.macaroon
# Use strfry from this compose
- NOSTR_RELAYS=ws://strfry:7777
# Disable external liquidity provider for regtest
- DISABLE_LIQUIDITY_PROVIDER=true
# Admin token for HTTP API access (development only)
- ADMIN_TOKEN=lamassu-dev-admin-token
# Extension HTTP API URL (for LNURL callbacks from external wallets)
# Use HOST_IP env var for your machine's LAN IP (required for phone wallets)
- EXTENSION_SERVICE_URL=http://${HOST_IP:-192.168.1.190}:1777
restart: unless-stopped
networks:
- regtest
# Auto-miner for regtest (mines 1 block every MINE_INTERVAL seconds)
# Keeps Lightning channels active during development
miner:
image: boltz/bitcoin-core:25.0
container_name: lamassu-miner
entrypoint: /bin/sh
command:
- -c
- |
echo "Auto-miner started (interval: $${MINE_INTERVAL}s)"
# Wait for bitcoind to be ready
while ! bitcoin-cli -regtest -rpcconnect=bitcoind getblockchaininfo > /dev/null 2>&1; do
echo "Waiting for bitcoind..."
sleep 5
done
echo "bitcoind ready, starting mining loop"
while true; do
bitcoin-cli -regtest -rpcconnect=bitcoind -generate 1 > /dev/null 2>&1 && echo "Mined block $(bitcoin-cli -regtest -rpcconnect=bitcoind getblockcount)"
sleep $${MINE_INTERVAL}
done
environment:
- MINE_INTERVAL=${MINE_INTERVAL:-120}
volumes:
- bitcoin-data:/root/.bitcoin
restart: unless-stopped
networks:
- regtest
# PostgreSQL for optional server-side state
postgres:
image: postgres:16-alpine
container_name: lamassu-postgres
ports:
- '5432:5432'
environment:
POSTGRES_DB: lamassu_dev
POSTGRES_USER: lamassu
POSTGRES_PASSWORD: lamassu_dev_password
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U lamassu -d lamassu_dev']
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- regtest
volumes:
strfry-data:
lightning-pub-data:
lightning-pub-appdata:
postgres-data:
# Mount the bitcoin-data volume from the regtest environment
bitcoin-data:
external: true
name: lnbits_bitcoin-data
networks:
regtest:
# The regtest environment uses 'lnbits_default' as its Docker network
# (named after the original LNbits regtest project)
name: lnbits_default
external: true

335
docker/start-with-regtest.sh Executable file
View file

@ -0,0 +1,335 @@
#!/bin/bash
#
# Start lamassu-next development environment with comprehensive regtest
#
# This script:
# 1. Connects to the regtest environment at ~/dev/local/docker/regtest
# 2. Starts Lightning.Pub with the specified image/worktree
# 3. Creates and funds an ATM account
# 4. Displays connection info for Zeus wallet and Lightning.Pub nprofile
#
# Prerequisites:
# ~/dev/local/docker/regtest must be running:
# cd ~/dev/local/docker/regtest && ./start-regtest
#
# Usage:
# ./start-with-regtest.sh # Start with default image
# ./start-with-regtest.sh --image myimage # Use specific Docker image
# ./start-with-regtest.sh --worktree ~/path # Build from worktree
# ./start-with-regtest.sh down # Stop services
# ./start-with-regtest.sh logs # Follow logs
# ./start-with-regtest.sh status # Show connection info
#
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REGTEST_DIR="${REGTEST_DIR:-$HOME/dev/local/docker/regtest}"
DEFAULT_IMAGE="lightning-pub-withdraw:latest"
ATM_FUNDING_SATS=100000
# Colors
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
log() { echo -e "${GREEN}[lamassu]${NC} $1"; }
warn() { echo -e "${YELLOW}[lamassu]${NC} $1"; }
error() { echo -e "${RED}[lamassu]${NC} $1"; }
info() { echo -e "${CYAN}[lamassu]${NC} $1"; }
# Get local IP for external wallet access
get_local_ip() {
ip route get 1 2>/dev/null | awk '{print $7; exit}' || hostname -I | awk '{print $1}'
}
# Check if regtest is running
check_regtest() {
if ! docker network inspect lnbits_default &>/dev/null; then
error "Regtest network not found!"
echo ""
echo "Start the regtest environment first:"
echo " cd $REGTEST_DIR && ./start-regtest"
exit 1
fi
# Check if lnd-4 is running (Lightning.Pub's backend)
if ! docker exec lnbits-lnd-4-1 lncli --network=regtest getinfo &>/dev/null 2>&1; then
warn "lnd-4 not responding. Waiting..."
sleep 5
fi
}
# Build Lightning.Pub from worktree
build_from_worktree() {
local worktree="$1"
local image_name="lightning-pub-custom:latest"
if [[ ! -d "$worktree" ]]; then
error "Worktree not found: $worktree"
exit 1
fi
log "Building Lightning.Pub from $worktree..."
docker build -t "$image_name" "$worktree"
echo "$image_name"
}
# Wait for Lightning.Pub to be ready and get nprofile
wait_for_lightning_pub() {
log "Waiting for Lightning.Pub to start..."
local max_attempts=30
local attempt=0
while [[ $attempt -lt $max_attempts ]]; do
if docker logs lamassu-lightning-pub 2>&1 | grep -q "LightningPub listening"; then
sleep 2 # Extra time for Nostr middleware
return 0
fi
attempt=$((attempt + 1))
sleep 2
done
error "Lightning.Pub failed to start within 60 seconds"
docker logs lamassu-lightning-pub 2>&1 | tail -20
return 1
}
# Get Lightning.Pub nprofile from logs
get_nprofile() {
docker logs lamassu-lightning-pub 2>&1 | grep -oP 'nprofile:\s*\K\S+' | tail -1
}
# Get Lightning.Pub pubkey from logs
get_pubkey() {
docker logs lamassu-lightning-pub 2>&1 | grep -oP 'pubkey:\s*\K[a-f0-9]+' | tail -1
}
# Generate lndconnect URL for Zeus (using lnd-3 which has REST exposed)
generate_lndconnect() {
local lnd_container="lnbits-lnd-3-1"
local lnd_data="$REGTEST_DIR/data/lnd-3"
local local_ip=$(get_local_ip)
local rest_port=8082 # lnd-3's REST port
# Get cert and macaroon
local cert_path="$lnd_data/tls.cert"
local mac_path="$lnd_data/data/chain/bitcoin/regtest/admin.macaroon"
if [[ ! -f "$cert_path" ]] || [[ ! -f "$mac_path" ]]; then
warn "lnd-3 credentials not found. Zeus connection string unavailable."
return 1
fi
# Base64url encode (replace + with -, / with _, remove =)
local cert_b64=$(base64 -w0 "$cert_path" | tr '+/' '-_' | tr -d '=')
local mac_b64=$(base64 -w0 "$mac_path" | tr '+/' '-_' | tr -d '=')
echo "lndconnect://${local_ip}:${rest_port}?cert=${cert_b64}&macaroon=${mac_b64}"
}
# Create and fund ATM account
setup_atm_account() {
log "Setting up ATM account..."
# Create app
local app_response=$(curl -s -X POST http://localhost:1776/api/admin/app/add \
-H "Content-Type: application/json" \
-H "Authorization: Bearer lamassu-dev-admin-token" \
-d '{"name":"atm-app","allow_user_creation":true}' 2>/dev/null)
if echo "$app_response" | grep -q '"status":"OK"'; then
local app_id=$(echo "$app_response" | grep -oP '"id":"\K[^"]+')
local app_token=$(echo "$app_response" | grep -oP '"auth_token":"\K[^"]+')
log "Created ATM app: $app_id"
# Store app token for later use
echo "$app_token" > "$SCRIPT_DIR/.atm-app-token"
echo "$app_id" > "$SCRIPT_DIR/.atm-app-id"
# TODO: Fund the app by creating an invoice and paying from lnd-3
# This requires the app to support invoice creation via HTTP API
# For now, the app starts with 0 balance
return 0
else
warn "Failed to create ATM app: $app_response"
return 1
fi
}
# Display connection information
show_connection_info() {
local nprofile=$(get_nprofile)
local pubkey=$(get_pubkey)
local local_ip=$(get_local_ip)
local lndconnect=$(generate_lndconnect 2>/dev/null || echo "")
echo ""
echo -e "${BOLD}═══════════════════════════════════════════════════════════════${NC}"
echo -e "${BOLD} LAMASSU REGTEST ENVIRONMENT ${NC}"
echo -e "${BOLD}═══════════════════════════════════════════════════════════════${NC}"
echo ""
echo -e "${CYAN}Lightning.Pub:${NC}"
echo -e " Pubkey: ${GREEN}$pubkey${NC}"
echo -e " nprofile: ${GREEN}$nprofile${NC}"
echo ""
echo -e "${CYAN}Service URLs:${NC}"
echo " Nostr Relay: ws://localhost:7777"
echo " Lightning.Pub: http://localhost:1776"
echo " Withdraw API: http://localhost:1777"
echo " PostgreSQL: localhost:5432"
echo ""
echo -e "${CYAN}External Access (from phone/other devices):${NC}"
echo " Nostr Relay: ws://${local_ip}:7777"
echo " Withdraw API: http://${local_ip}:1777"
echo ""
if [[ -n "$lndconnect" ]]; then
echo -e "${CYAN}Zeus Wallet Connection (lnd-3):${NC}"
echo -e " ${GREEN}$lndconnect${NC}"
echo ""
echo " Scan this with Zeus to connect to lnd-3 for testing payments."
echo ""
fi
echo -e "${CYAN}ATM Configuration (apps/machine/.env):${NC}"
echo " VITE_RELAY_URL=ws://localhost:7777"
echo " VITE_LIGHTNING_PUB_PUBKEY=$pubkey"
echo " VITE_EXTENSION_API_URL=http://localhost:1777"
echo ""
echo -e "${CYAN}CLI Helpers:${NC}"
echo " source $REGTEST_DIR/docker-scripts.sh"
echo " bitcoin-cli-sim -generate 1 # Mine blocks"
echo " lncli-sim 4 getinfo # lnd-4 (Lightning.Pub)"
echo " lncli-sim 3 getinfo # lnd-3 (Zeus wallet)"
echo ""
echo -e "${BOLD}═══════════════════════════════════════════════════════════════${NC}"
}
# Start services
start() {
local image="$DEFAULT_IMAGE"
local worktree=""
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--image)
image="$2"
shift 2
;;
--worktree)
worktree="$2"
shift 2
;;
*)
shift
;;
esac
done
log "Checking prerequisites..."
check_regtest
# Build from worktree if specified
if [[ -n "$worktree" ]]; then
image=$(build_from_worktree "$worktree")
fi
# Check if image exists
if ! docker image inspect "$image" &>/dev/null; then
error "Docker image not found: $image"
echo ""
echo "Either:"
echo " 1. Build the image: docker build -t $image <path>"
echo " 2. Use --worktree: ./start-with-regtest.sh --worktree ~/path/to/lightning-pub"
exit 1
fi
log "Using Lightning.Pub image: $image"
# Export environment variables
export REGTEST_DATA_DIR="$REGTEST_DIR/data"
export LIGHTNING_PUB_IMAGE="$image"
export HOST_IP=$(get_local_ip)
log "Starting lamassu services..."
docker compose -f "$SCRIPT_DIR/docker-compose.regtest.yml" up -d
# Wait for Lightning.Pub and setup
if wait_for_lightning_pub; then
setup_atm_account || true
show_connection_info
else
error "Failed to start Lightning.Pub. Check logs with: $0 logs lightning-pub"
exit 1
fi
}
# Stop services
stop() {
log "Stopping lamassu services..."
docker compose -f "$SCRIPT_DIR/docker-compose.regtest.yml" down
rm -f "$SCRIPT_DIR/.atm-app-token" "$SCRIPT_DIR/.atm-app-id"
log "Services stopped."
}
# Show logs
logs() {
docker compose -f "$SCRIPT_DIR/docker-compose.regtest.yml" logs -f "$@"
}
# Show status/connection info
status() {
if ! docker ps --format '{{.Names}}' | grep -q lamassu-lightning-pub; then
error "Services not running. Start with: $0 start"
exit 1
fi
show_connection_info
}
# Main
case "${1:-start}" in
start)
shift || true
start "$@"
;;
stop|down)
stop
;;
logs)
shift
logs "$@"
;;
status|info)
status
;;
*)
echo "Usage: $0 [command] [options]"
echo ""
echo "Commands:"
echo " start Start services (default)"
echo " stop, down Stop services"
echo " logs [service] Follow logs"
echo " status, info Show connection info"
echo ""
echo "Options for 'start':"
echo " --image <name> Use specific Docker image"
echo " --worktree <path> Build from Lightning.Pub worktree"
echo ""
echo "Examples:"
echo " $0 # Start with default image"
echo " $0 --worktree ~/dev/lightning-pub/withdraw # Build and use worktree"
echo " $0 --image lightning-pub-custom:v1 # Use specific image"
exit 1
;;
esac

View file

@ -1,714 +0,0 @@
---
title: Architecture Review - KYC-Free Lightning-First Vision
created: 2026-01-22
updated: 2026-01-22
tags:
- architecture
- lightning
- kyc-free
- redesign
- vision
status: active
priority: critical
---
# Architecture Review: KYC-Free Lightning-First Vision
> [!abstract] Summary
> A comprehensive review of our project architecture, reimagining Lamassu from scratch as a **KYC-free, open-source, Lightning-native** Bitcoin ATM ecosystem. We have complete freedom to redesign - no backward compatibility concerns.
## Quick Links
- [[#Vision Statement]]
- [[#Current State Analysis]]
- [[#Proposed Architecture]]
- [[#Lightning Backend Options]]
- [[#Privacy Technologies]]
- [[#Critical Decisions]]
---
## Vision Statement
> [!important] Core Principles
> 1. **KYC-Free** - No identity collection, no compliance theater
> 2. **Open-Source First** - Every component auditable and forkable
> 3. **Lightning-Native** - Security through protocol, not policy
> 4. **Self-Custodial** - Operator and user control their own keys
> 5. **Privacy by Default** - Minimize data collection and retention
> 6. **Autonomous Machines** - Reduce server dependency
### What We're Building
The **ultimate Bitcoin Lightning ATM** with a wallet ecosystem that:
- Converts cash ↔ Lightning instantly
- Requires no identity verification
- Operates with minimal infrastructure
- Can function offline with ecash
- Supports NFC tap-to-pay
- Enables operator sovereignty
---
## Current State Analysis
### Lamassu Codebase Issues
The existing Lamassu codebase carries significant baggage:
| Component | Problem | Impact |
|-----------|---------|--------|
| `lib/compliance/` | KYC/AML workflows | 40% of server code |
| `lib/customers/` | Identity management | Database bloat |
| `lib/sanctions/` | OFAC screening | External dependencies |
| `lib/sms/` | Phone verification | Privacy violation |
| `lib/id-scan/` | Document verification | Third-party APIs |
| `lib/blacklist/` | User blocking | Centralized control |
| Multi-coin | Altcoin support | Code complexity |
> [!warning] Assessment
> **60%+ of lamassu-server code is compliance-related.** Rather than removing it surgically, a clean rebuild may be more efficient.
### Current LNbits Integration (As Documented)
Our current docs treat LNbits as a simple payment backend:
```
Machine → Server → LNbits → Lightning Network
```
**Problems with this approach:**
1. Server is still a bottleneck
2. Single point of failure
3. Not utilizing LNbits' full potential
4. Missing privacy technologies (Cashu, Fedimint)
5. Still designed around on-chain model
---
## Proposed Architecture
### Option A: Lean Server (Recommended)
```mermaid
graph TB
subgraph "ATM Machine"
tauri[Tauri + Vue 3]
ldk[LDK-Node / Phoenixd]
hal[Rust HAL]
end
subgraph "Minimal Coordinator"
api[Fastify API]
db[(SQLite/PostgreSQL)]
end
subgraph "Lightning Layer"
lnbits[LNbits]
cashu[Cashu Mint]
fedimint[Fedimint Gateway]
end
tauri -->|LNURL/BOLT12| lnbits
tauri -->|ecash| cashu
tauri -->|Optional| api
api --> db
lnbits --> fedimint
hal --> hardware[Hardware]
```
**Key Changes:**
- Machine can operate independently with embedded Lightning
- Server becomes optional coordinator (fleet management, analytics)
- Multiple Lightning backends supported
- Ecash for offline capability
### Option B: Serverless Machine
```mermaid
graph TB
subgraph "Autonomous ATM"
ui[Vue 3 UI]
xstate[XState v5]
ldk[LDK-Node]
cashu[Cashu Wallet]
hal[Rust HAL]
end
ldk -->|Direct| ln[Lightning Network]
cashu -->|Swap| mint[Cashu Mint]
hal --> hw[Hardware]
admin[Admin Phone App] -->|Bluetooth/Local| ui
```
**Extreme autonomy:**
- No central server at all
- Machine runs its own Lightning node
- Admin via local connection (phone app)
- Perfect for single-operator deployments
### Option C: Fedimint Community Model
```mermaid
graph TB
subgraph "Community Federation"
g1[Guardian 1]
g2[Guardian 2]
g3[Guardian 3]
g4[Guardian 4]
end
subgraph "ATMs"
atm1[Machine 1]
atm2[Machine 2]
atm3[Machine 3]
end
subgraph "Gateway"
gw[Lightning Gateway]
end
atm1 --> g1
atm2 --> g2
atm3 --> g3
g1 --> gw
g2 --> gw
g3 --> gw
g4 --> gw
gw --> ln[Lightning Network]
```
**Community custody:**
- Multiple guardians share custody
- No single operator can rug
- Built-in ecash for privacy
- Ideal for community/coop deployments
---
## Lightning Backend Options
### Comparison Matrix
| Backend | Self-Custodial | Complexity | Offline | Privacy | Best For |
|---------|---------------|------------|---------|---------|----------|
| **LDK-Node** | Yes | High | No | Good | Embedded in machine |
| **Phoenixd** | Yes | Low | No | Good | Simple server setup |
| **LNbits** | Depends | Medium | Via Cashu | Good | Multi-wallet, extensions |
| **Cashu** | No (mint) | Low | Yes | Excellent | Offline, privacy |
| **Fedimint** | Federated | High | Yes | Excellent | Community custody |
| **Breez SDK** | Yes | Medium | No | Good | Mobile-first |
### Recommendation: Layered Approach
```
┌─────────────────────────────────────────────┐
│ Layer 3: User-Facing Protocols │
│ LNURL-withdraw, CLINK Offers, NFC │
├─────────────────────────────────────────────┤
│ Layer 2: Privacy & Offline │
│ Cashu ecash, Fedimint e-cash │
├─────────────────────────────────────────────┤
│ Layer 1: Lightning Backends │
│ LNbits (primary), Phoenixd, LDK-Node │
└─────────────────────────────────────────────┘
```
**Use each layer for its strengths:**
- **LNbits** - Backend abstraction, multi-wallet, extensions
- **Cashu** - Offline payments, instant settlement, privacy
- **LNURL** - User experience (scan QR to receive)
- **CLINK Offers** - Static payment codes over Nostr (replaces BOLT12)
---
## Privacy Technologies
### Cashu Integration
> [!decision] Cashu for Offline & Privacy
> Cashu ecash enables offline ATM operation and enhanced privacy.
**How it works for ATM:**
```mermaid
sequenceDiagram
participant User
participant ATM
participant Mint as Cashu Mint
participant LN as Lightning
User->>ATM: Insert $20 cash
ATM->>Mint: Request ecash tokens
Mint->>ATM: Issue 20,000 sat tokens
ATM->>User: Display QR (Cashu tokens)
User->>User: Scan with Cashu wallet
Note over User,LN: Later, user can...
User->>Mint: Redeem tokens
Mint->>LN: Pay Lightning invoice
```
**Benefits:**
- ATM doesn't need to know user's Lightning wallet
- User receives ecash, redeems whenever
- ATM can operate offline (pre-loaded tokens)
- Perfect privacy (blinded signatures)
**Cashu Libraries:**
- `cashu-ts` - TypeScript SDK
- `cashu-rs` - Rust implementation
- `nutshell` - Python reference
### Fedimint Integration
> [!note] Fedimint for Community Operations
> When multiple operators want shared custody without single points of failure.
**Architecture:**
```typescript
// Federation of 4 guardians (3-of-4 threshold)
const federation = {
guardians: [
'operator1.onion',
'operator2.onion',
'operator3.onion',
'operator4.onion',
],
threshold: 3,
modules: ['wallet', 'mint', 'ln'],
}
```
**Use Cases:**
- Bitcoin circular economy communities
- Cooperative ATM networks
- Regions with unstable operators
---
## Protocol Stack
### LNURL for ATM UX
> [!tip] LNURL-withdraw is Perfect for ATMs
> User scans QR from ATM screen to pull sats to their wallet.
**Cash-In Flow (User buys Bitcoin):**
```mermaid
sequenceDiagram
participant User
participant ATM
participant LNbits
User->>ATM: Insert $50 cash
ATM->>LNbits: Create LNURL-withdraw
LNbits->>ATM: lnurl1dp68gurn8ghj7...
ATM->>ATM: Display QR code
User->>User: Scan with any LN wallet
User->>LNbits: Request invoice (via LNURL)
LNbits->>User: Pay invoice to user's wallet
ATM->>ATM: Transaction complete
```
**Benefits:**
- Works with ANY Lightning wallet
- No camera needed on ATM
- User controls destination
- Privacy preserved
**Cash-Out Flow (User sells Bitcoin):**
```mermaid
sequenceDiagram
participant User
participant ATM
participant LNbits
User->>ATM: Select "Sell Bitcoin"
ATM->>LNbits: Create invoice
LNbits->>ATM: BOLT11 invoice
ATM->>ATM: Display QR code
User->>User: Scan & pay invoice
LNbits->>ATM: Payment confirmed
ATM->>User: Dispense cash
```
### CLINK Offers (Replaces BOLT12)
> [!decision] CLINK Offers for Static Payment Codes
> Nostr-native static payment codes - superior to BOLT12.
**Why NOT BOLT12:**
| Problem | Impact |
|---------|--------|
| Onion messages | Tor-like routing adds latency, every hop = failure point |
| Global round-trips | Requests can circle the world multiple times |
| Mobile node normalization | Encourages unreliable always-offline nodes |
| Redundant | LND keysend already provides static payments |
| Astroturfed | NGO-pushed spec with questionable motives |
**Why CLINK:**
- Uses Nostr relays (commodity infrastructure, trustless via NIP-44)
- No HTTP callbacks, WebSockets, or Tor-like messaging
- Keys decoupled from Lightning node identity
- Already working: ShockWallet, Lightning.Pub, Stacker News
```typescript
// CLINK Offer (noffer) - static payment code
const atmOffer = 'noffer1qqs...'
// Invoice request flows through Nostr relays
// No onion message round-trips!
// Using @shocknet/clink-sdk
import { createOffer, requestInvoice } from '@shocknet/clink-sdk'
const offer = createOffer({
pubkey: atmNostrPubkey,
relays: ['wss://relay.damus.io', 'wss://nos.lol'],
priceType: 'variable', // ATM calculates based on cash inserted
})
```
**CLINK Protocol:**
- **Kind 21001**: Offer Request/Response
- **Kind 21002**: Debit Request/Response
- **Kind 21003**: Management Delegation
**References:**
- [CLINK Spec](https://github.com/shocknet/CLINK)
- [CLINK Demo](https://clinkme.dev/)
- [Lightning.Pub](https://github.com/shocknet/Lightning.Pub)
### NFC BOLT Cards
> [!tip] Tap-to-Withdraw with NFC
> Pre-programmed NFC cards for instant cash withdrawal.
**How BOLT Cards work:**
1. NFC card contains LNURL-withdraw with rotating auth
2. User taps card on ATM
3. ATM reads LNURL, requests invoice
4. Card's backing service pays invoice
5. ATM dispenses cash
**Implementation:**
- Cards use NXP NTAG 424 DNA (secure element)
- Each tap generates unique auth code
- Supports spending limits per tap/day
- Compatible with: Coinos, LNbits, BTCPay
```typescript
// LNbits BoltCards extension
const card = {
uid: '04:E1:5F:...',
cardName: 'ATM Withdrawal Card',
maxWithdrawPerTap: 50000, // sats
dailyLimit: 200000, // sats
}
```
---
## Simplified Transaction Flows
### Cash → Lightning (Buy)
```
┌─────────────────────────────────────────────────────────────┐
│ SIMPLIFIED BUY FLOW │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. User inserts cash [$20, $50, $100] │
│ │
│ 2. ATM displays QR [LNURL-withdraw] │
│ "Scan to receive Bitcoin" │
│ │
│ 3. User scans with ANY [Phoenix, Zeus, Wallet of │
│ Lightning wallet Satoshi, Breez, etc.] │
│ │
│ 4. Sats arrive instantly [~2 seconds] │
│ │
│ Done. No account. No KYC. No email. No phone. │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Lightning → Cash (Sell)
```
┌─────────────────────────────────────────────────────────────┐
│ SIMPLIFIED SELL FLOW │
├─────────────────────────────────────────────────────────────┤
│ │
│ Option A: Pay Invoice │
│ 1. Select amount to withdraw [$20, $50, $100] │
│ 2. ATM shows Lightning invoice QR │
│ 3. User pays from any wallet │
│ 4. Cash dispensed │
│ │
│ Option B: NFC Tap (BOLT Card) │
│ 1. User taps NFC card │
│ 2. ATM reads LNURL-withdraw │
│ 3. Cash dispensed │
│ [Single tap, ~3 seconds total] │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Offline Mode (Cashu)
```
┌─────────────────────────────────────────────────────────────┐
│ OFFLINE CASH-IN FLOW │
├─────────────────────────────────────────────────────────────┤
│ │
│ ATM has pre-loaded Cashu tokens from mint │
│ │
│ 1. User inserts $50 cash │
│ │
│ 2. ATM displays Cashu token QR │
│ (No internet required!) │
│ │
│ 3. User scans with Cashu wallet │
│ [Minibits, Nutstash, eNuts] │
│ │
│ 4. User can later swap ecash → Lightning │
│ when they have connectivity │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## Revised Component Architecture
### What We Keep from Lamassu
| Component | Keep? | Notes |
|-----------|-------|-------|
| Hardware drivers | Yes | Port to Rust HAL |
| Bill validator protocols | Yes | ID003, eSSP, ccTalk |
| Bill dispenser drivers | Yes | Puloon, Fujitsu |
| Brain state machine | Rewrite | Simplify with XState v5 |
| Admin UI | Partial | Rebuild in Vue 3 |
| Server API | Minimal | Strip compliance code |
### What We Remove
| Component | Why Remove |
|-----------|------------|
| `lib/compliance/` | No KYC |
| `lib/customers/` | No identity storage |
| `lib/sanctions/` | No OFAC screening |
| `lib/sms/` | No phone verification |
| `lib/id-scan/` | No document scanning |
| `lib/blacklist/` | No user blocking |
| Multi-coin support | Bitcoin only |
| Fiat exchange rates | Lightning is the unit |
### New Components to Build
| Component | Purpose | Technology |
|-----------|---------|------------|
| `lightning-service` | Backend abstraction | LNbits + Cashu + Fedimint |
| `lnurl-server` | LNURL-withdraw/pay | Fastify + LNbits |
| `clink-handler` | Static offers via Nostr | @shocknet/clink-sdk |
| `cashu-bridge` | Offline capability | cashu-ts |
| `nfc-handler` | BOLT card support | libnfc + Rust |
| `admin-app` | Operator mobile app | Vue 3 + Capacitor |
---
## Revised Tech Stack
### Server (Coordinator)
```yaml
Runtime: Node.js 22 LTS
Language: TypeScript (strict)
Framework: Fastify
API: tRPC (admin), LNURL (public)
Database: SQLite (single) / PostgreSQL (fleet)
ORM: Drizzle
Lightning: LNbits API
Ecash: Cashu client
```
### Machine
```yaml
Shell: Tauri 2.x (Rust)
UI: Vue 3 + Pinia + shadcn-vue
State: XState v5
Hardware: Rust HAL + napi-rs
Lightning: LDK-Node or Phoenixd (optional)
Ecash: Cashu wallet
NFC: libnfc bindings
```
### Mobile Admin App
```yaml
Framework: Vue 3 + Ionic/Capacitor
Connectivity: Bluetooth LE, Local WiFi
Features: Machine pairing, balance check, settings
```
---
## Critical Decisions Needed
### Decision 1: Server Model
| Option | Pros | Cons |
|--------|------|------|
| **A: Lean Server** | Fleet management, familiar model | Single point of failure |
| **B: Serverless** | Maximum autonomy | Complex admin |
| **C: Fedimint** | Community custody | Requires federation |
> [!question] Recommendation
> Start with **Option A (Lean Server)** for faster development, design for Option B compatibility.
### Decision 2: Primary Lightning Backend
| Option | Pros | Cons |
|--------|------|------|
| **LNbits** | Extensions, multi-wallet | Requires server |
| **Phoenixd** | Simple, self-custodial | ACINQ dependency |
| **LDK-Node** | Embedded, maximum control | Complex |
> [!question] Recommendation
> **LNbits** as primary (proven, extensible), with **Cashu** for offline mode.
### Decision 3: Ecash Strategy
| Option | Pros | Cons |
|--------|------|------|
| **Cashu** | Simple, growing ecosystem | Single mint trust |
| **Fedimint** | Federated trust | Complex setup |
| **Both** | Maximum flexibility | Maintenance burden |
> [!question] Recommendation
> **Cashu** first (simpler), add Fedimint support later.
### Decision 4: Rebuild vs Refactor
| Option | Effort | Risk | Result |
|--------|--------|------|--------|
| **Rebuild** | 6-12 months | Medium | Clean architecture |
| **Refactor** | 12-18 months | High | Frankenstein code |
> [!question] Recommendation
> **Rebuild** the core, reuse hardware drivers.
---
## Implementation Roadmap
### Phase 1: Foundation
- [ ] Create new monorepo structure
- [ ] Set up devenv.nix for development
- [ ] Port hardware drivers to Rust HAL
- [ ] Implement LNURL-withdraw flow
- [ ] Basic Vue 3 machine UI
### Phase 2: Lightning Integration
- [ ] LNbits integration (simplified from current docs)
- [ ] LNURL-pay for cash-out
- [ ] Cashu ecash support
- [ ] NFC BOLT card support
### Phase 3: Operator Tools
- [ ] Minimal admin API
- [ ] Vue 3 admin dashboard
- [ ] Mobile admin app
- [ ] Fleet management (optional)
### Phase 4: Advanced Features
- [ ] CLINK Offers (Nostr-native static codes)
- [ ] Fedimint integration
- [ ] LDK-Node embedded option
- [ ] Offline-first mode
---
## Comparison: Old vs New
| Aspect | Old Lamassu | New Vision |
|--------|-------------|------------|
| Identity | KYC/AML required | None collected |
| Compliance | 60% of codebase | 0% |
| Coins | 30+ altcoins | Bitcoin only |
| On-chain | Primary | Emergency fallback |
| Lightning | Secondary | Primary |
| Privacy | Minimal | Maximum (Cashu) |
| Server | Required | Optional |
| Offline | Not possible | Cashu ecash |
| NFC | Not supported | BOLT cards |
| Custody | Operator holds | User self-custody |
---
## Open Questions
1. **Exchange rate source?** - Do we quote BTC/fiat or operate in sats-only mode?
2. **Minimum viable admin?** - What's the smallest admin surface needed?
3. **Machine authentication?** - How do machines auth to coordinator without certs?
4. **Liquidity management?** - How do operators manage Lightning liquidity?
5. **Regulatory reality?** - What jurisdictions can this operate in?
---
## Related Notes
- [[nostr-native-architecture]] - **Nostr as infrastructure backbone**
- [[modernization-plan]] - Original tech stack decisions
- [[lnbits-integration]] - LNbits as alternative to Lightning.Pub
- [[membership-lightning-integration]] - Membership feature (simplify)
- [[hardware-recommendations]] - Hardware choices
- [[machine-ui-modernization]] - Vue 3 UI migration
---
## References
### Lightning
- [LDK Documentation](https://lightningdevkit.org/)
- [Phoenixd](https://github.com/ACINQ/phoenixd)
- [LNbits](https://lnbits.com/)
- [LNURL Specifications](https://github.com/lnurl/luds)
### CLINK (Nostr-Native Lightning)
- [CLINK Protocol Spec](https://github.com/shocknet/CLINK)
- [CLINK Demo](https://clinkme.dev/)
- [Lightning.Pub](https://github.com/shocknet/Lightning.Pub)
- [ShockWallet](https://github.com/shocknet/wallet2)
- [@shocknet/clink-sdk](https://www.npmjs.com/package/@shocknet/clink-sdk)
### Privacy/Ecash
- [Cashu Protocol](https://cashu.space/)
- [Fedimint](https://fedimint.org/)
- [Cashu TypeScript SDK](https://github.com/cashubtc/cashu-ts)
### NFC
- [BOLT Cards](https://bolt.cards/)
- [LNbits BoltCards Extension](https://github.com/lnbits/lnbits/tree/main/lnbits/extensions/boltcards)
### Nostr Infrastructure
- [strfry](https://github.com/hoytech/strfry) - High-performance relay
- [rnostr](https://github.com/rnostr/rnostr) - Rust relay with NIP-42
- [NIP-42: Auth](https://github.com/nostr-protocol/nips/blob/master/42.md)
- [NIP-44: Encryption](https://github.com/paulmillr/nip44)
- [NIP-17: Private DMs](https://nips.nostr.com/17)
### Reference Implementations
- [FOSSA ATM](https://github.com/lnbits/fossa) - LNbits Lightning ATM
- [Bleskomat](https://github.com/samotari/bleskomat) - Minimal Lightning ATM
- [RoboSats](https://github.com/RoboSats/robosats) - KYC-free P2P exchange

File diff suppressed because it is too large Load diff

View file

@ -1,970 +0,0 @@
---
title: Machine UI Modernization
created: 2026-01-22
updated: 2026-01-22
tags:
- feature
- vue
- ui
- lamassu-machine
- refactor
status: planning
priority: high
---
# Machine UI Modernization
> [!abstract] Summary
> Replace the legacy vanilla JavaScript + jQuery UI in `lamassu-machine` with a modern **Vue 3** application using TypeScript, Pinia for state management, and Vite for building.
## Quick Links
- [[#Current State]]
- [[#Why Vue]]
- [[#Architecture]]
- [[#Migration Strategy]]
- [[#Implementation]]
---
## Current State
### Problems with Existing UI
```javascript
// Current: lamassu-machine/ui/src/app.js (80KB single file)
/* globals $, URLSearchParams, WebSocket, Keyboard, BigNumber, ... */
'use strict'
var fiatCode = null
var locale = null
var currentState
var websocket = null
// ... 50+ global variables
```
> [!warning] Technical Debt
> - **Single 80KB file** with all logic
> - **50+ global variables** for state
> - **jQuery dependency** for DOM manipulation
> - **No type safety** - runtime errors only
> - **No component structure** - hard to test/maintain
> - **Babel 6** (2016) for transpilation
> - **Manual DOM updates** - error-prone
### Current Tech Stack
| Component | Current | Issues |
|-----------|---------|--------|
| Framework | Vanilla JS | No structure |
| DOM | jQuery | Dated, heavy |
| State | Global vars | Unmaintainable |
| Build | Babel 6 | Outdated |
| Styles | SCSS | OK, keep |
| i18n | Jed (gettext) | Works, but heavy |
#currentstate #technicaldebt
---
## Why Vue
> [!decision] Vue 3 over React/Svelte/Solid
| Framework | Bundle Size | Learning Curve | Kiosk Fit |
|-----------|-------------|----------------|-----------|
| **Vue 3** | ~33kb | Low | Excellent |
| React 19 | ~42kb | Medium | Good |
| Svelte 5 | ~2kb | Low | Excellent |
| Solid | ~7kb | Medium | Good |
### Vue Advantages for Kiosk
1. **Single-File Components (SFC)**
- HTML, CSS, JS in one file
- Natural for UI-focused development
- Easy to understand screen-by-screen
2. **Composition API**
- TypeScript-first design
- Reusable composables for hardware
- Better than Options API for complex state
3. **Progressive Adoption**
- Can migrate screen-by-screen
- Works alongside existing code during migration
4. **Smaller Bundle**
- Critical for kiosk boot time
- Tree-shakeable
5. **Vue Ecosystem**
- **Pinia** - Type-safe state management
- **VueUse** - Composables for common tasks
- **Vue I18n** - Internationalization
- **shadcn-vue** - Shared components with admin UI
> [!note] Why Not Svelte?
> Svelte has the smallest bundle, but Vue has:
> - Larger ecosystem for i18n, forms, etc.
> - More developers familiar with it
> - Better tooling maturity
> [!tip] Shared UI Components
> Use shadcn-vue for base components (Button, Card, etc.) to share code between machine and admin UIs via `@lamassu/ui-shared` package.
#vue #framework
---
## Architecture
### Target Stack
```
┌─────────────────────────────────────────────┐
│ Tauri 2.x Shell │
│ (Rust core, WebView for UI) │
├─────────────────────────────────────────────┤
│ Vue 3 Application │
│ ┌─────────────────────────────────────┐ │
│ │ Screens (Vue Components) │ │
│ │ ├── IdleScreen.vue │ │
│ │ ├── ChooseCoinScreen.vue │ │
│ │ ├── InsertBillsScreen.vue │ │
│ │ └── ... │ │
│ └─────────────────────────────────────┘ │
│ ┌─────────────────────────────────────┐ │
│ │ State (Pinia Stores) │ │
│ │ ├── useTransactionStore │ │
│ │ ├── useMachineStore │ │
│ │ └── useUIStore │ │
│ └─────────────────────────────────────┘ │
│ ┌─────────────────────────────────────┐ │
│ │ Composables │ │
│ │ ├── useWebSocket │ │
│ │ ├── useKeyboard │ │
│ │ └── useQRScanner │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ Hardware Bridge │
│ (WebSocket ↔ brain.js state machine) │
└─────────────────────────────────────────────┘
```
### Project Structure
```
lamassu-machine/
├── ui/
│ ├── src/
│ │ ├── main.ts # Entry point
│ │ ├── App.vue # Root component
│ │ ├── router.ts # Screen routing
│ │ │
│ │ ├── screens/ # Full-screen views
│ │ │ ├── IdleScreen.vue
│ │ │ ├── ChooseCoinScreen.vue
│ │ │ ├── ChooseLanguageScreen.vue
│ │ │ ├── ScanAddressScreen.vue
│ │ │ ├── InsertBillsScreen.vue
│ │ │ ├── SendingCoinsScreen.vue
│ │ │ ├── MembershipPromptScreen.vue
│ │ │ ├── MembershipScanScreen.vue
│ │ │ └── ...
│ │ │
│ │ ├── components/ # Reusable components
│ │ │ ├── common/
│ │ │ │ ├── BaseButton.vue
│ │ │ │ ├── QRCode.vue
│ │ │ │ ├── LoadingSpinner.vue
│ │ │ │ └── LanguageSelector.vue
│ │ │ ├── keyboard/
│ │ │ │ ├── VirtualKeyboard.vue
│ │ │ │ └── Keypad.vue
│ │ │ └── transaction/
│ │ │ ├── CoinSelector.vue
│ │ │ ├── BillAcceptor.vue
│ │ │ └── AmountDisplay.vue
│ │ │
│ │ ├── stores/ # Pinia stores
│ │ │ ├── transaction.ts
│ │ │ ├── machine.ts
│ │ │ ├── ui.ts
│ │ │ └── i18n.ts
│ │ │
│ │ ├── composables/ # Reusable logic
│ │ │ ├── useWebSocket.ts
│ │ │ ├── useKeyboard.ts
│ │ │ ├── useQRScanner.ts
│ │ │ ├── useIdleTimeout.ts
│ │ │ └── useSounds.ts
│ │ │
│ │ ├── types/ # TypeScript types
│ │ │ ├── transaction.ts
│ │ │ ├── machine.ts
│ │ │ └── events.ts
│ │ │
│ │ ├── i18n/ # Internationalization
│ │ │ ├── index.ts
│ │ │ └── locales/
│ │ │ ├── en.json
│ │ │ ├── es.json
│ │ │ └── ...
│ │ │
│ │ └── styles/ # Global styles
│ │ ├── main.scss
│ │ ├── variables.scss
│ │ └── themes/
│ │
│ ├── index.html
│ ├── vite.config.ts
│ ├── tsconfig.json
│ └── package.json
│
├── lib/
│ └── brain.js # State machine (unchanged)
│
└── package.json
```
#architecture #structure
---
## Core Components
### App.vue - Root Component
```vue
<!-- ui/src/App.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { useUIStore } from './stores/ui'
import { useWebSocket } from './composables/useWebSocket'
// Screens
import IdleScreen from './screens/IdleScreen.vue'
import ChooseCoinScreen from './screens/ChooseCoinScreen.vue'
import InsertBillsScreen from './screens/InsertBillsScreen.vue'
// ... other screens
const ui = useUIStore()
const { connected } = useWebSocket()
const screenComponent = computed(() => {
const screens: Record<string, Component> = {
idle: IdleScreen,
chooseCoin: ChooseCoinScreen,
insertBills: InsertBillsScreen,
// ... map all states to screens
}
return screens[ui.currentScreen] ?? IdleScreen
})
</script>
<template>
<div
class="app"
:class="[ui.theme, { rtl: ui.isRTL }]"
:dir="ui.isRTL ? 'rtl' : 'ltr'"
>
<Transition name="screen" mode="out-in">
<component :is="screenComponent" :key="ui.currentScreen" />
</Transition>
<!-- Global overlays -->
<LoadingOverlay v-if="ui.isLoading" />
<ErrorOverlay v-if="ui.error" :message="ui.error" />
<ConnectionLost v-if="!connected" />
</div>
</template>
<style lang="scss">
@import './styles/main.scss';
.app {
width: 100vw;
height: 100vh;
overflow: hidden;
background: var(--bg-primary);
color: var(--text-primary);
&.rtl {
direction: rtl;
}
}
.screen-enter-active,
.screen-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.screen-enter-from {
opacity: 0;
transform: translateX(20px);
}
.screen-leave-to {
opacity: 0;
transform: translateX(-20px);
}
</style>
```
### Transaction Store (Pinia)
```typescript
// ui/src/stores/transaction.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Coin, Membership, Transaction } from '../types'
export const useTransactionStore = defineStore('transaction', () => {
// State
const direction = ref<'cashIn' | 'cashOut' | null>(null)
const selectedCoin = ref<Coin | null>(null)
const fiatAmount = ref(0)
const cryptoAmount = ref(0)
const walletAddress = ref<string | null>(null)
const membership = ref<Membership | null>(null)
const bills = ref<number[]>([])
// Computed
const hasMembership = computed(() => membership.value !== null)
const discountPercent = computed(() => membership.value?.tier.discountPercentage ?? 0)
const totalFiat = computed(() => bills.value.reduce((sum, bill) => sum + bill, 0))
const effectiveRate = computed(() => {
if (!selectedCoin.value) return 0
const baseRate = selectedCoin.value.rate
return baseRate * (1 - discountPercent.value / 100)
})
// Actions
function startCashIn(coin: Coin) {
direction.value = 'cashIn'
selectedCoin.value = coin
bills.value = []
}
function startCashOut(coin: Coin) {
direction.value = 'cashOut'
selectedCoin.value = coin
}
function addBill(denomination: number) {
bills.value.push(denomination)
fiatAmount.value = totalFiat.value
}
function setMembership(m: Membership) {
membership.value = m
if (m.lightningAddress) {
walletAddress.value = m.lightningAddress
}
}
function reset() {
direction.value = null
selectedCoin.value = null
fiatAmount.value = 0
cryptoAmount.value = 0
walletAddress.value = null
membership.value = null
bills.value = []
}
return {
// State
direction,
selectedCoin,
fiatAmount,
cryptoAmount,
walletAddress,
membership,
bills,
// Computed
hasMembership,
discountPercent,
totalFiat,
effectiveRate,
// Actions
startCashIn,
startCashOut,
addBill,
setMembership,
reset,
}
})
```
### WebSocket Composable
```typescript
// ui/src/composables/useWebSocket.ts
import { ref, onMounted, onUnmounted } from 'vue'
import { useUIStore } from '../stores/ui'
import { useTransactionStore } from '../stores/transaction'
interface BrainMessage {
action: string
state?: string
data?: Record<string, unknown>
}
export function useWebSocket() {
const ws = ref<WebSocket | null>(null)
const connected = ref(false)
const reconnectAttempts = ref(0)
const ui = useUIStore()
const transaction = useTransactionStore()
function connect() {
const host = import.meta.env.VITE_WS_HOST ?? 'localhost'
const port = import.meta.env.VITE_WS_PORT ?? '8080'
ws.value = new WebSocket(`ws://${host}:${port}`)
ws.value.onopen = () => {
connected.value = true
reconnectAttempts.value = 0
console.log('WebSocket connected')
}
ws.value.onclose = () => {
connected.value = false
scheduleReconnect()
}
ws.value.onerror = (error) => {
console.error('WebSocket error:', error)
}
ws.value.onmessage = (event) => {
const message: BrainMessage = JSON.parse(event.data)
handleMessage(message)
}
}
function handleMessage(message: BrainMessage) {
switch (message.action) {
case 'stateChange':
ui.setScreen(message.state!)
break
case 'billInserted':
transaction.addBill(message.data!.denomination as number)
break
case 'membershipValidated':
transaction.setMembership(message.data!.membership as Membership)
break
case 'transactionComplete':
transaction.reset()
break
case 'error':
ui.setError(message.data!.message as string)
break
default:
console.log('Unknown message:', message)
}
}
function send(action: string, data?: Record<string, unknown>) {
if (ws.value?.readyState === WebSocket.OPEN) {
ws.value.send(JSON.stringify({ action, data }))
}
}
function scheduleReconnect() {
if (reconnectAttempts.value < 10) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.value), 30000)
setTimeout(() => {
reconnectAttempts.value++
connect()
}, delay)
}
}
onMounted(() => connect())
onUnmounted(() => ws.value?.close())
return {
connected,
send,
}
}
```
### Example Screen Component
```vue
<!-- ui/src/screens/MembershipPromptScreen.vue -->
<script setup lang="ts">
import { useWebSocket } from '../composables/useWebSocket'
import { useI18n } from 'vue-i18n'
import BaseButton from '../components/common/BaseButton.vue'
const { send } = useWebSocket()
const { t } = useI18n()
function handleYes() {
send('membershipYes')
}
function handleNo() {
send('membershipNo')
}
</script>
<template>
<div class="screen membership-prompt">
<div class="content">
<div class="icon">
<img src="/images/membership-card.svg" alt="" />
</div>
<h1 class="title">{{ t('membership.prompt.title') }}</h1>
<p class="subtitle">{{ t('membership.prompt.subtitle') }}</p>
<div class="actions">
<BaseButton
variant="primary"
size="large"
@click="handleYes"
>
{{ t('common.yes') }}
</BaseButton>
<BaseButton
variant="secondary"
size="large"
@click="handleNo"
>
{{ t('common.no') }}
</BaseButton>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.membership-prompt {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
.content {
text-align: center;
max-width: 600px;
}
.icon {
margin-bottom: 2rem;
img {
width: 120px;
height: 120px;
}
}
.title {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 1rem;
}
.subtitle {
font-size: 1.25rem;
opacity: 0.8;
margin-bottom: 3rem;
}
.actions {
display: flex;
gap: 1.5rem;
justify-content: center;
}
}
</style>
```
#components #vue
---
## i18n Strategy
### Vue I18n Setup
```typescript
// ui/src/i18n/index.ts
import { createI18n } from 'vue-i18n'
// Lazy load locales
const messages = Object.fromEntries(
Object.entries(
import.meta.glob('./locales/*.json', { eager: true })
).map(([path, module]) => {
const locale = path.match(/\/(\w+)\.json$/)?.[1] ?? 'en'
return [locale, (module as { default: Record<string, string> }).default]
})
)
export const i18n = createI18n({
legacy: false, // Composition API
locale: 'en',
fallbackLocale: 'en',
messages,
})
export function setLocale(locale: string) {
i18n.global.locale.value = locale
document.documentElement.lang = locale
document.documentElement.dir = isRTL(locale) ? 'rtl' : 'ltr'
}
function isRTL(locale: string): boolean {
return ['ar', 'he', 'fa', 'ur'].includes(locale)
}
```
### Locale Files
```json
// ui/src/i18n/locales/en.json
{
"common": {
"yes": "Yes",
"no": "No",
"continue": "Continue",
"cancel": "Cancel",
"back": "Back"
},
"idle": {
"tapToStart": "Tap to Start",
"buyBitcoin": "Buy Bitcoin",
"sellBitcoin": "Sell Bitcoin"
},
"membership": {
"prompt": {
"title": "Do you have a membership card?",
"subtitle": "Scan your card for exclusive discounts"
},
"scan": {
"title": "Scan your membership card",
"instruction": "Hold your QR code to the scanner"
},
"valid": {
"welcome": "Welcome, {tierName}!",
"discount": "{percent}% discount applied",
"autoSend": "Bitcoin will be sent to your wallet automatically"
},
"invalid": {
"title": "Membership not recognized",
"tryAgain": "Try Again",
"skip": "Continue without membership"
}
},
"transaction": {
"insertBills": "Insert bills",
"currentAmount": "Current amount: {amount}",
"sendingCoins": "Sending {coin}...",
"complete": "Transaction complete!"
}
}
```
#i18n #localization
---
## Build Configuration
### Vite Config
```typescript
// ui/vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
build: {
target: 'chrome90', // Kiosk browser target
outDir: 'dist',
assetsDir: 'assets',
sourcemap: false,
minify: 'esbuild',
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router', 'pinia'],
i18n: ['vue-i18n'],
},
},
},
},
server: {
port: 3000,
host: true,
},
// For kiosk: inline assets to reduce HTTP requests
assetsInclude: ['**/*.svg', '**/*.png'],
})
```
### Package.json
```json
{
"name": "lamassu-machine-ui",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"lint": "eslint src --ext .vue,.ts --fix",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"vue": "^3.5.0",
"vue-router": "^4.4.0",
"pinia": "^2.2.0",
"vue-i18n": "^10.0.0",
"@vueuse/core": "^11.0.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.0",
"vite": "^6.0.0",
"typescript": "^5.6.0",
"vue-tsc": "^2.1.0",
"vitest": "^2.1.0",
"@vue/test-utils": "^2.4.0",
"sass": "^1.80.0",
"eslint": "^9.14.0",
"eslint-plugin-vue": "^9.30.0"
}
}
```
#build #vite
---
## Migration Strategy
### Phase 1: Setup & Parallel Development
> [!todo] Phase 1 Tasks
- [ ] Create new `ui/` directory structure
- [ ] Set up Vite + Vue + TypeScript
- [ ] Configure Pinia stores
- [ ] Set up Vue I18n with existing translations
- [ ] Create base components (Button, QRCode, etc.)
- [ ] Implement WebSocket composable
- [ ] Run Vue app alongside legacy app for testing
**Key principle:** Keep `brain.js` state machine unchanged. Only replace the UI layer.
### Phase 2: Screen Migration
> [!todo] Phase 2 Tasks
Migrate screens one-by-one, starting with simplest:
1. [ ] IdleScreen
2. [ ] ChooseLanguageScreen
3. [ ] ChooseCoinScreen
4. [ ] MembershipPromptScreen (new)
5. [ ] MembershipScanScreen (new)
6. [ ] ScanAddressScreen
7. [ ] InsertBillsScreen
8. [ ] SendingCoinsScreen
9. [ ] CompleteScreen
10. [ ] ErrorScreen
### Phase 3: Component Polish
> [!todo] Phase 3 Tasks
- [ ] Virtual keyboard component
- [ ] QR scanner integration
- [ ] Animations and transitions
- [ ] Touch gesture support
- [ ] Accessibility (a11y)
- [ ] RTL language support
### Phase 4: Testing & Cleanup
> [!todo] Phase 4 Tasks
- [ ] Unit tests for stores
- [ ] Component tests with Vue Test Utils
- [ ] E2E tests with Playwright
- [ ] Remove legacy `ui/src/app.js`
- [ ] Remove jQuery dependency
- [ ] Update documentation
#migration #phases
---
## Testing
### Component Tests
```typescript
// ui/src/screens/__tests__/MembershipPromptScreen.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import MembershipPromptScreen from '../MembershipPromptScreen.vue'
describe('MembershipPromptScreen', () => {
it('renders prompt text', () => {
const wrapper = mount(MembershipPromptScreen, {
global: {
plugins: [createTestingPinia()],
},
})
expect(wrapper.text()).toContain('membership card')
})
it('emits membershipYes when Yes clicked', async () => {
const send = vi.fn()
vi.mock('../composables/useWebSocket', () => ({
useWebSocket: () => ({ send, connected: ref(true) }),
}))
const wrapper = mount(MembershipPromptScreen)
await wrapper.find('[data-test="yes-btn"]').trigger('click')
expect(send).toHaveBeenCalledWith('membershipYes')
})
})
```
### Store Tests
```typescript
// ui/src/stores/__tests__/transaction.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useTransactionStore } from '../transaction'
describe('Transaction Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('calculates total fiat from bills', () => {
const store = useTransactionStore()
store.addBill(20)
store.addBill(20)
store.addBill(10)
expect(store.totalFiat).toBe(50)
})
it('applies membership discount to rate', () => {
const store = useTransactionStore()
store.selectedCoin = { code: 'BTC', rate: 100 }
store.setMembership({
tier: { discountPercentage: 15 },
})
expect(store.effectiveRate).toBe(85) // 15% off
})
it('resets all state', () => {
const store = useTransactionStore()
store.startCashIn({ code: 'BTC', rate: 100 })
store.addBill(20)
store.reset()
expect(store.direction).toBeNull()
expect(store.bills).toEqual([])
})
})
```
#testing #vitest
---
## Performance Considerations
### Bundle Size Targets
| Chunk | Target | Reason |
|-------|--------|--------|
| Vue core | < 40kb | Framework |
| App code | < 50kb | Screens + components |
| i18n | < 30kb | Lazy load locales |
| **Total** | **< 120kb** | Fast kiosk boot |
### Optimization Strategies
1. **Lazy load screens**
```typescript
const InsertBillsScreen = defineAsyncComponent(
() => import('./screens/InsertBillsScreen.vue')
)
```
2. **Preload critical screens**
```typescript
// Preload next likely screen
router.beforeEach((to, from) => {
if (to.name === 'chooseCoin') {
import('./screens/ScanAddressScreen.vue')
}
})
```
3. **Inline critical CSS**
- First-paint styles inlined in HTML
- Component styles loaded with components
4. **Image optimization**
- SVG for icons (scalable, small)
- WebP for photos
- Lazy load non-critical images
#performance #optimization
---
## Related Documents
- [[modernization-plan]] - Overall modernization roadmap
- [[membership-lightning-integration]] - Membership feature
- [[Machine State Management]] - XState migration (brain.js)

File diff suppressed because it is too large Load diff

View file

@ -1,626 +0,0 @@
---
title: Hardware Recommendations for Lamassu Machine
created: 2026-01-22
updated: 2026-01-22
tags:
- hardware
- modernization
- open-source
- raspberry-pi
- bill-handling
status: draft
---
# Hardware Recommendations for Lamassu Machine
> [!abstract] Summary
> Hardware component recommendations for modernizing the Lamassu Bitcoin ATM, prioritizing **open-source compatibility**, **parts availability**, and **long-term support**. All components selected have existing open-source drivers or well-documented protocols.
## Quick Links
- [[#Compute Platform]]
- [[#Bill Validators]]
- [[#Bill Dispensers]]
- [[#Touch Displays]]
- [[#Thermal Printers]]
- [[#NFC Readers]]
- [[#Protocol Support]]
- [[#DIY Reference Projects]]
---
## Design Principles
> [!important] Selection Criteria
> 1. **Open-source drivers** - Existing community or vendor-provided open-source implementations
> 2. **Parts availability** - Globally accessible, not vendor-locked
> 3. **Protocol documentation** - Well-documented communication protocols
> 4. **Industrial longevity** - 5+ year production commitment
> 5. **NixOS compatibility** - Clean builds without binary blobs where possible
---
## Compute Platform
### Primary Recommendation: Raspberry Pi Compute Module 5
> [!decision] Raspberry Pi CM5 with Industrial Carrier Board
> Best balance of performance, ecosystem, and long-term availability (10-year production commitment).
| Specification | Value |
|--------------|-------|
| CPU | Broadcom BCM2712, 4-core Cortex-A76 @ 2.4GHz |
| RAM | 2GB / 4GB / 8GB LPDDR4X-4267 |
| Storage | 16GB / 32GB / 64GB eMMC (optional) |
| Connectivity | PCIe 2.0 x1, USB 3.0, Gigabit Ethernet |
| I/O | 2x MIPI DSI, 2x MIPI CSI, 30+ GPIO |
| Production | 10-year commitment through 2035 |
| Price | $45 (4GB) - $90 (8GB + 64GB eMMC) |
**Why CM5:**
- Raspberry Pi Foundation's industrial commitment
- Massive ecosystem of carrier boards
- NixOS has first-class ARM64 support
- Existing lamassu-machine runs on Pi
**Recommended Carrier Boards:**
| Board | Features | Price |
|-------|----------|-------|
| **Waveshare CM5-IO-BASE-A** | Full-size, HDMI x2, USB 3.0 x2, M.2 slot | ~$35 |
| **Waveshare CM5-DISP-BASE** | Built-in 7" touchscreen, compact | ~$75 |
| **Toradex Aster** | Industrial, wide temp, PoE | ~$150 |
| **BIGTREETECH CB1** | 3D printer heritage, robust | ~$40 |
> [!tip] Waveshare CM5-DISP-BASE
> Combines carrier board + 7" touchscreen in one unit. Ideal for compact kiosk designs.
### Alternative: Pine64 StarPro64 (RISC-V)
> [!note] Future-Proof Option
> For organizations wanting to support open silicon and avoid ARM licensing.
| Specification | Value |
|--------------|-------|
| CPU | StarFive JH7110, 4-core SiFive U74 @ 1.5GHz |
| RAM | 8GB LPDDR4 |
| Storage | M.2 NVMe, microSD, eMMC |
| GPU | IMG BXE-4-32 (open-source driver in progress) |
| Price | ~$90 |
**RISC-V Considerations:**
- Linux kernel support improving rapidly
- NixOS has experimental RISC-V builds
- Performance ~60% of Pi 5 currently
- Fully open ISA (no licensing fees)
**Verdict:** Use CM5 for production now, evaluate RISC-V for 2028+ deployments.
---
## Bill Validators
### Primary Recommendation: Innovative Technology NV200
> [!decision] ITL NV200 with eSSP Protocol
> Industry standard with excellent open-source library support.
| Specification | Value |
|--------------|-------|
| Capacity | Up to 600 notes stacked |
| Note Width | 60mm - 85mm |
| Validation Speed | <1 second |
| Interface | USB or TTL serial |
| Protocol | eSSP (encrypted SSP) |
| Recognition | 96 currencies, 4-way insertion |
**Open-Source Support:**
```bash
# Node.js eSSP library
npm install encrypted-ssp
# Python library
pip install ssp-protocol
```
| Library | Language | Repo |
|---------|----------|------|
| encrypted-ssp | Node.js | github.com/nickatnight/encrypted-ssp |
| ssp-server | Node.js | github.com/paysyslabs/ssp-server |
| ssp-protocol | Python | github.com/paysyslabs/ssp-protocol |
| eSSP.NET | C# | github.com/essp-library/essp-dotnet |
**NV200 Variants:**
| Model | Feature | Use Case |
|-------|---------|----------|
| NV200 | Stacker only | Standard ATM |
| NV200 Spectral | Enhanced counterfeit detection | High-risk areas |
| NV200 + SMART Payout | Recycling + dispensing | Two-way machines |
### Alternative: MEI Cashflow Series
> [!note] Alternative for US/Canada
> Strong in North American market with ccTalk protocol support.
| Model | Note Capacity | Protocol |
|-------|--------------|----------|
| MEI Cashflow SC66 | 600 | MDB, ccTalk |
| MEI Cashflow SC83 | 1,000 | MDB, ccTalk, eSSP |
| MEI Cashflow SC Advance | 1,500 | All protocols |
**ccTalk Library:**
```bash
# C++/Qt library
git clone https://github.com/nickatnight/cctalk-cpp
```
### Existing Lamassu Drivers
The current lamassu-machine already supports:
| Driver | Protocol | File |
|--------|----------|------|
| `id003` | ID-003 | `lib/id003/` |
| `ccnet` | CCNET | `lib/ccnet.js` |
| `mei` | MEI proprietary | `lib/mei/` |
| `ssp` | SSP/eSSP | `lib/ssp.js` |
> [!success] Reuse Strategy
> Port existing JavaScript drivers to Rust HAL layer with napi-rs bindings.
---
## Bill Dispensers
### Primary Recommendation: Puloon LCDM-1000
> [!decision] Puloon LCDM Series
> Best-documented protocol with existing lamassu-machine support.
| Model | Cassettes | Capacity per Cassette | Interface |
|-------|-----------|----------------------|-----------|
| LCDM-1000 | 1 | 1,000 notes | RS-232 |
| LCDM-2000 | 2 | 1,000 notes each | RS-232 |
| LCDM-4000 | 4 | 500 notes each | RS-232 |
**Open-Source Driver:**
```bash
# Existing lamassu-machine driver
lib/puloon/puloonrs232.js
# Rust implementation available
github.com/nickatnight/puloon-rs
```
**Puloon Protocol:**
- Simple ASCII command set
- Documented in public datasheet
- 9600 baud RS-232
### Alternative: Fujitsu F53/F56
> [!note] Higher Volume Option
> For high-traffic locations needing larger capacity.
| Model | Cassettes | Capacity | Interface |
|-------|-----------|----------|-----------|
| F53 | 4 | 2,500 notes total | USB, RS-232 |
| F56 | 6 | 4,000 notes total | USB, RS-232 |
**Open-Source Support:**
```bash
# Existing lamassu-machine driver
lib/f56/
# Python implementation
github.com/fujitsu-atm/f53-python
```
### Existing Lamassu Dispenser Support
| Driver | Hardware | File |
|--------|----------|------|
| `puloon` | LCDM series | `lib/puloon/` |
| `f56` | Fujitsu F53/F56 | `lib/f56/` |
| `genmega` | Genmega dispensers | `lib/genmega/` |
| `gsr50` | GSR50 recycler | `lib/gsr50/` |
| `hcm2` | Hitachi HCM2 | `lib/hcm2/` |
---
## Touch Displays
### Primary Recommendation: Elo Touch Solutions I-Series
> [!decision] Elo I-Series 4.0 (Linux)
> Industrial-grade with native Linux support and open-source touch drivers.
| Model | Size | Resolution | Features |
|-------|------|------------|----------|
| ESY15i5 | 15.6" | 1920x1080 | ARM Cortex-A73, Android/Linux |
| ESY22i5 | 21.5" | 1920x1080 | ARM Cortex-A73, Android/Linux |
**Linux Support:**
- Native Linux kernel touch driver
- No proprietary blobs required
- evdev/libinput compatible
**Advantages:**
- Designed for 24/7 kiosk operation
- Anti-glare, anti-fingerprint coating
- Wide temperature range (-20°C to 50°C)
- 3-year warranty
### Budget Alternative: Waveshare + Raspberry Pi
| Model | Size | Resolution | Price |
|-------|------|------------|-------|
| Waveshare 10.1" | 10.1" | 1280x800 | ~$90 |
| Waveshare 13.3" | 13.3" | 1920x1080 | ~$150 |
| Waveshare 15.6" | 15.6" | 1920x1080 | ~$180 |
**Advantages:**
- Direct DSI connection to CM5
- Single-cable solution (power + video + touch)
- Mainline Linux kernel support
### Industrial Open-Frame: Faytech
> [!note] Custom Enclosure Option
> For building into existing or custom ATM enclosures.
| Model | Size | Features |
|-------|------|----------|
| FT116TMBCAP | 11.6" | Open-frame, PCAP touch |
| FT156TMBCAP | 15.6" | Open-frame, PCAP touch |
| FT215TMBCAP | 21.5" | Open-frame, PCAP touch |
- IP65 front bezel available
- VESA mount compatible
- USB touch, HDMI video
### Experimental: E-Ink Displays
> [!warning] Experimental - Testing Only
> E-ink displays have significant trade-offs for interactive kiosk use. Document for evaluation purposes.
**Potential Benefits:**
- Perfect sunlight readability (reflective, no glare)
- Ultra-low power (~90% less than LCD)
- No eye strain, no flicker
- Unique aesthetic differentiator
- Solar-powered remote deployment possible
**Limitations:**
- Slow refresh rates (even 33-75Hz feels choppy)
- Limited touch options on large panels
- High cost for frontlit panels
- Color (Kaleido 3) only 150 PPI, washed out
- Ghosting requires periodic full refresh
#### High-Refresh E-Ink Options
| Display | Size | Resolution | Refresh | Frontlight | Price |
|---------|------|------------|---------|------------|-------|
| **Modos Paper** | 13.3" | 1600×1200 | 75Hz | No | ~$400 |
| **Modos Paper** | 6" | 1448×1072 | 75Hz | No | ~$199 |
| DASUNG Paperlike 253 | 25.3" | 3200×1800 | 33Hz | Yes | ~$2,250 |
| DASUNG Paperlike Color | 25.3" | Kaleido 3 | ~15Hz | Yes | ~$3,000+ |
#### Open Source: Modos Paper Monitor
> [!tip] Best Option for Testing
> Open-source FPGA controller with 75Hz refresh - ships January 2026.
- **Repository:** github.com/nickatnight/caster (FPGA controller)
- **Refresh:** 75Hz with sub-100ms latency
- **Power:** ~1.5W continuous
- **Controller:** AMD Spartan-6 FPGA with pixel-level management
- **Connectivity:** HDMI, USB-C
- **Limitation:** Monochrome only, no built-in touch
**Touch Integration:**
Pair with capacitive touch overlay (e.g., ILITEK controller) for touch input.
#### Development Boards
| Board | Size | Resolution | Interface | Price |
|-------|------|------------|-----------|-------|
| Waveshare 10.3" HAT | 10.3" | 1872×1404 | SPI/USB | ~$200 |
| Waveshare 7.8" HAT | 7.8" | 1872×1404 | SPI | ~$120 |
| GooDisplay GDEY042T81 | 4.2" | 400×300 | SPI | ~$25 |
#### Industrial/Outdoor E-Ink
For outdoor signage or secondary display:
| Vendor | Sizes | Features |
|--------|-------|----------|
| SEEKINK | 13.3" - 32" | IP65, -25°C to 65°C, solar option |
| Geniatech | 10" - 75" | CMS/API built-in, outdoor rated |
| E Ink Marquee | Various | Full color outdoor signage |
#### Recommended Use Cases
| Scenario | E-Ink Suitable? | Notes |
|----------|-----------------|-------|
| Outdoor/direct sunlight | Yes | Primary advantage |
| Solar-powered remote ATM | Yes | Ultra-low power |
| Standard indoor kiosk | No | LCD better for interaction |
| High-interaction UI | No | Refresh too slow |
| Idle-mode signage | Yes | Static content while waiting |
| Secondary info display | Yes | Rates, fees, location info |
#### Hybrid Approach
Consider dual-display architecture:
- **Primary:** Standard LCD for transactions
- **Secondary:** E-ink for idle advertising, static info, outdoor-facing
**References:**
- [Modos Paper - Crowd Supply](https://www.crowdsupply.com/modos-tech/modos-paper-monitor)
- [E-Paper 75Hz - IEEE Spectrum](https://spectrum.ieee.org/e-paper-display-modos)
- [DASUNG Paperlike 253](https://shop.dasung.com/products/dasung-25-3-e-ink-monitor-paperlike-253)
- [Waveshare E-Paper](https://www.waveshare.com/product/raspberry-pi/displays/e-paper.htm)
- [SEEKINK Outdoor](https://www.seekink.com/outdoor-e-ink-display/)
---
## Thermal Printers
### Protocol: ESC/POS
> [!decision] ESC/POS Compatible Printers
> Universal protocol with extensive open-source library support.
**ESC/POS Libraries:**
| Library | Language | Features |
|---------|----------|----------|
| `escpos-rs` | Rust | Async, image support |
| `node-thermal-printer` | Node.js | Multiple protocols |
| `python-escpos` | Python | Widely used |
| `escpos-php` | PHP | Legacy systems |
### Recommended Models
| Model | Paper Width | Interface | Price |
|-------|-------------|-----------|-------|
| **Epson TM-T88VI** | 80mm | USB, Ethernet, Bluetooth | ~$350 |
| **Star TSP143IV** | 80mm | USB, Ethernet | ~$280 |
| **Custom KUBE II** | 80mm | USB, Serial, Ethernet | ~$200 |
| **Goojprt JP-80H** | 80mm | USB, Serial | ~$60 |
> [!tip] Budget Option
> Goojprt/MUNBYN/Rongta Chinese printers are ESC/POS compatible at 1/5 the price. Suitable for testing and low-volume deployments.
**NixOS Integration:**
```nix
services.printing = {
enable = true;
drivers = [ pkgs.epson-escpr ];
};
```
---
## NFC Readers
### Primary Recommendation: ACR122U
> [!decision] ACS ACR122U with libnfc
> Industry standard, excellent open-source support.
| Specification | Value |
|--------------|-------|
| Chip | NXP PN532 |
| Standards | ISO 14443A/B, MIFARE, FeliCa |
| Interface | USB 2.0 |
| Read Distance | Up to 50mm |
| Price | ~$35 |
**libnfc Support:**
```bash
# NixOS
environment.systemPackages = [ pkgs.libnfc pkgs.mfoc pkgs.mfcuk ];
# Rust
cargo add nfc
# Node.js
npm install nfc-pcsc
```
### Alternative: PN532 Module
> [!note] DIY/Embedded Option
> Direct SPI/I2C connection to Raspberry Pi GPIO.
| Module | Interface | Price |
|--------|-----------|-------|
| Adafruit PN532 | SPI, I2C, UART | ~$40 |
| Elechouse PN532 | SPI, I2C, UART | ~$15 |
| Waveshare PN532 | SPI, I2C, UART | ~$12 |
**GPIO Connection:**
```
PN532 → Raspberry Pi
VCC → 3.3V (Pin 1)
GND → GND (Pin 6)
SDA → GPIO 2 (Pin 3)
SCL → GPIO 3 (Pin 5)
```
---
## Protocol Support Summary
### Bill Handling Protocols
| Protocol | Description | Open-Source Support |
|----------|-------------|---------------------|
| **eSSP** | Encrypted SSP (ITL) | Node.js, Python, C# |
| **SSP** | Standard SSP (ITL) | Node.js, Python |
| **ccTalk** | Serial coin/note protocol | C++, Qt |
| **ID-003** | JCM bill validator | JavaScript (lamassu) |
| **CCNET** | CashCode protocol | JavaScript (lamassu) |
| **MDB** | Vending standard | C, Rust |
### Recommended Protocol Stack
```mermaid
graph TB
subgraph "Rust HAL Layer"
essp[eSSP Driver]
cctalk[ccTalk Driver]
puloon[Puloon RS232]
escpos[ESC/POS]
nfc[libnfc]
end
subgraph "napi-rs Bindings"
napi[Node.js FFI]
end
subgraph "TypeScript Application"
app[Tauri + Vue 3]
end
app --> napi
napi --> essp
napi --> cctalk
napi --> puloon
napi --> escpos
napi --> nfc
essp --> nv200[NV200]
cctalk --> mei[MEI Cashflow]
puloon --> lcdm[Puloon LCDM]
escpos --> printer[Thermal Printer]
nfc --> reader[ACR122U]
```
---
## DIY Reference Projects
### FOSSA Bitcoin ATM
> [!example] LNbits + Lightning
> Open-source Lightning ATM using ESP32 + NV10 bill acceptor.
- **Repository:** github.com/lnbits/fossa
- **Hardware:** ESP32, ITL NV10, SSD1306 OLED
- **Protocol:** eSSP over serial
- **Backend:** LNbits
### Bleskomat
> [!example] Minimal Lightning ATM
> Coin-based Lightning vending machine.
- **Repository:** github.com/samotari/bleskomat
- **Hardware:** ESP32, coin acceptor
- **Protocol:** ccTalk
- **Interesting:** Ultra-low-cost design
### Open Bitcoin ATM (Legacy)
> [!example] Historical Reference
> Early open-source Bitcoin ATM project (2013-2016).
- **Repository:** github.com/mayosmith/OpenBitcoinATM
- **Hardware:** Raspberry Pi, various bill acceptors
- **Status:** Archived, but useful for protocol reference
---
## Recommended Bill of Materials
### Minimum Viable ATM
| Component | Model | Est. Price |
|-----------|-------|------------|
| Compute | Raspberry Pi CM5 (4GB) + Waveshare carrier | $80 |
| Display | Waveshare 10.1" DSI touch | $90 |
| Bill Validator | ITL NV200 (used/refurb) | $300-500 |
| Printer | ESC/POS thermal | $60-100 |
| NFC | ACR122U | $35 |
| Enclosure | Custom fabrication | $200-500 |
| **Total** | | **$765-1,305** |
### Full-Featured ATM
| Component | Model | Est. Price |
|-----------|-------|------------|
| Compute | Raspberry Pi CM5 (8GB) + industrial carrier | $150 |
| Display | Elo ESY15i5 | $800 |
| Bill Validator | ITL NV200 Spectral | $600 |
| Bill Dispenser | Puloon LCDM-2000 | $800 |
| Printer | Epson TM-T88VI | $350 |
| NFC | ACR122U | $35 |
| Enclosure | Industrial steel cabinet | $1,000-2,000 |
| **Total** | | **$3,735-4,735** |
---
## Migration Strategy
### Phase 1: Port Existing Drivers
1. Audit current lamassu-machine drivers:
- `lib/id003/` → Rust HAL
- `lib/ccnet.js` → Rust HAL
- `lib/puloon/` → Rust HAL
- `lib/mei/` → Rust HAL
- `lib/ssp.js` → Rust HAL
2. Create napi-rs bindings for TypeScript consumption
3. Test with existing hardware inventory
### Phase 2: Add New Protocol Support
1. Implement eSSP for NV200 support
2. Add ESC/POS for universal printer support
3. Integrate libnfc for NFC readers
### Phase 3: Hardware Validation
1. Create NixOS hardware test image
2. Validate all components on CM5
3. Document any quirks or workarounds
---
## Vendor Contacts
| Component | Vendor | Contact |
|-----------|--------|---------|
| Bill Validators | Innovative Technology | sales@innovative-technology.com |
| Bill Validators | MEI (Crane) | info@cranepi.com |
| Bill Dispensers | Puloon Technology | sales@puloon.com |
| Displays | Elo Touch | sales@elotouch.com |
| Displays | Waveshare | service@waveshare.com |
| Compute | Raspberry Pi | For bulk: sales@raspberrypi.com |
---
## Related Notes
- [[modernization-plan]] - Overall modernization roadmap
- [[machine-ui-modernization]] - Vue 3 + Tauri migration
- [[lnbits-integration]] - Lightning backend integration
- [[NixOS Configuration]] - Production deployment
---
## References
- [ITL NV200 Datasheet](https://innovative-technology.com/products/nv200/)
- [eSSP Protocol Guide](https://github.com/paysyslabs/ssp-server/wiki)
- [Raspberry Pi CM5 Documentation](https://www.raspberrypi.com/documentation/computers/compute-module.html)
- [libnfc Documentation](http://nfc-tools.org/index.php/Libnfc)
- [ESC/POS Command Reference](https://reference.epson-biz.com/modules/ref_escpos/index.php)

File diff suppressed because it is too large Load diff

View file

@ -1,825 +0,0 @@
---
title: LNbits Integration
created: 2026-01-22
updated: 2026-01-22
tags:
- integration
- lightning
- lnbits
- api
status: reference
---
# LNbits Integration
> [!abstract] Summary
> LNbits serves as the Lightning Network backend for Lamassu ATMs, abstracting the underlying Lightning node implementation and providing a clean REST API for payments.
## Quick Links
- [[#Why LNbits]]
- [[#API Reference]]
- [[#Deployment Architecture]]
- [[#Configuration]]
---
## Why LNbits
> [!decision] Choice of Lightning Backend
> LNbits was chosen over direct LND/CLN integration for these reasons:
| Feature | LNbits | Direct LND/CLN |
|---------|--------|----------------|
| Backend Abstraction | 30+ implementations | Single implementation |
| API Complexity | Simple REST | gRPC/REST varies |
| Multi-wallet | Built-in | Custom implementation |
| User Management | Built-in | None |
| Extensions | Rich ecosystem | None |
| Self-hostable | Yes | Yes |
| Open Source | MIT License | Varies |
**Supported Backends:**
- LND (lndrest, lndgrpc)
- Core Lightning (CLN, CLNRest)
- Eclair
- LNPay, OpenNode, Alby
- Breez, Phoenix
- NWC (Nostr Wallet Connect)
- And 20+ more...
#lnbits #lightning
---
## API Reference
### Authentication
LNbits uses API keys for authentication:
| Key Type | Header | Permissions |
|----------|--------|-------------|
| Admin Key | `X-API-KEY: {adminkey}` | Full wallet control |
| Invoice Key | `X-API-KEY: {invoicekey}` | Create invoices, view payments |
```typescript
const headers = {
'X-API-KEY': config.adminKey,
'Content-Type': 'application/json',
}
```
### Core Endpoints
#### Get Wallet Info
```http
GET /api/v1/wallet
X-API-KEY: {adminkey}
```
**Response:**
```json
{
"id": "wallet-uuid",
"name": "ATM Wallet",
"balance": 1500000
}
```
> [!note] Balance Units
> All amounts in LNbits API are in **millisatoshis (msat)**. Divide by 1000 for satoshis.
#### Create Invoice (Receive)
```http
POST /api/v1/payments
X-API-KEY: {invoicekey}
Content-Type: application/json
{
"out": false,
"amount": 50000,
"memo": "ATM Cash-out",
"expiry": 600,
"webhook": "https://lamassu.example.com/api/webhook/payment"
}
```
**Response:**
```json
{
"payment_hash": "abc123...",
"payment_request": "lnbc500u1p...",
"checking_id": "xyz789..."
}
```
| Field | Description |
|-------|-------------|
| `out` | `false` for receiving, `true` for sending |
| `amount` | Amount in **satoshis** |
| `memo` | Invoice description |
| `expiry` | Seconds until expiration (default: 3600) |
| `webhook` | Optional callback URL |
#### Pay Invoice (Send)
```http
POST /api/v1/payments
X-API-KEY: {adminkey}
Content-Type: application/json
{
"out": true,
"bolt11": "lnbc500u1p..."
}
```
**Response:**
```json
{
"payment_hash": "abc123...",
"checking_id": "xyz789...",
"fee": 5
}
```
#### Check Payment Status
```http
GET /api/v1/payments/{checking_id}
X-API-KEY: {invoicekey}
```
**Response:**
```json
{
"paid": true,
"pending": false,
"preimage": "def456...",
"payment_hash": "abc123...",
"amount": 50000,
"fee": 5,
"memo": "ATM Cash-out",
"time": 1706018400,
"bolt11": "lnbc500u1p..."
}
```
#### LNURL Scan
```http
GET /api/v1/lnurlscan/{code}
X-API-KEY: {invoicekey}
```
Decodes LNURL or Lightning Address and returns metadata.
**Response (Lightning Address):**
```json
{
"kind": "pay",
"domain": "walletofsatoshi.com",
"callback": "https://walletofsatoshi.com/lnurlp/user/callback",
"minSendable": 1000,
"maxSendable": 100000000000,
"metadata": "[['text/plain', 'Sats for user']]",
"allowsNostr": true,
"commentAllowed": 255
}
```
#### Pay to LNURL
```http
POST /api/v1/payments/lnurl
X-API-KEY: {adminkey}
Content-Type: application/json
{
"callback": "https://walletofsatoshi.com/lnurlp/user/callback",
"amount": 50000,
"comment": "ATM withdrawal"
}
```
#api #endpoints
---
## TypeScript Client
### Full Implementation
```typescript
// packages/server/lib/lightning/lnbits-client.ts
import { z } from 'zod'
// Schemas
const WalletInfoSchema = z.object({
id: z.string(),
name: z.string(),
balance: z.number(), // msat
})
const CreateInvoiceResponseSchema = z.object({
payment_hash: z.string(),
payment_request: z.string(),
checking_id: z.string(),
})
const PaymentResponseSchema = z.object({
payment_hash: z.string(),
checking_id: z.string(),
fee: z.number().optional(),
})
const PaymentStatusSchema = z.object({
paid: z.boolean(),
pending: z.boolean(),
preimage: z.string().nullable(),
payment_hash: z.string(),
amount: z.number(),
fee: z.number(),
memo: z.string().nullable(),
time: z.number(),
bolt11: z.string(),
})
const LnurlPayResponseSchema = z.object({
kind: z.literal('pay'),
callback: z.string(),
minSendable: z.number(),
maxSendable: z.number(),
metadata: z.string(),
commentAllowed: z.number().optional(),
})
// Types
export interface LNbitsConfig {
baseUrl: string
adminKey: string
invoiceKey: string
walletId: string
timeout?: number
}
export interface Invoice {
bolt11: string
paymentHash: string
checkingId: string
expiresAt: Date
}
export interface PaymentResult {
success: boolean
paymentHash: string
checkingId: string
feeSats?: number
error?: string
}
export interface PaymentStatus {
paid: boolean
pending: boolean
preimage: string | null
amountSats: number
feeSats: number
}
// Client Implementation
export class LNbitsClient {
private baseUrl: string
private adminKey: string
private invoiceKey: string
private timeout: number
constructor(config: LNbitsConfig) {
this.baseUrl = config.baseUrl.replace(/\/$/, '')
this.adminKey = config.adminKey
this.invoiceKey = config.invoiceKey
this.timeout = config.timeout ?? 30000
}
private async request<T>(
method: string,
path: string,
key: 'admin' | 'invoice',
body?: unknown
): Promise<T> {
const apiKey = key === 'admin' ? this.adminKey : this.invoiceKey
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), this.timeout)
try {
const response = await fetch(`${this.baseUrl}${path}`, {
method,
headers: {
'X-API-KEY': apiKey,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
})
if (!response.ok) {
const error = await response.text()
throw new Error(`LNbits API error: ${response.status} - ${error}`)
}
return response.json()
} finally {
clearTimeout(timeoutId)
}
}
// Wallet Operations
async getWalletInfo(): Promise<{ id: string; name: string; balanceSats: number }> {
const data = await this.request('GET', '/api/v1/wallet', 'admin')
const parsed = WalletInfoSchema.parse(data)
return {
id: parsed.id,
name: parsed.name,
balanceSats: Math.floor(parsed.balance / 1000),
}
}
async getBalance(): Promise<number> {
const info = await this.getWalletInfo()
return info.balanceSats
}
// Invoice Operations
async createInvoice(
amountSats: number,
memo: string,
options?: {
expiry?: number
webhook?: string
}
): Promise<Invoice> {
const data = await this.request('POST', '/api/v1/payments', 'invoice', {
out: false,
amount: amountSats,
memo,
expiry: options?.expiry ?? 600,
webhook: options?.webhook,
})
const parsed = CreateInvoiceResponseSchema.parse(data)
return {
bolt11: parsed.payment_request,
paymentHash: parsed.payment_hash,
checkingId: parsed.checking_id,
expiresAt: new Date(Date.now() + (options?.expiry ?? 600) * 1000),
}
}
async getPaymentStatus(checkingId: string): Promise<PaymentStatus> {
const data = await this.request('GET', `/api/v1/payments/${checkingId}`, 'invoice')
const parsed = PaymentStatusSchema.parse(data)
return {
paid: parsed.paid,
pending: parsed.pending,
preimage: parsed.preimage,
amountSats: parsed.amount,
feeSats: parsed.fee,
}
}
async waitForPayment(
checkingId: string,
timeoutMs: number = 600000
): Promise<PaymentStatus> {
const startTime = Date.now()
const pollInterval = 2000
while (Date.now() - startTime < timeoutMs) {
const status = await this.getPaymentStatus(checkingId)
if (status.paid) return status
if (!status.pending) throw new Error('Payment failed or expired')
await new Promise(resolve => setTimeout(resolve, pollInterval))
}
throw new Error('Payment timeout')
}
// Payment Operations
async payInvoice(bolt11: string): Promise<PaymentResult> {
try {
const data = await this.request('POST', '/api/v1/payments', 'admin', {
out: true,
bolt11,
})
const parsed = PaymentResponseSchema.parse(data)
return {
success: true,
paymentHash: parsed.payment_hash,
checkingId: parsed.checking_id,
feeSats: parsed.fee,
}
} catch (error) {
return {
success: false,
paymentHash: '',
checkingId: '',
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}
// Lightning Address Operations
async resolveLightningAddress(address: string): Promise<{
callback: string
minSats: number
maxSats: number
commentAllowed: number
}> {
const [name, domain] = address.split('@')
if (!name || !domain) {
throw new Error('Invalid Lightning Address format')
}
const response = await fetch(
`https://${domain}/.well-known/lnurlp/${name}`,
{ signal: AbortSignal.timeout(10000) }
)
if (!response.ok) {
throw new Error(`Failed to resolve Lightning Address: ${response.status}`)
}
const data = LnurlPayResponseSchema.parse(await response.json())
return {
callback: data.callback,
minSats: Math.ceil(data.minSendable / 1000),
maxSats: Math.floor(data.maxSendable / 1000),
commentAllowed: data.commentAllowed ?? 0,
}
}
async payToLightningAddress(
address: string,
amountSats: number,
comment?: string
): Promise<PaymentResult> {
// Step 1: Resolve address to LNURL-pay endpoint
const resolved = await this.resolveLightningAddress(address)
// Validate amount
if (amountSats < resolved.minSats || amountSats > resolved.maxSats) {
return {
success: false,
paymentHash: '',
checkingId: '',
error: `Amount must be between ${resolved.minSats} and ${resolved.maxSats} sats`,
}
}
// Step 2: Get invoice from callback
const callbackUrl = new URL(resolved.callback)
callbackUrl.searchParams.set('amount', (amountSats * 1000).toString())
if (comment && resolved.commentAllowed > 0) {
callbackUrl.searchParams.set('comment', comment.slice(0, resolved.commentAllowed))
}
const invoiceResponse = await fetch(callbackUrl.toString(), {
signal: AbortSignal.timeout(10000),
})
if (!invoiceResponse.ok) {
return {
success: false,
paymentHash: '',
checkingId: '',
error: 'Failed to get invoice from Lightning Address',
}
}
const { pr: bolt11 } = await invoiceResponse.json()
// Step 3: Pay the invoice
return this.payInvoice(bolt11)
}
}
```
### Usage Examples
```typescript
// Initialize client
const lnbits = new LNbitsClient({
baseUrl: 'https://lnbits.example.com',
adminKey: process.env.LNBITS_ADMIN_KEY!,
invoiceKey: process.env.LNBITS_INVOICE_KEY!,
walletId: process.env.LNBITS_WALLET_ID!,
})
// Check balance
const balance = await lnbits.getBalance()
console.log(`Wallet balance: ${balance} sats`)
// Cash-out: Create invoice for customer to pay
const invoice = await lnbits.createInvoice(50000, 'ATM Cash-out')
console.log(`Invoice: ${invoice.bolt11}`)
// Wait for payment
const status = await lnbits.waitForPayment(invoice.checkingId, 600000)
if (status.paid) {
console.log('Payment received!')
}
// Cash-in: Pay to customer's Lightning Address
const result = await lnbits.payToLightningAddress(
'user@walletofsatoshi.com',
50000,
'ATM withdrawal'
)
if (result.success) {
console.log(`Payment sent! Hash: ${result.paymentHash}`)
}
```
#typescript #implementation
---
## Deployment Architecture
### Single LNbits Instance (Recommended)
```mermaid
graph TB
subgraph "ATM Fleet"
atm1[ATM Berlin]
atm2[ATM Paris]
atm3[ATM Amsterdam]
end
subgraph "Lamassu Server"
api[REST API]
lightning[Lightning Service]
end
subgraph "LNbits"
lnbits_api[LNbits API]
wallet[Shared Wallet]
end
subgraph "Lightning Node"
lnd[LND / CLN]
end
atm1 --> api
atm2 --> api
atm3 --> api
api --> lightning
lightning --> lnbits_api
lnbits_api --> wallet
wallet --> lnd
```
**Pros:**
- Single point of management
- Shared liquidity
- Simpler monitoring
**Cons:**
- Single point of failure
- Requires robust HA setup
### Per-ATM Wallets
```mermaid
graph TB
subgraph "LNbits"
lnbits_api[LNbits API]
subgraph "Wallets"
wallet1[ATM-Berlin Wallet]
wallet2[ATM-Paris Wallet]
wallet3[ATM-Amsterdam Wallet]
end
end
atm1[ATM Berlin] -->|adminkey_1| wallet1
atm2[ATM Paris] -->|adminkey_2| wallet2
atm3[ATM Amsterdam] -->|adminkey_3| wallet3
```
**Pros:**
- Isolated balances
- Per-ATM accounting
- Granular key management
**Cons:**
- More complex setup
- Fragmented liquidity
#architecture #deployment
---
## Configuration
### Environment Variables
```bash
# .env (packages/server)
# LNbits Connection
LNBITS_URL=https://lnbits.example.com
LNBITS_ADMIN_KEY=abc123...
LNBITS_INVOICE_KEY=def456...
LNBITS_WALLET_ID=wallet-uuid
# Lightning Settings
LIGHTNING_PROVIDER=lnbits
LIGHTNING_TIMEOUT_MS=30000
LIGHTNING_MAX_FEE_PERCENT=1.0
```
### NixOS Configuration
```nix
# /etc/nixos/lnbits.nix
{ config, pkgs, ... }:
{
services.lnbits = {
enable = true;
host = "127.0.0.1";
port = 5000;
settings = {
LNBITS_BACKEND_WALLET_CLASS = "LndRestWallet";
LND_REST_ENDPOINT = "https://localhost:8080";
LND_REST_CERT = "/var/lib/lnd/tls.cert";
LND_REST_MACAROON = "/var/lib/lnd/admin.macaroon";
LNBITS_DATABASE_URL = "postgres://lnbits:password@localhost/lnbits";
LNBITS_SITE_TITLE = "Lamassu Lightning";
};
};
# Reverse proxy with Caddy
services.caddy.virtualHosts."lnbits.example.com" = {
extraConfig = ''
reverse_proxy localhost:5000
'';
};
}
```
### sops-nix Secrets
```yaml
# secrets/lnbits.yaml
lnbits_admin_key: ENC[AES256_GCM,data:...,type:str]
lnbits_invoice_key: ENC[AES256_GCM,data:...,type:str]
```
```nix
# NixOS module
sops.secrets.lnbits_admin_key = {
sopsFile = ./secrets/lnbits.yaml;
owner = "lamassu";
};
sops.secrets.lnbits_invoice_key = {
sopsFile = ./secrets/lnbits.yaml;
owner = "lamassu";
};
```
#configuration #nixos #secrets
---
## Monitoring & Alerts
### Health Checks
```typescript
// Health check endpoint
async function checkLNbitsHealth(): Promise<HealthStatus> {
try {
const balance = await lnbits.getBalance()
return {
healthy: true,
balance,
timestamp: new Date(),
}
} catch (error) {
return {
healthy: false,
error: error.message,
timestamp: new Date(),
}
}
}
```
### Balance Alerts
```typescript
const LOW_BALANCE_THRESHOLD = 100000 // 100k sats
async function checkBalanceAlerts() {
const balance = await lnbits.getBalance()
if (balance < LOW_BALANCE_THRESHOLD) {
await sendAlert({
level: 'warning',
message: `Low LNbits balance: ${balance} sats`,
action: 'Top up Lightning wallet',
})
}
}
```
### Prometheus Metrics
```typescript
// Expose metrics for Prometheus
import { Counter, Gauge } from 'prom-client'
const lnbitsBalance = new Gauge({
name: 'lnbits_wallet_balance_sats',
help: 'Current LNbits wallet balance in satoshis',
})
const lnbitsPayments = new Counter({
name: 'lnbits_payments_total',
help: 'Total LNbits payments',
labelNames: ['direction', 'status'],
})
// Update metrics
setInterval(async () => {
const balance = await lnbits.getBalance()
lnbitsBalance.set(balance)
}, 60000)
```
#monitoring #alerts #prometheus
---
## Error Handling
### Common Errors
| Error | Cause | Resolution |
|-------|-------|------------|
| `INSUFFICIENT_BALANCE` | Wallet balance too low | Top up wallet |
| `INVOICE_EXPIRED` | Invoice not paid in time | Create new invoice |
| `PAYMENT_FAILED` | Route not found | Check node connectivity |
| `RATE_LIMITED` | Too many requests | Implement backoff |
| `UNAUTHORIZED` | Invalid API key | Check key configuration |
### Retry Strategy
```typescript
import pRetry from 'p-retry'
async function payWithRetry(bolt11: string): Promise<PaymentResult> {
return pRetry(
async () => {
const result = await lnbits.payInvoice(bolt11)
if (!result.success && result.error?.includes('ROUTE')) {
throw new Error('Retryable: No route found')
}
return result
},
{
retries: 3,
minTimeout: 1000,
maxTimeout: 10000,
onFailedAttempt: (error) => {
console.log(`Payment attempt ${error.attemptNumber} failed`)
},
}
)
}
```
#errors #retry
---
## Related Documents
- [[membership-lightning-integration]] - Membership feature using LNbits
- [[modernization-plan]] - Overall modernization roadmap
- [[API Key Authentication]] - Server authentication

View file

@ -1,525 +0,0 @@
---
title: Lamassu Modernization Plan
created: 2026-01-22
updated: 2026-01-22
tags:
- architecture
- refactoring
- roadmap
- nix
status: draft
---
# Lamassu Modernization Plan
> [!abstract] Summary
> Aggressive refactoring plan to bring Lamassu Bitcoin ATM software to 2026 standards, focusing on **reproducibility**, **security**, and **open-source maintainability** with NixOS deployment.
## Quick Links
- [[#Architecture Overview]]
- [[#Technology Decisions]]
- [[#Migration Phases]]
- [[#Trade-offs]]
---
## Architecture Overview
```mermaid
graph TB
subgraph "NixOS Production Server"
deploy[deploy-rs / Colmena]
sops[sops-nix secrets]
subgraph "lamassu-server"
fastify[Fastify + tRPC + GraphQL]
otel[OpenTelemetry]
end
pg[(PostgreSQL + Drizzle)]
jaeger[Jaeger / Grafana]
end
subgraph "Admin UI"
vue_admin[Vue 3 + shadcn-vue]
tailwind[Tailwind CSS]
end
subgraph "lamassu-machine"
tauri[Tauri 2.x Rust Core]
vue_machine[Vue 3 + Pinia]
xstate[XState v5]
hal[Rust HAL napi-rs]
hw[Hardware Drivers]
end
vue_admin -->|tRPC| fastify
tauri -->|WebSocket| fastify
fastify --> pg
fastify --> otel
otel --> jaeger
hal --> hw
```
---
## Technology Decisions
### Runtime & Language
> [!decision] Full TypeScript + Node.js 22 LTS
> While Bun offers 3-4x performance, Node.js remains the backbone of enterprise applications. For mission-critical financial software, **reliability > raw performance**.
| Aspect | Current | Target |
|--------|---------|--------|
| Runtime | Node.js 22 | Node.js 22 LTS |
| Language | JS + partial TS | Full TypeScript (strict) |
| Modules | CommonJS + ESM mix | ESM only |
**Action Items:**
- [ ] Migrate all JavaScript to TypeScript
- [ ] Enable `strictNullChecks` and `noUncheckedIndexedAccess`
- [ ] Eliminate all CommonJS requires
#typescript #nodejs
---
### Backend Framework
> [!decision] Express → Fastify
> Fastify provides 70-80k req/s vs Express's 20-30k, with first-class TypeScript support and JSON Schema validation.
| Framework | Performance | TypeScript | Ecosystem |
|-----------|-------------|------------|-----------|
| Express | 20-30k req/s | Partial | Mature |
| **Fastify** | 70-80k req/s | First-class | Growing |
| Hono | Ultra-light | Good | Edge-focused |
**Why Fastify:**
- JSON Schema validation built-in
- HTTP/2 support
- Plugin architecture
- Better for long-running server processes
#backend #fastify
---
### API Layer
> [!decision] Hybrid: tRPC + GraphQL
> tRPC for Admin UI (type-safe monorepo), GraphQL for machine communication (stable contract).
```mermaid
graph LR
AdminUI -->|tRPC| Server
Machine -->|GraphQL| Server
```
**tRPC Benefits:**
- End-to-end type safety without codegen
- Faster iteration
- Smaller bundle
**Keep GraphQL for:**
- Machine API (stable contract)
- Potential non-TypeScript clients
- Consider Yoga over Apollo (lighter)
#api #trpc #graphql
---
### Database & ORM
> [!decision] Kysely → Drizzle ORM
> SQL-first, schema-as-code, zero binary dependencies (critical for NixOS reproducibility).
| ORM | Bundle | Cold Start | Migrations |
|-----|--------|------------|------------|
| Prisma | Heavy | Slow | Excellent |
| Kysely | Light | Fast | Basic |
| **Drizzle** | ~7kb | Fastest | Good |
**Why Drizzle:**
- SQL-like syntax (readable)
- Zero binary dependencies
- 90% reduction in cold starts
- TypeScript schema definitions
#database #drizzle #postgresql
---
### Frontend (Admin UI)
> [!decision] React → Vue 3
> Unify on Vue 3 across all UIs (admin + machine) for consistency and shared code.
| Aspect | Before (React) | After (Vue 3) |
|--------|---------------|---------------|
| Framework | React 18 | Vue 3 |
| UI Library | MUI (~300kb) | shadcn-vue (~15kb) |
| State | Zustand | Pinia |
| API | Apollo GraphQL | tRPC |
| Bundle | ~400kb | ~60kb |
**Benefits:**
- Single framework across all UIs
- Shared composables between admin and machine
- 70% bundle size reduction
- End-to-end type safety with tRPC
See [[admin-ui-modernization]] for detailed migration plan.
#frontend #vue #tailwind
---
### Machine State Management
> [!decision] Machina.js → XState v5
> Actor-based state management with visual editor and TypeScript inference.
**Current:** `lib/brain.js` (134KB monolith)
**XState Benefits:**
- Visual state machine editor (Stately.ai)
- TypeScript 5.0+ with excellent inference
- Actor model for complex flows
- Production-tested at scale
> [!warning] Learning Curve
> XState has a steep learning curve. The mental model differs significantly from Redux/Context.
#statemachine #xstate
---
### Machine UI Framework
> [!decision] Vanilla JS → Vue 3 + Tauri 2.x
> Vue 3 for UI consistency, Tauri for security-first kiosk shell.
**UI Layer: Vue 3**
| Aspect | Before | After |
|--------|--------|-------|
| Framework | Vanilla JS + jQuery | Vue 3 |
| State | Global variables | Pinia |
| Build | Babel 6 | Vite |
| File | 80KB monolith | Component-based |
**Shell: Tauri 2.x**
| Aspect | Electron | Tauri |
|--------|----------|-------|
| Bundle Size | ~100MB | **~2.5MB** |
| RAM Usage | 150-300MB | **30-50MB** |
| Startup | 1-2s | **<0.5s** |
| Security | Full Node access | **Explicit allowlist** |
**Benefits:**
- Same Vue 3 skills as admin UI
- Shared ui-shared package
- Tauri's Rust core for hardware drivers
- Security by default
See [[machine-ui-modernization]] for detailed migration plan.
#tauri #vue #kiosk #security
---
### Hardware Abstraction
> [!decision] JavaScript → Rust + napi-rs
> Memory safety at compile time for hardware drivers.
```
TypeScript Application
↓
napi-rs bindings
↓
Rust HAL Layer
↓
Hardware (bill validators, printers, etc.)
```
**Benefits:**
- No null pointer dereferences
- No buffer overflows
- Pre-built binaries for platforms
- Integrates with Tauri (both Rust)
#rust #napi #hardware
---
### Schema Validation
> [!decision] Yup → Zod
> Better TypeScript integration, larger ecosystem, tRPC compatibility.
| Library | Bundle | Ecosystem | tRPC |
|---------|--------|-----------|------|
| Yup | ~15kb | Mature | Manual |
| **Zod** | ~17kb | Large | Native |
| Valibot | ~1.4kb | Growing | Adapter |
**Use Valibot** for machine-side code where bundle size matters.
#validation #zod
---
### Observability
> [!decision] OpenTelemetry
> Vendor-neutral, unified traces/metrics/logs.
```typescript
import { NodeSDK } from '@opentelemetry/sdk-node'
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
```
**Self-hosted stack:**
- **Jaeger** → Distributed tracing
- **Prometheus + Grafana** → Metrics
- All deployable via NixOS modules
#observability #opentelemetry #monitoring
---
### Authentication
> [!decision] Passkey-First Authentication
> Resistant to phishing and credential theft.
**Current:** Client certs + Argon2 + SimpleWebAuthn
**Target:**
- Passkeys as primary auth (WebAuthn)
- Keep client certs for machine-to-server
- Upgrade to SimpleWebAuthn v10+
> [!warning] 2025 Context
> 4B credentials leaked in January 2025. Password-based auth is a liability.
#security #passkeys #webauthn
---
## NixOS Infrastructure
### Development Environment
> [!decision] devenv
> 100% reproducible development environments.
```nix
# devenv.nix
{ pkgs, ... }: {
languages.javascript = {
enable = true;
package = pkgs.nodejs_22;
pnpm.enable = true;
};
languages.typescript.enable = true;
languages.rust.enable = true;
services.postgres = {
enable = true;
initialDatabases = [{ name = "lamassu"; }];
};
pre-commit.hooks = {
prettier.enable = true;
eslint.enable = true;
};
}
```
**Benefits:**
- Single `devenv.nix` replaces Docker, brew, apt
- DevContainer generation for VS Code
- Built-in PostgreSQL service
- Pre-commit hooks integration
#nix #devenv #reproducibility
---
### Secrets Management
> [!decision] sops-nix
> Atomic, declarative secret provisioning.
**Features:**
- Supports age, GPG, AWS KMS, HashiCorp Vault
- Works with existing SSH keys
- Version-control friendly (encrypted in git)
- Compatible with all NixOS deployment tools
```nix
sops.secrets.database_password = {
sopsFile = ./secrets/db.yaml;
owner = "lamassu";
};
```
#secrets #sops #security
---
### Production Deployment
> [!decision] deploy-rs
> Automatic rollback on failure - critical for ATM servers.
| Tool | Rollback | Secrets | Parallel |
|------|----------|---------|----------|
| **deploy-rs** | Automatic | External | Yes |
| Colmena | Manual | Built-in | Yes |
**Why deploy-rs:**
- Connects after activation to confirm availability
- Auto-rollback if machine becomes unreachable
- Critical for network config changes on remote ATMs
#deployment #deploy-rs #nixos
---
### Node.js Packaging
> [!tip] dream2nix
> Auto-generates Nix derivations from `package-lock.json`.
```nix
{
inputs.dream2nix.url = "github:nix-community/dream2nix";
outputs = { dream2nix, ... }:
dream2nix.lib.makeFlakeOutputs {
source = ./.;
};
}
```
#nix #packaging
---
## Testing Strategy
### Unit/Integration
> [!check] Keep Vitest
> Already in use, works well with TypeScript.
### E2E Testing
> [!decision] Add Playwright
> TypeScript-first, auto-wait eliminates flaky tests.
**Best Practices:**
- Use accessible locators (`getByRole`, `getByLabel`)
- Test isolation (each test independent)
- Run `tsc --noEmit` in CI
```typescript
test('user can complete transaction', async ({ page }) => {
await page.getByRole('button', { name: 'Start' }).click()
await expect(page.getByText('Insert bill')).toBeVisible()
})
```
#testing #playwright #vitest
---
## Migration Phases
### Phase 1: Foundation
> [!todo] High Impact, Lower Risk
- [ ] Full TypeScript migration
- [ ] devenv for development environment
- [ ] Drizzle ORM migration
- [ ] OpenTelemetry instrumentation
- [ ] Nix flake for builds
### Phase 2: Backend Modernization
- [ ] Express → Fastify migration
- [ ] Add tRPC for admin API
- [ ] Zod schema validation
- [ ] Playwright E2E tests
### Phase 3: Machine Modernization
> [!warning] Higher Risk - Requires extensive testing
- [ ] XState v5 for state machine
- [ ] Tauri migration
- [ ] Rust HAL for hardware drivers
### Phase 4: Deployment
- [ ] NixOS module for lamassu-server
- [ ] sops-nix secrets management
- [ ] deploy-rs for production deployments
---
## Trade-offs
| Decision | We Get | We Lose |
|----------|--------|---------|
| Node.js over Bun | Stability, ecosystem | Raw performance |
| Fastify over Hono | Mature plugins | Minimal bundle |
| Drizzle over Prisma | Bundle size, speed | DX features |
| Vue 3 over React | Unified UI, smaller bundle | React ecosystem |
| Tauri over Electron | Security, efficiency | Ecosystem maturity |
| XState over simple FSM | Visualization, debugging | Simplicity |
---
## References
### Backend
- [Fastify vs Express 2025](https://medium.com/codetodeploy/express-or-fastify-in-2025-whats-the-right-node-js-framework-for-you-6ea247141a86)
- [tRPC vs GraphQL](https://betterstack.com/community/guides/scaling-nodejs/trpc-vs-graphql/)
- [Drizzle vs Prisma vs Kysely](https://levelup.gitconnected.com/the-2025-typescript-orm-battle-prisma-vs-drizzle-vs-kysely-007ffdfded67)
### Machine
- [Tauri vs Electron 2025](https://www.dolthub.com/blog/2025-11-13-electron-vs-tauri/)
- [XState v5](https://stately.ai/blog/2023-12-01-xstate-v5)
- [napi-rs Guide](https://blog.logrocket.com/building-nodejs-modules-rust-napi-rs/)
### NixOS
- [devenv](https://devenv.sh/)
- [sops-nix](https://github.com/Mic92/sops-nix)
- [deploy-rs](https://github.com/serokell/deploy-rs)
### Security
- [Passkeys Guide](https://www.passkeys.com/guide)
- [OpenTelemetry Node.js](https://opentelemetry.io/docs/languages/js/)
---
## Related Notes
- [[architecture-review]] - **KYC-free Lightning-first architecture review**
- [[nostr-native-architecture]] - **Nostr as infrastructure backbone**
- [[CLAUDE]] - Claude Code guidance
- [[admin-ui-modernization]] - Vue 3 migration for admin dashboard
- [[machine-ui-modernization]] - Vue 3 migration for kiosk UI
- [[hardware-recommendations]] - Hardware component recommendations
- [[membership-lightning-integration]] - Membership & LNbits feature
- [[lnbits-integration]] - Lightning backend integration
- [[Architecture Decision Records]] - ADRs for each decision
- [[NixOS Configuration]] - Production NixOS setup

View file

@ -1,828 +0,0 @@
---
title: Nostr-Native ATM Architecture
created: 2026-01-22
updated: 2026-01-22
tags:
- architecture
- nostr
- lightning-pub
- kyc-free
- decentralized
status: active
priority: critical
---
# Nostr-Native ATM Architecture
> [!abstract] Summary
> A radical rethinking of ATM infrastructure where **Nostr becomes the backbone** for identity, communication, and payments. Replaces traditional server infrastructure with Lightning.Pub and a private Nostr relay, eliminating KYC vectors like phone numbers while enabling a truly decentralized, censorship-resistant system.
## Quick Links
- [[#Vision: Nostr as Infrastructure]]
- [[#Lightning.Pub as Core Server]]
- [[#Private Relay Architecture]]
- [[#Machine Identity]]
- [[#Replacing SMS with Nostr]]
- [[#Event Schema]]
---
## Vision: Nostr as Infrastructure
### The Problem with Traditional ATM Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ TRADITIONAL LAMASSU ARCHITECTURE │
├─────────────────────────────────────────────────────────────┤
│ │
│ ATM ──HTTPS/WSS──► Server ──► PostgreSQL │
│ │ │
│ ├──► SMS Gateway (Twilio) │
│ ├──► Email Service │
│ ├──► KYC Provider │
│ └──► Lightning Node │
│ │
│ Problems: │
│ • Phone numbers = KYC vector │
│ • Complex server infrastructure │
│ • DNS, SSL, port forwarding required │
│ • Single point of failure │
│ • Centralized command/control │
│ │
└─────────────────────────────────────────────────────────────┘
```
### The Nostr-Native Solution
```
┌─────────────────────────────────────────────────────────────┐
│ NOSTR-NATIVE ATM ARCHITECTURE │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ ATM 1 │ │ ATM 2 │ │ ATM N │ │
│ │ npub_1 │ │ npub_2 │ │ npub_n │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ └────────────┼────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────┐ │
│ │ Private Nostr Relay │◄─── NIP-42 Auth │
│ │ (strfry / rnostr) │ Whitelist: ATMs + │
│ └───────────┬────────────┘ Operators only │
│ │ │
│ ┌───────────┼───────────┐ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │Lightning │ │ Operator │ │ Public │ │
│ │ Pub │ │Dashboard │ │ Relays │ │
│ │(LND wrap)│ │ (Vue 3) │ │(fallback)│ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ Benefits: │
│ • No phone numbers (Nostr DMs instead) │
│ • Zero server config (no DNS/SSL/ports) │
│ • Decentralized communication │
│ • Cryptographic machine identity │
│ • Censorship-resistant │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## Lightning.Pub as Core Server
> [!decision] Lightning.Pub Replaces lamassu-server
> A Nostr-native account system that wraps LND and eliminates traditional server complexity.
### Why Lightning.Pub?
| Aspect | Traditional Server | Lightning.Pub |
|--------|-------------------|---------------|
| Network config | DNS, SSL, ports, firewall | Zero (uses Nostr relays) |
| Deployment | Complex | One-line install |
| Communication | HTTPS/WebSocket | Nostr events (NIP-44 encrypted) |
| Account system | Custom implementation | Built-in sublayers |
| CLINK support | Must implement | Native |
| Lightning | Separate integration | Wraps LND directly |
### One-Line Deployment
```bash
# Linux
wget -qO- https://deploy.lightning.pub | bash
# macOS
curl -fsSL https://deploy.lightning.pub | bash
# Everything confined to ~/lightning_pub/
# No sudo, no root, no system changes
```
### Architecture
```
Lightning.Pub
├── LND (Lightning Network Daemon)
│ └── Neutrino (SPV Bitcoin)
├── Account System
│ ├── Application Pools (operator level)
│ └── User Accounts (ATM wallets)
├── CLINK Native
│ ├── noffer (static payment codes)
│ └── ndebit (authorized payments)
├── Nostr Communication
│ └── NIP-44 encrypted events
└── Optional: LNURL Bridge (legacy support)
```
### What Lightning.Pub Gives Us
1. **No Port Forwarding** - Nostr relays handle all communication
2. **Multi-User Accounts** - Each ATM gets its own account
3. **CLINK Native** - Static payment codes work out of the box
4. **Liquidity Management** - Auto-quotes from LSPs (Zeus, Voltage, Flashsats)
5. **Watchdog Security** - Monitors for drainage attacks
6. **Production Tested** - Years of real-world deployment
### Configuration for ATM Fleet
```bash
# ~/lightning_pub/.env
# Private relay for machine communication
NOSTR_RELAYS="wss://relay.youratm.company wss://nos.lol"
# Disable bootstrap peering for full sovereignty
DISABLE_LIQUIDITY_PROVIDER=true
# Custom LNURL domain (optional, for legacy wallets)
SERVICE_URL=https://ln.youratm.company
```
---
## Private Relay Architecture
> [!decision] Run a Restricted Nostr Relay
> NIP-42 authenticated relay that only accepts events from known machines and operators.
### Why a Private Relay?
| Concern | Public Relay | Private Relay |
|---------|--------------|---------------|
| Who can read | Anyone | Whitelisted npubs only |
| Who can write | Anyone | Whitelisted npubs only |
| Machine commands | Exposed | Encrypted, restricted |
| Fleet data | Public | Private |
| Censorship | Relay can censor | You control |
### Relay Options
| Relay | Language | NIP-42 | Performance | Notes |
|-------|----------|--------|-------------|-------|
| **strfry** | C++ | Yes | Excellent | Plugin system, negentropy sync |
| **rnostr** | Rust | Yes | Excellent | LMDB storage, inspired by strfry |
| **nostr-rs-relay** | Rust | Yes | Good | SQLite/PostgreSQL |
### NIP-42 Authentication
```
┌─────────────────────────────────────────────────────────────┐
│ NIP-42 AUTH FLOW │
├─────────────────────────────────────────────────────────────┤
│ │
│ ATM connects to relay │
│ │ │
│ ▼ │
│ Relay sends AUTH challenge │
│ ["AUTH", "<random-challenge>"] │
│ │ │
│ ▼ │
│ ATM signs challenge with its nsec │
│ { │
│ "kind": 22242, │
│ "tags": [ │
│ ["relay", "wss://relay.youratm.company"], │
│ ["challenge", "<random-challenge>"] │
│ ], │
│ "content": "", │
│ "sig": "<signature>" │
│ } │
│ │ │
│ ▼ │
│ Relay verifies npub is in whitelist │
│ │ │
│ ├── Yes → Connection allowed │
│ └── No → Connection rejected │
│ │
└─────────────────────────────────────────────────────────────┘
```
### strfry Configuration
```toml
# strfry.conf
[relay]
bind = "0.0.0.0"
port = 7777
realIpHeader = "X-Forwarded-For"
[relay.info]
name = "ATM Fleet Relay"
description = "Private relay for ATM communication"
contact = "operator@youratm.company"
# Require NIP-42 authentication
authRequired = true
[relay.writePolicy]
plugin = "./plugins/whitelist.js"
[relay.negentropy]
enabled = true
```
### Whitelist Plugin (noteguard style)
```javascript
// plugins/whitelist.js
const ALLOWED_PUBKEYS = new Set([
'npub1_atm_001...', // ATM 1
'npub1_atm_002...', // ATM 2
'npub1_operator...', // Operator
])
export function writePolicy(event, sourceInfo) {
if (!sourceInfo.authedPubkey) {
return { action: 'reject', message: 'auth-required: authenticate first' }
}
if (!ALLOWED_PUBKEYS.has(sourceInfo.authedPubkey)) {
return { action: 'reject', message: 'restricted: not authorized' }
}
return { action: 'accept' }
}
```
### NixOS Module for Relay
```nix
# relay.nix
{ config, pkgs, ... }:
{
services.strfry = {
enable = true;
settings = {
relay = {
bind = "127.0.0.1";
port = 7777;
info = {
name = "ATM Fleet Relay";
description = "Private NIP-42 authenticated relay";
};
authRequired = true;
};
};
};
# Nginx reverse proxy with SSL
services.nginx.virtualHosts."relay.youratm.company" = {
enableACME = true;
forceSSL = true;
locations."/" = {
proxyPass = "http://127.0.0.1:7777";
proxyWebsockets = true;
};
};
}
```
---
## Machine Identity
> [!decision] Each ATM Has a Nostr Keypair
> Hardware-bound identity that replaces certificates and enables cryptographic authentication.
### Identity Model
```
┌─────────────────────────────────────────────────────────────┐
│ MACHINE IDENTITY │
├─────────────────────────────────────────────────────────────┤
│ │
│ Traditional: │
│ • Client certificate (complex PKI) │
│ • API keys (can be leaked) │
│ • IP-based auth (unreliable) │
│ │
│ Nostr-Native: │
│ • Machine has nsec (private key) │
│ • npub is machine identity │
│ • All events signed by machine │
│ • Operator whitelist controls access │
│ • No certificate authority needed │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Key Generation & Storage
```typescript
// Machine first boot - generate identity
import { generateSecretKey, getPublicKey } from 'nostr-tools'
import { writeFileSync } from 'fs'
function initMachineIdentity() {
const nsec = generateSecretKey()
const npub = getPublicKey(nsec)
// Store in secure location (TPM, encrypted file, etc.)
writeFileSync('/etc/lamassu/machine.nsec', nsec, { mode: 0o600 })
console.log(`Machine identity: ${npub}`)
console.log('Add this npub to operator whitelist')
return { nsec, npub }
}
```
### Secure Key Storage Options
| Method | Security | Complexity | Best For |
|--------|----------|------------|----------|
| Encrypted file | Medium | Low | Development |
| TPM 2.0 | High | Medium | Production |
| Secure enclave | Highest | High | High-security |
| HSM | Highest | Highest | Enterprise |
### Tauri Integration
```rust
// src-tauri/src/identity.rs
use nostr_sdk::prelude::*;
use std::fs;
pub struct MachineIdentity {
keys: Keys,
}
impl MachineIdentity {
pub fn load_or_create() -> Result<Self, Error> {
let nsec_path = "/etc/lamassu/machine.nsec";
let keys = if fs::metadata(nsec_path).is_ok() {
// Load existing
let nsec = fs::read_to_string(nsec_path)?;
Keys::parse(&nsec)?
} else {
// Generate new
let keys = Keys::generate();
fs::write(nsec_path, keys.secret_key()?.to_bech32()?)?;
keys
};
Ok(Self { keys })
}
pub fn npub(&self) -> String {
self.keys.public_key().to_bech32().unwrap()
}
pub fn sign_event(&self, event: UnsignedEvent) -> Result<Event, Error> {
event.sign(&self.keys)
}
}
```
---
## Replacing SMS with Nostr
> [!decision] Nostr DMs Replace Phone-Based Messaging
> No phone numbers = no KYC vector. Users provide npub for receipts.
### What SMS Was Used For (Old Lamassu)
| Use Case | Old Method | New Method |
|----------|------------|------------|
| Transaction receipt | SMS to phone | NIP-17 DM to npub |
| Verification code | SMS OTP | Not needed (no KYC) |
| Operator alerts | SMS/Email | Nostr events to operator npub |
| Customer notifications | SMS | Optional NIP-17 DM |
### NIP-17 Private Direct Messages
```typescript
// Send encrypted receipt to user
import { nip44, nip59 } from 'nostr-tools'
async function sendReceipt(
userNpub: string,
receipt: TransactionReceipt
) {
const content = JSON.stringify({
type: 'transaction_receipt',
txid: receipt.txid,
amount: receipt.amountSats,
timestamp: receipt.timestamp,
atmId: receipt.atmNpub,
})
// NIP-17: Encrypted gift-wrapped message
const sealedEvent = await nip59.seal(
machineKeys,
userNpub,
{
kind: 14, // Direct message
content,
tags: [],
}
)
// Publish to relay
await relay.publish(sealedEvent)
}
```
### User Flow (Optional Receipt)
```
┌─────────────────────────────────────────────────────────────┐
│ OPTIONAL RECEIPT FLOW │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. User completes transaction │
│ │
│ 2. ATM asks: "Want a receipt?" │
│ [No Thanks] [Yes, via Nostr] │
│ │
│ 3. If yes, user provides npub: │
│ • Scan NFC card with npub │
│ • Scan QR code of npub │
│ • Type npub manually │
│ │
│ 4. ATM sends NIP-17 encrypted DM │
│ • Only user can decrypt │
│ • Contains: amount, txid, timestamp │
│ • No phone number collected! │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Operator Alerts via Nostr
```typescript
// Machine publishes alert event
async function sendOperatorAlert(
alertType: 'low_cash' | 'error' | 'offline',
details: object
) {
const event = {
kind: 30078, // Replaceable application-specific
pubkey: machineNpub,
content: nip44.encrypt(
machineNsec,
operatorNpub,
JSON.stringify({
type: alertType,
machineId: machineNpub,
timestamp: Date.now(),
details,
})
),
tags: [
['d', `alert:${machineNpub}`], // Replaceable identifier
['p', operatorNpub],
],
}
await relay.publish(signEvent(event, machineNsec))
}
```
---
## Event Schema
> [!tip] Custom Event Kinds for ATM Operations
> Define application-specific events for machine status, transactions, and commands.
### Event Kinds
| Kind | Type | Description |
|------|------|-------------|
| 21001 | CLINK | Offer Request/Response |
| 21002 | CLINK | Debit Request/Response |
| 21003 | CLINK | Management Delegation |
| 30078 | Replaceable | Machine Status |
| 30079 | Replaceable | Cash Levels |
| 14 | NIP-17 | Encrypted Receipt DM |
| 22242 | Ephemeral | NIP-42 Auth |
### Machine Status Event (Kind 30078)
```typescript
interface MachineStatusEvent {
kind: 30078
pubkey: string // Machine npub
content: string // NIP-44 encrypted JSON
tags: [
['d', 'status'], // Replaceable identifier
['p', string], // Operator npub
]
}
// Decrypted content:
interface MachineStatus {
online: boolean
lastTransaction: number // timestamp
cashLevels: {
validator: number // bills in validator
dispenser: CassetteLevel[]
}
errors: string[]
version: string
}
```
### Transaction Record Event
```typescript
interface TransactionEvent {
kind: 30079
pubkey: string // Machine npub
content: string // NIP-44 encrypted
tags: [
['d', `tx:${txid}`],
['p', string], // Operator npub
]
}
// Decrypted content:
interface TransactionRecord {
txid: string
type: 'cash_in' | 'cash_out'
amountFiat: number
amountSats: number
fee: number
timestamp: number
paymentMethod: 'lnurl_withdraw' | 'clink_offer' | 'invoice' | 'cashu'
// No user identity stored!
}
```
### Operator Command Event
```typescript
interface CommandEvent {
kind: 21003 // CLINK manage
pubkey: string // Operator npub
content: string // NIP-44 encrypted
tags: [
['p', string], // Target machine npub
]
}
// Decrypted content:
interface OperatorCommand {
command: 'restart' | 'update' | 'disable' | 'enable' | 'set_limits'
params?: object
timestamp: number
signature: string // Operator signs command
}
```
---
## Full Stack Architecture
### Component Diagram
```
┌─────────────────────────────────────────────────────────────────────┐
│ NOSTR-NATIVE ATM STACK │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ATM MACHINE │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ │
│ │ │ Vue 3 UI │ │ XState v5 │ │ Rust HAL │ │ │
│ │ │ (Tauri) │ │ (State) │ │ (Bill/Dispense) │ │ │
│ │ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │ │
│ │ │ │ │ │ │
│ │ ┌──────┴────────────────┴─────────────────────┴──────────┐ │ │
│ │ │ Nostr Client │ │ │
│ │ │ • Machine nsec/npub identity │ │ │
│ │ │ • CLINK SDK for payments │ │ │
│ │ │ • NIP-44 encryption │ │ │
│ │ │ • Event publishing/subscription │ │ │
│ │ └────────────────────────┬───────────────────────────────┘ │ │
│ └───────────────────────────┼──────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ PRIVATE NOSTR RELAY │ │
│ │ • strfry / rnostr │ │
│ │ • NIP-42 authentication required │ │
│ │ • Whitelist: ATM npubs + Operator npubs │ │
│ │ • Negentropy sync for offline reconciliation │ │
│ └────────────────────────────┬──────────────────────────────────┘ │
│ │ │
│ ┌────────────────────┼────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────────┐ │
│ │ Lightning.Pub │ │ Operator │ │ Public Relays │ │
│ │ │ │ Dashboard │ │ (Fallback) │ │
│ │ • LND node │ │ │ │ │ │
│ │ • Accounts │ │ • Vue 3 app │ │ • nos.lol │ │
│ │ • CLINK native│ │ • Subscribe │ │ • relay.damus.io │ │
│ │ • Liquidity │ │ to events │ │ • For CLINK with │ │
│ └───────────────┘ │ • Send cmds │ │ external users │ │
│ └───────────────┘ └───────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### Data Flow: Cash-In Transaction
```mermaid
sequenceDiagram
participant User
participant ATM
participant Relay as Private Relay
participant LPub as Lightning.Pub
participant LN as Lightning Network
User->>ATM: Insert $50 cash
ATM->>ATM: Validate bills (HAL)
ATM->>Relay: Publish status event
ATM->>ATM: Generate CLINK offer (variable price)
ATM->>ATM: Display QR code
User->>User: Scan with ShockWallet
User->>Relay: CLINK offer request (Kind 21001)
Relay->>ATM: Forward request
ATM->>LPub: Request invoice (amount calculated)
LPub->>ATM: BOLT11 invoice
ATM->>Relay: CLINK response with invoice
Relay->>User: Forward response
User->>LN: Pay invoice
LN->>LPub: Payment received
LPub->>Relay: Payment confirmation event
Relay->>ATM: Forward confirmation
ATM->>ATM: Transaction complete
ATM->>Relay: Publish transaction record
opt User provided npub
ATM->>Relay: Send NIP-17 receipt DM
end
```
---
## Migration Path
### Phase 1: Add Nostr Layer
```
Existing Lamassu ──► Add Nostr client
Add private relay
Keep existing server (parallel)
```
### Phase 2: Lightning.Pub Integration
```
Add Lightning.Pub ──► Route payments through LPub
CLINK offers enabled
Account system active
```
### Phase 3: Full Migration
```
Remove old server ──► Nostr-only communication
NIP-17 receipts (no SMS)
Private relay primary
```
### Phase 4: Optional Enhancements
```
Advanced features ──► Cashu ecash integration
Fedimint support
Multi-relay redundancy
```
---
## Security Considerations
### Threat Model
| Threat | Mitigation |
|--------|------------|
| Relay compromise | NIP-44 encryption (relay can't read) |
| Key theft | TPM/HSM storage, key rotation |
| Replay attacks | Timestamps, nonces in events |
| Rogue operator | Multi-sig commands (future) |
| Network sniffing | WebSocket over TLS, NIP-44 |
### Key Rotation
```typescript
// Periodic key rotation for machines
async function rotateMachineKey(oldNsec: string) {
const newKeys = generateKeys()
// Publish key rotation event (signed by old key)
const rotationEvent = {
kind: 30078,
content: nip44.encrypt(oldNsec, operatorNpub, JSON.stringify({
type: 'key_rotation',
oldPubkey: getPublicKey(oldNsec),
newPubkey: newKeys.npub,
timestamp: Date.now(),
})),
tags: [
['d', 'key_rotation'],
['p', operatorNpub],
],
}
await relay.publish(signEvent(rotationEvent, oldNsec))
// Operator must update whitelist
// Then switch to new key
}
```
---
## Comparison: Old vs Nostr-Native
| Aspect | Old Lamassu | Nostr-Native |
|--------|-------------|--------------|
| Communication | HTTPS/WebSocket | Nostr events |
| Authentication | Client certs | NIP-42 + npub whitelist |
| Encryption | TLS | NIP-44 (content-level) |
| Identity | PKI certificates | Nostr keypairs |
| Receipts | SMS (phone = KYC) | NIP-17 DMs (npub) |
| Alerts | Email/SMS | Nostr events |
| Server config | DNS, SSL, ports | Zero config |
| Deployment | Complex | One-line |
| Censorship | Server can be seized | Relay-agnostic |
| Privacy | Phone numbers leaked | Pseudonymous npubs |
---
## Open Questions
1. **Relay redundancy** - Should machines connect to multiple relays?
2. **Offline operation** - How long can machine operate without relay?
3. **Key escrow** - How to recover if machine key is lost?
4. **Multi-operator** - Can multiple operators share a fleet?
5. **Cashu over Nostr** - Use Nostr for ecash token delivery?
---
## Related Notes
- [[architecture-review]] - Overall KYC-free architecture
- [[lnbits-integration]] - LNbits as alternative backend
- [[hardware-recommendations]] - Hardware choices
- [[machine-ui-modernization]] - Vue 3 UI migration
---
## References
### Lightning.Pub
- [Lightning.Pub GitHub](https://github.com/shocknet/Lightning.Pub)
- [ShockWallet](https://github.com/shocknet/wallet2)
- [CLINK Protocol](https://github.com/shocknet/CLINK)
### Nostr Relays
- [strfry](https://github.com/hoytech/strfry)
- [rnostr](https://github.com/rnostr/rnostr)
- [nostr-rs-relay](https://sr.ht/~gheartsfield/nostr-rs-relay/)
- [noteguard](https://github.com/damus-io/noteguard) - strfry plugin system
### NIPs
- [NIP-42: Authentication](https://github.com/nostr-protocol/nips/blob/master/42.md)
- [NIP-44: Versioned Encryption](https://github.com/paulmillr/nip44)
- [NIP-17: Private Direct Messages](https://nips.nostr.com/17)
- [NIP-59: Gift Wraps](https://github.com/nostr-protocol/nips/blob/master/59.md)

@ -1 +0,0 @@
Subproject commit c15dbbcae8902d99e056b9397bd46cbb640edce1

@ -1 +0,0 @@
Subproject commit 8c83ad8bdbd49621a20c01d725b1ce0c8eee813e

View file

@ -1,67 +0,0 @@
# Dependencies
node_modules/
.pnpm-store/
# Build outputs
dist/
.next/
.nuxt/
.output/
target/
*.node
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Environment
.env
.env.*
!.env.example
# Secrets
*.nsec
*.pem
*.key
.secrets.baseline
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
# Testing
coverage/
.nyc_output/
# Caches
.turbo/
.cache/
.parcel-cache/
.eslintcache
*.tsbuildinfo
# OS
.DS_Store
Thumbs.db
# Electron
apps/machine/dist-electron/
apps/machine/release/
# devenv
.devenv/
.direnv/
.pre-commit-config.yaml
# Docker
docker/**/data/
# Temporary
tmp/
temp/
*.tmp
*.timestamp-*.mjs

View file

@ -1,308 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code when working with the Lamassu Next codebase.
## Project Overview
**Lamassu Next** is a Nostr-native Lightning ATM system. Key principles:
- **KYC-Free**: No identity collection, no compliance theater
- **Lightning-Native**: Security encapsulated in Lightning protocol
- **Nostr as Infrastructure**: Relay for communication, keypairs for identity
- **Open Source First**: Every component auditable and forkable
## Architecture
```
lamassu-next/
├── apps/
│ ├── machine/ # Electron + Vue 3 ATM kiosk application ✅
│ ├── dashboard/ # Vue 3 operator dashboard (planned)
│ └── relay/ # strfry relay configuration (planned)
├── packages/
│ ├── hal/ # TypeScript Hardware Abstraction Layer ✅
│ ├── nostr-client/ # Nostr client library ✅
│ ├── clink/ # CLINK protocol implementation ✅
│ ├── state-machine/ # XState v5 ATM state machine ✅
│ ├── lightning/ # Lightning.Pub RPC client ✅
│ ├── cashu/ # Cashu ecash (placeholder)
│ └── ui-shared/ # Shared Vue components (placeholder)
└── docker/ # Development infrastructure ✅
```
## Implementation Status
### Completed Packages
| Package | Description | Tests |
| ------------------------ | ------------------------------------------------------ | ----- |
| `@lamassu/nostr-client` | Nostr relay client with NIP-42 auth, NIP-44 encryption | 13 |
| `@lamassu/clink` | CLINK protocol (kinds 21001-21003), noffer encoding | 7 |
| `@lamassu/lightning` | Lightning.Pub RPC client (kind 21000) | 10 |
| `@lamassu/state-machine` | XState v5 ATM state machine (idle, cashIn, cashOut) | 14 |
| `@lamassu/hal` | Hardware drivers (ID003 validator, F56 dispenser) | - |
### Placeholder Packages
| Package | Description |
| -------------------- | --------------------------------- |
| `@lamassu/cashu` | Cashu ecash for offline operation |
| `@lamassu/ui-shared` | Shared Vue 3 components |
### Completed Applications
- **apps/machine** - Electron ATM kiosk with Vue 3 UI (HAL integrated, ready for hardware testing)
### Planned Components
- **apps/dashboard** - Operator dashboard for fleet management
### Critical Documentation
- **`packages/lightning/TROUBLESHOOTING.md`** - Lightning.Pub integration gotchas. Read this BEFORE debugging payment issues. Contains solutions to 9 non-obvious issues that took 5+ hours to diagnose.
## Commands
```bash
# Enter development environment
devenv shell
# Start development
pnpm dev
# Build all packages
pnpm build
# Run tests
pnpm test
# Infrastructure management
infra-up # Start all Docker services
infra-down # Stop all Docker services
infra-status # Show service status
infra-logs # Follow service logs
# Bitcoin/Lightning (regtest)
btccli # Bitcoin CLI
lncli # LND CLI (Lightning.Pub's node)
lncli-alice # LND CLI (Alice's node for testing payments)
mine-blocks # Mine regtest blocks (default: 1)
setup-channel # Setup channel between Alice and LND
alice-pay # Pay invoice from Alice's node
relay-test # Test Nostr relay connection
# Testing (E2E)
test-setup # Validate test environment (services, channels, payments)
test-payment # Quick e2e payment test (ATM → customer)
fund-atm # Fund ATM account (default: 100k sats)
alice-invoice # Create invoice on Alice's node
node-info # Show node pubkeys and channel info
```
## Development Infrastructure
The `docker/` directory contains a complete development environment:
| Service | Container | Port(s) | Description |
| ------------- | --------------------- | ----------- | ------------------------------------- |
| strfry | lamassu-relay | 7777 | Private Nostr relay |
| bitcoind | lamassu-bitcoind | 18443 | Bitcoin Core (regtest) |
| LND | lamassu-lnd | 10009, 8080 | Lightning node (Lightning.Pub's node) |
| LND Alice | lamassu-lnd-alice | 10010, 8081 | Second LND for payment testing |
| Lightning.Pub | lamassu-lightning-pub | 1776 | Nostr-native account system |
| PostgreSQL | lamassu-postgres | 5432 | Database for server-side state |
### Quick Start
```bash
devenv shell # Enter dev environment
infra-up # Start all services (30-60s first run)
mine-blocks 101 # Fund the regtest wallet
setup-channel # Open channel between Alice and LND
```
### Testing Payments
The development setup includes two LND nodes to enable proper payment testing:
1. **LND** (`lamassu-lnd`) - Used by Lightning.Pub to create invoices
2. **Alice** (`lamassu-lnd-alice`) - Used to pay invoices (simulates external payers)
```bash
# Get Lightning.Pub admin token
curl -X POST "http://localhost:1776/api/admin/app/auth" \
-H "Authorization: Bearer lamassu-dev-admin-token" \
-d '{"name": "wallet"}'
# Create user and invoice
curl -X POST "http://localhost:1776/api/app/user/add" -H "Authorization: Bearer $APP_TOKEN" \
-d '{"identifier": "test-user", "balance": 0}'
curl -X POST "http://localhost:1776/api/app/user/add/invoice" -H "Authorization: Bearer $APP_TOKEN" \
-d '{"receiver_identifier": "test-user", "payer_identifier": "external", "http_callback_url": "", "invoice_req": {"amountSats": 1000, "memo": "Test"}}'
# Pay from Alice
alice-pay <invoice>
```
### MCP Tools Available
Claude has access to these MCP servers for development:
| MCP Server | Purpose |
| ------------ | ----------------------------------------- |
| docker-mcp | Container management (logs, status, etc.) |
| nostr-mcp | Nostr operations (post notes, profiles) |
| postgres-mcp | Database queries and schema inspection |
| mcp-nixos | NixOS/Nix package queries |
| forgejo-mcp | Git operations on Forgejo |
Use these to interact with infrastructure directly during development.
## Key Technologies
| Component | Technology | Notes |
| ------------- | -------------- | --------------------------------------- |
| Runtime | Node.js 22 LTS | Strict TypeScript, ESM |
| ATM Shell | Electron | Node.js main process, Vue 3 renderer |
| State Machine | XState v5 | Actor model, service injection |
| Hardware | TypeScript | ID003, F56 drivers from lamassu-machine |
| Messaging | Nostr | NIP-01, NIP-42, NIP-44 |
| Payments | CLINK + RPC | Kind 21000 (RPC), 21001-21003 (CLINK) |
| Backend | Lightning.Pub | Nostr-native account system |
## Custom Skills
The following skills are available for development assistance:
### `/security` - Security Review
Audit code for Bitcoin/Lightning/ATM-specific vulnerabilities.
```
/security packages/lightning/src/
/security --staged
```
### `/nostr-check` - Nostr Conformity
Validate NIP compliance and Nostr protocol implementation.
```
/nostr-check packages/nostr-client/src/events.ts --nips NIP-01,NIP-44
```
### `/lightning-check` - Lightning.Pub Conformity
Validate CLINK protocol and Lightning.Pub integration.
```
/lightning-check packages/clink/src/ --clink
```
### `/test` - Testing Agent
Run tests, generate test cases, validate transaction flows.
```
/test coverage packages/state-machine/
/test flow cash-out
/test generate packages/lightning/src/client.ts
```
### `/docs` - Documentation Agent
Keep documentation synchronized with code.
```
/docs sync packages/clink/
/docs api packages/nostr-client/src/
```
### `/hal-check` - HAL Validation
Validate Rust HAL drivers against lamassu-machine implementations.
```
/hal-check port id003
/hal-check safety packages/hal/src/dispensers/
```
## Code Style
### TypeScript
- ESM only (`import`/`export`)
- Strict mode with `strictNullChecks` and `noUncheckedIndexedAccess`
- Zod for runtime validation
- No `any` types
### Rust (HAL)
- Stable toolchain
- `#![deny(unsafe_code)]` unless justified
- Error handling with `thiserror`
- Async with `tokio`
### Formatting
- Prettier for TypeScript (2 spaces, no semicolons, single quotes)
- rustfmt for Rust
- Pre-commit hooks enforce formatting
## Hardware Drivers
Drivers are ported from `lamassu-machine/lib/`:
| Category | Drivers |
| ---------- | ------------------------------------------------------------ |
| Validators | id003, ccnet, cashflow_sc, bnr_advance, genmega, hcm2, gsr50 |
| Dispensers | puloon, f56, genmega, hcm2, gsr50 |
| Printers | nippon, zebra, genmega |
When porting:
1. Read JS driver thoroughly
2. Document protocol from JS code
3. Implement Rust version
4. Test against same hardware
5. Use `/hal-check port <driver>` to validate
## Nostr Event Kinds
| Kind | Description |
| ----- | ----------------------------------------------- |
| 21000 | Lightning.Pub RPC (generic request/response) |
| 21001 | CLINK Offer (invoice request/response) |
| 21002 | CLINK Debit (payment authorization) |
| 21003 | CLINK Manage (offer management) |
| 30078 | Service Beacon (replaceable, service discovery) |
| 30079 | Transaction Record (replaceable) |
## Security Priorities
1. **Private keys** - Never log nsec, protect with 0600 permissions
2. **Payments** - Validate invoices, verify preimages, prevent double-pay
3. **Hardware** - Validate dispense amounts, handle errors gracefully
4. **Encryption** - Use NIP-44 for all sensitive data
## Testing Requirements
- Unit tests for all packages
- Integration tests for cross-package interactions
- E2E tests for full transaction flows
- **Cash-out flow is critical path** (95%+ of activity)
## Related Documentation
- `docs/architecture-comparison.md` - Nostr-native vs traditional lamassu-server comparison
- `docs/ndebit-cash-in-flow.md` - Technical walkthrough of cash-in implementation
- `packages/lightning/TROUBLESHOOTING.md` - Lightning.Pub integration gotchas (must read!)
- `.claude/skills/*.md` - Custom skill documentation
## External Resources
- [CLINK Protocol Spec](https://github.com/shocknet/clink)
- [Lightning.Pub](https://github.com/shocknet/Lightning.Pub)
- [NIP-44 Encryption](https://github.com/nostr-protocol/nips/blob/master/44.md)
- [LND Hold Invoices](https://docs.lightning.engineering/lightning-network-tools/lnd/hold-invoices)

@ -1 +0,0 @@
Subproject commit 5909e609576e078e4c2189fc6cce464b8dca8a71

1
lnbits

@ -1 +0,0 @@
Subproject commit d2e89148357e99f766a32ed9b67ab2fc2e799b2d

Some files were not shown because too many files have changed in this diff Show more