diff --git a/lamassu-next/.claude/skills/docs.md b/.claude/skills/docs.md similarity index 100% rename from lamassu-next/.claude/skills/docs.md rename to .claude/skills/docs.md diff --git a/lamassu-next/.claude/skills/hal-check.md b/.claude/skills/hal-check.md similarity index 100% rename from lamassu-next/.claude/skills/hal-check.md rename to .claude/skills/hal-check.md diff --git a/lamassu-next/.claude/skills/lightning-check.md b/.claude/skills/lightning-check.md similarity index 100% rename from lamassu-next/.claude/skills/lightning-check.md rename to .claude/skills/lightning-check.md diff --git a/lamassu-next/.claude/skills/nostr-check.md b/.claude/skills/nostr-check.md similarity index 100% rename from lamassu-next/.claude/skills/nostr-check.md rename to .claude/skills/nostr-check.md diff --git a/lamassu-next/.claude/skills/security.md b/.claude/skills/security.md similarity index 100% rename from lamassu-next/.claude/skills/security.md rename to .claude/skills/security.md diff --git a/lamassu-next/.claude/skills/test.md b/.claude/skills/test.md similarity index 100% rename from lamassu-next/.claude/skills/test.md rename to .claude/skills/test.md diff --git a/.devenv.flake.nix b/.devenv.flake.nix new file mode 100644 index 0000000..aeeee91 --- /dev/null +++ b/.devenv.flake.nix @@ -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" = /../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; + }; +} diff --git a/.gitignore b/.gitignore index ea40c05..92356a7 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index f1bf9df..0000000 --- a/.gitmodules +++ /dev/null @@ -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 diff --git a/lamassu-next/.prettierrc b/.prettierrc similarity index 100% rename from lamassu-next/.prettierrc rename to .prettierrc diff --git a/CLAUDE.md b/CLAUDE.md index f8af785..8193682 100644 --- a/CLAUDE.md +++ b/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 '' -``` +- **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 +``` + +### 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 ` 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) diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index 43242d6..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,3 +0,0 @@ -[workspace] -resolver = "2" -members = ["lamassu-next/apps/machine/src-tauri"] diff --git a/lamassu-next/README.md b/README.md similarity index 100% rename from lamassu-next/README.md rename to README.md diff --git a/lamassu-next/apps/machine/.env.example b/apps/machine/.env.example similarity index 100% rename from lamassu-next/apps/machine/.env.example rename to apps/machine/.env.example diff --git a/lamassu-next/apps/machine/components.json b/apps/machine/components.json similarity index 100% rename from lamassu-next/apps/machine/components.json rename to apps/machine/components.json diff --git a/lamassu-next/apps/machine/electron/main.ts b/apps/machine/electron/main.ts similarity index 96% rename from lamassu-next/apps/machine/electron/main.ts rename to apps/machine/electron/main.ts index cfab582..961efc6 100644 --- a/lamassu-next/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -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', diff --git a/lamassu-next/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts similarity index 96% rename from lamassu-next/apps/machine/electron/preload.ts rename to apps/machine/electron/preload.ts index bcd7c62..506c0a1 100644 --- a/lamassu-next/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -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 diff --git a/lamassu-next/apps/machine/electron/tsconfig.json b/apps/machine/electron/tsconfig.json similarity index 100% rename from lamassu-next/apps/machine/electron/tsconfig.json rename to apps/machine/electron/tsconfig.json diff --git a/lamassu-next/apps/machine/electron/tsconfig.preload.json b/apps/machine/electron/tsconfig.preload.json similarity index 100% rename from lamassu-next/apps/machine/electron/tsconfig.preload.json rename to apps/machine/electron/tsconfig.preload.json diff --git a/lamassu-next/apps/machine/index.html b/apps/machine/index.html similarity index 100% rename from lamassu-next/apps/machine/index.html rename to apps/machine/index.html diff --git a/lamassu-next/apps/machine/package.json b/apps/machine/package.json similarity index 100% rename from lamassu-next/apps/machine/package.json rename to apps/machine/package.json diff --git a/lamassu-next/apps/machine/src/App.vue b/apps/machine/src/App.vue similarity index 100% rename from lamassu-next/apps/machine/src/App.vue rename to apps/machine/src/App.vue diff --git a/lamassu-next/apps/machine/src/components/QRCode.vue b/apps/machine/src/components/QRCode.vue similarity index 100% rename from lamassu-next/apps/machine/src/components/QRCode.vue rename to apps/machine/src/components/QRCode.vue diff --git a/lamassu-next/apps/machine/src/components/ui/alert/Alert.vue b/apps/machine/src/components/ui/alert/Alert.vue similarity index 100% rename from lamassu-next/apps/machine/src/components/ui/alert/Alert.vue rename to apps/machine/src/components/ui/alert/Alert.vue diff --git a/lamassu-next/apps/machine/src/components/ui/alert/AlertDescription.vue b/apps/machine/src/components/ui/alert/AlertDescription.vue similarity index 100% rename from lamassu-next/apps/machine/src/components/ui/alert/AlertDescription.vue rename to apps/machine/src/components/ui/alert/AlertDescription.vue diff --git a/lamassu-next/apps/machine/src/components/ui/alert/AlertTitle.vue b/apps/machine/src/components/ui/alert/AlertTitle.vue similarity index 100% rename from lamassu-next/apps/machine/src/components/ui/alert/AlertTitle.vue rename to apps/machine/src/components/ui/alert/AlertTitle.vue diff --git a/lamassu-next/apps/machine/src/components/ui/alert/index.ts b/apps/machine/src/components/ui/alert/index.ts similarity index 100% rename from lamassu-next/apps/machine/src/components/ui/alert/index.ts rename to apps/machine/src/components/ui/alert/index.ts diff --git a/lamassu-next/apps/machine/src/components/ui/badge/Badge.vue b/apps/machine/src/components/ui/badge/Badge.vue similarity index 100% rename from lamassu-next/apps/machine/src/components/ui/badge/Badge.vue rename to apps/machine/src/components/ui/badge/Badge.vue diff --git a/lamassu-next/apps/machine/src/components/ui/badge/index.ts b/apps/machine/src/components/ui/badge/index.ts similarity index 100% rename from lamassu-next/apps/machine/src/components/ui/badge/index.ts rename to apps/machine/src/components/ui/badge/index.ts diff --git a/lamassu-next/apps/machine/src/components/ui/button/Button.vue b/apps/machine/src/components/ui/button/Button.vue similarity index 100% rename from lamassu-next/apps/machine/src/components/ui/button/Button.vue rename to apps/machine/src/components/ui/button/Button.vue diff --git a/lamassu-next/apps/machine/src/components/ui/button/index.ts b/apps/machine/src/components/ui/button/index.ts similarity index 100% rename from lamassu-next/apps/machine/src/components/ui/button/index.ts rename to apps/machine/src/components/ui/button/index.ts diff --git a/lamassu-next/apps/machine/src/components/ui/card/Card.vue b/apps/machine/src/components/ui/card/Card.vue similarity index 100% rename from lamassu-next/apps/machine/src/components/ui/card/Card.vue rename to apps/machine/src/components/ui/card/Card.vue diff --git a/lamassu-next/apps/machine/src/components/ui/card/CardAction.vue b/apps/machine/src/components/ui/card/CardAction.vue similarity index 100% rename from lamassu-next/apps/machine/src/components/ui/card/CardAction.vue rename to apps/machine/src/components/ui/card/CardAction.vue diff --git a/lamassu-next/apps/machine/src/components/ui/card/CardContent.vue b/apps/machine/src/components/ui/card/CardContent.vue similarity index 100% rename from lamassu-next/apps/machine/src/components/ui/card/CardContent.vue rename to apps/machine/src/components/ui/card/CardContent.vue diff --git a/lamassu-next/apps/machine/src/components/ui/card/CardDescription.vue b/apps/machine/src/components/ui/card/CardDescription.vue similarity index 100% rename from lamassu-next/apps/machine/src/components/ui/card/CardDescription.vue rename to apps/machine/src/components/ui/card/CardDescription.vue diff --git a/lamassu-next/apps/machine/src/components/ui/card/CardFooter.vue b/apps/machine/src/components/ui/card/CardFooter.vue similarity index 100% rename from lamassu-next/apps/machine/src/components/ui/card/CardFooter.vue rename to apps/machine/src/components/ui/card/CardFooter.vue diff --git a/lamassu-next/apps/machine/src/components/ui/card/CardHeader.vue b/apps/machine/src/components/ui/card/CardHeader.vue similarity index 100% rename from lamassu-next/apps/machine/src/components/ui/card/CardHeader.vue rename to apps/machine/src/components/ui/card/CardHeader.vue diff --git a/lamassu-next/apps/machine/src/components/ui/card/CardTitle.vue b/apps/machine/src/components/ui/card/CardTitle.vue similarity index 100% rename from lamassu-next/apps/machine/src/components/ui/card/CardTitle.vue rename to apps/machine/src/components/ui/card/CardTitle.vue diff --git a/lamassu-next/apps/machine/src/components/ui/card/index.ts b/apps/machine/src/components/ui/card/index.ts similarity index 100% rename from lamassu-next/apps/machine/src/components/ui/card/index.ts rename to apps/machine/src/components/ui/card/index.ts diff --git a/lamassu-next/apps/machine/src/components/ui/input/Input.vue b/apps/machine/src/components/ui/input/Input.vue similarity index 100% rename from lamassu-next/apps/machine/src/components/ui/input/Input.vue rename to apps/machine/src/components/ui/input/Input.vue diff --git a/lamassu-next/apps/machine/src/components/ui/input/index.ts b/apps/machine/src/components/ui/input/index.ts similarity index 100% rename from lamassu-next/apps/machine/src/components/ui/input/index.ts rename to apps/machine/src/components/ui/input/index.ts diff --git a/lamassu-next/apps/machine/src/components/ui/skeleton/Skeleton.vue b/apps/machine/src/components/ui/skeleton/Skeleton.vue similarity index 100% rename from lamassu-next/apps/machine/src/components/ui/skeleton/Skeleton.vue rename to apps/machine/src/components/ui/skeleton/Skeleton.vue diff --git a/lamassu-next/apps/machine/src/components/ui/skeleton/index.ts b/apps/machine/src/components/ui/skeleton/index.ts similarity index 100% rename from lamassu-next/apps/machine/src/components/ui/skeleton/index.ts rename to apps/machine/src/components/ui/skeleton/index.ts diff --git a/lamassu-next/apps/machine/src/config/device.ts b/apps/machine/src/config/device.ts similarity index 100% rename from lamassu-next/apps/machine/src/config/device.ts rename to apps/machine/src/config/device.ts diff --git a/lamassu-next/apps/machine/src/config/index.ts b/apps/machine/src/config/index.ts similarity index 100% rename from lamassu-next/apps/machine/src/config/index.ts rename to apps/machine/src/config/index.ts diff --git a/lamassu-next/apps/machine/src/lib/utils.ts b/apps/machine/src/lib/utils.ts similarity index 100% rename from lamassu-next/apps/machine/src/lib/utils.ts rename to apps/machine/src/lib/utils.ts diff --git a/lamassu-next/apps/machine/src/main.ts b/apps/machine/src/main.ts similarity index 100% rename from lamassu-next/apps/machine/src/main.ts rename to apps/machine/src/main.ts diff --git a/lamassu-next/apps/machine/src/services/hal.ts b/apps/machine/src/services/hal.ts similarity index 100% rename from lamassu-next/apps/machine/src/services/hal.ts rename to apps/machine/src/services/hal.ts diff --git a/lamassu-next/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts similarity index 100% rename from lamassu-next/apps/machine/src/services/lightning.ts rename to apps/machine/src/services/lightning.ts diff --git a/lamassu-next/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts similarity index 100% rename from lamassu-next/apps/machine/src/stores/atm.ts rename to apps/machine/src/stores/atm.ts diff --git a/lamassu-next/apps/machine/src/style.css b/apps/machine/src/style.css similarity index 100% rename from lamassu-next/apps/machine/src/style.css rename to apps/machine/src/style.css diff --git a/lamassu-next/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts similarity index 100% rename from lamassu-next/apps/machine/src/types/electron.d.ts rename to apps/machine/src/types/electron.d.ts diff --git a/lamassu-next/apps/machine/src/views/CashInView.vue b/apps/machine/src/views/CashInView.vue similarity index 100% rename from lamassu-next/apps/machine/src/views/CashInView.vue rename to apps/machine/src/views/CashInView.vue diff --git a/lamassu-next/apps/machine/src/views/CashOutView.vue b/apps/machine/src/views/CashOutView.vue similarity index 100% rename from lamassu-next/apps/machine/src/views/CashOutView.vue rename to apps/machine/src/views/CashOutView.vue diff --git a/lamassu-next/apps/machine/src/views/IdleView.vue b/apps/machine/src/views/IdleView.vue similarity index 100% rename from lamassu-next/apps/machine/src/views/IdleView.vue rename to apps/machine/src/views/IdleView.vue diff --git a/lamassu-next/apps/machine/src/vite-env.d.ts b/apps/machine/src/vite-env.d.ts similarity index 100% rename from lamassu-next/apps/machine/src/vite-env.d.ts rename to apps/machine/src/vite-env.d.ts diff --git a/lamassu-next/apps/machine/tsconfig.json b/apps/machine/tsconfig.json similarity index 100% rename from lamassu-next/apps/machine/tsconfig.json rename to apps/machine/tsconfig.json diff --git a/lamassu-next/apps/machine/tsconfig.node.json b/apps/machine/tsconfig.node.json similarity index 100% rename from lamassu-next/apps/machine/tsconfig.node.json rename to apps/machine/tsconfig.node.json diff --git a/lamassu-next/apps/machine/vite.config.ts b/apps/machine/vite.config.ts similarity index 100% rename from lamassu-next/apps/machine/vite.config.ts rename to apps/machine/vite.config.ts diff --git a/lamassu-next/devenv.lock b/devenv.lock similarity index 100% rename from lamassu-next/devenv.lock rename to devenv.lock diff --git a/lamassu-next/devenv.nix b/devenv.nix similarity index 100% rename from lamassu-next/devenv.nix rename to devenv.nix diff --git a/lamassu-next/devenv.yaml b/devenv.yaml similarity index 100% rename from lamassu-next/devenv.yaml rename to devenv.yaml diff --git a/docker/.state/atm-app-id b/docker/.state/atm-app-id new file mode 100644 index 0000000..e461f80 --- /dev/null +++ b/docker/.state/atm-app-id @@ -0,0 +1 @@ +02f5340554b29537f4b9c3bb6c131f69c08044b2114939849d3bec8c4df456e7 diff --git a/docker/.state/atm-app-token b/docker/.state/atm-app-token new file mode 100644 index 0000000..6ac561e --- /dev/null +++ b/docker/.state/atm-app-token @@ -0,0 +1 @@ +eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBJZCI6IjAyZjUzNDA1NTRiMjk1MzdmNGI5YzNiYjZjMTMxZjY5YzA4MDQ0YjIxMTQ5Mzk4NDlkM2JlYzhjNGRmNDU2ZTciLCJpYXQiOjE3NzExODIwMTZ9.uE473GYJdB946daNCZ-5PqPe1JGihSM6KnL66n1AN7k diff --git a/docker/.state/lightning-pub-nprofile b/docker/.state/lightning-pub-nprofile new file mode 100644 index 0000000..00d067e --- /dev/null +++ b/docker/.state/lightning-pub-nprofile @@ -0,0 +1 @@ +nprofile1qyg8wue69uhhxarjvee8jw3hxumnwqpq35fnkv4nguyhrutu4tspkjlfaqs95pnnegzqkg24h040vn3852jshankcx diff --git a/docker/.state/lightning-pub-pubkey b/docker/.state/lightning-pub-pubkey new file mode 100644 index 0000000..3434c71 --- /dev/null +++ b/docker/.state/lightning-pub-pubkey @@ -0,0 +1 @@ +8d133b32b3470971f17caae01b4be9e8205a0673ca040b2155bbeaf64e27a2a5 diff --git a/docker/dev.sh b/docker/dev.sh new file mode 100755 index 0000000..fff2d17 --- /dev/null +++ b/docker/dev.sh @@ -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 [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 Build Lightning.Pub from git worktree" + echo " --image Use specific Docker image" + echo " --skip-regtest Don't auto-start regtest" + echo " --fund Auto-fund ATM with 100k sats after startup" + echo " --fund= 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 diff --git a/lamassu-next/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml similarity index 100% rename from lamassu-next/docker/docker-compose.dev.yml rename to docker/docker-compose.dev.yml diff --git a/docker/docker-compose.regtest.yml b/docker/docker-compose.regtest.yml new file mode 100644 index 0000000..915ce4f --- /dev/null +++ b/docker/docker-compose.regtest.yml @@ -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 diff --git a/docker/start-with-regtest.sh b/docker/start-with-regtest.sh new file mode 100755 index 0000000..b688e6e --- /dev/null +++ b/docker/start-with-regtest.sh @@ -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 " + 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 Use specific Docker image" + echo " --worktree 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 diff --git a/lamassu-next/docker/strfry.conf b/docker/strfry.conf similarity index 100% rename from lamassu-next/docker/strfry.conf rename to docker/strfry.conf diff --git a/lamassu-next/docs/adr/001-hal-architecture.md b/docs/adr/001-hal-architecture.md similarity index 100% rename from lamassu-next/docs/adr/001-hal-architecture.md rename to docs/adr/001-hal-architecture.md diff --git a/lamassu-next/docs/architecture-comparison.md b/docs/architecture-comparison.md similarity index 100% rename from lamassu-next/docs/architecture-comparison.md rename to docs/architecture-comparison.md diff --git a/docs/architecture-review.md b/docs/architecture-review.md deleted file mode 100644 index 1e26c89..0000000 --- a/docs/architecture-review.md +++ /dev/null @@ -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 diff --git a/lamassu-next/docs/clink-protocol.md b/docs/clink-protocol.md similarity index 100% rename from lamassu-next/docs/clink-protocol.md rename to docs/clink-protocol.md diff --git a/lamassu-next/docs/device-configuration.md b/docs/device-configuration.md similarity index 100% rename from lamassu-next/docs/device-configuration.md rename to docs/device-configuration.md diff --git a/docs/features/admin-ui-modernization.md b/docs/features/admin-ui-modernization.md deleted file mode 100644 index b4db30f..0000000 --- a/docs/features/admin-ui-modernization.md +++ /dev/null @@ -1,1018 +0,0 @@ ---- -title: Admin UI Modernization -created: 2026-01-22 -updated: 2026-01-22 -tags: - - feature - - vue - - admin - - ui - - refactor -status: planning -priority: high ---- - -# Admin UI Modernization - -> [!abstract] Summary -> Migrate the admin dashboard from **React 18 + MUI** to **Vue 3** for consistency with the machine UI, using **shadcn-vue** for components and tRPC for type-safe API communication. - -## Quick Links - -- [[#Current State]] -- [[#Why Unify on Vue]] -- [[#Architecture]] -- [[#Migration Strategy]] -- [[#Implementation]] - ---- - -## Current State - -### Existing Tech Stack - -``` -packages/admin-ui/ -├── src/ -│ ├── pages/ # React page components -│ ├── components/ # Reusable React components -│ ├── hooks/ # Custom React hooks -│ └── ... -├── package.json # React 18, MUI, Apollo Client -└── vite.config.js -``` - -| Component | Current | Notes | -|-----------|---------|-------| -| Framework | React 18.3 | Functional components + hooks | -| UI Library | MUI v7.1 | Heavy bundle (~300kb) | -| State | Zustand | Lightweight | -| API Client | Apollo Client | GraphQL | -| Styling | Tailwind CSS v4 | Already modern | -| Build | Vite + SWC | Already modern | -| Charts | D3 | Keep | -| Tables | Material React Table | Replace | - -### Problems - -> [!warning] Issues with Current Setup - -1. **Two frameworks** - React (admin) + Vanilla JS (machine) = different mental models -2. **Heavy bundle** - MUI adds ~300kb to bundle -3. **GraphQL complexity** - Apollo Client is powerful but heavy -4. **Inconsistent patterns** - Different state management approaches -5. **Developer context switching** - Between React and future Vue machine UI - -#currentstate - ---- - -## Why Unify on Vue - -> [!decision] Single Framework Strategy - -### Benefits of Vue Everywhere - -| Aspect | Two Frameworks | Single Framework (Vue) | -|--------|---------------|------------------------| -| Learning curve | High | Lower | -| Code sharing | None | Components, composables | -| Hiring | React + Vue devs | Vue devs only | -| Maintenance | 2x patterns | 1x patterns | -| Bundle optimization | Separate | Shared chunks possible | - -### Vue 3 vs React 19 for Admin - -| Feature | Vue 3 | React 19 | -|---------|-------|----------| -| Bundle size | ~33kb | ~42kb | -| DevTools | Excellent | Excellent | -| TypeScript | First-class | First-class | -| Learning curve | Lower | Medium | -| Component syntax | SFC (cleaner) | JSX | -| State management | Pinia (simpler) | Context/Zustand | - -> [!tip] Strategic Alignment -> With machine UI moving to Vue 3, unifying admin UI reduces cognitive load and enables shared utilities. - -#vue #consistency - ---- - -## Architecture - -### Target Stack - -``` -┌─────────────────────────────────────────────────────────┐ -│ Admin Dashboard │ -├─────────────────────────────────────────────────────────┤ -│ Vue 3 + TypeScript │ -│ ┌─────────────────────────────────────────────────────┐│ -│ │ Pages (Vue Router) ││ -│ │ ├── DashboardPage.vue ││ -│ │ ├── TransactionsPage.vue ││ -│ │ ├── MachinesPage.vue ││ -│ │ ├── CustomersPage.vue ││ -│ │ ├── MembershipsPage.vue (new) ││ -│ │ └── SettingsPage.vue ││ -│ └─────────────────────────────────────────────────────┘│ -│ ┌─────────────────────────────────────────────────────┐│ -│ │ UI Components (shadcn-vue + Radix Vue) ││ -│ │ ├── Button, Card, Dialog, Table... ││ -│ │ └── Custom: CryptoAmount, StatusBadge... ││ -│ └─────────────────────────────────────────────────────┘│ -│ ┌─────────────────────────────────────────────────────┐│ -│ │ State (Pinia) ││ -│ │ ├── useAuthStore ││ -│ │ ├── useMachinesStore ││ -│ │ └── useSettingsStore ││ -│ └─────────────────────────────────────────────────────┘│ -│ ┌─────────────────────────────────────────────────────┐│ -│ │ API Layer (tRPC Client) ││ -│ │ └── Type-safe server communication ││ -│ └─────────────────────────────────────────────────────┘│ -├─────────────────────────────────────────────────────────┤ -│ Tailwind CSS v4 (keep) │ -└─────────────────────────────────────────────────────────┘ -``` - -### Project Structure - -``` -packages/admin-ui/ -├── src/ -│ ├── main.ts # Entry point -│ ├── App.vue # Root component -│ ├── router/ -│ │ └── index.ts # Vue Router config -│ │ -│ ├── pages/ # Route-level components -│ │ ├── DashboardPage.vue -│ │ ├── transactions/ -│ │ │ ├── TransactionsPage.vue -│ │ │ └── TransactionDetailPage.vue -│ │ ├── machines/ -│ │ │ ├── MachinesPage.vue -│ │ │ └── MachineDetailPage.vue -│ │ ├── customers/ -│ │ │ ├── CustomersPage.vue -│ │ │ └── CustomerDetailPage.vue -│ │ ├── memberships/ -│ │ │ ├── MembershipsPage.vue -│ │ │ ├── MembershipDetailPage.vue -│ │ │ └── TiersConfigPage.vue -│ │ ├── settings/ -│ │ │ ├── SettingsPage.vue -│ │ │ ├── WalletSettingsPage.vue -│ │ │ └── LightningSettingsPage.vue -│ │ └── auth/ -│ │ ├── LoginPage.vue -│ │ └── SetupPasskeyPage.vue -│ │ -│ ├── components/ # Reusable components -│ │ ├── layout/ -│ │ │ ├── AppSidebar.vue -│ │ │ ├── AppHeader.vue -│ │ │ └── AppBreadcrumb.vue -│ │ ├── common/ -│ │ │ ├── DataTable.vue # Wrapper around PrimeVue -│ │ │ ├── StatusBadge.vue -│ │ │ ├── CryptoAmount.vue -│ │ │ ├── FiatAmount.vue -│ │ │ └── DateTimeDisplay.vue -│ │ ├── charts/ -│ │ │ ├── TransactionChart.vue -│ │ │ ├── VolumeChart.vue -│ │ │ └── MachineStatusChart.vue -│ │ ├── machines/ -│ │ │ ├── MachineCard.vue -│ │ │ ├── MachineStatusIndicator.vue -│ │ │ └── CassetteStatus.vue -│ │ └── memberships/ -│ │ ├── MembershipCard.vue -│ │ ├── TierBadge.vue -│ │ └── QRCodeGenerator.vue -│ │ -│ ├── stores/ # Pinia stores -│ │ ├── auth.ts -│ │ ├── machines.ts -│ │ ├── transactions.ts -│ │ ├── customers.ts -│ │ ├── memberships.ts -│ │ └── settings.ts -│ │ -│ ├── composables/ # Reusable logic -│ │ ├── useAuth.ts -│ │ ├── usePagination.ts -│ │ ├── useFilters.ts -│ │ ├── useExport.ts -│ │ └── useNotifications.ts -│ │ -│ ├── api/ # tRPC client -│ │ ├── client.ts -│ │ └── types.ts # Shared types from server -│ │ -│ ├── types/ # TypeScript types -│ │ ├── machine.ts -│ │ ├── transaction.ts -│ │ ├── customer.ts -│ │ └── membership.ts -│ │ -│ ├── utils/ # Utility functions -│ │ ├── formatters.ts -│ │ ├── validators.ts -│ │ └── constants.ts -│ │ -│ └── styles/ # Global styles -│ ├── main.css # Tailwind imports -│ └── primevue-theme.css # PrimeVue customization -│ -├── index.html -├── vite.config.ts -├── tsconfig.json -├── tailwind.config.ts -└── package.json -``` - -#architecture #structure - ---- - -## UI Component Library - -### shadcn-vue vs Alternatives - -| Library | Approach | Bundle | Tailwind | Control | -|---------|----------|--------|----------|---------| -| **shadcn-vue** | Copy to repo | Zero overhead | Native | Full | -| PrimeVue | npm install | Tree-shake | Adapter | Theme only | -| Vuetify 3 | npm install | ~300kb | No | Theme only | -| Headless UI | npm install | ~10kb | Yes | Style only | -| Radix Vue | npm install | ~15kb | Yes | Full | - -> [!decision] shadcn-vue -> - **Copy, don't install** - Components live in your codebase -> - **Tailwind-native** - No style conflicts or overrides -> - **Radix Vue primitives** - Accessible, unstyled foundation -> - **Full control** - Modify any component directly -> - **No library updates** - You own the code -> - **Consistent** - Same approach for admin + machine UI - -### shadcn-vue Setup - -```bash -# Initialize shadcn-vue -npx shadcn-vue@latest init - -# Add components as needed -npx shadcn-vue@latest add button -npx shadcn-vue@latest add card -npx shadcn-vue@latest add data-table -npx shadcn-vue@latest add dialog -npx shadcn-vue@latest add dropdown-menu -npx shadcn-vue@latest add input -npx shadcn-vue@latest add toast -``` - -```typescript -// src/main.ts -import { createApp } from 'vue' -import App from './App.vue' -import router from './router' -import { pinia } from './stores' - -import './styles/globals.css' // Tailwind + shadcn styles - -const app = createApp(App) - -app.use(router) -app.use(pinia) - -app.mount('#app') -``` - -### Component Structure - -``` -src/components/ -├── ui/ # shadcn-vue components (copied) -│ ├── button/ -│ │ ├── Button.vue -│ │ └── index.ts -│ ├── card/ -│ │ ├── Card.vue -│ │ ├── CardHeader.vue -│ │ ├── CardContent.vue -│ │ └── index.ts -│ ├── data-table/ -│ │ ├── DataTable.vue -│ │ ├── DataTablePagination.vue -│ │ └── index.ts -│ ├── dialog/ -│ ├── dropdown-menu/ -│ ├── input/ -│ ├── toast/ -│ └── ... -└── custom/ # Your custom components - ├── CryptoAmount.vue - ├── FiatAmount.vue - └── StatusBadge.vue -``` - -### Component Examples - -#### Data Table with TanStack Table - -```vue - - - - -``` - -#### Transaction List Page - -```vue - - - - -``` - -#shadcn #components - ---- - -## API Layer: tRPC - -### Why tRPC over GraphQL - -| Aspect | GraphQL (Apollo) | tRPC | -|--------|------------------|------| -| Bundle size | ~50kb | ~5kb | -| Type safety | Codegen required | Automatic | -| Learning curve | Higher | Lower | -| Caching | Built-in | TanStack Query | -| Boilerplate | Schema + resolvers | Just functions | - -> [!decision] tRPC for Admin API -> Since both server and admin-ui are TypeScript, tRPC provides end-to-end type safety without code generation. - -### tRPC Client Setup - -```typescript -// src/api/client.ts -import { createTRPCClient, httpBatchLink } from '@trpc/client' -import type { AppRouter } from '@lamassu/server/trpc' -import { useAuthStore } from '@/stores/auth' - -export const trpc = createTRPCClient({ - links: [ - httpBatchLink({ - url: `${import.meta.env.VITE_API_URL}/trpc`, - headers() { - const auth = useAuthStore() - return { - Authorization: auth.token ? `Bearer ${auth.token}` : '', - } - }, - }), - ], -}) -``` - -### Using tRPC in Stores - -```typescript -// src/stores/memberships.ts -import { defineStore } from 'pinia' -import { ref } from 'vue' -import { trpc } from '@/api/client' -import type { Membership, CreateMembershipInput } from '@/types/membership' - -export const useMembershipsStore = defineStore('memberships', () => { - const memberships = ref([]) - const loading = ref(false) - const error = ref(null) - - async function fetchMemberships(filters?: { tierId?: string; search?: string }) { - loading.value = true - error.value = null - try { - // Fully typed! IDE knows exact return type - memberships.value = await trpc.membership.list.query(filters) - } catch (e) { - error.value = e instanceof Error ? e.message : 'Unknown error' - } finally { - loading.value = false - } - } - - async function createMembership(input: CreateMembershipInput) { - // TypeScript ensures input matches server expectations - const newMembership = await trpc.membership.create.mutate(input) - memberships.value.push(newMembership) - return newMembership - } - - async function deleteMembership(id: string) { - await trpc.membership.delete.mutate({ id }) - memberships.value = memberships.value.filter(m => m.id !== id) - } - - return { - memberships, - loading, - error, - fetchMemberships, - createMembership, - deleteMembership, - } -}) -``` - -### TanStack Query Integration (Optional) - -```typescript -// For more advanced caching/refetching -import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query' -import { trpc } from '@/api/client' - -export function useMemberships(filters?: { tierId?: string }) { - return useQuery({ - queryKey: ['memberships', filters], - queryFn: () => trpc.membership.list.query(filters), - }) -} - -export function useCreateMembership() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: (input: CreateMembershipInput) => - trpc.membership.create.mutate(input), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['memberships'] }) - }, - }) -} -``` - -#trpc #api - ---- - -## Authentication - -### Passkey-First Auth Flow - -```vue - - - - -``` - -#auth #passkey - ---- - -## Shared Code with Machine UI - -### Shared Package Structure - -``` -packages/ -├── admin-ui/ # Vue 3 admin dashboard -├── machine-ui/ # Vue 3 kiosk UI (moved from lamassu-machine) -└── ui-shared/ # Shared Vue components & utilities - ├── src/ - │ ├── components/ - │ │ ├── QRCode.vue - │ │ ├── CryptoIcon.vue - │ │ └── LoadingSpinner.vue - │ ├── composables/ - │ │ ├── useCrypto.ts - │ │ ├── useFormatters.ts - │ │ └── useValidators.ts - │ ├── types/ - │ │ ├── coin.ts - │ │ ├── transaction.ts - │ │ └── membership.ts - │ └── index.ts - └── package.json -``` - -### Shared Composables - -```typescript -// packages/ui-shared/src/composables/useFormatters.ts -import { computed } from 'vue' - -export function useFormatters(locale = 'en-US') { - const formatFiat = (amount: number, currency: string) => { - return new Intl.NumberFormat(locale, { - style: 'currency', - currency, - }).format(amount) - } - - const formatCrypto = (sats: number, coin: string) => { - if (coin === 'BTC') { - return `${(sats / 100_000_000).toFixed(8)} BTC` - } - return `${sats} sats` - } - - const formatDate = (date: Date | string) => { - return new Intl.DateTimeFormat(locale, { - dateStyle: 'medium', - timeStyle: 'short', - }).format(new Date(date)) - } - - return { - formatFiat, - formatCrypto, - formatDate, - } -} -``` - -```typescript -// Usage in both admin-ui and machine-ui -import { useFormatters } from '@lamassu/ui-shared' - -const { formatFiat, formatCrypto } = useFormatters() -``` - -#shared #monorepo - ---- - -## Migration Strategy - -### Phase 1: Setup & Infrastructure - -> [!todo] Phase 1 Tasks - -- [ ] Create new Vue 3 project in `packages/admin-ui-v2/` -- [ ] Set up Vite + Vue + TypeScript -- [ ] Configure PrimeVue with Tailwind -- [ ] Set up Vue Router with auth guards -- [ ] Set up Pinia stores -- [ ] Configure tRPC client -- [ ] Create layout components (Sidebar, Header) - -### Phase 2: Core Pages Migration - -> [!todo] Phase 2 Tasks - -Migrate pages in order of complexity: - -1. [ ] Login / Auth pages -2. [ ] Dashboard (overview) -3. [ ] Machines list & detail -4. [ ] Transactions list & detail -5. [ ] Customers list & detail -6. [ ] Settings pages - -### Phase 3: New Features - -> [!todo] Phase 3 Tasks - -- [ ] Memberships management (new) -- [ ] Discount tiers configuration (new) -- [ ] Lightning/LNbits settings (new) -- [ ] Enhanced analytics dashboard - -### Phase 4: Polish & Cutover - -> [!todo] Phase 4 Tasks - -- [ ] Dark mode support -- [ ] Responsive design review -- [ ] Accessibility audit -- [ ] Performance optimization -- [ ] E2E tests with Playwright -- [ ] Remove old React admin-ui -- [ ] Update deployment configs - -#migration #phases - ---- - -## Build & Deployment - -### Vite Config - -```typescript -// packages/admin-ui/vite.config.ts -import { defineConfig } from 'vite' -import vue from '@vitejs/plugin-vue' -import { resolve } from 'path' -import tailwindcss from '@tailwindcss/vite' - -export default defineConfig({ - plugins: [ - vue(), - tailwindcss(), - ], - - resolve: { - alias: { - '@': resolve(__dirname, 'src'), - }, - }, - - build: { - target: 'es2022', - outDir: 'dist', - sourcemap: true, - rollupOptions: { - output: { - manualChunks: { - vue: ['vue', 'vue-router', 'pinia'], - charts: ['chart.js', 'vue-chartjs'], - }, - }, - }, - }, - - server: { - port: 3001, - proxy: { - '/api': { - target: 'http://localhost:3000', - changeOrigin: true, - }, - '/trpc': { - target: 'http://localhost:3000', - changeOrigin: true, - }, - }, - }, -}) -``` - -### Package.json - -```json -{ - "name": "@lamassu/admin-ui", - "version": "2.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vue-tsc --noEmit && vite build", - "preview": "vite preview", - "test": "vitest", - "test:e2e": "playwright test", - "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", - "radix-vue": "^1.9.0", - "class-variance-authority": "^0.7.0", - "clsx": "^2.1.0", - "tailwind-merge": "^2.5.0", - "@tanstack/vue-table": "^8.20.0", - "@trpc/client": "^11.0.0", - "@tanstack/vue-query": "^5.60.0", - "@vueuse/core": "^11.0.0", - "@simplewebauthn/browser": "^10.0.0", - "lucide-vue-next": "^0.460.0", - "chart.js": "^4.4.0", - "vue-chartjs": "^5.3.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", - "@playwright/test": "^1.48.0", - "tailwindcss": "^4.0.0", - "autoprefixer": "^10.4.0", - "eslint": "^9.14.0", - "eslint-plugin-vue": "^9.30.0" - } -} -``` - -#build #deployment - ---- - -## Comparison Summary - -| Aspect | Before (React) | After (Vue 3) | -|--------|---------------|---------------| -| Framework | React 18 | Vue 3 | -| UI Library | MUI (~300kb) | shadcn-vue (own code) | -| State | Zustand | Pinia | -| API | Apollo GraphQL | tRPC | -| Styling | Tailwind | Tailwind (keep) | -| Build | Vite | Vite (keep) | -| Types | TypeScript | TypeScript (keep) | -| Machine UI | Different (Vanilla) | Same (Vue 3) | - -### Bundle Size Targets - -| Chunk | Before | After | -|-------|--------|-------| -| Framework | ~42kb | ~33kb | -| UI Library | ~300kb | ~15kb* | -| API Client | ~50kb | ~5kb | -| **Total** | **~400kb** | **~60kb** | - -*shadcn-vue components are copied to your codebase, only includes what you use - -#comparison #summary - ---- - -## Related Documents - -- [[machine-ui-modernization]] - Kiosk UI Vue migration -- [[modernization-plan]] - Overall modernization roadmap -- [[membership-lightning-integration]] - New membership feature -- [[lnbits-integration]] - Lightning backend diff --git a/docs/features/machine-ui-modernization.md b/docs/features/machine-ui-modernization.md deleted file mode 100644 index e49603c..0000000 --- a/docs/features/machine-ui-modernization.md +++ /dev/null @@ -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 - - - - - - -``` - -### 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(null) - const fiatAmount = ref(0) - const cryptoAmount = ref(0) - const walletAddress = ref(null) - const membership = ref(null) - const bills = ref([]) - - // 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 -} - -export function useWebSocket() { - const ws = ref(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) { - 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 - - - - - - -``` - -#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 }).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) diff --git a/docs/features/membership-lightning-integration.md b/docs/features/membership-lightning-integration.md deleted file mode 100644 index 9c5c73a..0000000 --- a/docs/features/membership-lightning-integration.md +++ /dev/null @@ -1,1027 +0,0 @@ ---- -title: Membership & Lightning Integration -created: 2026-01-22 -updated: 2026-01-22 -tags: - - feature - - lightning - - lnbits - - membership - - loyalty -status: planning -priority: high ---- - -# Membership & Lightning Integration - -> [!abstract] Summary -> A membership/loyalty system that provides **tiered discounts** AND **automatic Lightning wallet integration** through a single membership ID scan. Powered by [[LNbits Integration|LNbits]] as the Lightning backend. - -## Quick Links - -- [[#User Flows]] -- [[#Architecture]] -- [[#Database Schema]] -- [[#API Design]] -- [[#Machine Changes]] -- [[#Implementation Phases]] - ---- - -## Overview - -### The Problem - -Current ATM flow requires: -1. User selects coin -2. User scans their wallet QR code -3. User inserts cash -4. Server sends BTC to scanned address - -**Pain points:** -- No loyalty/rewards program -- User must have wallet app ready -- No recurring customer recognition -- Manual address entry every time - -### The Solution - -**One scan, two benefits:** - -``` -Membership QR Scan - ↓ -┌──────────────────────────────────┐ -│ • Tier discount applied (15%) │ -│ • Lightning address retrieved │ -│ • No wallet QR needed! │ -└──────────────────────────────────┘ -``` - -#membership #lightning #ux - ---- - -## User Flows - -### Cash-In Flow (Buy BTC with Cash) - -```mermaid -stateDiagram-v2 - [*] --> ChooseCoin - ChooseCoin --> MembershipPrompt - - MembershipPrompt --> MembershipScan: Yes - MembershipPrompt --> ScanWalletAddress: No - - MembershipScan --> MembershipValid: Valid ID - MembershipScan --> MembershipInvalid: Invalid - - MembershipInvalid --> MembershipPrompt: Retry - MembershipInvalid --> ScanWalletAddress: Skip - - MembershipValid --> InsertBills: Address from server - ScanWalletAddress --> InsertBills - - InsertBills --> SendCoins - SendCoins --> [*] -``` - -> [!tip] Key Benefit -> Members skip the wallet address scan entirely. Server already knows their Lightning address from membership data. - -**Member Flow:** -1. User starts cash-in transaction -2. "Do you have a membership card?" → **[Yes]** -3. Scan membership QR code -4. Server validates: `user_12345` → Gold tier (15% discount) + Lightning address -5. "Welcome Gold Member! 15% discount applied. Bitcoin will be sent to your wallet automatically." -6. User inserts cash -7. Server sends BTC to user's Lightning address via LNbits - -**Non-Member Flow:** -1. User starts cash-in transaction -2. "Do you have a membership card?" → **[No]** -3. User scans wallet QR code (standard flow) -4. User inserts cash -5. Server sends BTC to scanned address - -### Cash-Out Flow (Sell BTC for Cash) - -```mermaid -stateDiagram-v2 - [*] --> ChooseAmount - ChooseAmount --> MembershipPrompt - - MembershipPrompt --> MembershipScan: Yes - MembershipPrompt --> ScanInvoice: No - - MembershipScan --> MembershipValid: Valid - MembershipValid --> ScanInvoice: Discount applied - - ScanInvoice --> WaitForPayment - WaitForPayment --> DispenseCash: Payment received - DispenseCash --> [*] -``` - -> [!note] Cash-Out Still Requires Invoice -> For cash-out, user still provides a Lightning invoice (security requirement). Membership only provides the discount. - -**Member Flow:** -1. User starts cash-out transaction -2. "Do you have a membership card?" → **[Yes]** -3. Scan membership QR code -4. Server validates: Gold tier → 15% discount applied -5. User scans Lightning invoice to receive payment -6. ATM pays invoice via LNbits -7. User receives cash - -#userflow #cashin #cashout - ---- - -## Architecture - -### System Overview - -```mermaid -graph TB - subgraph "ATM Machine" - brain[brain.js State Machine] - trader[Trader API Client] - ui[Kiosk UI] - end - - subgraph "Lamassu Server" - membership[Membership Service] - lightning[Lightning Service] - graphql[GraphQL API] - rest[REST API] - end - - subgraph "LNbits" - lnbits_api[LNbits API] - wallet[ATM Wallet] - funding[Funding Source] - end - - subgraph "Lightning Network" - ln[Lightning Nodes] - end - - ui --> brain - brain --> trader - trader -->|REST| rest - trader -->|GraphQL| graphql - - rest --> membership - rest --> lightning - graphql --> membership - - lightning --> lnbits_api - lnbits_api --> wallet - wallet --> funding - funding --> ln -``` - -### LNbits as Lightning Backend - -> [!decision] Why LNbits? -> - Abstracts 30+ Lightning implementations (LND, CLN, etc.) -> - Clean REST API for integration -> - Multi-wallet support (per-ATM or shared) -> - Built-in LNURL/Lightning Address support -> - Open source, self-hostable -> - PostgreSQL backend (matches Lamassu) - -**Integration Pattern:** - -```typescript -// packages/server/lib/lightning/lnbits-client.ts -interface LNbitsConfig { - baseUrl: string // https://lnbits.example.com - adminKey: string // For paying out (cash-in) - invoiceKey: string // For creating invoices (cash-out) -} - -class LNbitsClient { - // Cash-in: Pay to user's Lightning address - async payToLightningAddress(address: string, amountSats: number): Promise - - // Cash-out: Create invoice for user to pay - async createInvoice(amountSats: number, memo: string): Promise - - // Check payment status - async getPaymentStatus(paymentHash: string): Promise - - // Get wallet balance - async getBalance(): Promise -} -``` - -See [[LNbits Integration]] for detailed API documentation. - -#architecture #lnbits - ---- - -## Database Schema - -### New Tables - -```sql --- Discount tiers (Bronze, Silver, Gold, Platinum) -CREATE TABLE discount_tiers ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(50) NOT NULL UNIQUE, -- 'gold' - display_name VARCHAR(100) NOT NULL, -- 'Gold Member' - discount_percentage INT NOT NULL, -- 15 - min_monthly_volume INT DEFAULT 0, -- Auto-upgrade threshold (sats) - priority INT DEFAULT 0, -- Display order - color VARCHAR(7), -- '#FFD700' for UI - created TIMESTAMPTZ DEFAULT NOW(), - enabled BOOLEAN DEFAULT TRUE -); - --- Memberships linking external IDs to tiers + Lightning -CREATE TABLE memberships ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - external_user_id VARCHAR(255) NOT NULL UNIQUE, -- QR/NFC payload - tier_id UUID REFERENCES discount_tiers(id), - customer_id UUID REFERENCES customers(id), -- Link to existing customer - - -- Lightning wallet info - lightning_address VARCHAR(255), -- user@wallet.com - lightning_address_type VARCHAR(20), -- 'lnurl-pay', 'bolt12' - lnbits_user_id VARCHAR(255), -- If using LNbits accounts - lnbits_wallet_id VARCHAR(255), -- If using LNbits wallets - - -- Metadata - metadata JSONB, -- Flexible extra data - created TIMESTAMPTZ DEFAULT NOW(), - last_used TIMESTAMPTZ, - enabled BOOLEAN DEFAULT TRUE -); - --- Usage audit log -CREATE TABLE membership_usage ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - membership_id UUID REFERENCES memberships(id), - device_id VARCHAR(255), -- ATM machine ID - tx_id UUID, -- Transaction reference - action VARCHAR(20), -- 'cash_in', 'cash_out' - amount_fiat INT, -- Original amount - amount_crypto BIGINT, -- Sats - discount_applied INT, -- Percentage applied - discount_saved INT, -- Amount saved in fiat - lightning_payment_hash VARCHAR(64), -- For tracking - created TIMESTAMPTZ DEFAULT NOW() -); - --- Indexes -CREATE INDEX idx_memberships_external_id ON memberships(external_user_id); -CREATE INDEX idx_memberships_tier ON memberships(tier_id); -CREATE INDEX idx_memberships_lightning ON memberships(lightning_address); -CREATE INDEX idx_membership_usage_membership ON membership_usage(membership_id); -CREATE INDEX idx_membership_usage_created ON membership_usage(created); -``` - -### Seed Data - -```sql -INSERT INTO discount_tiers (name, display_name, discount_percentage, priority, color) VALUES - ('bronze', 'Bronze Member', 5, 1, '#CD7F32'), - ('silver', 'Silver Member', 10, 2, '#C0C0C0'), - ('gold', 'Gold Member', 15, 3, '#FFD700'), - ('platinum', 'Platinum Member', 20, 4, '#E5E4E2'); -``` - -### Drizzle Schema - -```typescript -// packages/typesafe-db/src/schema/membership.ts -import { pgTable, uuid, varchar, integer, boolean, timestamp, jsonb } from 'drizzle-orm/pg-core' - -export const discountTiers = pgTable('discount_tiers', { - id: uuid('id').primaryKey().defaultRandom(), - name: varchar('name', { length: 50 }).notNull().unique(), - displayName: varchar('display_name', { length: 100 }).notNull(), - discountPercentage: integer('discount_percentage').notNull(), - minMonthlyVolume: integer('min_monthly_volume').default(0), - priority: integer('priority').default(0), - color: varchar('color', { length: 7 }), - created: timestamp('created', { withTimezone: true }).defaultNow(), - enabled: boolean('enabled').default(true), -}) - -export const memberships = pgTable('memberships', { - id: uuid('id').primaryKey().defaultRandom(), - externalUserId: varchar('external_user_id', { length: 255 }).notNull().unique(), - tierId: uuid('tier_id').references(() => discountTiers.id), - customerId: uuid('customer_id').references(() => customers.id), - lightningAddress: varchar('lightning_address', { length: 255 }), - lightningAddressType: varchar('lightning_address_type', { length: 20 }), - lnbitsUserId: varchar('lnbits_user_id', { length: 255 }), - lnbitsWalletId: varchar('lnbits_wallet_id', { length: 255 }), - metadata: jsonb('metadata'), - created: timestamp('created', { withTimezone: true }).defaultNow(), - lastUsed: timestamp('last_used', { withTimezone: true }), - enabled: boolean('enabled').default(true), -}) - -export const membershipUsage = pgTable('membership_usage', { - id: uuid('id').primaryKey().defaultRandom(), - membershipId: uuid('membership_id').references(() => memberships.id), - deviceId: varchar('device_id', { length: 255 }), - txId: uuid('tx_id'), - action: varchar('action', { length: 20 }), - amountFiat: integer('amount_fiat'), - amountCrypto: integer('amount_crypto'), - discountApplied: integer('discount_applied'), - discountSaved: integer('discount_saved'), - lightningPaymentHash: varchar('lightning_payment_hash', { length: 64 }), - created: timestamp('created', { withTimezone: true }).defaultNow(), -}) -``` - -#database #drizzle #schema - ---- - -## API Design - -### REST Endpoints (Machine → Server) - -#### Validate Membership - -```http -POST /api/v1/membership/validate -Content-Type: application/json -X-Machine-ID: machine_abc123 - -{ - "membershipId": "MEM-2026-GOLD-12345" -} -``` - -**Response (Success):** -```json -{ - "valid": true, - "membership": { - "id": "uuid-here", - "externalUserId": "MEM-2026-GOLD-12345", - "tier": { - "name": "gold", - "displayName": "Gold Member", - "discountPercentage": 15, - "color": "#FFD700" - }, - "lightningAddress": "user123@walletofsatoshi.com", - "lightningAddressType": "lnurl-pay", - "customerName": "John D." - } -} -``` - -**Response (Invalid):** -```json -{ - "valid": false, - "error": "MEMBERSHIP_NOT_FOUND", - "message": "Membership ID not recognized" -} -``` - -#### Pay to Member (Cash-In) - -```http -POST /api/v1/membership/pay -Content-Type: application/json -X-Machine-ID: machine_abc123 - -{ - "membershipId": "MEM-2026-GOLD-12345", - "amountSats": 50000, - "amountFiat": 25.00, - "currency": "USD", - "txId": "tx-uuid-here" -} -``` - -**Response:** -```json -{ - "success": true, - "paymentHash": "abc123...", - "preimage": "def456...", - "feeSats": 5, - "discountApplied": 15, - "discountSavedFiat": 3.75 -} -``` - -#### Create Invoice for Member (Cash-Out) - -```http -POST /api/v1/membership/invoice -Content-Type: application/json -X-Machine-ID: machine_abc123 - -{ - "membershipId": "MEM-2026-GOLD-12345", - "amountSats": 50000, - "memo": "ATM Cash-out - Gold Member" -} -``` - -**Response:** -```json -{ - "bolt11": "lnbc500u1p...", - "paymentHash": "abc123...", - "expiresAt": "2026-01-22T15:30:00Z", - "discountApplied": 15 -} -``` - -### GraphQL API (Admin) - -```graphql -# Types -type DiscountTier { - id: ID! - name: String! - displayName: String! - discountPercentage: Int! - minMonthlyVolume: Int - priority: Int! - color: String - enabled: Boolean! - memberCount: Int! -} - -type Membership { - id: ID! - externalUserId: String! - tier: DiscountTier! - customer: Customer - lightningAddress: String - lightningAddressType: String - lnbitsUserId: String - metadata: JSONObject - created: DateTime! - lastUsed: DateTime - enabled: Boolean! - usageStats: MembershipStats! -} - -type MembershipStats { - totalTransactions: Int! - totalVolumeSats: BigInt! - totalDiscountSaved: Float! - lastUsed: DateTime -} - -type MembershipUsage { - id: ID! - membership: Membership! - deviceId: String - action: String! - amountFiat: Int! - amountCrypto: BigInt! - discountApplied: Int! - discountSaved: Int! - created: DateTime! -} - -# Queries -type Query { - discountTiers: [DiscountTier!]! - memberships( - tierId: ID - search: String - limit: Int = 50 - offset: Int = 0 - ): [Membership!]! - membership(id: ID!): Membership - membershipByExternalId(externalUserId: String!): Membership - membershipUsage( - membershipId: ID - deviceId: String - startDate: DateTime - endDate: DateTime - limit: Int = 100 - ): [MembershipUsage!]! -} - -# Mutations -type Mutation { - # Tier management - createDiscountTier(input: CreateTierInput!): DiscountTier! - updateDiscountTier(id: ID!, input: UpdateTierInput!): DiscountTier! - deleteDiscountTier(id: ID!): Boolean! - - # Membership management - createMembership(input: CreateMembershipInput!): Membership! - updateMembership(id: ID!, input: UpdateMembershipInput!): Membership! - deleteMembership(id: ID!): Boolean! - - # Bulk operations - importMemberships(memberships: [CreateMembershipInput!]!): ImportResult! - - # LNbits integration - syncMembershipWithLNbits(id: ID!): Membership! -} - -input CreateMembershipInput { - externalUserId: String! - tierName: String! - lightningAddress: String - customerId: ID - metadata: JSONObject -} - -input UpdateMembershipInput { - tierName: String - lightningAddress: String - customerId: ID - metadata: JSONObject - enabled: Boolean -} -``` - -### tRPC Router (Future) - -```typescript -// packages/server/lib/trpc/routers/membership.ts -import { router, protectedProcedure } from '../trpc' -import { z } from 'zod' - -export const membershipRouter = router({ - validate: protectedProcedure - .input(z.object({ membershipId: z.string() })) - .query(async ({ input, ctx }) => { - return ctx.membershipService.validate(input.membershipId) - }), - - list: protectedProcedure - .input(z.object({ - tierId: z.string().optional(), - search: z.string().optional(), - limit: z.number().default(50), - offset: z.number().default(0), - })) - .query(async ({ input, ctx }) => { - return ctx.membershipService.list(input) - }), - - create: protectedProcedure - .input(createMembershipSchema) - .mutation(async ({ input, ctx }) => { - return ctx.membershipService.create(input) - }), -}) -``` - -#api #rest #graphql #trpc - ---- - -## Machine Changes - -### New States in brain.js - -```javascript -// lib/brain.js - Add to state machine - -states: { - // ... existing states ... - - membershipPrompt: { - _onEnter: function () { - this._transitionState('membershipPrompt') - }, - membershipYes: 'membershipScan', - membershipNo: function () { - // Continue to standard flow - return this.tx.direction === 'cashIn' ? 'scanAddress' : 'insertBills' - } - }, - - membershipScan: { - _onEnter: function () { - this._transitionState('membershipScan', { timeout: 60000 }) - this.scanner.scanMembership() - }, - membershipScanned: function (membershipId) { - this.tx.membershipId = membershipId - return 'membershipValidating' - }, - timeout: 'membershipPrompt', - cancelMembership: 'membershipPrompt' - }, - - membershipValidating: { - _onEnter: function () { - this._transitionState('membershipValidating') - this.trader.validateMembership(this.tx.membershipId) - .then(result => this.emit('membershipResult', result)) - .catch(err => this.emit('membershipError', err)) - }, - membershipResult: function (result) { - if (result.valid) { - this.tx.membership = result.membership - this.tx.discountPercentage = result.membership.tier.discountPercentage - this.tx.lightningAddress = result.membership.lightningAddress - return 'membershipValid' - } - return 'membershipInvalid' - }, - membershipError: 'membershipInvalid' - }, - - membershipValid: { - _onEnter: function () { - const tier = this.tx.membership.tier - this._transitionState('membershipValid', { - tierName: tier.displayName, - discountPercentage: tier.discountPercentage, - hasLightningAddress: !!this.tx.lightningAddress - }) - }, - continue: function () { - if (this.tx.direction === 'cashIn' && this.tx.lightningAddress) { - // Skip address scan - we have Lightning address! - return 'insertBills' - } - return this.tx.direction === 'cashIn' ? 'scanAddress' : 'insertBills' - } - }, - - membershipInvalid: { - _onEnter: function () { - this._transitionState('membershipInvalid') - }, - retry: 'membershipScan', - skip: function () { - return this.tx.direction === 'cashIn' ? 'scanAddress' : 'insertBills' - } - } -} -``` - -### New Trader Methods - -```javascript -// lib/trader.js - -Trader.prototype.validateMembership = async function (membershipId) { - const response = await this.request({ - method: 'POST', - path: '/api/v1/membership/validate', - body: { membershipId } - }) - return response.data -} - -Trader.prototype.payToMembership = async function (membershipId, amountSats, amountFiat, txId) { - const response = await this.request({ - method: 'POST', - path: '/api/v1/membership/pay', - body: { membershipId, amountSats, amountFiat, currency: this.currency, txId } - }) - return response.data -} - -Trader.prototype.createMembershipInvoice = async function (membershipId, amountSats, memo) { - const response = await this.request({ - method: 'POST', - path: '/api/v1/membership/invoice', - body: { membershipId, amountSats, memo } - }) - return response.data -} -``` - -### UI Screens - -``` -screens/ -├── membership-prompt.html # "Do you have a membership card?" -├── membership-scan.html # "Scan your membership QR code" -├── membership-validating.html # Loading spinner -├── membership-valid.html # "Welcome Gold Member! 15% discount" -└── membership-invalid.html # "Membership not recognized" -``` - -#machine #statemachine #ui - ---- - -## LNbits Integration - -### Server Configuration - -```typescript -// packages/server/lib/lightning/config.ts -export interface LightningConfig { - provider: 'lnbits' | 'lnd' | 'cln' - lnbits?: { - baseUrl: string - adminKey: string // For outgoing payments (cash-in) - invoiceKey: string // For creating invoices (cash-out) - walletId: string - } -} -``` - -### LNbits Client Implementation - -```typescript -// packages/server/lib/lightning/lnbits-client.ts -import { z } from 'zod' - -const PaymentResponseSchema = z.object({ - payment_hash: z.string(), - checking_id: z.string(), -}) - -const InvoiceResponseSchema = z.object({ - payment_hash: z.string(), - payment_request: z.string(), // bolt11 - checking_id: z.string(), -}) - -export class LNbitsClient { - constructor(private config: LNbitsConfig) {} - - async payToLightningAddress( - address: string, - amountSats: number, - comment?: string - ): Promise { - // Step 1: Resolve Lightning Address to LNURL - const [name, domain] = address.split('@') - const lnurlResponse = await fetch( - `https://${domain}/.well-known/lnurlp/${name}` - ) - const lnurlData = await lnurlResponse.json() - - // Step 2: Get invoice from LNURL callback - const amountMsat = amountSats * 1000 - const callbackUrl = new URL(lnurlData.callback) - callbackUrl.searchParams.set('amount', amountMsat.toString()) - if (comment) callbackUrl.searchParams.set('comment', comment) - - const invoiceResponse = await fetch(callbackUrl.toString()) - const { pr: bolt11 } = await invoiceResponse.json() - - // Step 3: Pay the invoice via LNbits - return this.payInvoice(bolt11) - } - - async payInvoice(bolt11: string): Promise { - const response = await fetch(`${this.config.baseUrl}/api/v1/payments`, { - method: 'POST', - headers: { - 'X-API-KEY': this.config.adminKey, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ out: true, bolt11 }), - }) - - const data = PaymentResponseSchema.parse(await response.json()) - return { - success: true, - paymentHash: data.payment_hash, - checkingId: data.checking_id, - } - } - - async createInvoice( - amountSats: number, - memo: string, - expiry: number = 600 - ): Promise { - const response = await fetch(`${this.config.baseUrl}/api/v1/payments`, { - method: 'POST', - headers: { - 'X-API-KEY': this.config.invoiceKey, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - out: false, - amount: amountSats, - memo, - expiry, - }), - }) - - const data = InvoiceResponseSchema.parse(await response.json()) - return { - bolt11: data.payment_request, - paymentHash: data.payment_hash, - checkingId: data.checking_id, - expiresAt: new Date(Date.now() + expiry * 1000), - } - } - - async getPaymentStatus(checkingId: string): Promise { - const response = await fetch( - `${this.config.baseUrl}/api/v1/payments/${checkingId}`, - { - headers: { 'X-API-KEY': this.config.invoiceKey }, - } - ) - - const data = await response.json() - return { - paid: data.paid === true, - pending: data.pending === true, - preimage: data.preimage, - } - } - - async getBalance(): Promise { - const response = await fetch(`${this.config.baseUrl}/api/v1/wallet`, { - headers: { 'X-API-KEY': this.config.adminKey }, - }) - - const data = await response.json() - return Math.floor(data.balance / 1000) // msat to sats - } -} -``` - -### Supported Lightning Addresses - -| Provider | Format | Type | -|----------|--------|------| -| Wallet of Satoshi | `user@walletofsatoshi.com` | Custodial | -| Alby | `user@getalby.com` | Custodial | -| Blink | `user@blink.sv` | Custodial | -| Strike | `user@strike.me` | Custodial | -| Phoenix | LNURL-pay | Self-custodial | -| Breez | LNURL-pay | Self-custodial | -| Zeus | Own node | Self-custodial | -| LNbits | `user@lnbits.instance.com` | Self-hosted | - -#lnbits #lightning #integration - ---- - -## Implementation Phases - -### Phase 1: Database & Core Services - -> [!todo] Phase 1 Tasks - -- [ ] Create database migration for `discount_tiers`, `memberships`, `membership_usage` -- [ ] Seed default discount tiers -- [ ] Implement `MembershipService` in TypeScript -- [ ] Implement `LNbitsClient` for Lightning operations -- [ ] Add REST endpoints: `/membership/validate`, `/membership/pay`, `/membership/invoice` -- [ ] Add GraphQL types and resolvers for admin API -- [ ] Write unit tests for membership service -- [ ] Write integration tests for LNbits client - -**Files to create:** -``` -packages/server/ -├── lib/ -│ ├── membership/ -│ │ ├── index.ts -│ │ ├── membership-service.ts -│ │ ├── membership-types.ts -│ │ └── membership-repository.ts -│ └── lightning/ -│ ├── index.ts -│ ├── lnbits-client.ts -│ ├── lightning-address-resolver.ts -│ └── lightning-types.ts -├── routes/ -│ └── membership-routes.ts -└── migrations/ - └── 20260122_membership_tables.sql - -packages/typesafe-db/src/ -└── schema/ - └── membership.ts -``` - -### Phase 2: Machine Integration - -> [!todo] Phase 2 Tasks - -- [ ] Add new states to `brain.js` state machine -- [ ] Implement membership scan flow -- [ ] Modify cash-in flow to skip wallet scan for members -- [ ] Add Trader methods for membership API -- [ ] Create UI screens for membership flow -- [ ] Add i18n translations -- [ ] Test with mock membership data - -**Files to modify:** -``` -lamassu-machine/ -├── lib/ -│ ├── brain.js # Add membership states -│ └── trader.js # Add membership methods -├── ui/ -│ ├── src/app.js # Add membership handlers -│ └── html/ -│ ├── membership-prompt.html -│ ├── membership-scan.html -│ ├── membership-valid.html -│ └── membership-invalid.html -└── i18n/ - └── ui/ # Add translations -``` - -### Phase 3: Admin Dashboard - -> [!todo] Phase 3 Tasks - -- [ ] Create membership management pages -- [ ] Create tier configuration UI -- [ ] Add membership import/export -- [ ] Add usage analytics dashboard -- [ ] Generate membership QR codes -- [ ] Build member lookup interface - -**Files to create:** -``` -packages/admin-ui/src/ -├── pages/ -│ ├── Memberships/ -│ │ ├── index.tsx -│ │ ├── MembershipList.tsx -│ │ ├── MembershipDetail.tsx -│ │ └── CreateMembership.tsx -│ └── DiscountTiers/ -│ ├── index.tsx -│ └── TierConfig.tsx -└── components/ - └── membership/ - ├── MembershipQRCode.tsx - └── UsageChart.tsx -``` - -### Phase 4: LNbits Deep Integration - -> [!todo] Phase 4 Tasks - -- [ ] Test Lightning Address resolution for all major wallets -- [ ] Implement LNURL-pay flow with amount limits -- [ ] Handle edge cases (offline wallets, expired invoices) -- [ ] Add webhook support for payment notifications -- [ ] Implement automatic LNbits user creation for members -- [ ] Add balance monitoring and alerts - -### Phase 5: Web Portal (Optional) - -> [!todo] Phase 5 Tasks - -- [ ] Self-service membership registration -- [ ] Member dashboard (transaction history, tier status) -- [ ] QR code download/print -- [ ] Lightning Address configuration -- [ ] Upgrade tier based on volume - -#implementation #phases #roadmap - ---- - -## Security Considerations - -> [!warning] Security Requirements - -1. **Membership ID Format**: Use cryptographically random IDs, not sequential -2. **Rate Limiting**: Limit membership validation attempts per machine -3. **Audit Trail**: Log all membership usage for compliance -4. **Lightning Key Security**: Store LNbits admin key in encrypted config -5. **Amount Limits**: Enforce per-transaction and daily limits per tier -6. **Address Verification**: Validate Lightning Address format before storage - -### Membership ID Format - -``` -MEM-{YEAR}-{TIER}-{RANDOM} -MEM-2026-GOLD-A7X9K2M4 -``` - -- 4-digit year -- Tier indicator (for visual identification) -- 8-character random alphanumeric - -#security - ---- - -## Related Documents - -- [[modernization-plan]] - Overall modernization roadmap -- [[LNbits Integration]] - Detailed LNbits API documentation -- [[API Key Authentication]] - Prerequisite for programmatic access -- [[CLAUDE]] - Development guidance diff --git a/docs/hardware/hardware-recommendations.md b/docs/hardware/hardware-recommendations.md deleted file mode 100644 index 9f1e56f..0000000 --- a/docs/hardware/hardware-recommendations.md +++ /dev/null @@ -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) diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md deleted file mode 100644 index 98e93e2..0000000 --- a/docs/implementation-plan.md +++ /dev/null @@ -1,2558 +0,0 @@ ---- -title: Implementation Plan - Nostr-Native Lightning ATM -created: 2026-01-22 -updated: 2026-01-22 -tags: - - implementation - - roadmap - - development - - nostr - - lightning -status: active -priority: critical ---- - -# Implementation Plan: Nostr-Native Lightning ATM - -> [!abstract] Summary -> Concrete implementation plan for building the Nostr-native Lightning ATM from scratch. Organized in phases with clear dependencies, deliverables, and testable milestones. - -## Quick Links - -- [[#Phase 0: Foundation]] -- [[#Phase 1: Core Infrastructure]] -- [[#Phase 2: Machine Shell]] -- [[#Phase 3: Hardware Abstraction]] -- [[#Phase 4: Payment Flows]] -- [[#Phase 5: Operator Tools]] -- [[#Monorepo Structure]] - ---- - -## Target Market Context - -> [!important] Cash-Out Dominant Market -> Market research shows **95%+ of activity is cash-out** (users selling Bitcoin for cash). This is typical for: -> - Remittance corridors (receiving Bitcoin from abroad) -> - Bitcoin-paid workers converting to local currency -> - Merchants liquidating Bitcoin revenue -> -> **Implications:** -> - Bill dispenser is **essential** for testing, not optional -> - Cash-out flow is the **critical path** -> - Cash-in still important (equal weight in code) but secondary for go-live - ---- - -## Guiding Principles - -> [!important] Build Philosophy -> 1. **Cash-out first** - Primary use case, test with real dispenser early -> 2. **Vertical slices** - Each phase delivers working functionality -> 3. **Test on real hardware early** - Dispenser essential, not optional -> 4. **Ship incrementally** - Working cash-out ATM > feature-complete vaporware -> 5. **Port, don't rewrite** - Existing drivers are battle-tested - ---- - -## Hardware Driver Porting Strategy - -> [!important] Key Principle -> The existing `lamassu-machine/lib/` contains **working, battle-tested drivers** for all current Lamassu hardware. These drivers have been refined over years of production use. Our strategy is to **port these drivers to Rust** rather than rewrite from scratch. - -### Why Port Instead of Rewrite? - -| Approach | Pros | Cons | -|----------|------|------| -| **Port existing** | Known working, protocol bugs already fixed, edge cases handled | Need to understand JS code first | -| Rewrite from spec | "Clean" code | Protocol docs often incomplete, will hit same bugs again | - -### Porting Process - -``` -1. Read existing JavaScript driver thoroughly -2. Document the protocol (commands, responses, state machine) -3. Implement Rust version using existing code as specification -4. Test against same hardware -5. Verify identical behavior -``` - -### Driver Complexity Ranking - -Based on lamassu-machine source code analysis: - -| Driver | Lines | Complexity | Priority | -|--------|-------|------------|----------| -| `puloon` | ~600 | Low | **Critical** (cash-out) | -| `id003` | ~1,000 | Medium | **High** (default validator) | -| `ccnet` | ~800 | Medium | Medium | -| `f56` | ~1,200 | Medium-High | High (FSM-based) | -| `mei/cashflow_sc` | ~700 | Medium | Medium | -| `hcm2` | ~1,500 | **High** | Low (recycler) | -| `gsr50` | ~1,100 | **High** | Low (recycler) | - -> [!tip] Start with Puloon + ID003 -> These two drivers cover the most common hardware and have reasonable complexity. The Puloon dispenser is critical for the cash-out-dominant market. - -### Testing Strategy - -1. **Unit tests** - Test protocol encoding/decoding against known values from JS -2. **Integration tests** - Run against mock hardware (from `lib/mocks/`) -3. **Hardware tests** - Run against real hardware, compare behavior to JS driver - ---- - -## Monorepo Structure - -``` -lamassu-next/ -├── .github/ -│ └── workflows/ # CI/CD -├── apps/ -│ ├── machine/ # Tauri + Vue 3 ATM application -│ │ ├── src/ # Vue 3 frontend -│ │ ├── src-tauri/ # Rust backend -│ │ └── package.json -│ ├── dashboard/ # Vue 3 operator dashboard -│ │ ├── src/ -│ │ └── package.json -│ └── relay/ # Private Nostr relay config -│ └── strfry.conf -├── packages/ -│ ├── hal/ # Rust Hardware Abstraction Layer -│ │ ├── src/ -│ │ │ ├── lib.rs -│ │ │ ├── validators/ # Bill validators -│ │ │ ├── dispensers/ # Bill dispensers -│ │ │ ├── printer/ # Receipt printer -│ │ │ └── nfc/ # NFC reader -│ │ ├── Cargo.toml -│ │ └── index.node # napi-rs output -│ ├── nostr-client/ # Shared Nostr client -│ │ ├── src/ -│ │ │ ├── identity.ts -│ │ │ ├── relay.ts -│ │ │ ├── events.ts -│ │ │ └── encryption.ts -│ │ └── package.json -│ ├── clink/ # CLINK SDK wrapper -│ │ ├── src/ -│ │ └── package.json -│ ├── state-machine/ # XState v5 machine logic -│ │ ├── src/ -│ │ │ ├── machines/ -│ │ │ │ ├── atm.ts -│ │ │ │ ├── cashIn.ts -│ │ │ │ └── cashOut.ts -│ │ │ └── index.ts -│ │ └── package.json -│ └── ui-shared/ # Shared Vue components -│ ├── src/ -│ └── package.json -├── devenv.nix # Development environment -├── devenv.yaml -├── flake.nix # Nix flake for builds -├── flake.lock -├── pnpm-workspace.yaml -├── package.json -└── turbo.json # Turborepo config -``` - ---- - -## Phase 0: Foundation - -> [!goal] Deliverable -> Reproducible development environment with all tooling ready. - -### 0.1 Initialize Monorepo - -```bash -# Create new repo (or restructure existing) -mkdir lamassu-next && cd lamassu-next -pnpm init -pnpm add -Dw turbo typescript @types/node -``` - -**turbo.json:** -```json -{ - "$schema": "https://turbo.build/schema.json", - "tasks": { - "build": { - "dependsOn": ["^build"], - "outputs": ["dist/**"] - }, - "dev": { - "cache": false, - "persistent": true - }, - "test": { - "dependsOn": ["build"] - }, - "lint": {} - } -} -``` - -**pnpm-workspace.yaml:** -```yaml -packages: - - 'apps/*' - - 'packages/*' -``` - -### 0.2 Development Environment (devenv.nix) - -```nix -# devenv.nix -{ pkgs, lib, ... }: - -{ - # Languages - languages.javascript = { - enable = true; - package = pkgs.nodejs_22; - pnpm = { - enable = true; - install.enable = true; - }; - }; - - languages.typescript.enable = true; - - languages.rust = { - enable = true; - channel = "stable"; - components = [ "rustc" "cargo" "clippy" "rustfmt" "rust-analyzer" ]; - }; - - # Services - services.postgres = { - enable = true; - initialDatabases = [{ name = "lamassu_dev"; }]; - listen_addresses = "127.0.0.1"; - }; - - # Packages - packages = with pkgs; [ - # Build tools - pkg-config - openssl - - # Tauri dependencies - gtk3 - webkitgtk - libappindicator-gtk3 - - # Hardware - libusb1 - libnfc - - # Nostr tools - nak # Nostr army knife CLI - - # Development - just # Task runner - jq - ]; - - # Pre-commit hooks - pre-commit.hooks = { - prettier.enable = true; - eslint.enable = true; - rustfmt.enable = true; - clippy.enable = true; - }; - - # Environment variables - env = { - RUST_BACKTRACE = "1"; - DATABASE_URL = "postgresql://localhost/lamassu_dev"; - }; - - # Scripts - scripts = { - dev.exec = "pnpm turbo dev"; - build.exec = "pnpm turbo build"; - test.exec = "pnpm turbo test"; - }; - - enterShell = '' - echo "🚀 Lamassu development environment" - echo " Node.js: $(node --version)" - echo " Rust: $(rustc --version)" - echo "" - echo "Commands: dev, build, test" - ''; -} -``` - -### 0.3 Lightning.Pub Local Setup - -```bash -# Option A: Docker for development -docker run -d \ - --name lightning-pub-dev \ - -p 9735:9735 \ - -v ~/lightning_pub_dev:/root/lightning_pub \ - ghcr.io/shocknet/lightning-pub:latest - -# Option B: Native install (Linux/macOS) -wget -qO- https://deploy.lightning.pub | bash -``` - -### 0.4 Private Relay Setup - -```bash -# Using strfry in Docker for dev -docker run -d \ - --name strfry-dev \ - -p 7777:7777 \ - -v ./apps/relay/strfry.conf:/etc/strfry.conf \ - ghcr.io/hoytech/strfry:latest -``` - -**apps/relay/strfry.conf:** -``` -relay { - bind = "0.0.0.0" - port = 7777 - - info { - name = "Lamassu Dev Relay" - description = "Development relay for ATM testing" - } - - # Require auth in production - # authRequired = true -} -``` - -### 0.5 Milestone Checklist - -- [ ] `pnpm install` works -- [ ] `devenv shell` enters environment -- [ ] Lightning.Pub running, can create wallet -- [ ] strfry relay running, can publish events -- [ ] `nak` CLI can interact with relay - ---- - -## Phase 1: Core Infrastructure - -> [!goal] Deliverable -> Nostr client library and machine identity system. - -### 1.1 Nostr Client Package - -**packages/nostr-client/src/index.ts:** -```typescript -export * from './identity' -export * from './relay' -export * from './events' -export * from './encryption' -``` - -**packages/nostr-client/src/identity.ts:** -```typescript -import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools' -import { readFileSync, writeFileSync, existsSync } from 'fs' - -export interface MachineIdentity { - nsec: Uint8Array - npub: string - npubBech32: string -} - -export function loadOrCreateIdentity(path: string): MachineIdentity { - if (existsSync(path)) { - const nsecBech32 = readFileSync(path, 'utf-8').trim() - const { data: nsec } = nip19.decode(nsecBech32) - const npub = getPublicKey(nsec as Uint8Array) - return { - nsec: nsec as Uint8Array, - npub, - npubBech32: nip19.npubEncode(npub), - } - } - - const nsec = generateSecretKey() - const npub = getPublicKey(nsec) - const nsecBech32 = nip19.nsecEncode(nsec) - - writeFileSync(path, nsecBech32, { mode: 0o600 }) - - return { - nsec, - npub, - npubBech32: nip19.npubEncode(npub), - } -} -``` - -**packages/nostr-client/src/relay.ts:** -```typescript -import { Relay, finalizeEvent, type Event } from 'nostr-tools' -import type { MachineIdentity } from './identity' - -export class ATMRelayClient { - private relay: Relay | null = null - private identity: MachineIdentity - - constructor( - private relayUrl: string, - identity: MachineIdentity - ) { - this.identity = identity - } - - async connect(): Promise { - this.relay = await Relay.connect(this.relayUrl) - console.log(`Connected to ${this.relayUrl}`) - - // NIP-42 auth will be handled automatically if required - } - - async publish(event: Partial): Promise { - if (!this.relay) throw new Error('Not connected') - - const signed = finalizeEvent( - { - kind: event.kind!, - content: event.content || '', - tags: event.tags || [], - created_at: Math.floor(Date.now() / 1000), - }, - this.identity.nsec - ) - - await this.relay.publish(signed) - } - - subscribe( - filters: object[], - onEvent: (event: Event) => void - ): () => void { - if (!this.relay) throw new Error('Not connected') - - const sub = this.relay.subscribe(filters, { - onevent: onEvent, - }) - - return () => sub.close() - } - - async disconnect(): Promise { - this.relay?.close() - this.relay = null - } -} -``` - -**packages/nostr-client/src/events.ts:** -```typescript -// ATM-specific event kinds -export const EVENT_KINDS = { - // CLINK - CLINK_OFFER: 21001, - CLINK_DEBIT: 21002, - CLINK_MANAGE: 21003, - - // Machine status (replaceable) - MACHINE_STATUS: 30078, - TRANSACTION_RECORD: 30079, - - // Encrypted DMs - PRIVATE_DM: 14, - - // Auth - NIP42_AUTH: 22242, -} as const - -export interface MachineStatusContent { - online: boolean - version: string - lastTransaction?: number - cashLevels: { - validator: number - dispenserCassettes: Array<{ - denomination: number - count: number - }> - } - errors: string[] -} - -export interface TransactionContent { - txid: string - type: 'cash_in' | 'cash_out' - amountFiat: number - amountSats: number - currency: string - fee: number - timestamp: number - paymentMethod: 'clink_offer' | 'lnurl_withdraw' | 'invoice' | 'cashu' -} -``` - -### 1.2 CLINK Integration - -**packages/clink/src/index.ts:** -```typescript -import { nip44 } from 'nostr-tools' -import type { MachineIdentity } from '@lamassu/nostr-client' - -export interface CLINKOffer { - pubkey: string - relays: string[] - priceType: 'fixed' | 'variable' | 'spontaneous' - amount?: number // For fixed price - description?: string -} - -export function createOffer( - identity: MachineIdentity, - relays: string[], - priceType: CLINKOffer['priceType'], - amount?: number -): string { - // Encode as noffer1... bech32 string - const data = { - pubkey: identity.npub, - relays, - priceType, - amount, - } - - // TLV encoding for noffer - return encodeNoffer(data) -} - -export async function handleOfferRequest( - request: any, - identity: MachineIdentity, - generateInvoice: (amountMsat: number) => Promise -): Promise { - const amountMsat = request.amount || calculateAmount(request) - const invoice = await generateInvoice(amountMsat) - - // Create encrypted response - const response = nip44.encrypt( - identity.nsec, - request.senderPubkey, - JSON.stringify({ invoice }) - ) - - return response -} - -function encodeNoffer(data: object): string { - // TODO: Implement proper TLV encoding per CLINK spec - const json = JSON.stringify(data) - const bytes = new TextEncoder().encode(json) - // bech32 encode with 'noffer' prefix - return `noffer1${Buffer.from(bytes).toString('hex')}` -} - -function calculateAmount(request: any): number { - // Calculate based on fiat amount and exchange rate - return request.fiatAmount * request.satsPerUnit -} -``` - -### 1.3 Milestone Checklist - -- [ ] `@lamassu/nostr-client` package builds -- [ ] Can generate machine identity -- [ ] Can connect to strfry relay -- [ ] Can publish and receive events -- [ ] `@lamassu/clink` package builds -- [ ] Can create noffer string -- [ ] Integration test: publish event, receive on another client - ---- - -## Phase 2: Machine Shell (Tauri + Vue 3) - -> [!goal] Deliverable -> ATM application shell that can display UI and communicate via Nostr. - -### 2.1 Initialize Tauri App - -```bash -cd apps/machine -pnpm create tauri-app . --template vue-ts -pnpm add @lamassu/nostr-client @lamassu/clink @lamassu/state-machine -pnpm add @vueuse/core pinia xstate @xstate/vue -``` - -### 2.2 Vue 3 App Structure - -``` -apps/machine/src/ -├── App.vue -├── main.ts -├── assets/ -├── components/ -│ ├── ui/ # shadcn-vue components -│ │ ├── Button.vue -│ │ ├── Card.vue -│ │ └── ... -│ ├── CashInScreen.vue -│ ├── CashOutScreen.vue -│ ├── IdleScreen.vue -│ ├── QRDisplay.vue -│ └── NumPad.vue -├── composables/ -│ ├── useNostr.ts -│ ├── useHardware.ts -│ └── useMachine.ts -├── stores/ -│ ├── transaction.ts -│ └── machine.ts -└── machines/ - └── atm.ts # XState machine -``` - -### 2.3 XState Machine - -**apps/machine/src/machines/atm.ts:** -```typescript -import { setup, assign } from 'xstate' - -interface ATMContext { - // Transaction - fiatAmount: number - satsAmount: number - currency: string - - // Payment - invoice: string | null - clinkOffer: string | null - paymentStatus: 'pending' | 'paid' | 'failed' | null - - // Hardware - billsInserted: number[] - cashDispensed: boolean - - // User - userNpub: string | null - - // Errors - error: string | null -} - -type ATMEvent = - | { type: 'SELECT_CASH_IN' } - | { type: 'SELECT_CASH_OUT' } - | { type: 'CANCEL' } - | { type: 'BILL_INSERTED'; denomination: number } - | { type: 'FINISH_INSERTING' } - | { type: 'PAYMENT_RECEIVED' } - | { type: 'PAYMENT_FAILED'; error: string } - | { type: 'CASH_DISPENSED' } - | { type: 'USER_SCANNED_NPUB'; npub: string } - | { type: 'TIMEOUT' } - -export const atmMachine = setup({ - types: { - context: {} as ATMContext, - events: {} as ATMEvent, - }, - actions: { - resetContext: assign({ - fiatAmount: 0, - satsAmount: 0, - invoice: null, - clinkOffer: null, - paymentStatus: null, - billsInserted: [], - cashDispensed: false, - userNpub: null, - error: null, - }), - addBill: assign({ - billsInserted: ({ context, event }) => { - if (event.type !== 'BILL_INSERTED') return context.billsInserted - return [...context.billsInserted, event.denomination] - }, - fiatAmount: ({ context, event }) => { - if (event.type !== 'BILL_INSERTED') return context.fiatAmount - return context.fiatAmount + event.denomination - }, - }), - calculateSats: assign({ - satsAmount: ({ context }) => { - // TODO: Get exchange rate - const rate = 100_000 // sats per dollar (example) - const fee = 0.02 // 2% - return Math.floor(context.fiatAmount * rate * (1 - fee)) - }, - }), - setUserNpub: assign({ - userNpub: ({ event }) => { - if (event.type !== 'USER_SCANNED_NPUB') return null - return event.npub - }, - }), - }, - guards: { - hasInsertedBills: ({ context }) => context.billsInserted.length > 0, - hasSufficientAmount: ({ context }) => context.fiatAmount >= 1, - }, -}).createMachine({ - id: 'atm', - initial: 'idle', - context: { - fiatAmount: 0, - satsAmount: 0, - currency: 'USD', - invoice: null, - clinkOffer: null, - paymentStatus: null, - billsInserted: [], - cashDispensed: false, - userNpub: null, - error: null, - }, - states: { - idle: { - entry: 'resetContext', - on: { - SELECT_CASH_IN: 'cashIn', - SELECT_CASH_OUT: 'cashOut', - }, - }, - - // === CASH IN (Buy Bitcoin) === - cashIn: { - initial: 'insertingBills', - states: { - insertingBills: { - on: { - BILL_INSERTED: { - actions: ['addBill'], - }, - FINISH_INSERTING: { - guard: 'hasInsertedBills', - target: 'calculatingAmount', - }, - CANCEL: '#atm.idle', - }, - }, - calculatingAmount: { - entry: 'calculateSats', - always: 'generatingOffer', - }, - generatingOffer: { - invoke: { - src: 'generateClinkOffer', - onDone: { - target: 'displayingQR', - actions: assign({ - clinkOffer: ({ event }) => event.output, - }), - }, - onError: { - target: 'error', - actions: assign({ - error: ({ event }) => event.error.message, - }), - }, - }, - }, - displayingQR: { - on: { - PAYMENT_RECEIVED: 'askForReceipt', - TIMEOUT: '#atm.idle', - CANCEL: '#atm.idle', - }, - }, - askForReceipt: { - on: { - USER_SCANNED_NPUB: { - actions: 'setUserNpub', - target: 'sendingReceipt', - }, - CANCEL: 'complete', // Skip receipt - }, - }, - sendingReceipt: { - invoke: { - src: 'sendNostrReceipt', - onDone: 'complete', - onError: 'complete', // Don't fail transaction for receipt - }, - }, - complete: { - after: { - 3000: '#atm.idle', - }, - }, - error: { - on: { - CANCEL: '#atm.idle', - }, - }, - }, - }, - - // === CASH OUT (Sell Bitcoin) === - cashOut: { - initial: 'selectingAmount', - states: { - selectingAmount: { - on: { - SELECT_AMOUNT: { - target: 'generatingInvoice', - actions: assign({ - fiatAmount: ({ event }) => event.amount, - }), - }, - CANCEL: '#atm.idle', - }, - }, - generatingInvoice: { - invoke: { - src: 'generateInvoice', - onDone: { - target: 'awaitingPayment', - actions: assign({ - invoice: ({ event }) => event.output, - }), - }, - onError: { - target: 'error', - }, - }, - }, - awaitingPayment: { - on: { - PAYMENT_RECEIVED: 'dispensingCash', - TIMEOUT: '#atm.idle', - CANCEL: '#atm.idle', - }, - }, - dispensingCash: { - invoke: { - src: 'dispenseCash', - onDone: 'complete', - onError: 'error', - }, - }, - complete: { - after: { - 3000: '#atm.idle', - }, - }, - error: { - on: { - CANCEL: '#atm.idle', - }, - }, - }, - }, - }, -}) -``` - -### 2.4 Nostr Composable - -**apps/machine/src/composables/useNostr.ts:** -```typescript -import { ref, onMounted, onUnmounted } from 'vue' -import { ATMRelayClient, loadOrCreateIdentity } from '@lamassu/nostr-client' -import { invoke } from '@tauri-apps/api/core' - -const RELAY_URL = import.meta.env.VITE_RELAY_URL || 'ws://localhost:7777' -const IDENTITY_PATH = '/etc/lamassu/machine.nsec' - -export function useNostr() { - const connected = ref(false) - const identity = ref | null>(null) - let client: ATMRelayClient | null = null - - onMounted(async () => { - // Load identity (via Tauri for secure storage) - const nsecPath = await invoke('get_identity_path') - identity.value = loadOrCreateIdentity(nsecPath) - - // Connect to relay - client = new ATMRelayClient(RELAY_URL, identity.value) - await client.connect() - connected.value = true - - // Publish online status - await publishStatus({ online: true }) - }) - - onUnmounted(async () => { - await publishStatus({ online: false }) - await client?.disconnect() - }) - - async function publishStatus(status: object) { - if (!client || !identity.value) return - - await client.publish({ - kind: 30078, - content: JSON.stringify(status), - tags: [['d', 'status']], - }) - } - - async function publishTransaction(tx: object) { - if (!client || !identity.value) return - - await client.publish({ - kind: 30079, - content: JSON.stringify(tx), - tags: [['d', `tx:${tx.txid}`]], - }) - } - - function subscribeToCommands(onCommand: (cmd: object) => void) { - if (!client || !identity.value) return () => {} - - return client.subscribe( - [{ kinds: [21003], '#p': [identity.value.npub] }], - (event) => { - // Decrypt and handle command - const cmd = JSON.parse(event.content) // TODO: decrypt - onCommand(cmd) - } - ) - } - - return { - connected, - identity, - publishStatus, - publishTransaction, - subscribeToCommands, - } -} -``` - -### 2.5 Tauri Commands - -**apps/machine/src-tauri/src/lib.rs:** -```rust -use std::path::PathBuf; - -#[tauri::command] -fn get_identity_path() -> String { - // In production, use secure location - if cfg!(debug_assertions) { - dirs::home_dir() - .unwrap() - .join(".lamassu/machine.nsec") - .to_string_lossy() - .to_string() - } else { - "/etc/lamassu/machine.nsec".to_string() - } -} - -#[tauri::command] -async fn get_exchange_rate(currency: String) -> Result { - // TODO: Fetch from price feed - Ok(100_000.0) // sats per dollar -} - -#[cfg_attr(mobile, tauri::mobile_entry_point)] -pub fn run() { - tauri::Builder::default() - .plugin(tauri_plugin_shell::init()) - .invoke_handler(tauri::generate_handler![ - get_identity_path, - get_exchange_rate, - ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); -} -``` - -### 2.6 Milestone Checklist - -- [ ] Tauri app builds and runs -- [ ] Vue 3 UI displays idle screen -- [ ] Can navigate between screens -- [ ] XState machine handles state transitions -- [ ] Connects to Nostr relay -- [ ] Publishes status events -- [ ] Can display QR codes - ---- - -## Phase 3: Hardware Abstraction Layer - -> [!goal] Deliverable -> Rust HAL with napi-rs bindings for ALL existing Lamassu hardware. - -> [!important] Porting Strategy -> The existing `lamassu-machine/lib/` contains **working, tested drivers** for all current Lamassu hardware. Our strategy is to **port these drivers to Rust** rather than rewrite from scratch. The existing JavaScript implementations serve as the specification. - -### 3.0 Existing Driver Catalog (lamassu-machine) - -The following drivers exist in the current lamassu-machine codebase and MUST be supported: - -#### Bill Validators - -| Driver | Protocol | Files | Notes | -|--------|----------|-------|-------| -| **id003** | JCM ID-003 | `lib/id003/*.js` | Default validator, FSM-based | -| **ccnet** | CashCode CCNET | `lib/ccnet/*.js` | Common in NA, has emulator | -| **cashflow_sc** | MEI CashFlow SC | `lib/mei/cashflow_sc.js` | MEI validator (11KB) | -| **bnr_advance** | MEI BNR Advance | `lib/mei/bnr_advance.js` | MEI recycler | -| **genmega** | Genmega proprietary | `lib/genmega/genmega-validator/` | Integrated with Genmega ATMs | -| **hcm2** | Hitachi HCM2 | `lib/hcm2/hcm2.js` | Recycler (23KB, complex) | -| **gsr50** | GSR50 | `lib/gsr50/gsr50.js` | Recycler (17KB) | - -#### Bill Dispensers - -| Driver | Protocol | Files | Notes | -|--------|----------|-------|-------| -| **puloon** | Puloon RS-232 | `lib/puloon/*.js` | LCDM series, common | -| **f56** | Fujitsu F53/F56 | `lib/f56/*.js` | Multi-cassette, FSM-based | -| **genmega** | Genmega proprietary | `lib/genmega/genmega-dispenser/` | Integrated dispensers | -| **hcm2** | Hitachi HCM2 | `lib/hcm2/hcm2.js` | Recycler dispense mode | -| **gsr50** | GSR50 | `lib/gsr50/gsr50.js` | Recycler dispense mode | - -#### Printers - -| Driver | Protocol | Files | Notes | -|--------|----------|-------|-------| -| **nippon** | ESC/POS variant | `lib/printer/nippon.js` | Nippon thermal (9KB) | -| **zebra** | ZPL (Zebra) | `lib/printer/zebra.js` | Label printing (13KB) | -| **genmega** | Genmega proprietary | `lib/printer/genmega.js` | Integrated printer (7KB) | - -#### Scanners/Camera - -| Driver | Files | Notes | -|--------|-------|-------| -| **manatee** | `lib/capture/scanner/manatee.js` | Manatee barcode scanner | -| **zxing** | `lib/capture/scanner/zxing.js` | ZXing-based QR scanning | -| **scanner-node** | `lib/scanner-node.js` | Node camera (7KB) | -| **scanner-genmega** | `lib/scanner-genmega.js` | Genmega integrated (2KB) | - -#### Other Peripherals - -| Driver | Files | Notes | -|--------|-------|-------| -| **leds** | `lib/leds/*.js` | LED control (3 files) | - -### 3.1 HAL Crate Structure - -``` -packages/hal/ -├── Cargo.toml -├── src/ -│ ├── lib.rs -│ ├── error.rs -│ ├── validators/ -│ │ ├── mod.rs -│ │ ├── traits.rs # BillValidator trait -│ │ ├── id003.rs # JCM ID-003 (port from lib/id003/) -│ │ ├── ccnet.rs # CashCode CCNET (port from lib/ccnet/) -│ │ ├── mei_cashflow.rs # MEI CashFlow SC -│ │ ├── mei_bnr.rs # MEI BNR Advance -│ │ ├── genmega.rs # Genmega validator -│ │ ├── hcm2.rs # Hitachi HCM2 recycler -│ │ ├── gsr50.rs # GSR50 recycler -│ │ └── mock.rs # Mock for testing -│ ├── dispensers/ -│ │ ├── mod.rs -│ │ ├── traits.rs # BillDispenser trait -│ │ ├── puloon.rs # Puloon LCDM (port from lib/puloon/) -│ │ ├── f56.rs # Fujitsu F53/F56 (port from lib/f56/) -│ │ ├── genmega.rs # Genmega dispenser -│ │ ├── hcm2.rs # Hitachi HCM2 dispense -│ │ ├── gsr50.rs # GSR50 dispense -│ │ └── mock.rs -│ ├── printer/ -│ │ ├── mod.rs -│ │ ├── traits.rs # Printer trait -│ │ ├── nippon.rs # Nippon ESC/POS -│ │ ├── zebra.rs # Zebra ZPL -│ │ ├── genmega.rs # Genmega printer -│ │ └── escpos.rs # Generic ESC/POS -│ ├── scanner/ -│ │ ├── mod.rs -│ │ ├── traits.rs # Scanner trait -│ │ ├── manatee.rs # Manatee barcode -│ │ ├── zxing.rs # ZXing QR -│ │ └── genmega.rs # Genmega scanner -│ ├── leds/ -│ │ ├── mod.rs -│ │ └── traits.rs # LED control trait -│ └── nfc/ -│ ├── mod.rs -│ └── acr122u.rs # ACR122U reader -└── index.node # napi-rs output -``` - -### 3.2 Bill Validator Trait - -> [!note] Interface Derived from Existing Code -> This trait is derived from the common interface used across all existing lamassu-machine validators in `lib/bill-validator.js`. - -**packages/hal/src/validators/traits.rs:** -```rust -use async_trait::async_trait; -use thiserror::Error; - -#[derive(Debug, Clone)] -pub struct BillEvent { - pub denomination: u32, - pub currency: String, - pub timestamp: u64, - pub event_type: BillEventType, -} - -#[derive(Debug, Clone)] -pub enum BillEventType { - Inserted, // Bill detected and validated - Accepted, // Bill sent to stacker - Rejected, // Bill returned to customer - Stacked, // Bill successfully stacked - Jammed, // Bill jammed -} - -#[derive(Debug, Error)] -pub enum ValidatorError { - #[error("Connection failed: {0}")] - ConnectionFailed(String), - #[error("Communication error: {0}")] - CommunicationError(String), - #[error("Bill rejected: {0}")] - BillRejected(String), - #[error("Stacker full")] - StackerFull, - #[error("Hardware error: {0}")] - HardwareError(String), -} - -/// Unified interface for all bill validators -/// Implementations: ID003, CCNET, MEI CashFlow, MEI BNR, Genmega, HCM2, GSR50 -#[async_trait] -pub trait BillValidator: Send + Sync { - /// Get validator type name (for logging/debugging) - fn driver_name(&self) -> &'static str; - - /// Connect to the validator - async fn connect(&mut self) -> Result<(), ValidatorError>; - - /// Disconnect from the validator - async fn disconnect(&mut self) -> Result<(), ValidatorError>; - - /// Enable bill acceptance - async fn enable(&mut self) -> Result<(), ValidatorError>; - - /// Disable bill acceptance - async fn disable(&mut self) -> Result<(), ValidatorError>; - - /// Accept the currently held bill into stacker - async fn accept(&mut self) -> Result<(), ValidatorError>; - - /// Reject the currently held bill - async fn reject(&mut self) -> Result<(), ValidatorError>; - - /// Get current bill count in stacker - async fn get_bill_count(&self) -> Result; - - /// Subscribe to bill events - fn subscribe(&self) -> tokio::sync::broadcast::Receiver; - - /// Run the validator state machine (poll for events) - /// Most validators need continuous polling - async fn run(&mut self) -> Result<(), ValidatorError>; -} -``` - -### 3.3 ID003 Implementation (JCM - Default) - -> [!note] Porting Reference -> Port from `lamassu-machine/lib/id003/`: -> - `id003.js` - Main driver -> - `id003rs232.js` - Serial communication -> - `id003fsm.js` - State machine -> - `crc.js` - CRC calculation - -**packages/hal/src/validators/id003.rs:** -```rust -use super::traits::*; -use async_trait::async_trait; -use tokio::sync::broadcast; -use tokio_serial::{SerialPortBuilderExt, SerialStream}; - -/// ID003 protocol states (from id003fsm.js) -#[derive(Debug, Clone, PartialEq)] -enum Id003State { - Disconnected, - PowerUp, - Initialize, - Enable, - Accepting, - Escrowed, - Stacking, - VendValid, - Stacked, - Rejecting, - Returning, - Disabled, - Holding, - Inhibit, -} - -pub struct Id003Validator { - port_path: String, - serial: Option, - event_tx: broadcast::Sender, - state: Id003State, - enabled: bool, - fiat_code: String, -} - -impl Id003Validator { - pub fn new(port_path: &str, fiat_code: &str) -> Self { - let (event_tx, _) = broadcast::channel(16); - Self { - port_path: port_path.to_string(), - serial: None, - event_tx, - state: Id003State::Disconnected, - enabled: false, - fiat_code: fiat_code.to_string(), - } - } - - /// Build ID003 packet with CRC (from id003rs232.js) - fn build_packet(&self, data: &[u8]) -> Vec { - let mut packet = vec![0x02]; // SYNC - packet.push(data.len() as u8 + 4); // LEN (data + SYNC + LEN + CRC16) - packet.extend_from_slice(data); - - // CRC-16 (from crc.js) - let crc = self.calculate_crc(&packet); - packet.push((crc & 0xFF) as u8); - packet.push((crc >> 8) as u8); - packet - } - - fn calculate_crc(&self, data: &[u8]) -> u16 { - // CRC-CCITT from lamassu-machine/lib/id003/crc.js - let mut crc: u16 = 0x0000; - for &byte in data { - let mut x = ((crc >> 8) as u8) ^ byte; - x ^= x >> 4; - crc = (crc << 8) ^ ((x as u16) << 12) ^ ((x as u16) << 5) ^ (x as u16); - } - crc - } - - async fn send_command(&mut self, cmd: &[u8]) -> Result, ValidatorError> { - let serial = self.serial.as_mut() - .ok_or(ValidatorError::ConnectionFailed("Not connected".into()))?; - - let packet = self.build_packet(cmd); - - use tokio::io::{AsyncWriteExt, AsyncReadExt}; - serial.write_all(&packet).await - .map_err(|e| ValidatorError::CommunicationError(e.to_string()))?; - - let mut buf = vec![0u8; 256]; - let n = serial.read(&mut buf).await - .map_err(|e| ValidatorError::CommunicationError(e.to_string()))?; - - Ok(buf[..n].to_vec()) - } - - /// Parse response and emit events (from id003fsm.js) - fn handle_response(&mut self, response: &[u8]) -> Option { - if response.len() < 3 { - return None; - } - - let status = response[2]; - match status { - 0x81 => { // ESCROWED - let denomination = self.parse_denomination(&response[3..]); - self.state = Id003State::Escrowed; - Some(BillEvent { - denomination, - currency: self.fiat_code.clone(), - timestamp: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(), - event_type: BillEventType::Inserted, - }) - } - 0x82 => { // STACKED - self.state = Id003State::Stacked; - Some(BillEvent { - denomination: 0, // From context - currency: self.fiat_code.clone(), - timestamp: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(), - event_type: BillEventType::Stacked, - }) - } - 0x83 => { // RETURNED - self.state = Id003State::Enable; - Some(BillEvent { - denomination: 0, - currency: self.fiat_code.clone(), - timestamp: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(), - event_type: BillEventType::Rejected, - }) - } - _ => None, - } - } - - fn parse_denomination(&self, data: &[u8]) -> u32 { - // Denomination mapping depends on validator configuration - // Reference: id003.js denominationTable - if data.is_empty() { return 0; } - match data[0] { - 1 => 1, - 2 => 5, - 3 => 10, - 4 => 20, - 5 => 50, - 6 => 100, - _ => 0, - } - } -} - -#[async_trait] -impl BillValidator for Id003Validator { - fn driver_name(&self) -> &'static str { - "id003" - } - - async fn connect(&mut self) -> Result<(), ValidatorError> { - let serial = tokio_serial::new(&self.port_path, 9600) - .open_native_async() - .map_err(|e| ValidatorError::ConnectionFailed(e.to_string()))?; - - self.serial = Some(serial); - self.state = Id003State::PowerUp; - - // Initialize sequence (from id003fsm.js) - self.send_command(&[0x40]).await?; // RESET - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - - self.send_command(&[0x11]).await?; // ENABLE_DENOM (all) - self.state = Id003State::Initialize; - - Ok(()) - } - - async fn disconnect(&mut self) -> Result<(), ValidatorError> { - self.serial = None; - self.state = Id003State::Disconnected; - Ok(()) - } - - async fn enable(&mut self) -> Result<(), ValidatorError> { - self.send_command(&[0x13]).await?; // ENABLE - self.enabled = true; - self.state = Id003State::Enable; - Ok(()) - } - - async fn disable(&mut self) -> Result<(), ValidatorError> { - self.send_command(&[0x14]).await?; // DISABLE - self.enabled = false; - self.state = Id003State::Disabled; - Ok(()) - } - - async fn accept(&mut self) -> Result<(), ValidatorError> { - if self.state != Id003State::Escrowed { - return Err(ValidatorError::HardwareError("No bill escrowed".into())); - } - self.send_command(&[0x15]).await?; // STACK - self.state = Id003State::Stacking; - Ok(()) - } - - async fn reject(&mut self) -> Result<(), ValidatorError> { - if self.state != Id003State::Escrowed { - return Err(ValidatorError::HardwareError("No bill escrowed".into())); - } - self.send_command(&[0x16]).await?; // RETURN - self.state = Id003State::Returning; - Ok(()) - } - - async fn get_bill_count(&self) -> Result { - // ID003 doesn't track count - return 0 - Ok(0) - } - - fn subscribe(&self) -> broadcast::Receiver { - self.event_tx.subscribe() - } - - async fn run(&mut self) -> Result<(), ValidatorError> { - // Continuous polling loop (from id003fsm.js) - loop { - let response = self.send_command(&[0x10]).await?; // STATUS - if let Some(event) = self.handle_response(&response) { - let _ = self.event_tx.send(event); - } - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - } - } -} -``` - -### 3.4 Bill Dispenser Trait - -**packages/hal/src/dispensers/traits.rs:** -```rust -use async_trait::async_trait; -use thiserror::Error; - -#[derive(Debug, Clone)] -pub struct CassetteStatus { - pub denomination: u32, - pub count: u32, - pub capacity: u32, -} - -#[derive(Debug, Error)] -pub enum DispenserError { - #[error("Connection failed: {0}")] - ConnectionFailed(String), - #[error("Communication error: {0}")] - CommunicationError(String), - #[error("Insufficient bills: need {0}, have {1}")] - InsufficientBills(u32, u32), - #[error("Cassette empty: {0}")] - CassetteEmpty(u32), - #[error("Bill jam")] - BillJam, - #[error("Hardware error: {0}")] - HardwareError(String), -} - -#[async_trait] -pub trait BillDispenser: Send + Sync { - /// Connect to the dispenser - async fn connect(&mut self) -> Result<(), DispenserError>; - - /// Disconnect from the dispenser - async fn disconnect(&mut self) -> Result<(), DispenserError>; - - /// Get status of all cassettes - async fn get_cassette_status(&self) -> Result, DispenserError>; - - /// Dispense bills from a specific cassette - /// Returns number of bills actually dispensed - async fn dispense(&mut self, denomination: u32, count: u32) -> Result; - - /// Reset after a jam or error - async fn reset(&mut self) -> Result<(), DispenserError>; - - /// Check if dispenser is ready - async fn is_ready(&self) -> Result; -} -``` - -### 3.5 Puloon LCDM Implementation - -> [!note] Porting Reference -> Port from `lamassu-machine/lib/puloon/`: -> - `puloonrs232.js` - Serial communication (10KB) -> - `puloon-dispenser.js` - High-level interface -> - `puloon_data.js` - Protocol constants - -**packages/hal/src/dispensers/puloon.rs:** -```rust -use super::traits::*; -use async_trait::async_trait; -use tokio_serial::{SerialPortBuilderExt, SerialStream}; - -/// Puloon command codes (from puloon_data.js) -const CMD_RESET: u8 = 0x44; // 'D' - Reset -const CMD_DISPENSE: u8 = 0x45; // 'E' - Dispense -const CMD_STATUS: u8 = 0x46; // 'F' - Status -const CMD_PRESENT: u8 = 0x47; // 'G' - Present check - -pub struct PuloonDispenser { - port_path: String, - serial: Option, - cassettes: Vec, - fiat_code: String, -} - -impl PuloonDispenser { - pub fn new(port_path: &str, fiat_code: &str) -> Self { - Self { - port_path: port_path.to_string(), - serial: None, - cassettes: vec![], - fiat_code: fiat_code.to_string(), - } - } - - /// Build Puloon packet (from puloonrs232.js) - fn build_packet(&self, data: &[u8]) -> Vec { - let mut packet = vec![0x02]; // STX - packet.extend_from_slice(data); - packet.push(0x03); // ETX - - // BCC = XOR of all bytes between STX and ETX (exclusive) - let bcc = data.iter().fold(0x03u8, |acc, &b| acc ^ b); - packet.push(bcc); - packet - } - - async fn send_command(&mut self, cmd: &[u8]) -> Result, DispenserError> { - let serial = self.serial.as_mut() - .ok_or(DispenserError::ConnectionFailed("Not connected".into()))?; - - let packet = self.build_packet(cmd); - - use tokio::io::{AsyncWriteExt, AsyncReadExt}; - serial.write_all(&packet).await - .map_err(|e| DispenserError::CommunicationError(e.to_string()))?; - - // Wait for ACK + response - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - - let mut buf = vec![0u8; 256]; - let n = serial.read(&mut buf).await - .map_err(|e| DispenserError::CommunicationError(e.to_string()))?; - - Ok(buf[..n].to_vec()) - } - - /// Parse dispense response (from puloonrs232.js dispense callback) - fn parse_dispense_response(&self, response: &[u8]) -> Result { - if response.len() < 5 { - return Err(DispenserError::CommunicationError("Short response".into())); - } - - // Check for errors - let status = response[3]; - match status { - 0x30 => Ok(response[4] as u32), // Success, return count - 0x31 => Err(DispenserError::BillJam), - 0x32 => Err(DispenserError::CassetteEmpty(0)), - _ => Err(DispenserError::HardwareError(format!("Unknown status: {}", status))), - } - } -} - -#[async_trait] -impl BillDispenser for PuloonDispenser { - fn driver_name(&self) -> &'static str { - "puloon" - } - - async fn connect(&mut self) -> Result<(), DispenserError> { - let serial = tokio_serial::new(&self.port_path, 9600) - .open_native_async() - .map_err(|e| DispenserError::ConnectionFailed(e.to_string()))?; - - self.serial = Some(serial); - - // Reset and get status (from puloon-dispenser.js init) - self.send_command(&[CMD_RESET]).await?; - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - - // Query status to populate cassettes - let status = self.send_command(&[CMD_STATUS]).await?; - // Parse cassette info from status response - // TODO: Parse cassette configuration - - Ok(()) - } - - async fn disconnect(&mut self) -> Result<(), DispenserError> { - self.serial = None; - Ok(()) - } - - async fn get_cassette_status(&self) -> Result, DispenserError> { - Ok(self.cassettes.clone()) - } - - async fn dispense(&mut self, denomination: u32, count: u32) -> Result { - // Find cassette with this denomination - let cassette_idx = self.cassettes.iter() - .position(|c| c.denomination == denomination) - .ok_or(DispenserError::HardwareError( - format!("No cassette for denomination {}", denomination) - ))?; - - // Check availability - if self.cassettes[cassette_idx].count < count { - return Err(DispenserError::InsufficientBills( - count, - self.cassettes[cassette_idx].count - )); - } - - // Build dispense command: CMD + cassette_index + count - let cmd = vec![CMD_DISPENSE, cassette_idx as u8 + 1, count as u8]; - let response = self.send_command(&cmd).await?; - - let dispensed = self.parse_dispense_response(&response)?; - - // Update local count - self.cassettes[cassette_idx].count -= dispensed; - - Ok(dispensed) - } - - async fn reset(&mut self) -> Result<(), DispenserError> { - self.send_command(&[CMD_RESET]).await?; - Ok(()) - } - - async fn is_ready(&self) -> Result { - Ok(self.serial.is_some()) - } - - async fn bills_present(&self) -> Result { - // Puloon doesn't have optical sensor for bills at exit - // Return true (bills assumed present after dispense) - Ok(true) - } - - async fn wait_for_bills_removed(&self) -> Result<(), DispenserError> { - // Puloon doesn't detect removal - return immediately - Ok(()) - } -} -``` - -### 3.5.1 Printer Trait - -> [!note] Porting Reference -> Port from `lamassu-machine/lib/printer/`: -> - `nippon.js` - Nippon thermal (9KB) -> - `zebra.js` - Zebra ZPL (13KB) -> - `genmega.js` - Genmega integrated (7KB) - -**packages/hal/src/printer/traits.rs:** -```rust -use async_trait::async_trait; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum PrinterError { - #[error("Connection failed: {0}")] - ConnectionFailed(String), - #[error("Communication error: {0}")] - CommunicationError(String), - #[error("Out of paper")] - OutOfPaper, - #[error("Paper jam")] - PaperJam, - #[error("Hardware error: {0}")] - HardwareError(String), -} - -/// Receipt data structure -pub struct ReceiptData { - pub operator_name: String, - pub transaction_id: String, - pub transaction_type: String, // "cash_in" or "cash_out" - pub fiat_amount: f64, - pub fiat_currency: String, - pub crypto_amount: f64, - pub crypto_currency: String, - pub timestamp: u64, - pub qr_data: Option, // For CLINK offer or LNURL -} - -/// Unified interface for all printers -/// Implementations: Nippon, Zebra, Genmega, Generic ESC/POS -#[async_trait] -pub trait Printer: Send + Sync { - fn driver_name(&self) -> &'static str; - - async fn connect(&mut self) -> Result<(), PrinterError>; - async fn disconnect(&mut self) -> Result<(), PrinterError>; - - /// Print a receipt - async fn print_receipt(&mut self, data: &ReceiptData) -> Result<(), PrinterError>; - - /// Check if printer is ready (has paper, no jams) - async fn is_ready(&self) -> Result; - - /// Cut paper (if supported) - async fn cut(&mut self) -> Result<(), PrinterError>; -} -``` - -### 3.6 napi-rs Bindings - -**packages/hal/src/lib.rs:** -```rust -use napi::bindgen_prelude::*; -use napi_derive::napi; - -mod error; -mod validators; -mod dispensers; -mod printer; -mod scanner; -mod leds; -mod nfc; - -use validators::{ - traits::BillValidator, - id003::Id003Validator, - ccnet::CcnetValidator, - mei_cashflow::MeiCashflowValidator, - genmega::GenmegaValidator, - hcm2::Hcm2Validator, - gsr50::Gsr50Validator, - mock::MockValidator, -}; - -use dispensers::{ - traits::BillDispenser, - puloon::PuloonDispenser, - f56::F56Dispenser, - genmega::GenmegaDispenser, - hcm2::Hcm2Dispenser, - gsr50::Gsr50Dispenser, - mock::MockDispenser, -}; - -/// Supported validator drivers (matching lamassu-machine/lib/bill-validator.js) -#[napi] -pub enum ValidatorDriver { - Id003, // JCM ID-003 (default) - Ccnet, // CashCode CCNET - CashflowSc, // MEI CashFlow SC - BnrAdvance, // MEI BNR Advance - Genmega, // Genmega validator - Hcm2, // Hitachi HCM2 recycler - Gsr50, // GSR50 recycler - Mock, // Mock for testing -} - -/// Supported dispenser drivers (matching lamassu-machine) -#[napi] -pub enum DispenserDriver { - Puloon, // Puloon LCDM series - F56, // Fujitsu F53/F56 - Genmega, // Genmega dispenser - Hcm2, // Hitachi HCM2 recycler - Gsr50, // GSR50 recycler - Mock, // Mock for testing -} - -#[napi] -pub struct BillValidatorWrapper { - inner: Box, -} - -#[napi] -impl BillValidatorWrapper { - #[napi(constructor)] - pub fn new(driver: ValidatorDriver, port: Option, fiat_code: Option) -> Result { - let fiat = fiat_code.unwrap_or_else(|| "USD".to_string()); - - let validator: Box = match driver { - ValidatorDriver::Id003 => { - let port = port.ok_or_else(|| Error::from_reason("Port required"))?; - Box::new(Id003Validator::new(&port, &fiat)) - } - ValidatorDriver::Ccnet => { - let port = port.ok_or_else(|| Error::from_reason("Port required"))?; - Box::new(CcnetValidator::new(&port, &fiat)) - } - ValidatorDriver::CashflowSc => { - let port = port.ok_or_else(|| Error::from_reason("Port required"))?; - Box::new(MeiCashflowValidator::new(&port, &fiat)) - } - ValidatorDriver::BnrAdvance => { - let port = port.ok_or_else(|| Error::from_reason("Port required"))?; - Box::new(MeiCashflowValidator::new(&port, &fiat)) // BNR uses same base - } - ValidatorDriver::Genmega => { - Box::new(GenmegaValidator::new(&fiat)) - } - ValidatorDriver::Hcm2 => { - let port = port.ok_or_else(|| Error::from_reason("Port required"))?; - Box::new(Hcm2Validator::new(&port, &fiat)) - } - ValidatorDriver::Gsr50 => { - let port = port.ok_or_else(|| Error::from_reason("Port required"))?; - Box::new(Gsr50Validator::new(&port, &fiat)) - } - ValidatorDriver::Mock => Box::new(MockValidator::new()), - }; - - Ok(Self { inner: validator }) - } - - #[napi(getter)] - pub fn driver_name(&self) -> String { - self.inner.driver_name().to_string() - } - - #[napi] - pub async fn connect(&mut self) -> Result<()> { - self.inner.connect().await - .map_err(|e| Error::from_reason(e.to_string())) - } - - #[napi] - pub async fn enable(&mut self) -> Result<()> { - self.inner.enable().await - .map_err(|e| Error::from_reason(e.to_string())) - } - - #[napi] - pub async fn disable(&mut self) -> Result<()> { - self.inner.disable().await - .map_err(|e| Error::from_reason(e.to_string())) - } - - #[napi] - pub async fn accept(&mut self) -> Result<()> { - self.inner.accept().await - .map_err(|e| Error::from_reason(e.to_string())) - } - - #[napi] - pub async fn reject(&mut self) -> Result<()> { - self.inner.reject().await - .map_err(|e| Error::from_reason(e.to_string())) - } - - #[napi] - pub async fn run(&mut self) -> Result<()> { - self.inner.run().await - .map_err(|e| Error::from_reason(e.to_string())) - } -} - -#[napi] -pub struct BillDispenserWrapper { - inner: Box, -} - -#[napi] -impl BillDispenserWrapper { - #[napi(constructor)] - pub fn new(driver: DispenserDriver, port: Option, fiat_code: Option) -> Result { - let fiat = fiat_code.unwrap_or_else(|| "USD".to_string()); - - let dispenser: Box = match driver { - DispenserDriver::Puloon => { - let port = port.ok_or_else(|| Error::from_reason("Port required"))?; - Box::new(PuloonDispenser::new(&port, &fiat)) - } - DispenserDriver::F56 => { - let port = port.ok_or_else(|| Error::from_reason("Port required"))?; - Box::new(F56Dispenser::new(&port, &fiat)) - } - DispenserDriver::Genmega => { - Box::new(GenmegaDispenser::new(&fiat)) - } - DispenserDriver::Hcm2 => { - let port = port.ok_or_else(|| Error::from_reason("Port required"))?; - Box::new(Hcm2Dispenser::new(&port, &fiat)) - } - DispenserDriver::Gsr50 => { - let port = port.ok_or_else(|| Error::from_reason("Port required"))?; - Box::new(Gsr50Dispenser::new(&port, &fiat)) - } - DispenserDriver::Mock => Box::new(MockDispenser::new()), - }; - - Ok(Self { inner: dispenser }) - } - - #[napi(getter)] - pub fn driver_name(&self) -> String { - self.inner.driver_name().to_string() - } - - #[napi] - pub async fn connect(&mut self) -> Result<()> { - self.inner.connect().await - .map_err(|e| Error::from_reason(e.to_string())) - } - - #[napi] - pub async fn disconnect(&mut self) -> Result<()> { - self.inner.disconnect().await - .map_err(|e| Error::from_reason(e.to_string())) - } - - #[napi] - pub async fn dispense(&mut self, denomination: u32, count: u32) -> Result { - self.inner.dispense(denomination, count).await - .map_err(|e| Error::from_reason(e.to_string())) - } - - #[napi] - pub async fn reset(&mut self) -> Result<()> { - self.inner.reset().await - .map_err(|e| Error::from_reason(e.to_string())) - } - - #[napi] - pub async fn is_ready(&self) -> Result { - self.inner.is_ready().await - .map_err(|e| Error::from_reason(e.to_string())) - } - - #[napi] - pub async fn bills_present(&self) -> Result { - self.inner.bills_present().await - .map_err(|e| Error::from_reason(e.to_string())) - } - - #[napi] - pub async fn wait_for_bills_removed(&self) -> Result<()> { - self.inner.wait_for_bills_removed().await - .map_err(|e| Error::from_reason(e.to_string())) - } -} -``` - -### 3.7 TypeScript Usage - -```typescript -import { - BillValidatorWrapper, - BillDispenserWrapper, - ValidatorDriver, - DispenserDriver, -} from '@lamassu/hal' - -// === Bill Validator === -// Development with mock -const mockValidator = new BillValidatorWrapper(ValidatorDriver.Mock) - -// Production - match your hardware: -// JCM validators (most common) -const id003Validator = new BillValidatorWrapper( - ValidatorDriver.Id003, - '/dev/ttyUSB0', - 'USD' -) - -// CashCode CCNET -const ccnetValidator = new BillValidatorWrapper( - ValidatorDriver.Ccnet, - '/dev/ttyUSB0', - 'USD' -) - -// MEI CashFlow SC -const meiValidator = new BillValidatorWrapper( - ValidatorDriver.CashflowSc, - '/dev/ttyUSB0', - 'USD' -) - -// Genmega (integrated - no port needed) -const genmegaValidator = new BillValidatorWrapper( - ValidatorDriver.Genmega, - undefined, - 'USD' -) - -// Recyclers (combined validator/dispenser) -const hcm2Validator = new BillValidatorWrapper( - ValidatorDriver.Hcm2, - '/dev/ttyUSB0', - 'USD' -) - -// Connect and enable -await id003Validator.connect() -await id003Validator.enable() - -// Run the validator state machine (polls for events) -// This runs in background, emits events -id003Validator.run().catch(console.error) - -// Listen for bills (via event emitter pattern) -id003Validator.on('bill', (event) => { - console.log(`Bill inserted: ${event.denomination} ${event.currency}`) - console.log(`Event type: ${event.eventType}`) // 'inserted', 'accepted', 'rejected', 'stacked' -}) - -// === Bill Dispenser === -// Development with mock -const mockDispenser = new BillDispenserWrapper(DispenserDriver.Mock) - -// Production - match your hardware: -// Puloon LCDM series (common) -const puloonDispenser = new BillDispenserWrapper( - DispenserDriver.Puloon, - '/dev/ttyUSB1', - 'USD' -) - -// Fujitsu F53/F56 -const f56Dispenser = new BillDispenserWrapper( - DispenserDriver.F56, - '/dev/ttyUSB1', - 'USD' -) - -// Genmega (integrated) -const genmegaDispenser = new BillDispenserWrapper( - DispenserDriver.Genmega, - undefined, - 'USD' -) - -await puloonDispenser.connect() - -// Check if dispenser is ready -const ready = await puloonDispenser.isReady() -console.log('Dispenser ready:', ready) - -// Dispense $60 (3 x $20 bills) -const dispensed = await puloonDispenser.dispense(20, 3) -console.log(`Dispensed ${dispensed} bills`) - -// For F56 and others that detect bills at exit: -const billsPresent = await puloonDispenser.billsPresent() -if (billsPresent) { - await puloonDispenser.waitForBillsRemoved() -} -``` - -### 3.8 Configuration Mapping - -Map existing lamassu-machine device config to new HAL: - -```typescript -// Existing lamassu-machine config format (from brain.js): -// deviceConfig = { deviceType: 'id003', device: '/dev/ttyValidator' } - -function migrateValidatorConfig(oldConfig: { deviceType: string; device?: string }): { - driver: ValidatorDriver - port?: string -} { - const driverMap: Record = { - 'id003': ValidatorDriver.Id003, // default - 'ccnet': ValidatorDriver.Ccnet, - 'cashflowSc': ValidatorDriver.CashflowSc, - 'bnrAdvance': ValidatorDriver.BnrAdvance, - 'genmega': ValidatorDriver.Genmega, - 'hcm2': ValidatorDriver.Hcm2, - 'gsr50': ValidatorDriver.Gsr50, - } - - return { - driver: driverMap[oldConfig.deviceType] ?? ValidatorDriver.Id003, - port: oldConfig.device, - } -} - -// Usage: -const legacyConfig = { deviceType: 'ccnet', device: '/dev/ttyUSB0' } -const newConfig = migrateValidatorConfig(legacyConfig) -const validator = new BillValidatorWrapper(newConfig.driver, newConfig.port, 'USD') -``` - -### 3.9 Milestone Checklist - -**Core Infrastructure:** -- [ ] HAL crate compiles -- [ ] napi-rs bindings build -- [ ] Can import in TypeScript -- [ ] Mock validator works (events, state machine) -- [ ] Mock dispenser works (cassettes, dispense) - -**Bill Validators (port from lamassu-machine):** -- [ ] ID003 (JCM) - `lib/id003/` - **Priority: High** (default driver) -- [ ] CCNET (CashCode) - `lib/ccnet/` - Priority: Medium -- [ ] MEI CashFlow SC - `lib/mei/cashflow_sc.js` - Priority: Medium -- [ ] MEI BNR Advance - `lib/mei/bnr_advance.js` - Priority: Low -- [ ] Genmega - `lib/genmega/genmega-validator/` - Priority: Low -- [ ] HCM2 (Hitachi) - `lib/hcm2/hcm2.js` - Priority: Low (recycler) -- [ ] GSR50 - `lib/gsr50/gsr50.js` - Priority: Low (recycler) - -**Bill Dispensers (port from lamassu-machine):** -- [ ] Puloon LCDM - `lib/puloon/` - **Priority: Critical** (cash-out dominant) -- [ ] Fujitsu F56 - `lib/f56/` - Priority: High (multi-cassette) -- [ ] Genmega - `lib/genmega/genmega-dispenser/` - Priority: Low -- [ ] HCM2 (Hitachi) - `lib/hcm2/hcm2.js` - Priority: Low (recycler) -- [ ] GSR50 - `lib/gsr50/gsr50.js` - Priority: Low (recycler) - -**Printers (port from lamassu-machine):** -- [ ] Nippon ESC/POS - `lib/printer/nippon.js` - Priority: Medium -- [ ] Zebra ZPL - `lib/printer/zebra.js` - Priority: Low -- [ ] Genmega - `lib/printer/genmega.js` - Priority: Low -- [ ] Generic ESC/POS - Priority: Medium (fallback) - -**Scanners (port from lamassu-machine):** -- [ ] Manatee - `lib/capture/scanner/manatee.js` - Priority: Low -- [ ] ZXing - `lib/capture/scanner/zxing.js` - Priority: Medium -- [ ] Genmega - `lib/scanner-genmega.js` - Priority: Low - -**Hardware Testing:** -- [ ] ID003 tested with real JCM validator -- [ ] Puloon tested with real LCDM dispenser -- [ ] Cash-out flow works end-to-end with real hardware - ---- - -## Phase 4: Payment Flows - -> [!goal] Deliverable -> Complete cash-out (primary) and cash-in flows working end-to-end. - -> [!important] Cash-Out First -> Given 95%+ cash-out activity in target market: -> 1. **4.2 Cash-Out** - Implement and test first -> 2. **4.3 Cash-In** - Implement second -> 3. Both flows share Lightning.Pub integration - -### 4.1 Lightning.Pub Integration - -**packages/lightning/src/index.ts:** -```typescript -import { nip44 } from 'nostr-tools' -import type { MachineIdentity } from '@lamassu/nostr-client' - -export class LightningPubClient { - constructor( - private nprofile: string, // Lightning.Pub connection string - private identity: MachineIdentity - ) {} - - async createInvoice(amountMsat: number, memo: string): Promise { - // Send CLINK request for invoice - // ... - return 'lnbc...' - } - - async payInvoice(bolt11: string): Promise<{ preimage: string }> { - // Use CLINK debit to pay - // ... - return { preimage: '...' } - } - - async getBalance(): Promise { - // Query balance via Nostr - // ... - return 0 - } - - subscribeToPayments(onPayment: (payment: any) => void): () => void { - // Subscribe to payment events - // ... - return () => {} - } -} -``` - -### 4.2 Cash-In Flow Integration - -```typescript -// In XState machine actors -const generateClinkOffer = fromPromise(async ({ input }) => { - const { identity, amountSats, relays } = input - - // Create CLINK offer - const offer = createOffer(identity, relays, 'fixed', amountSats) - - // Start listening for offer requests - // When request comes in, generate invoice from Lightning.Pub - // Send invoice back via CLINK response - - return offer -}) - -const sendNostrReceipt = fromPromise(async ({ input }) => { - const { identity, userNpub, transaction, relay } = input - - if (!userNpub) return // User didn't want receipt - - // Create NIP-17 encrypted DM - const receipt = { - type: 'transaction_receipt', - txid: transaction.txid, - amount: transaction.amountSats, - timestamp: Date.now(), - } - - // Encrypt with NIP-44 - const encrypted = nip44.encrypt( - identity.nsec, - userNpub, - JSON.stringify(receipt) - ) - - // Publish as gift-wrapped DM (NIP-59) - await relay.publish({ - kind: 14, - content: encrypted, - tags: [['p', userNpub]], - }) -}) -``` - -### 4.3 Cash-Out Flow Integration - -```typescript -const generateInvoice = fromPromise(async ({ input }) => { - const { lightningPub, amountSats, memo } = input - - const invoice = await lightningPub.createInvoice( - amountSats * 1000, // msat - memo || 'ATM cash withdrawal' - ) - - return invoice -}) - -const dispenseCash = fromPromise(async ({ input }) => { - const { dispenser, amountFiat, denominations } = input - - // Calculate bills to dispense - const bills = calculateBillsToDispense(amountFiat, denominations) - - // Dispense each denomination - for (const [denomination, count] of Object.entries(bills)) { - await dispenser.dispense(parseInt(denomination), count) - } - - return { success: true } -}) -``` - -### 4.4 Cashu Integration (Offline Mode) - -**packages/cashu/src/index.ts:** -```typescript -import { CashuMint, CashuWallet, getEncodedToken } from '@cashu/cashu-ts' - -export class ATMCashuWallet { - private wallet: CashuWallet - - constructor(mintUrl: string) { - const mint = new CashuMint(mintUrl) - this.wallet = new CashuWallet(mint) - } - - async preloadTokens(amountSats: number): Promise { - // Mint tokens to have ready for offline operation - const { proofs } = await this.wallet.mintTokens(amountSats) - // Store proofs locally - } - - async createTokenForUser(amountSats: number): Promise { - // Create cashu token for user - const proofs = await this.getProofsForAmount(amountSats) - return getEncodedToken({ - token: [{ mint: this.wallet.mint.mintUrl, proofs }] - }) - } - - private async getProofsForAmount(amount: number) { - // Select proofs from local storage - // ... - } -} -``` - -### 4.5 Milestone Checklist - -**Priority 1: Cash-Out (95% of volume)** -- [ ] Lightning.Pub client connects -- [ ] Can create invoices (for user to pay) -- [ ] Invoice payment detection working -- [ ] Dispenser integration (real hardware!) -- [ ] Cash-out flow works end-to-end with real dispenser -- [ ] NFC BOLT card tap-to-withdraw working - -**Priority 2: Cash-In** -- [ ] Can receive payments (CLINK debit) -- [ ] CLINK offer request/response working -- [ ] Cash-in flow works end-to-end (mock validator OK) -- [ ] NIP-17 receipts delivered - -**Priority 3: Advanced** -- [ ] Cashu offline mode works -- [ ] Real bill validator integration - ---- - -## Phase 5: Operator Tools - -> [!goal] Deliverable -> Dashboard for monitoring and managing ATM fleet. - -### 5.1 Dashboard App - -``` -apps/dashboard/ -├── src/ -│ ├── App.vue -│ ├── main.ts -│ ├── views/ -│ │ ├── Dashboard.vue -│ │ ├── Machines.vue -│ │ ├── Transactions.vue -│ │ └── Settings.vue -│ ├── components/ -│ │ ├── MachineCard.vue -│ │ ├── TransactionTable.vue -│ │ └── AlertBanner.vue -│ └── composables/ -│ └── useFleet.ts -└── package.json -``` - -### 5.2 Fleet Composable - -**apps/dashboard/src/composables/useFleet.ts:** -```typescript -import { ref, computed, onMounted, onUnmounted } from 'vue' -import { ATMRelayClient, loadOrCreateIdentity } from '@lamassu/nostr-client' - -interface MachineStatus { - npub: string - online: boolean - lastSeen: number - cashLevels: object - errors: string[] -} - -export function useFleet() { - const machines = ref>(new Map()) - const transactions = ref([]) - let client: ATMRelayClient | null = null - let unsubscribe: (() => void) | null = null - - const onlineMachines = computed(() => - [...machines.value.values()].filter(m => m.online) - ) - - const totalCashOnHand = computed(() => { - // Sum cash across all machines - return 0 - }) - - onMounted(async () => { - const identity = loadOrCreateIdentity('~/.lamassu/operator.nsec') - client = new ATMRelayClient( - import.meta.env.VITE_RELAY_URL, - identity - ) - await client.connect() - - // Subscribe to all machine status events - unsubscribe = client.subscribe( - [{ kinds: [30078, 30079] }], - handleEvent - ) - }) - - onUnmounted(() => { - unsubscribe?.() - client?.disconnect() - }) - - function handleEvent(event: any) { - if (event.kind === 30078) { - // Machine status update - const status = JSON.parse(event.content) - machines.value.set(event.pubkey, { - npub: event.pubkey, - ...status, - lastSeen: event.created_at, - }) - } else if (event.kind === 30079) { - // Transaction record - const tx = JSON.parse(event.content) - transactions.value.unshift(tx) - } - } - - async function sendCommand(machineNpub: string, command: object) { - if (!client) return - - await client.publish({ - kind: 21003, // CLINK manage - content: JSON.stringify(command), - tags: [['p', machineNpub]], - }) - } - - return { - machines, - transactions, - onlineMachines, - totalCashOnHand, - sendCommand, - } -} -``` - -### 5.3 Milestone Checklist - -- [ ] Dashboard builds and runs -- [ ] Connects to relay -- [ ] Shows machine status in real-time -- [ ] Displays transaction history -- [ ] Can send commands to machines -- [ ] Alerts for low cash / errors - ---- - -## Development Hardware - -> [!warning] Cash-Out is Primary Use Case -> With 95%+ activity being cash-out, **bill dispenser is required** for meaningful testing - not optional. - -### Minimum Dev Kit (Cash-Out Priority) - -| Component | Model | Purpose | Est. Cost | -|-----------|-------|---------|-----------| -| SBC | Raspberry Pi 5 (8GB) | Development machine | $80 | -| Display | Waveshare 10.1" touch | UI development | $90 | -| **Bill Dispenser** | Puloon LCDM-1000 (used) | **Cash-out testing (critical)** | $400-600 | -| NFC Reader | ACR122U | BOLT card tap-to-withdraw | $35 | -| Printer | Generic ESC/POS | Receipt testing | $50 | -| **Total** | | | **~$655-855** | - -### Full Dev Kit (Two-Way) - -| Component | Model | Purpose | Est. Cost | -|-----------|-------|---------|-----------| -| SBC | Raspberry Pi 5 (8GB) | Development machine | $80 | -| Display | Waveshare 10.1" touch | Larger UI for two-way | $90 | -| **Bill Dispenser** | Puloon LCDM-1000 (used) | Cash-out (primary) | $400-600 | -| Bill Validator | ITL NV200 (used) | Cash-in (secondary) | $300-500 | -| NFC Reader | ACR122U | BOLT card testing | $35 | -| Printer | Generic ESC/POS | Receipt testing | $50 | -| **Total** | | | **~$955-1,355** | - -### Bill Dispenser Options - -All of these have **existing tested drivers** in lamassu-machine: - -| Model | Cassettes | Protocol | Driver | Notes | Est. Used Price | -|-------|-----------|----------|--------|-------|-----------------| -| **Puloon LCDM-1000** | 1 | RS-232 | `puloon` | **Most common**, well-tested | $400-600 | -| Puloon LCDM-2000 | 2 | RS-232 | `puloon` | Multi-denomination | $600-900 | -| **Fujitsu F53/F56** | 4 | USB/RS-232 | `f56` | High capacity, FSM-based | $800-1,200 | -| Genmega CDU | varies | Proprietary | `genmega` | Genmega ATMs only | N/A | -| Hitachi HCM2 | 4 | RS-232 | `hcm2` | Recycler (complex) | $1,000-1,500 | -| GSR50 | 4 | RS-232 | `gsr50` | Recycler | $800-1,200 | - -> [!tip] Start with Puloon LCDM-1000 -> Single cassette handles the primary use case. The `puloon` driver is the most battle-tested dispenser driver in lamassu-machine. Multi-cassette (Fujitsu F56) for multiple denominations can come later. - -### Bill Validator Options (Secondary Priority) - -All of these have **existing tested drivers** in lamassu-machine: - -| Model | Protocol | Driver | Notes | Est. Used Price | -|-------|----------|--------|-------|-----------------| -| **JCM iVizion/iPro** | ID-003 | `id003` | **Default driver**, most common | $200-350 | -| **CashCode SM/MVU** | CCNET | `ccnet` | Common in NA, has emulator | $250-400 | -| **MEI CashFlow SC** | Proprietary | `cashflowSc` | MEI ecosystem | $250-400 | -| MEI BNR Advance | Proprietary | `bnrAdvance` | Recycler capable | $400-600 | -| ITL NV200 | eSSP | *New driver needed* | Industry standard | $300-500 | - -> [!tip] Use Existing Driver Hardware -> For fastest development, use hardware that already has a working lamassu-machine driver (JCM, CashCode, MEI). The ID003 driver is the most battle-tested. - -### Sourcing Used Equipment - -- **eBay** - Search "bill dispenser ATM" or "Puloon LCDM" -- **Alibaba** - New units, but longer shipping -- **ATM parts suppliers** - atmpartsnow.com, atmequipment.com -- **Bitcoin ATM operators** - Sometimes sell retired units -- **ATM route liquidations** - Best prices, bulk deals - -### Mock-First Development - -For initial UI/flow development, mocks work - but **test with real dispenser early**: - -```typescript -// Start with mocks -const validator = new BillValidatorWrapper('mock') -const dispenser = new BillDispenserWrapper('mock') - -// CLI to simulate bills -// pnpm mock:bill 20 <- Simulates $20 bill inserted -// pnpm mock:bill 50 <- Simulates $50 bill inserted -``` - ---- - -## Testing Strategy - -### Unit Tests - -```bash -# Run all unit tests -pnpm test - -# Run specific package -pnpm --filter @lamassu/nostr-client test -``` - -### Integration Tests - -```bash -# Start test infrastructure -docker compose -f docker-compose.test.yml up -d - -# Run integration tests -pnpm test:integration -``` - -### E2E Tests (Playwright) - -```bash -# Run E2E tests against mock hardware -pnpm test:e2e - -# Run E2E tests against real hardware (CI skip) -REAL_HARDWARE=true pnpm test:e2e -``` - ---- - -## Next Steps - -1. **Initialize the monorepo** (Phase 0.1-0.2) -2. **Get devenv.nix working** (Phase 0.2) -3. **Set up Lightning.Pub locally** (Phase 0.3) -4. **Build nostr-client package** (Phase 1.1) -5. **First milestone: Publish event to relay** - ---- - -## Related Notes - -- [[nostr-native-architecture]] - Architecture overview -- [[architecture-review]] - KYC-free vision -- [[hardware-recommendations]] - Hardware choices -- [[modernization-plan]] - Original tech decisions diff --git a/docs/integrations/lnbits-integration.md b/docs/integrations/lnbits-integration.md deleted file mode 100644 index ac8b32a..0000000 --- a/docs/integrations/lnbits-integration.md +++ /dev/null @@ -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( - method: string, - path: string, - key: 'admin' | 'invoice', - body?: unknown - ): Promise { - 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 { - const info = await this.getWalletInfo() - return info.balanceSats - } - - // Invoice Operations - - async createInvoice( - amountSats: number, - memo: string, - options?: { - expiry?: number - webhook?: string - } - ): Promise { - 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 { - 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 { - 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 { - 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 { - // 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 { - 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 { - 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 diff --git a/lamassu-next/docs/machine-installation.md b/docs/machine-installation.md similarity index 100% rename from lamassu-next/docs/machine-installation.md rename to docs/machine-installation.md diff --git a/docs/modernization-plan.md b/docs/modernization-plan.md deleted file mode 100644 index 22b8b5b..0000000 --- a/docs/modernization-plan.md +++ /dev/null @@ -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 diff --git a/lamassu-next/docs/ndebit-cash-in-flow.md b/docs/ndebit-cash-in-flow.md similarity index 100% rename from lamassu-next/docs/ndebit-cash-in-flow.md rename to docs/ndebit-cash-in-flow.md diff --git a/docs/nostr-native-architecture.md b/docs/nostr-native-architecture.md deleted file mode 100644 index 6d2fc6e..0000000 --- a/docs/nostr-native-architecture.md +++ /dev/null @@ -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", ""] │ -│ │ │ -│ ▼ │ -│ ATM signs challenge with its nsec │ -│ { │ -│ "kind": 22242, │ -│ "tags": [ │ -│ ["relay", "wss://relay.youratm.company"], │ -│ ["challenge", ""] │ -│ ], │ -│ "content": "", │ -│ "sig": "" │ -│ } │ -│ │ │ -│ ▼ │ -│ 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 { - 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.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) diff --git a/lamassu-next/flake.nix b/flake.nix similarity index 100% rename from lamassu-next/flake.nix rename to flake.nix diff --git a/lamassu-install b/lamassu-install deleted file mode 160000 index c15dbbc..0000000 --- a/lamassu-install +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c15dbbcae8902d99e056b9397bd46cbb640edce1 diff --git a/lamassu-machine b/lamassu-machine deleted file mode 160000 index 8c83ad8..0000000 --- a/lamassu-machine +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 8c83ad8bdbd49621a20c01d725b1ce0c8eee813e diff --git a/lamassu-next/.gitignore b/lamassu-next/.gitignore deleted file mode 100644 index 92356a7..0000000 --- a/lamassu-next/.gitignore +++ /dev/null @@ -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 diff --git a/lamassu-next/CLAUDE.md b/lamassu-next/CLAUDE.md deleted file mode 100644 index d379ce5..0000000 --- a/lamassu-next/CLAUDE.md +++ /dev/null @@ -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 -``` - -### 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 ` 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) diff --git a/lamassu-server b/lamassu-server deleted file mode 160000 index 5909e60..0000000 --- a/lamassu-server +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5909e609576e078e4c2189fc6cce464b8dca8a71 diff --git a/lnbits b/lnbits deleted file mode 160000 index d2e8914..0000000 --- a/lnbits +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d2e89148357e99f766a32ed9b67ab2fc2e799b2d diff --git a/lamassu-next/package.json b/package.json similarity index 100% rename from lamassu-next/package.json rename to package.json diff --git a/lamassu-next/packages/cashu/package.json b/packages/cashu/package.json similarity index 100% rename from lamassu-next/packages/cashu/package.json rename to packages/cashu/package.json diff --git a/lamassu-next/packages/cashu/src/__tests__/index.test.ts b/packages/cashu/src/__tests__/index.test.ts similarity index 100% rename from lamassu-next/packages/cashu/src/__tests__/index.test.ts rename to packages/cashu/src/__tests__/index.test.ts diff --git a/lamassu-next/packages/cashu/src/index.ts b/packages/cashu/src/index.ts similarity index 100% rename from lamassu-next/packages/cashu/src/index.ts rename to packages/cashu/src/index.ts diff --git a/lamassu-next/packages/cashu/tsconfig.json b/packages/cashu/tsconfig.json similarity index 100% rename from lamassu-next/packages/cashu/tsconfig.json rename to packages/cashu/tsconfig.json diff --git a/lamassu-next/packages/clink/package.json b/packages/clink/package.json similarity index 100% rename from lamassu-next/packages/clink/package.json rename to packages/clink/package.json diff --git a/lamassu-next/packages/clink/src/__tests__/noffer.test.ts b/packages/clink/src/__tests__/noffer.test.ts similarity index 100% rename from lamassu-next/packages/clink/src/__tests__/noffer.test.ts rename to packages/clink/src/__tests__/noffer.test.ts diff --git a/lamassu-next/packages/clink/src/client.ts b/packages/clink/src/client.ts similarity index 100% rename from lamassu-next/packages/clink/src/client.ts rename to packages/clink/src/client.ts diff --git a/lamassu-next/packages/clink/src/index.ts b/packages/clink/src/index.ts similarity index 100% rename from lamassu-next/packages/clink/src/index.ts rename to packages/clink/src/index.ts diff --git a/lamassu-next/packages/clink/src/ndebit.ts b/packages/clink/src/ndebit.ts similarity index 100% rename from lamassu-next/packages/clink/src/ndebit.ts rename to packages/clink/src/ndebit.ts diff --git a/lamassu-next/packages/clink/src/noffer.ts b/packages/clink/src/noffer.ts similarity index 100% rename from lamassu-next/packages/clink/src/noffer.ts rename to packages/clink/src/noffer.ts diff --git a/lamassu-next/packages/clink/src/types.ts b/packages/clink/src/types.ts similarity index 100% rename from lamassu-next/packages/clink/src/types.ts rename to packages/clink/src/types.ts diff --git a/lamassu-next/packages/clink/tsconfig.json b/packages/clink/tsconfig.json similarity index 100% rename from lamassu-next/packages/clink/tsconfig.json rename to packages/clink/tsconfig.json diff --git a/lamassu-next/packages/clink/vitest.config.ts b/packages/clink/vitest.config.ts similarity index 100% rename from lamassu-next/packages/clink/vitest.config.ts rename to packages/clink/vitest.config.ts diff --git a/lamassu-next/packages/hal/Cargo.toml b/packages/hal/Cargo.toml similarity index 100% rename from lamassu-next/packages/hal/Cargo.toml rename to packages/hal/Cargo.toml diff --git a/lamassu-next/packages/hal/build.rs b/packages/hal/build.rs similarity index 100% rename from lamassu-next/packages/hal/build.rs rename to packages/hal/build.rs diff --git a/lamassu-next/packages/hal/package.json b/packages/hal/package.json similarity index 100% rename from lamassu-next/packages/hal/package.json rename to packages/hal/package.json diff --git a/lamassu-next/packages/hal/src/dispensers/f56/bills.ts b/packages/hal/src/dispensers/f56/bills.ts similarity index 100% rename from lamassu-next/packages/hal/src/dispensers/f56/bills.ts rename to packages/hal/src/dispensers/f56/bills.ts diff --git a/lamassu-next/packages/hal/src/dispensers/f56/f56-dlevel-fsm.ts b/packages/hal/src/dispensers/f56/f56-dlevel-fsm.ts similarity index 100% rename from lamassu-next/packages/hal/src/dispensers/f56/f56-dlevel-fsm.ts rename to packages/hal/src/dispensers/f56/f56-dlevel-fsm.ts diff --git a/lamassu-next/packages/hal/src/dispensers/f56/f56-fsm.ts b/packages/hal/src/dispensers/f56/f56-fsm.ts similarity index 100% rename from lamassu-next/packages/hal/src/dispensers/f56/f56-fsm.ts rename to packages/hal/src/dispensers/f56/f56-fsm.ts diff --git a/lamassu-next/packages/hal/src/dispensers/f56/f56-rs232.ts b/packages/hal/src/dispensers/f56/f56-rs232.ts similarity index 100% rename from lamassu-next/packages/hal/src/dispensers/f56/f56-rs232.ts rename to packages/hal/src/dispensers/f56/f56-rs232.ts diff --git a/lamassu-next/packages/hal/src/dispensers/f56/index.ts b/packages/hal/src/dispensers/f56/index.ts similarity index 100% rename from lamassu-next/packages/hal/src/dispensers/f56/index.ts rename to packages/hal/src/dispensers/f56/index.ts diff --git a/lamassu-next/packages/hal/src/dispensers/index.ts b/packages/hal/src/dispensers/index.ts similarity index 100% rename from lamassu-next/packages/hal/src/dispensers/index.ts rename to packages/hal/src/dispensers/index.ts diff --git a/lamassu-next/packages/hal/src/dispensers/mock.rs b/packages/hal/src/dispensers/mock.rs similarity index 100% rename from lamassu-next/packages/hal/src/dispensers/mock.rs rename to packages/hal/src/dispensers/mock.rs diff --git a/lamassu-next/packages/hal/src/dispensers/mod.rs b/packages/hal/src/dispensers/mod.rs similarity index 100% rename from lamassu-next/packages/hal/src/dispensers/mod.rs rename to packages/hal/src/dispensers/mod.rs diff --git a/lamassu-next/packages/hal/src/dispensers/traits.rs b/packages/hal/src/dispensers/traits.rs similarity index 100% rename from lamassu-next/packages/hal/src/dispensers/traits.rs rename to packages/hal/src/dispensers/traits.rs diff --git a/lamassu-next/packages/hal/src/error.rs b/packages/hal/src/error.rs similarity index 100% rename from lamassu-next/packages/hal/src/error.rs rename to packages/hal/src/error.rs diff --git a/lamassu-next/packages/hal/src/index.ts b/packages/hal/src/index.ts similarity index 100% rename from lamassu-next/packages/hal/src/index.ts rename to packages/hal/src/index.ts diff --git a/lamassu-next/packages/hal/src/lib.rs b/packages/hal/src/lib.rs similarity index 100% rename from lamassu-next/packages/hal/src/lib.rs rename to packages/hal/src/lib.rs diff --git a/lamassu-next/packages/hal/src/types.ts b/packages/hal/src/types.ts similarity index 100% rename from lamassu-next/packages/hal/src/types.ts rename to packages/hal/src/types.ts diff --git a/lamassu-next/packages/hal/src/utils/crc.ts b/packages/hal/src/utils/crc.ts similarity index 100% rename from lamassu-next/packages/hal/src/utils/crc.ts rename to packages/hal/src/utils/crc.ts diff --git a/lamassu-next/packages/hal/src/validators/id003/id003-fsm.ts b/packages/hal/src/validators/id003/id003-fsm.ts similarity index 100% rename from lamassu-next/packages/hal/src/validators/id003/id003-fsm.ts rename to packages/hal/src/validators/id003/id003-fsm.ts diff --git a/lamassu-next/packages/hal/src/validators/id003/id003-rs232.ts b/packages/hal/src/validators/id003/id003-rs232.ts similarity index 100% rename from lamassu-next/packages/hal/src/validators/id003/id003-rs232.ts rename to packages/hal/src/validators/id003/id003-rs232.ts diff --git a/lamassu-next/packages/hal/src/validators/id003/index.ts b/packages/hal/src/validators/id003/index.ts similarity index 100% rename from lamassu-next/packages/hal/src/validators/id003/index.ts rename to packages/hal/src/validators/id003/index.ts diff --git a/lamassu-next/packages/hal/src/validators/index.ts b/packages/hal/src/validators/index.ts similarity index 100% rename from lamassu-next/packages/hal/src/validators/index.ts rename to packages/hal/src/validators/index.ts diff --git a/lamassu-next/packages/hal/src/validators/mock.rs b/packages/hal/src/validators/mock.rs similarity index 100% rename from lamassu-next/packages/hal/src/validators/mock.rs rename to packages/hal/src/validators/mock.rs diff --git a/lamassu-next/packages/hal/src/validators/mod.rs b/packages/hal/src/validators/mod.rs similarity index 100% rename from lamassu-next/packages/hal/src/validators/mod.rs rename to packages/hal/src/validators/mod.rs diff --git a/lamassu-next/packages/hal/src/validators/traits.rs b/packages/hal/src/validators/traits.rs similarity index 100% rename from lamassu-next/packages/hal/src/validators/traits.rs rename to packages/hal/src/validators/traits.rs diff --git a/lamassu-next/packages/hal/tsconfig.json b/packages/hal/tsconfig.json similarity index 100% rename from lamassu-next/packages/hal/tsconfig.json rename to packages/hal/tsconfig.json diff --git a/lamassu-next/packages/lightning/TROUBLESHOOTING.md b/packages/lightning/TROUBLESHOOTING.md similarity index 100% rename from lamassu-next/packages/lightning/TROUBLESHOOTING.md rename to packages/lightning/TROUBLESHOOTING.md diff --git a/lamassu-next/packages/lightning/package.json b/packages/lightning/package.json similarity index 100% rename from lamassu-next/packages/lightning/package.json rename to packages/lightning/package.json diff --git a/lamassu-next/packages/lightning/src/__tests__/client.test.ts b/packages/lightning/src/__tests__/client.test.ts similarity index 100% rename from lamassu-next/packages/lightning/src/__tests__/client.test.ts rename to packages/lightning/src/__tests__/client.test.ts diff --git a/lamassu-next/packages/lightning/src/client.ts b/packages/lightning/src/client.ts similarity index 100% rename from lamassu-next/packages/lightning/src/client.ts rename to packages/lightning/src/client.ts diff --git a/lamassu-next/packages/lightning/src/index.ts b/packages/lightning/src/index.ts similarity index 100% rename from lamassu-next/packages/lightning/src/index.ts rename to packages/lightning/src/index.ts diff --git a/lamassu-next/packages/lightning/src/types.ts b/packages/lightning/src/types.ts similarity index 100% rename from lamassu-next/packages/lightning/src/types.ts rename to packages/lightning/src/types.ts diff --git a/lamassu-next/packages/lightning/tsconfig.json b/packages/lightning/tsconfig.json similarity index 100% rename from lamassu-next/packages/lightning/tsconfig.json rename to packages/lightning/tsconfig.json diff --git a/lamassu-next/packages/lightning/vitest.config.ts b/packages/lightning/vitest.config.ts similarity index 100% rename from lamassu-next/packages/lightning/vitest.config.ts rename to packages/lightning/vitest.config.ts diff --git a/lamassu-next/packages/nostr-client/atm-debit-agent.mjs b/packages/nostr-client/atm-debit-agent.mjs similarity index 100% rename from lamassu-next/packages/nostr-client/atm-debit-agent.mjs rename to packages/nostr-client/atm-debit-agent.mjs diff --git a/lamassu-next/packages/nostr-client/fund-dev.mjs b/packages/nostr-client/fund-dev.mjs similarity index 100% rename from lamassu-next/packages/nostr-client/fund-dev.mjs rename to packages/nostr-client/fund-dev.mjs diff --git a/lamassu-next/packages/nostr-client/generate-ndebit.mjs b/packages/nostr-client/generate-ndebit.mjs similarity index 100% rename from lamassu-next/packages/nostr-client/generate-ndebit.mjs rename to packages/nostr-client/generate-ndebit.mjs diff --git a/lamassu-next/packages/nostr-client/mock-machine.mjs b/packages/nostr-client/mock-machine.mjs similarity index 100% rename from lamassu-next/packages/nostr-client/mock-machine.mjs rename to packages/nostr-client/mock-machine.mjs diff --git a/lamassu-next/packages/nostr-client/nip44v1.mjs b/packages/nostr-client/nip44v1.mjs similarity index 100% rename from lamassu-next/packages/nostr-client/nip44v1.mjs rename to packages/nostr-client/nip44v1.mjs diff --git a/lamassu-next/packages/nostr-client/package-lock.json b/packages/nostr-client/package-lock.json similarity index 100% rename from lamassu-next/packages/nostr-client/package-lock.json rename to packages/nostr-client/package-lock.json diff --git a/lamassu-next/packages/nostr-client/package.json b/packages/nostr-client/package.json similarity index 100% rename from lamassu-next/packages/nostr-client/package.json rename to packages/nostr-client/package.json diff --git a/packages/nostr-client/run-debit-agent.mjs b/packages/nostr-client/run-debit-agent.mjs new file mode 100644 index 0000000..8dd87c9 --- /dev/null +++ b/packages/nostr-client/run-debit-agent.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node +/** + * ATM Debit Approval Agent (TESTING ONLY) + * + * ⚠️ WARNING: This script auto-approves ALL debit requests without validation! + * ⚠️ DO NOT use in production - use the integrated debit approval service instead. + * + * Purpose: + * - Standalone debugging tool for testing the GetLiveDebitRequests subscription + * - Helps diagnose relay connectivity and message decryption issues + * - Useful when the integrated service isn't receiving events + * + * Usage: + * # Set environment variables (or create .env file in apps/machine/) + * export ATM_PRIVATE_KEY= + * export LIGHTNING_PUB_PUBKEY= + * export RELAY_URL=ws://localhost:7777 + * + * # Run the script + * node run-debit-agent.mjs + * + * Production alternative: + * The ATM app (apps/machine) includes an integrated debit approval service + * with session-based single-use protection. See: + * - apps/machine/src/services/lightning.ts (startDebitApprovalService) + * - docs/ndebit-cash-in-flow.md + */ + +import { Relay } from 'nostr-tools/relay' +import { finalizeEvent, getPublicKey } from 'nostr-tools' +import * as nip44v1 from './nip44v1.mjs' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +// Load .env file from apps/machine if it exists +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const envPath = path.join(__dirname, '../../apps/machine/.env') +if (fs.existsSync(envPath)) { + const envContent = fs.readFileSync(envPath, 'utf-8') + for (const line of envContent.split('\n')) { + const trimmed = line.trim() + if (trimmed && !trimmed.startsWith('#')) { + const [key, ...valueParts] = trimmed.split('=') + if (key && valueParts.length > 0) { + // Map VITE_ prefixed vars to non-prefixed + const envKey = key.replace(/^VITE_/, '') + process.env[envKey] = valueParts.join('=') + } + } + } + console.log('[Config] Loaded .env from:', envPath) +} + +// Configuration from environment +const ATM_PRIVATE_KEY_HEX = process.env.ATM_PRIVATE_KEY +const LIGHTNING_PUB_PUBKEY = process.env.LIGHTNING_PUB_PUBKEY +const RELAY_URL = process.env.RELAY_URL || 'ws://localhost:7777' + +// Validate required config +if (!ATM_PRIVATE_KEY_HEX) { + console.error('ERROR: ATM_PRIVATE_KEY environment variable is required') + console.error('Set it directly or create apps/machine/.env with VITE_ATM_PRIVATE_KEY') + process.exit(1) +} + +if (!LIGHTNING_PUB_PUBKEY) { + console.error('ERROR: LIGHTNING_PUB_PUBKEY environment variable is required') + console.error('Set it directly or create apps/machine/.env with VITE_LIGHTNING_PUB_PUBKEY') + process.exit(1) +} + +const ATM_PRIVATE_KEY = Uint8Array.from(Buffer.from(ATM_PRIVATE_KEY_HEX, 'hex')) +const ATM_PUBLIC_KEY = getPublicKey(ATM_PRIVATE_KEY) + +console.log('') +console.log('='.repeat(60)) +console.log(' ATM Debit Approval Agent (TESTING ONLY)') +console.log('='.repeat(60)) +console.log('') +console.log('⚠️ WARNING: Auto-approves ALL requests without validation!') +console.log('⚠️ For production, use the integrated service in apps/machine') +console.log('') +console.log('ATM Pubkey:', ATM_PUBLIC_KEY) +console.log('Lightning.Pub Pubkey:', LIGHTNING_PUB_PUBKEY) +console.log('Relay URL:', RELAY_URL) +console.log('') + +async function main() { + // Connect to relay + console.log('Connecting to relay...') + const relay = await Relay.connect(RELAY_URL) + console.log('Connected!') + console.log('') + + // Create conversation key for NIP-44 v1 encryption + const conversationKey = nip44v1.getConversationKey(ATM_PRIVATE_KEY_HEX, LIGHTNING_PUB_PUBKEY) + + // Subscribe to GetLiveDebitRequests + console.log('Subscribing to live debit requests...') + + const subscribeRequest = { + rpcName: 'GetLiveDebitRequests', + authIdentifier: ATM_PUBLIC_KEY, + body: {}, + } + + const subEvent = finalizeEvent( + { + kind: 21000, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', LIGHTNING_PUB_PUBKEY]], + content: nip44v1.encrypt(JSON.stringify(subscribeRequest), conversationKey), + }, + ATM_PRIVATE_KEY + ) + + // Listen for debit requests + console.log('Listening for debit requests...') + console.log('(Test by scanning ndebit QR with ShockWallet)') + console.log('') + + const debitSub = relay.subscribe( + [ + { + kinds: [21000], + authors: [LIGHTNING_PUB_PUBKEY], + '#p': [ATM_PUBLIC_KEY], + since: Math.floor(Date.now() / 1000) - 5, + }, + ], + { + async onevent(evt) { + try { + const decrypted = nip44v1.decrypt(evt.content, conversationKey) + const message = JSON.parse(decrypted) + + // Check if this is a live debit request + if (message.requestId === 'GetLiveDebitRequests' && message.debit) { + console.log('') + console.log('========================================') + console.log('DEBIT REQUEST RECEIVED!') + console.log(' Request ID:', message.request_id) + console.log(' From npub:', message.npub?.substring(0, 16) + '...') + console.log(' Debit type:', message.debit.type) + + if (message.debit.invoice) { + console.log(' Invoice:', message.debit.invoice.substring(0, 50) + '...') + + // Auto-approve by responding with INVOICE type + console.log('') + console.log('⚠️ AUTO-APPROVING debit request (NO VALIDATION)...') + + const approveRequest = { + rpcName: 'RespondToDebit', + authIdentifier: ATM_PUBLIC_KEY, + body: { + npub: message.npub, + request_id: message.request_id, + response: { + type: 'invoice', + invoice: message.debit.invoice, + }, + }, + } + + const approveEvent = finalizeEvent( + { + kind: 21000, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', LIGHTNING_PUB_PUBKEY]], + content: nip44v1.encrypt(JSON.stringify(approveRequest), conversationKey), + }, + ATM_PRIVATE_KEY + ) + + await relay.publish(approveEvent) + console.log('APPROVED! (event id:', approveEvent.id.substring(0, 16) + '...)') + } + + console.log('========================================') + console.log('') + } else if (message.rpcName) { + console.log('RPC response:', message.rpcName, ':', message.status || 'received') + } else if (message.requestId) { + console.log('Live subscription active:', message.status) + } + } catch (err) { + // Ignore decryption failures for events not meant for us + if (!err.message?.includes('Unsupported')) { + // Uncomment for debugging: + // console.log('Decryption error:', err.message) + } + } + }, + } + ) + + await relay.publish(subEvent) + console.log('Subscription sent, waiting for debit requests...') + console.log('') + + // Keep running + process.on('SIGINT', () => { + console.log('\nShutting down...') + debitSub.close() + relay.close() + process.exit(0) + }) + + // Heartbeat + while (true) { + await new Promise((r) => setTimeout(r, 30000)) + console.log('Still listening...') + } +} + +main().catch((err) => { + console.error('Error:', err.message) + process.exit(1) +}) diff --git a/lamassu-next/packages/nostr-client/scripts/validate-schemas.ts b/packages/nostr-client/scripts/validate-schemas.ts similarity index 100% rename from lamassu-next/packages/nostr-client/scripts/validate-schemas.ts rename to packages/nostr-client/scripts/validate-schemas.ts diff --git a/lamassu-next/packages/nostr-client/src/__tests__/encryption.test.ts b/packages/nostr-client/src/__tests__/encryption.test.ts similarity index 100% rename from lamassu-next/packages/nostr-client/src/__tests__/encryption.test.ts rename to packages/nostr-client/src/__tests__/encryption.test.ts diff --git a/lamassu-next/packages/nostr-client/src/__tests__/events.test.ts b/packages/nostr-client/src/__tests__/events.test.ts similarity index 100% rename from lamassu-next/packages/nostr-client/src/__tests__/events.test.ts rename to packages/nostr-client/src/__tests__/events.test.ts diff --git a/lamassu-next/packages/nostr-client/src/__tests__/identity.test.ts b/packages/nostr-client/src/__tests__/identity.test.ts similarity index 100% rename from lamassu-next/packages/nostr-client/src/__tests__/identity.test.ts rename to packages/nostr-client/src/__tests__/identity.test.ts diff --git a/lamassu-next/packages/nostr-client/src/client.ts b/packages/nostr-client/src/client.ts similarity index 100% rename from lamassu-next/packages/nostr-client/src/client.ts rename to packages/nostr-client/src/client.ts diff --git a/lamassu-next/packages/nostr-client/src/encryption.ts b/packages/nostr-client/src/encryption.ts similarity index 100% rename from lamassu-next/packages/nostr-client/src/encryption.ts rename to packages/nostr-client/src/encryption.ts diff --git a/lamassu-next/packages/nostr-client/src/events.ts b/packages/nostr-client/src/events.ts similarity index 100% rename from lamassu-next/packages/nostr-client/src/events.ts rename to packages/nostr-client/src/events.ts diff --git a/lamassu-next/packages/nostr-client/src/identity.ts b/packages/nostr-client/src/identity.ts similarity index 100% rename from lamassu-next/packages/nostr-client/src/identity.ts rename to packages/nostr-client/src/identity.ts diff --git a/lamassu-next/packages/nostr-client/src/index.ts b/packages/nostr-client/src/index.ts similarity index 100% rename from lamassu-next/packages/nostr-client/src/index.ts rename to packages/nostr-client/src/index.ts diff --git a/lamassu-next/packages/nostr-client/src/types.ts b/packages/nostr-client/src/types.ts similarity index 100% rename from lamassu-next/packages/nostr-client/src/types.ts rename to packages/nostr-client/src/types.ts diff --git a/lamassu-next/packages/nostr-client/test-debit.mjs b/packages/nostr-client/test-debit.mjs similarity index 100% rename from lamassu-next/packages/nostr-client/test-debit.mjs rename to packages/nostr-client/test-debit.mjs diff --git a/lamassu-next/packages/nostr-client/test-ndebit.mjs b/packages/nostr-client/test-ndebit.mjs similarity index 100% rename from lamassu-next/packages/nostr-client/test-ndebit.mjs rename to packages/nostr-client/test-ndebit.mjs diff --git a/lamassu-next/packages/nostr-client/test-pay.mjs b/packages/nostr-client/test-pay.mjs similarity index 100% rename from lamassu-next/packages/nostr-client/test-pay.mjs rename to packages/nostr-client/test-pay.mjs diff --git a/lamassu-next/packages/nostr-client/tsconfig.json b/packages/nostr-client/tsconfig.json similarity index 100% rename from lamassu-next/packages/nostr-client/tsconfig.json rename to packages/nostr-client/tsconfig.json diff --git a/lamassu-next/packages/nostr-client/vitest.config.ts b/packages/nostr-client/vitest.config.ts similarity index 100% rename from lamassu-next/packages/nostr-client/vitest.config.ts rename to packages/nostr-client/vitest.config.ts diff --git a/lamassu-next/packages/state-machine/package.json b/packages/state-machine/package.json similarity index 100% rename from lamassu-next/packages/state-machine/package.json rename to packages/state-machine/package.json diff --git a/lamassu-next/packages/state-machine/src/__tests__/machine.test.ts b/packages/state-machine/src/__tests__/machine.test.ts similarity index 100% rename from lamassu-next/packages/state-machine/src/__tests__/machine.test.ts rename to packages/state-machine/src/__tests__/machine.test.ts diff --git a/lamassu-next/packages/state-machine/src/index.ts b/packages/state-machine/src/index.ts similarity index 100% rename from lamassu-next/packages/state-machine/src/index.ts rename to packages/state-machine/src/index.ts diff --git a/lamassu-next/packages/state-machine/src/machine.ts b/packages/state-machine/src/machine.ts similarity index 100% rename from lamassu-next/packages/state-machine/src/machine.ts rename to packages/state-machine/src/machine.ts diff --git a/lamassu-next/packages/state-machine/src/types.ts b/packages/state-machine/src/types.ts similarity index 100% rename from lamassu-next/packages/state-machine/src/types.ts rename to packages/state-machine/src/types.ts diff --git a/lamassu-next/packages/state-machine/tsconfig.json b/packages/state-machine/tsconfig.json similarity index 100% rename from lamassu-next/packages/state-machine/tsconfig.json rename to packages/state-machine/tsconfig.json diff --git a/lamassu-next/packages/state-machine/vitest.config.ts b/packages/state-machine/vitest.config.ts similarity index 100% rename from lamassu-next/packages/state-machine/vitest.config.ts rename to packages/state-machine/vitest.config.ts diff --git a/lamassu-next/packages/ui-shared/package.json b/packages/ui-shared/package.json similarity index 100% rename from lamassu-next/packages/ui-shared/package.json rename to packages/ui-shared/package.json diff --git a/lamassu-next/packages/ui-shared/src/__tests__/index.test.ts b/packages/ui-shared/src/__tests__/index.test.ts similarity index 100% rename from lamassu-next/packages/ui-shared/src/__tests__/index.test.ts rename to packages/ui-shared/src/__tests__/index.test.ts diff --git a/lamassu-next/packages/ui-shared/src/index.ts b/packages/ui-shared/src/index.ts similarity index 100% rename from lamassu-next/packages/ui-shared/src/index.ts rename to packages/ui-shared/src/index.ts diff --git a/lamassu-next/packages/ui-shared/tsconfig.json b/packages/ui-shared/tsconfig.json similarity index 100% rename from lamassu-next/packages/ui-shared/tsconfig.json rename to packages/ui-shared/tsconfig.json diff --git a/lamassu-next/packages/ui-shared/vite.config.ts b/packages/ui-shared/vite.config.ts similarity index 100% rename from lamassu-next/packages/ui-shared/vite.config.ts rename to packages/ui-shared/vite.config.ts diff --git a/lamassu-next/pnpm-lock.yaml b/pnpm-lock.yaml similarity index 92% rename from lamassu-next/pnpm-lock.yaml rename to pnpm-lock.yaml index 256ede1..5693dd1 100644 --- a/lamassu-next/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + express: + specifier: ^5.2.1 + version: 5.2.1 lucide-vue-next: specifier: ^0.563.0 version: 0.563.0(vue@3.5.27(typescript@5.9.3)) @@ -75,6 +78,9 @@ importers: '@tailwindcss/vite': specifier: ^4.0.0 version: 4.1.18(vite@6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 '@types/node': specifier: ^22.0.0 version: 22.19.7 @@ -1214,21 +1220,36 @@ packages: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/express-serve-static-core@5.1.1': + resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} '@types/http-cache-semantics@4.2.0': resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} @@ -1250,9 +1271,21 @@ packages: '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} + '@types/qs@6.14.0': + resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/verror@1.10.11': resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} @@ -1379,6 +1412,10 @@ packages: abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -1500,6 +1537,10 @@ packages: bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + boolean@3.2.0: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. @@ -1526,6 +1567,10 @@ packages: builder-util@25.1.7: resolution: {integrity: sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -1546,6 +1591,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + camelcase@5.3.1: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} @@ -1651,6 +1700,22 @@ packages: console-control-strings@1.1.0: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} @@ -1734,6 +1799,10 @@ packages: delegates@1.0.0: resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1771,6 +1840,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -1798,6 +1870,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + encoding@0.1.13: resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} @@ -1860,6 +1936,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1870,6 +1949,10 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1877,6 +1960,10 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + extract-zip@2.0.1: resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} engines: {node: '>= 10.17.0'} @@ -1907,6 +1994,10 @@ packages: filelist@1.0.4: resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -1919,6 +2010,14 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -1979,6 +2078,7 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.0: @@ -1987,12 +2087,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -2046,6 +2146,10 @@ packages: http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@5.0.0: resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} engines: {node: '>= 6'} @@ -2078,6 +2182,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -2103,6 +2211,10 @@ packages: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-ci@3.0.1: resolution: {integrity: sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==} hasBin: true @@ -2118,6 +2230,9 @@ packages: is-lambda@1.0.1: resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} @@ -2323,14 +2438,30 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@2.6.0: resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} engines: {node: '>=4.0.0'} @@ -2425,6 +2556,10 @@ packages: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + node-abi@3.87.0: resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} engines: {node: '>=10'} @@ -2484,6 +2619,10 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} deprecated: This package is no longer supported. + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -2491,6 +2630,10 @@ packages: ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2529,6 +2672,10 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -2552,6 +2699,9 @@ packages: resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} engines: {node: 20 || >=22} + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -2618,6 +2768,10 @@ packages: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -2635,10 +2789,22 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + qs@6.14.2: + resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} + engines: {node: '>=0.6'} + quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + read-binary-file-arch@1.0.6: resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} hasBin: true @@ -2705,6 +2871,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} @@ -2736,6 +2906,10 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + serialize-error@7.0.1: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} @@ -2744,9 +2918,16 @@ packages: resolution: {integrity: sha512-AmH3D9hHPFmnF/oq/rvigfiAouAKyK/TjnrkwZRYSFZxNggJxwvbAbfYrLeuvq7ktUdhuHdVdSjj852Z55R+uA==} engines: {node: '>=16.0.0'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2759,6 +2940,22 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -2814,6 +3011,10 @@ packages: resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} engines: {node: '>= 6'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -2902,6 +3103,10 @@ packages: resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -2958,6 +3163,10 @@ packages: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -2982,6 +3191,10 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -2991,6 +3204,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + verror@1.10.1: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} @@ -3923,6 +4140,11 @@ snapshots: '@tootallnate/once@2.0.0': {} + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.19.7 + '@types/cacheable-request@6.0.3': dependencies: '@types/http-cache-semantics': 4.2.0 @@ -3930,18 +4152,37 @@ snapshots: '@types/node': 22.19.7 '@types/responselike': 1.0.3 + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.19.7 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 '@types/estree@1.0.8': {} + '@types/express-serve-static-core@5.1.1': + dependencies: + '@types/node': 22.19.7 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 + '@types/fs-extra@9.0.13': dependencies: '@types/node': 22.19.7 '@types/http-cache-semantics@4.2.0': {} + '@types/http-errors@2.0.5': {} + '@types/keyv@3.1.4': dependencies: '@types/node': 22.19.7 @@ -3968,10 +4209,23 @@ snapshots: xmlbuilder: 15.1.1 optional: true + '@types/qs@6.14.0': {} + + '@types/range-parser@1.2.7': {} + '@types/responselike@1.0.3': dependencies: '@types/node': 22.19.7 + '@types/send@1.2.1': + dependencies: + '@types/node': 22.19.7 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.19.7 + '@types/verror@1.10.11': optional: true @@ -4147,6 +4401,11 @@ snapshots: abbrev@1.1.1: {} + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -4310,6 +4569,20 @@ snapshots: bluebird@3.7.2: {} + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.14.2 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + boolean@3.2.0: optional: true @@ -4359,6 +4632,8 @@ snapshots: transitivePeerDependencies: - supports-color + bytes@3.1.2: {} + cac@6.7.14: {} cacache@16.1.3: @@ -4401,6 +4676,11 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + camelcase@5.3.1: {} chai@5.3.3: @@ -4503,6 +4783,14 @@ snapshots: console-control-strings@1.1.0: {} + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + core-util-is@1.0.2: optional: true @@ -4572,6 +4860,8 @@ snapshots: delegates@1.0.0: {} + depd@2.0.0: {} + detect-libc@2.1.2: {} detect-node@2.1.0: @@ -4625,6 +4915,8 @@ snapshots: eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} + ejs@3.1.10: dependencies: jake: 10.9.4 @@ -4681,6 +4973,8 @@ snapshots: emoji-regex@9.2.2: {} + encodeurl@2.0.0: {} + encoding@0.1.13: dependencies: iconv-lite: 0.6.3 @@ -4807,6 +5101,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: optional: true @@ -4816,10 +5112,45 @@ snapshots: dependencies: '@types/estree': 1.0.8 + etag@1.8.1: {} + expect-type@1.3.0: {} exponential-backoff@3.1.3: {} + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extract-zip@2.0.1: dependencies: debug: 4.4.3 @@ -4849,6 +5180,17 @@ snapshots: dependencies: minimatch: 5.1.6 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -4867,6 +5209,10 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fs-constants@1.0.0: {} fs-extra@10.1.0: @@ -5037,6 +5383,14 @@ snapshots: http-cache-semantics@4.2.0: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@5.0.0: dependencies: '@tootallnate/once': 2.0.0 @@ -5085,6 +5439,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} imurmurhash@0.1.4: {} @@ -5102,6 +5460,8 @@ snapshots: ip-address@10.1.0: {} + ipaddr.js@1.9.1: {} + is-ci@3.0.1: dependencies: ci-info: 3.9.0 @@ -5112,6 +5472,8 @@ snapshots: is-lambda@1.0.1: {} + is-promise@4.0.0: {} + is-unicode-supported@0.1.0: {} isarray@1.0.0: {} @@ -5292,12 +5654,22 @@ snapshots: math-intrinsics@1.1.0: {} + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@2.6.0: {} mimic-fn@2.1.0: {} @@ -5373,6 +5745,8 @@ snapshots: negotiator@0.6.4: {} + negotiator@1.0.0: {} + node-abi@3.87.0: dependencies: semver: 7.7.3 @@ -5446,11 +5820,17 @@ snapshots: gauge: 4.0.4 set-blocking: 2.0.0 + object-inspect@1.13.4: {} + object-keys@1.1.1: optional: true ohash@2.0.11: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -5493,6 +5873,8 @@ snapshots: package-json-from-dist@1.0.1: {} + parseurl@1.3.3: {} + path-browserify@1.0.1: {} path-exists@4.0.0: {} @@ -5511,6 +5893,8 @@ snapshots: lru-cache: 11.2.4 minipass: 7.1.2 + path-to-regexp@8.3.0: {} + pathe@1.1.2: {} pathval@2.0.1: {} @@ -5560,6 +5944,11 @@ snapshots: err-code: 2.0.3 retry: 0.12.0 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -5577,8 +5966,21 @@ snapshots: pngjs: 5.0.0 yargs: 15.4.1 + qs@6.14.2: + dependencies: + side-channel: 1.1.0 + quick-lru@5.1.1: {} + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + read-binary-file-arch@1.0.6: dependencies: debug: 4.4.3 @@ -5695,6 +6097,16 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.56.0 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + rxjs@7.8.2: dependencies: tslib: 2.8.1 @@ -5718,6 +6130,22 @@ snapshots: semver@7.7.3: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + serialize-error@7.0.1: dependencies: type-fest: 0.13.1 @@ -5742,8 +6170,19 @@ snapshots: transitivePeerDependencies: - supports-color + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + set-blocking@2.0.0: {} + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -5752,6 +6191,34 @@ snapshots: shell-quote@1.8.3: {} + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -5804,6 +6271,8 @@ snapshots: stat-mode@1.0.0: {} + statuses@2.0.2: {} + std-env@3.10.0: {} string-width@4.2.3: @@ -5897,6 +6366,8 @@ snapshots: tmp@0.2.5: {} + toidentifier@1.0.1: {} + tree-kill@1.2.2: {} truncate-utf8-bytes@1.0.2: @@ -5944,6 +6415,12 @@ snapshots: type-fest@0.13.1: optional: true + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript@5.9.3: {} undici-types@6.21.0: {} @@ -5960,6 +6437,8 @@ snapshots: universalify@2.0.1: {} + unpipe@1.0.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -5968,6 +6447,8 @@ snapshots: util-deprecate@1.0.2: {} + vary@1.1.2: {} + verror@1.10.1: dependencies: assert-plus: 1.0.0 diff --git a/lamassu-next/pnpm-workspace.yaml b/pnpm-workspace.yaml similarity index 100% rename from lamassu-next/pnpm-workspace.yaml rename to pnpm-workspace.yaml diff --git a/lamassu-next/scripts/test-clink.ts b/scripts/test-clink.ts similarity index 100% rename from lamassu-next/scripts/test-clink.ts rename to scripts/test-clink.ts diff --git a/lamassu-next/tsconfig.json b/tsconfig.json similarity index 100% rename from lamassu-next/tsconfig.json rename to tsconfig.json diff --git a/lamassu-next/turbo.json b/turbo.json similarity index 100% rename from lamassu-next/turbo.json rename to turbo.json