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:
parent
30a2eb2199
commit
c98f126ba7
180 changed files with 2695 additions and 9587 deletions
513
.devenv.flake.nix
Normal file
513
.devenv.flake.nix
Normal 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
78
.gitignore
vendored
|
|
@ -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
12
.gitmodules
vendored
|
|
@ -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
404
CLAUDE.md
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["lamassu-next/apps/machine/src-tauri"]
|
||||
|
|
@ -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',
|
||||
|
|
@ -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
1
docker/.state/atm-app-id
Normal file
|
|
@ -0,0 +1 @@
|
|||
02f5340554b29537f4b9c3bb6c131f69c08044b2114939849d3bec8c4df456e7
|
||||
1
docker/.state/atm-app-token
Normal file
1
docker/.state/atm-app-token
Normal file
|
|
@ -0,0 +1 @@
|
|||
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBJZCI6IjAyZjUzNDA1NTRiMjk1MzdmNGI5YzNiYjZjMTMxZjY5YzA4MDQ0YjIxMTQ5Mzk4NDlkM2JlYzhjNGRmNDU2ZTciLCJpYXQiOjE3NzExODIwMTZ9.uE473GYJdB946daNCZ-5PqPe1JGihSM6KnL66n1AN7k
|
||||
1
docker/.state/lightning-pub-nprofile
Normal file
1
docker/.state/lightning-pub-nprofile
Normal file
|
|
@ -0,0 +1 @@
|
|||
nprofile1qyg8wue69uhhxarjvee8jw3hxumnwqpq35fnkv4nguyhrutu4tspkjlfaqs95pnnegzqkg24h040vn3852jshankcx
|
||||
1
docker/.state/lightning-pub-pubkey
Normal file
1
docker/.state/lightning-pub-pubkey
Normal file
|
|
@ -0,0 +1 @@
|
|||
8d133b32b3470971f17caae01b4be9e8205a0673ca040b2155bbeaf64e27a2a5
|
||||
655
docker/dev.sh
Executable file
655
docker/dev.sh
Executable 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
|
||||
148
docker/docker-compose.regtest.yml
Normal file
148
docker/docker-compose.regtest.yml
Normal 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
335
docker/start-with-regtest.sh
Executable 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
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
67
lamassu-next/.gitignore
vendored
67
lamassu-next/.gitignore
vendored
|
|
@ -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
|
||||
|
|
@ -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
lnbits
|
|
@ -1 +0,0 @@
|
|||
Subproject commit d2e89148357e99f766a32ed9b67ab2fc2e799b2d
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue