Compare commits
No commits in common. "fa4858ed66673d7b576cbf8e7092ec309e574a5a" and "695a8bf98d95b5f3d7ac99f81684123937f8176b" have entirely different histories.
fa4858ed66
...
695a8bf98d
51 changed files with 5806 additions and 845 deletions
|
|
@ -175,7 +175,7 @@ From git commits since last release:
|
||||||
|
|
||||||
### Suggested Updates
|
### Suggested Updates
|
||||||
1. `api/clink.md:45` - Add `timeout` parameter to createOffer
|
1. `api/clink.md:45` - Add `timeout` parameter to createOffer
|
||||||
2. `guides/development.md` - Add VITE_BITSPIRE_CASSETTES env var
|
2. `guides/development.md` - Add LIGHTNING_PUB_URL env var
|
||||||
|
|
||||||
### Missing Documentation
|
### Missing Documentation
|
||||||
- `packages/cashu/src/wallet.ts` - No API docs
|
- `packages/cashu/src/wallet.ts` - No API docs
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Validate HAL driver implementations in `packages/hal/` against published hardware protocol specs (JCM ID003, Fujitsu F56 DLE/STX, Puloon LCDM, MEI EBDS, etc.) and against the public-domain tree of `lamassu-machine` (commit `c0b69d1` and earlier) — which is the **last** lamassu-machine release published under a fully-open license.
|
Validate HAL driver implementations in `packages/hal/` against published hardware protocol specs (JCM ID003, Fujitsu F56 DLE/STX, Puloon LCDM, MEI EBDS, etc.) and against the v8.1.5 release line of `lamassu-machine` — which is the **last** lamassu-machine release published under a fully-open license.
|
||||||
|
|
||||||
> **Provenance.** Drivers in `packages/hal/` derive from `lamassu-machine` up to commit `c0b69d1` (2023-09-19, v8.6.0-beta.9), the last public-domain commit; `a9234d124d` added Lamassu's Appendix A licence the same day, so every 8.1.5+ *tag* is proprietary — the old "v8.1.5 is the boundary" was wrong. Since 2026-10-09 we hold permission to use the post-boundary code as prior art too (see CLAUDE.md → Provenance): reference over port, and name the source commit when a block is ported verbatim.
|
> **Provenance boundary.** Drivers in `packages/hal/` derive from `lamassu-machine` at v8.1.5 and earlier (plus hardware-vendor protocol specs). Lamassu Industries AG transitioned to a proprietary source-available license on 2024-01-26 with v8.1.6+ gated behind a paid Operator Support Agreement. **Do not** reference, port, or diff against v8.1.6+ — the only safe upstream tree for porting is `v8.1.5` or earlier. See [CLAUDE.md → Provenance + legal status](../../CLAUDE.md#provenance--legal-status) for the operating rules.
|
||||||
|
|
||||||
> **Language note.** ADR-001 selected TypeScript-in-Electron over Rust-in-Tauri for the HAL. Earlier versions of this skill referenced Rust patterns; that's obsolete. All checks below are TypeScript-flavored.
|
> **Language note.** ADR-001 selected TypeScript-in-Electron over Rust-in-Tauri for the HAL. Earlier versions of this skill referenced Rust patterns; that's obsolete. All checks below are TypeScript-flavored.
|
||||||
|
|
||||||
|
|
@ -16,7 +16,7 @@ Validate HAL driver implementations in `packages/hal/` against published hardwar
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
|
|
||||||
- `port` — Validate that a driver matches its lamassu-machine reference (`c0b69d1` tree unless the port names a later commit) (where the driver was ported from one)
|
- `port` — Validate that a driver matches its lamassu-machine v8.1.5 reference (where the driver was ported from one)
|
||||||
- `protocol` — Check protocol implementation against published vendor specs
|
- `protocol` — Check protocol implementation against published vendor specs
|
||||||
- `safety` — Type safety, error handling, hardware safety review
|
- `safety` — Type safety, error handling, hardware safety review
|
||||||
- `mock` — Validate mock implementation completeness
|
- `mock` — Validate mock implementation completeness
|
||||||
|
|
@ -31,9 +31,9 @@ Drivers:
|
||||||
|
|
||||||
### Source reference
|
### Source reference
|
||||||
|
|
||||||
Each TS driver in `packages/hal/` maps to (at most) one JS source in lamassu-machine at `c0b69d1` (the last public-domain commit):
|
Each TS driver in `packages/hal/` maps to (at most) one JS source in lamassu-machine v8.1.5 (the last fully-open release):
|
||||||
|
|
||||||
| TS Driver | JS Source (`c0b69d1` tree) |
|
| TS Driver | JS Source (v8.1.5 release tree) |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `validators/id003/*.ts` | `lib/id003/*.js` |
|
| `validators/id003/*.ts` | `lib/id003/*.js` |
|
||||||
| `validators/ccnet/*.ts` | `lib/ccnet/*.js` |
|
| `validators/ccnet/*.ts` | `lib/ccnet/*.js` |
|
||||||
|
|
@ -156,7 +156,7 @@ function buildPacket(data: Uint8Array): Uint8Array {
|
||||||
Against the v8.1.5 JS reference (line numbers may vary by tag):
|
Against the v8.1.5 JS reference (line numbers may vary by tag):
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// lamassu-machine c0b69d1 — lib/id003/id003rs232.js
|
// lamassu-machine v8.1.5 — lib/id003/id003rs232.js
|
||||||
function buildPacket(data) {
|
function buildPacket(data) {
|
||||||
const buf = Buffer.alloc(data.length + 4)
|
const buf = Buffer.alloc(data.length + 4)
|
||||||
buf[0] = 0x02 // SYNC
|
buf[0] = 0x02 // SYNC
|
||||||
|
|
@ -234,6 +234,6 @@ A discrepancy here (different CRC polynomial, different framing, different endia
|
||||||
|
|
||||||
## Forbidden operations
|
## Forbidden operations
|
||||||
|
|
||||||
- Port a post-`c0b69d1` block without naming its source commit in the commit message. The permission to reference that code is recorded in CLAUDE.md; the provenance of anything carried over must be recoverable from `git log`.
|
- Diff or read `lamassu-machine` source at v8.1.6 or later. Only `v8.1.5` (and the historical commit range leading up to it) is permissible to reference.
|
||||||
- Copy a value table (note lengths, timings) without a test over it — `bills.ts` carried a wrong GTQ window for months precisely because nothing asserted it.
|
- "Backport" any fix or feature from v8.1.6+ JS sources into TypeScript. If a bug fix is needed, implement from the protocol spec or hardware traces.
|
||||||
- Include attribution comments pointing at v8.1.6+ files even if the implementation is your own — readers should be able to trust file-header attributions as accurate.
|
- Include attribution comments pointing at v8.1.6+ files even if the implementation is your own — readers should be able to trust file-header attributions as accurate.
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ Where `target` can be:
|
||||||
## Relevant NIPs for bitSpire
|
## Relevant NIPs for bitSpire
|
||||||
|
|
||||||
### Core NIPs (Must Implement)
|
### Core NIPs (Must Implement)
|
||||||
| NIP | Description | Usage in bitSpire |
|
| NIP | Description | Usage in Lamassu |
|
||||||
|-----|-------------|------------------|
|
|-----|-------------|------------------|
|
||||||
| NIP-01 | Basic protocol | Event structure, relay communication |
|
| NIP-01 | Basic protocol | Event structure, relay communication |
|
||||||
| NIP-19 | bech32 entities | npub, nsec, nprofile encoding |
|
| NIP-19 | bech32 entities | npub, nsec, nprofile encoding |
|
||||||
|
|
@ -26,7 +26,7 @@ Where `target` can be:
|
||||||
| NIP-59 | Gift wrapping | Anonymous message delivery |
|
| NIP-59 | Gift wrapping | Anonymous message delivery |
|
||||||
|
|
||||||
### Application NIPs
|
### Application NIPs
|
||||||
| NIP | Description | Usage in bitSpire |
|
| NIP | Description | Usage in Lamassu |
|
||||||
|-----|-------------|------------------|
|
|-----|-------------|------------------|
|
||||||
| NIP-17 | Private DMs | Receipt delivery |
|
| NIP-17 | Private DMs | Receipt delivery |
|
||||||
| NIP-47 | Nostr Wallet Connect | Potential wallet integration |
|
| NIP-47 | Nostr Wallet Connect | Potential wallet integration |
|
||||||
|
|
|
||||||
513
.devenv.flake.nix
Normal file
513
.devenv.flake.nix
Normal file
|
|
@ -0,0 +1,513 @@
|
||||||
|
{
|
||||||
|
inputs =
|
||||||
|
let
|
||||||
|
vars = {
|
||||||
|
version = "1.11.2";
|
||||||
|
system = "x86_64-linux";
|
||||||
|
devenv_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next";
|
||||||
|
project_input_ref = "path:/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next";
|
||||||
|
devenv_dotfile = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next/.devenv";
|
||||||
|
devenv_dotfile_path = ./.devenv;
|
||||||
|
devenv_tmpdir = "/run/user/1000";
|
||||||
|
devenv_runtime = "/run/user/1000/devenv-f4ba770";
|
||||||
|
devenv_istesting = false;
|
||||||
|
devenv_direnvrc_latest_version = 1;
|
||||||
|
container_name = null;
|
||||||
|
active_profiles = [
|
||||||
|
];
|
||||||
|
hostname = "gizmo";
|
||||||
|
username = "padreug";
|
||||||
|
git_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages";
|
||||||
|
secretspec = null;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
git-hooks.url = "github:cachix/git-hooks.nix";
|
||||||
|
git-hooks.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
pre-commit-hooks.follows = "git-hooks";
|
||||||
|
nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling";
|
||||||
|
devenv.url = "github:cachix/devenv?dir=src/modules";
|
||||||
|
}
|
||||||
|
// (
|
||||||
|
if builtins.pathExists (vars.devenv_dotfile_path + "/flake.json") then
|
||||||
|
builtins.fromJSON (builtins.readFile (vars.devenv_dotfile_path + "/flake.json"))
|
||||||
|
else
|
||||||
|
{ }
|
||||||
|
);
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ nixpkgs, ... }@inputs:
|
||||||
|
let
|
||||||
|
vars = {
|
||||||
|
version = "1.11.2";
|
||||||
|
system = "x86_64-linux";
|
||||||
|
devenv_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next";
|
||||||
|
project_input_ref = "path:/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next";
|
||||||
|
devenv_dotfile = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next/.devenv";
|
||||||
|
devenv_dotfile_path = ./.devenv;
|
||||||
|
devenv_tmpdir = "/run/user/1000";
|
||||||
|
devenv_runtime = "/run/user/1000/devenv-f4ba770";
|
||||||
|
devenv_istesting = false;
|
||||||
|
devenv_direnvrc_latest_version = 1;
|
||||||
|
container_name = null;
|
||||||
|
active_profiles = [
|
||||||
|
];
|
||||||
|
hostname = "gizmo";
|
||||||
|
username = "padreug";
|
||||||
|
git_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages";
|
||||||
|
secretspec = null;
|
||||||
|
};
|
||||||
|
devenv =
|
||||||
|
if builtins.pathExists (vars.devenv_dotfile_path + "/devenv.json") then
|
||||||
|
builtins.fromJSON (builtins.readFile (vars.devenv_dotfile_path + "/devenv.json"))
|
||||||
|
else
|
||||||
|
{ };
|
||||||
|
|
||||||
|
systems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
"x86_64-darwin"
|
||||||
|
"aarch64-darwin"
|
||||||
|
];
|
||||||
|
|
||||||
|
# Function to create devenv configuration for a specific system with profiles support
|
||||||
|
mkDevenvForSystem =
|
||||||
|
targetSystem:
|
||||||
|
let
|
||||||
|
getOverlays =
|
||||||
|
inputName: inputAttrs:
|
||||||
|
map (
|
||||||
|
overlay:
|
||||||
|
let
|
||||||
|
input =
|
||||||
|
inputs.${inputName} or (throw "No such input `${inputName}` while trying to configure overlays.");
|
||||||
|
in
|
||||||
|
input.overlays.${overlay}
|
||||||
|
or (throw "Input `${inputName}` has no overlay called `${overlay}`. Supported overlays: ${nixpkgs.lib.concatStringsSep ", " (builtins.attrNames input.overlays)}")
|
||||||
|
) inputAttrs.overlays or [ ];
|
||||||
|
overlays = nixpkgs.lib.flatten (nixpkgs.lib.mapAttrsToList getOverlays (devenv.inputs or { }));
|
||||||
|
permittedUnfreePackages =
|
||||||
|
devenv.nixpkgs.per-platform."${targetSystem}".permittedUnfreePackages
|
||||||
|
or devenv.nixpkgs.permittedUnfreePackages or [ ];
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
system = targetSystem;
|
||||||
|
config = {
|
||||||
|
allowUnfree =
|
||||||
|
devenv.nixpkgs.per-platform."${targetSystem}".allowUnfree or devenv.nixpkgs.allowUnfree
|
||||||
|
or devenv.allowUnfree or false;
|
||||||
|
allowBroken =
|
||||||
|
devenv.nixpkgs.per-platform."${targetSystem}".allowBroken or devenv.nixpkgs.allowBroken
|
||||||
|
or devenv.allowBroken or false;
|
||||||
|
cudaSupport =
|
||||||
|
devenv.nixpkgs.per-platform."${targetSystem}".cudaSupport or devenv.nixpkgs.cudaSupport or false;
|
||||||
|
cudaCapabilities =
|
||||||
|
devenv.nixpkgs.per-platform."${targetSystem}".cudaCapabilities or devenv.nixpkgs.cudaCapabilities
|
||||||
|
or [ ];
|
||||||
|
permittedInsecurePackages =
|
||||||
|
devenv.nixpkgs.per-platform."${targetSystem}".permittedInsecurePackages
|
||||||
|
or devenv.nixpkgs.permittedInsecurePackages or devenv.permittedInsecurePackages or [ ];
|
||||||
|
allowUnfreePredicate =
|
||||||
|
if (permittedUnfreePackages != [ ]) then
|
||||||
|
(pkg: builtins.elem (nixpkgs.lib.getName pkg) permittedUnfreePackages)
|
||||||
|
else
|
||||||
|
(_: false);
|
||||||
|
};
|
||||||
|
inherit overlays;
|
||||||
|
};
|
||||||
|
inherit (pkgs) lib;
|
||||||
|
importModule =
|
||||||
|
path:
|
||||||
|
if lib.hasPrefix "./" path then
|
||||||
|
if lib.hasSuffix ".nix" path then
|
||||||
|
./. + (builtins.substring 1 255 path)
|
||||||
|
else
|
||||||
|
./. + (builtins.substring 1 255 path) + "/devenv.nix"
|
||||||
|
else if lib.hasPrefix "../" path then
|
||||||
|
# For parent directory paths, concatenate with /.
|
||||||
|
# ./. refers to the directory containing this file (project root)
|
||||||
|
# So ./. + "/../shared" = <project-root>/../shared
|
||||||
|
if lib.hasSuffix ".nix" path then ./. + "/${path}" else ./. + "/${path}/devenv.nix"
|
||||||
|
else
|
||||||
|
let
|
||||||
|
paths = lib.splitString "/" path;
|
||||||
|
name = builtins.head paths;
|
||||||
|
input = inputs.${name} or (throw "Unknown input ${name}");
|
||||||
|
subpath = "/${lib.concatStringsSep "/" (builtins.tail paths)}";
|
||||||
|
devenvpath = "${input}" + subpath;
|
||||||
|
devenvdefaultpath = devenvpath + "/devenv.nix";
|
||||||
|
in
|
||||||
|
if lib.hasSuffix ".nix" devenvpath then
|
||||||
|
devenvpath
|
||||||
|
else if builtins.pathExists devenvdefaultpath then
|
||||||
|
devenvdefaultpath
|
||||||
|
else
|
||||||
|
throw (devenvdefaultpath + " file does not exist for input ${name}.");
|
||||||
|
|
||||||
|
# Phase 1: Base evaluation to extract profile definitions
|
||||||
|
baseProject = pkgs.lib.evalModules {
|
||||||
|
specialArgs = inputs // {
|
||||||
|
inherit inputs;
|
||||||
|
};
|
||||||
|
modules = [
|
||||||
|
(
|
||||||
|
{ config, ... }:
|
||||||
|
{
|
||||||
|
_module.args.pkgs = pkgs.appendOverlays (config.overlays or [ ]);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
(inputs.devenv.modules + /top-level.nix)
|
||||||
|
(
|
||||||
|
{ options, ... }:
|
||||||
|
{
|
||||||
|
config.devenv = lib.mkMerge [
|
||||||
|
{
|
||||||
|
cliVersion = vars.version;
|
||||||
|
root = vars.devenv_root;
|
||||||
|
dotfile = vars.devenv_dotfile;
|
||||||
|
}
|
||||||
|
(pkgs.lib.optionalAttrs (builtins.hasAttr "tmpdir" options.devenv) {
|
||||||
|
tmpdir = vars.devenv_tmpdir;
|
||||||
|
})
|
||||||
|
(pkgs.lib.optionalAttrs (builtins.hasAttr "isTesting" options.devenv) {
|
||||||
|
isTesting = vars.devenv_istesting;
|
||||||
|
})
|
||||||
|
(pkgs.lib.optionalAttrs (builtins.hasAttr "runtime" options.devenv) {
|
||||||
|
runtime = vars.devenv_runtime;
|
||||||
|
})
|
||||||
|
(pkgs.lib.optionalAttrs (builtins.hasAttr "direnvrcLatestVersion" options.devenv) {
|
||||||
|
direnvrcLatestVersion = vars.devenv_direnvrc_latest_version;
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
)
|
||||||
|
(
|
||||||
|
{ options, ... }:
|
||||||
|
{
|
||||||
|
config = lib.mkMerge [
|
||||||
|
(pkgs.lib.optionalAttrs (builtins.hasAttr "git" options) {
|
||||||
|
git.root = vars.git_root;
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
)
|
||||||
|
(pkgs.lib.optionalAttrs (vars.container_name != null) {
|
||||||
|
container.isBuilding = pkgs.lib.mkForce true;
|
||||||
|
containers.${vars.container_name}.isBuilding = true;
|
||||||
|
})
|
||||||
|
]
|
||||||
|
++ (map importModule (devenv.imports or [ ]))
|
||||||
|
++ [
|
||||||
|
(if builtins.pathExists ./devenv.nix then ./devenv.nix else { })
|
||||||
|
(devenv.devenv or { })
|
||||||
|
(if builtins.pathExists ./devenv.local.nix then ./devenv.local.nix else { })
|
||||||
|
(
|
||||||
|
if builtins.pathExists (vars.devenv_dotfile_path + "/cli-options.nix") then
|
||||||
|
import (vars.devenv_dotfile_path + "/cli-options.nix")
|
||||||
|
else
|
||||||
|
{ }
|
||||||
|
)
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
# Phase 2: Extract and apply profiles using extendModules with priority overrides
|
||||||
|
project =
|
||||||
|
let
|
||||||
|
# Build ordered list of profile names: hostname -> user -> manual
|
||||||
|
manualProfiles = vars.active_profiles;
|
||||||
|
currentHostname = vars.hostname;
|
||||||
|
currentUsername = vars.username;
|
||||||
|
hostnameProfiles = lib.optional (
|
||||||
|
currentHostname != ""
|
||||||
|
&& builtins.hasAttr currentHostname (baseProject.config.profiles.hostname or { })
|
||||||
|
) "hostname.${currentHostname}";
|
||||||
|
userProfiles = lib.optional (
|
||||||
|
currentUsername != "" && builtins.hasAttr currentUsername (baseProject.config.profiles.user or { })
|
||||||
|
) "user.${currentUsername}";
|
||||||
|
|
||||||
|
# Ordered list of profiles to activate
|
||||||
|
orderedProfiles = hostnameProfiles ++ userProfiles ++ manualProfiles;
|
||||||
|
|
||||||
|
# Resolve profile extends with cycle detection
|
||||||
|
resolveProfileExtends =
|
||||||
|
profileName: visited:
|
||||||
|
if builtins.elem profileName visited then
|
||||||
|
throw "Circular dependency detected in profile extends: ${lib.concatStringsSep " -> " visited} -> ${profileName}"
|
||||||
|
else
|
||||||
|
let
|
||||||
|
profile = getProfileConfig profileName;
|
||||||
|
extends = profile.extends or [ ];
|
||||||
|
newVisited = visited ++ [ profileName ];
|
||||||
|
extendedProfiles = lib.flatten (map (name: resolveProfileExtends name newVisited) extends);
|
||||||
|
in
|
||||||
|
extendedProfiles ++ [ profileName ];
|
||||||
|
|
||||||
|
# Get profile configuration by name from baseProject
|
||||||
|
getProfileConfig =
|
||||||
|
profileName:
|
||||||
|
if lib.hasPrefix "hostname." profileName then
|
||||||
|
let
|
||||||
|
name = lib.removePrefix "hostname." profileName;
|
||||||
|
in
|
||||||
|
baseProject.config.profiles.hostname.${name}
|
||||||
|
else if lib.hasPrefix "user." profileName then
|
||||||
|
let
|
||||||
|
name = lib.removePrefix "user." profileName;
|
||||||
|
in
|
||||||
|
baseProject.config.profiles.user.${name}
|
||||||
|
else
|
||||||
|
let
|
||||||
|
availableProfiles = builtins.attrNames (baseProject.config.profiles or { });
|
||||||
|
hostnameProfiles = map (n: "hostname.${n}") (
|
||||||
|
builtins.attrNames (baseProject.config.profiles.hostname or { })
|
||||||
|
);
|
||||||
|
userProfiles = map (n: "user.${n}") (builtins.attrNames (baseProject.config.profiles.user or { }));
|
||||||
|
allAvailableProfiles = availableProfiles ++ hostnameProfiles ++ userProfiles;
|
||||||
|
in
|
||||||
|
baseProject.config.profiles.${profileName}
|
||||||
|
or (throw "Profile '${profileName}' not found. Available profiles: ${lib.concatStringsSep ", " allAvailableProfiles}");
|
||||||
|
|
||||||
|
# Fold over ordered profiles to build final list with extends
|
||||||
|
expandedProfiles = lib.foldl' (
|
||||||
|
acc: profileName:
|
||||||
|
let
|
||||||
|
allProfileNames = resolveProfileExtends profileName [ ];
|
||||||
|
in
|
||||||
|
acc ++ allProfileNames
|
||||||
|
) [ ] orderedProfiles;
|
||||||
|
|
||||||
|
# Map over expanded profiles and apply priorities
|
||||||
|
allPrioritizedModules = lib.imap0 (
|
||||||
|
index: profileName:
|
||||||
|
let
|
||||||
|
# Decrement priority for each profile (lower = higher precedence)
|
||||||
|
# Start with the next lowest priority after the default priority for values (100)
|
||||||
|
profilePriority = (lib.modules.defaultOverridePriority - 1) - index;
|
||||||
|
profileConfig = getProfileConfig profileName;
|
||||||
|
|
||||||
|
# Check if an option type needs explicit override to resolve conflicts
|
||||||
|
# Only apply overrides to LEAF values (scalars), not collection types that can merge
|
||||||
|
typeNeedsOverride =
|
||||||
|
type:
|
||||||
|
if type == null then
|
||||||
|
false
|
||||||
|
else
|
||||||
|
let
|
||||||
|
typeName = type.name or type._type or "";
|
||||||
|
|
||||||
|
# True leaf types that need priority resolution when they conflict
|
||||||
|
isLeafType = builtins.elem typeName [
|
||||||
|
"str"
|
||||||
|
"int"
|
||||||
|
"bool"
|
||||||
|
"enum"
|
||||||
|
"path"
|
||||||
|
"package"
|
||||||
|
"float"
|
||||||
|
"anything"
|
||||||
|
];
|
||||||
|
in
|
||||||
|
if isLeafType then
|
||||||
|
true
|
||||||
|
else if typeName == "nullOr" then
|
||||||
|
# For nullOr, check the wrapped type recursively
|
||||||
|
let
|
||||||
|
innerType =
|
||||||
|
type.elemType
|
||||||
|
or (if type ? nestedTypes && type.nestedTypes ? elemType then type.nestedTypes.elemType else null);
|
||||||
|
in
|
||||||
|
if innerType != null then typeNeedsOverride innerType else false
|
||||||
|
else
|
||||||
|
# Everything else (collections, submodules, etc.) should merge naturally
|
||||||
|
false;
|
||||||
|
|
||||||
|
# Check if a config path needs explicit override
|
||||||
|
pathNeedsOverride =
|
||||||
|
optionPath:
|
||||||
|
let
|
||||||
|
# Try direct option first
|
||||||
|
directOption = lib.attrByPath optionPath null baseProject.options;
|
||||||
|
in
|
||||||
|
if directOption != null && lib.isOption directOption then
|
||||||
|
typeNeedsOverride directOption.type
|
||||||
|
else if optionPath != [ ] then
|
||||||
|
# Check parent for freeform type
|
||||||
|
let
|
||||||
|
parentPath = lib.init optionPath;
|
||||||
|
parentOption = lib.attrByPath parentPath null baseProject.options;
|
||||||
|
in
|
||||||
|
if parentOption != null && lib.isOption parentOption then
|
||||||
|
let
|
||||||
|
# Look for freeform type:
|
||||||
|
# 1. Standard location: type.freeformType (primary)
|
||||||
|
# 2. Nested location: type.nestedTypes.freeformType (evaluated form)
|
||||||
|
freeformType = parentOption.type.freeformType or parentOption.type.nestedTypes.freeformType or null;
|
||||||
|
elementType =
|
||||||
|
if freeformType ? elemType then
|
||||||
|
freeformType.elemType
|
||||||
|
else if freeformType ? nestedTypes && freeformType.nestedTypes ? elemType then
|
||||||
|
freeformType.nestedTypes.elemType
|
||||||
|
else
|
||||||
|
freeformType;
|
||||||
|
in
|
||||||
|
typeNeedsOverride elementType
|
||||||
|
else
|
||||||
|
false
|
||||||
|
else
|
||||||
|
false;
|
||||||
|
|
||||||
|
# Support overriding both plain attrset modules and functions
|
||||||
|
applyModuleOverride =
|
||||||
|
config:
|
||||||
|
if builtins.isFunction config then
|
||||||
|
let
|
||||||
|
wrapper = args: applyOverrideRecursive (config args) [ ];
|
||||||
|
in
|
||||||
|
lib.mirrorFunctionArgs config wrapper
|
||||||
|
else
|
||||||
|
applyOverrideRecursive config [ ];
|
||||||
|
|
||||||
|
# Apply overrides recursively based on option types
|
||||||
|
applyOverrideRecursive =
|
||||||
|
config: optionPath:
|
||||||
|
if lib.isAttrs config && config ? _type then
|
||||||
|
config # Don't touch values with existing type metadata
|
||||||
|
else if lib.isAttrs config then
|
||||||
|
lib.mapAttrs (name: value: applyOverrideRecursive value (optionPath ++ [ name ])) config
|
||||||
|
else if pathNeedsOverride optionPath then
|
||||||
|
lib.mkOverride profilePriority config
|
||||||
|
else
|
||||||
|
config;
|
||||||
|
|
||||||
|
# Apply priority overrides recursively to the deferredModule imports structure
|
||||||
|
prioritizedConfig = (
|
||||||
|
profileConfig.module
|
||||||
|
// {
|
||||||
|
imports = lib.map (
|
||||||
|
importItem:
|
||||||
|
importItem
|
||||||
|
// {
|
||||||
|
imports = lib.map (nestedImport: applyModuleOverride nestedImport) (importItem.imports or [ ]);
|
||||||
|
}
|
||||||
|
) (profileConfig.module.imports or [ ]);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
in
|
||||||
|
prioritizedConfig
|
||||||
|
) expandedProfiles;
|
||||||
|
in
|
||||||
|
if allPrioritizedModules == [ ] then
|
||||||
|
baseProject
|
||||||
|
else
|
||||||
|
baseProject.extendModules { modules = allPrioritizedModules; };
|
||||||
|
|
||||||
|
config = project.config;
|
||||||
|
|
||||||
|
options = pkgs.nixosOptionsDoc {
|
||||||
|
options = builtins.removeAttrs project.options [ "_module" ];
|
||||||
|
warningsAreErrors = false;
|
||||||
|
# Unpack Nix types, e.g. literalExpression, mDoc.
|
||||||
|
transformOptions =
|
||||||
|
let
|
||||||
|
isDocType =
|
||||||
|
v:
|
||||||
|
builtins.elem v [
|
||||||
|
"literalDocBook"
|
||||||
|
"literalExpression"
|
||||||
|
"literalMD"
|
||||||
|
"mdDoc"
|
||||||
|
];
|
||||||
|
in
|
||||||
|
lib.attrsets.mapAttrs (
|
||||||
|
_: v:
|
||||||
|
if v ? _type && isDocType v._type then
|
||||||
|
v.text
|
||||||
|
else if v ? _type && v._type == "derivation" then
|
||||||
|
v.name
|
||||||
|
else
|
||||||
|
v
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
# Recursively search for outputs in the config.
|
||||||
|
# This is used when not building a specific output by attrpath.
|
||||||
|
build =
|
||||||
|
options: config:
|
||||||
|
lib.concatMapAttrs (
|
||||||
|
name: option:
|
||||||
|
if lib.isOption option then
|
||||||
|
let
|
||||||
|
typeName = option.type.name or "";
|
||||||
|
in
|
||||||
|
if
|
||||||
|
builtins.elem typeName [
|
||||||
|
"output"
|
||||||
|
"outputOf"
|
||||||
|
]
|
||||||
|
then
|
||||||
|
{ ${name} = config.${name}; }
|
||||||
|
else
|
||||||
|
{ }
|
||||||
|
else if builtins.isAttrs option && !lib.isDerivation option then
|
||||||
|
let
|
||||||
|
v = build option config.${name};
|
||||||
|
in
|
||||||
|
if v != { } then
|
||||||
|
{
|
||||||
|
${name} = v;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{ }
|
||||||
|
else
|
||||||
|
{ }
|
||||||
|
) options;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
inherit
|
||||||
|
config
|
||||||
|
options
|
||||||
|
build
|
||||||
|
project
|
||||||
|
;
|
||||||
|
shell = config.shell;
|
||||||
|
packages = {
|
||||||
|
optionsJSON = options.optionsJSON;
|
||||||
|
# deprecated
|
||||||
|
inherit (config)
|
||||||
|
info
|
||||||
|
procfileScript
|
||||||
|
procfileEnv
|
||||||
|
procfile
|
||||||
|
;
|
||||||
|
ci = config.ciDerivation;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Generate per-system devenv configurations
|
||||||
|
perSystem = nixpkgs.lib.genAttrs systems mkDevenvForSystem;
|
||||||
|
|
||||||
|
# Default devenv for the current system
|
||||||
|
currentSystemDevenv = perSystem.${vars.system};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShell = nixpkgs.lib.genAttrs systems (s: perSystem.${s}.shell);
|
||||||
|
packages = nixpkgs.lib.genAttrs systems (s: perSystem.${s}.packages);
|
||||||
|
|
||||||
|
# Per-system devenv configurations
|
||||||
|
devenv = {
|
||||||
|
# Default devenv for the current system
|
||||||
|
inherit (currentSystemDevenv)
|
||||||
|
config
|
||||||
|
options
|
||||||
|
build
|
||||||
|
shell
|
||||||
|
packages
|
||||||
|
project
|
||||||
|
;
|
||||||
|
# Per-system devenv configurations
|
||||||
|
inherit perSystem;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Legacy build output
|
||||||
|
build = currentSystemDevenv.build currentSystemDevenv.options currentSystemDevenv.config;
|
||||||
|
};
|
||||||
|
}
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -55,10 +55,13 @@ apps/machine/src/services/*.js
|
||||||
|
|
||||||
# devenv
|
# devenv
|
||||||
.devenv/
|
.devenv/
|
||||||
.devenv.flake.nix
|
|
||||||
.direnv/
|
.direnv/
|
||||||
.pre-commit-config.yaml
|
.pre-commit-config.yaml
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker/**/data/
|
||||||
|
docker/.state/
|
||||||
|
|
||||||
# Nix build outputs
|
# Nix build outputs
|
||||||
result
|
result
|
||||||
result-*
|
result-*
|
||||||
|
|
|
||||||
35
CLAUDE.md
35
CLAUDE.md
|
|
@ -17,27 +17,9 @@ Core principles:
|
||||||
|
|
||||||
The HAL drivers (validators / dispensers / printers) and the cash-flow state machine derive from Lamassu Industries AG's `lamassu-machine` repository, **only up to commit `c0b69d1ed196d396c5f057478c2ea290babd58ab`** ("chore: v8.6.0-beta.9", 2023-09-19) — the last commit published into the public domain (`UNLICENSE` in tree). The very next commit, `a9234d124d` ("chore: add LICENSE (#1019)", 2023-09-19), removed `UNLICENSE` and added Lamassu's proprietary "Appendix A SLA". **The `v8.1.5` tag (2023-09-21) already ships the Appendix A license** — the previously documented "8.1.5 is the open boundary" was wrong (verified against GitHub history 2026-07-04). Note the public-domain boundary sits on the 8.6-beta line, which is *further along* than 8.1.5 feature-wise.
|
The HAL drivers (validators / dispensers / printers) and the cash-flow state machine derive from Lamassu Industries AG's `lamassu-machine` repository, **only up to commit `c0b69d1ed196d396c5f057478c2ea290babd58ab`** ("chore: v8.6.0-beta.9", 2023-09-19) — the last commit published into the public domain (`UNLICENSE` in tree). The very next commit, `a9234d124d` ("chore: add LICENSE (#1019)", 2023-09-19), removed `UNLICENSE` and added Lamassu's proprietary "Appendix A SLA". **The `v8.1.5` tag (2023-09-21) already ships the Appendix A license** — the previously documented "8.1.5 is the open boundary" was wrong (verified against GitHub history 2026-07-04). Note the public-domain boundary sits on the 8.6-beta line, which is *further along* than 8.1.5 feature-wise.
|
||||||
|
|
||||||
**Reference permission (2026-10-09).** The maintainer taking over the Lamassu
|
**Hard rule when working in this repo:** do not pull, port, or copy lamassu-machine code from `a9234d124d` or later (which includes every 8.1.5+ tag). Reference only `c0b69d1` or earlier. If a HAL bug fix or feature exists upstream past that commit, either (a) reimplement from protocol docs / hardware specs without looking at the licensed source, or (b) raise the question with the maintainer first. To fetch the open tree safely: `git fetch --depth 1 origin c0b69d1ed196d396c5f057478c2ea290babd58ab` — never check out a tag.
|
||||||
codebase has given us permission to use lamassu-machine and lamassu-server — including
|
|
||||||
post-boundary code — as prior art in any way that improves this codebase (relayed by
|
|
||||||
padreug, 2026-10-09; the earlier hard rule — reference only `c0b69d1` or earlier — is
|
|
||||||
superseded). Prefer to *reference* over
|
|
||||||
*port*: read it, take the behaviour model, reimplement in our idiom. When a block is
|
|
||||||
ported verbatim, say so in the commit, with the source commit, so the provenance is in
|
|
||||||
`git log`. The pre-boundary tree needs no permission at all and is the first place to look.
|
|
||||||
|
|
||||||
**Boundaries, for the record.** lamassu-machine's last public-domain commit is `c0b69d1`
|
The `lamassu-server` boundary has not been re-verified against its own history and may differ — check its license-change commit before referencing it.
|
||||||
(2023-09-19, v8.6.0-beta.9); `a9234d124d` added Appendix A the same day, so every 8.1.5+
|
|
||||||
tag is proprietary. lamassu-server's last public-domain commit is `adbc9709` (2023-09-19,
|
|
||||||
v8.6.0-beta.9), licence added in `d06a8f54` the same day. Both GitHub repos are gone
|
|
||||||
(404); full history survives in `~/dev/repos/` and at Software Heritage (crawls 2026-03-02
|
|
||||||
and 2026-07-19, tips identical to the local mirrors). **`~/lamassu/lamassu-server` is a
|
|
||||||
squashed v12 repo** whose first commit (`e2c49ea`, 2025-12-31) already carries Appendix A —
|
|
||||||
it has no open era to reference; use `~/dev/repos/` for that. `~/lamassu/` also holds the
|
|
||||||
33 surviving github.com/lamassu dependency repos (cloned 2026-09-27) and
|
|
||||||
`bnr-xfs-salvage/` (the MIT bnr-xfs / bnr crates, checksums verified). The curated
|
|
||||||
*Lamassu Port Backlog* (claude.ai artifact `61af38f6`, 2026-09-27) ranks what is worth
|
|
||||||
taking and tags each item's provenance.
|
|
||||||
|
|
||||||
bitSpire is an independent project under AGPL-3.0 and is not affiliated with Lamassu Industries AG.
|
bitSpire is an independent project under AGPL-3.0 and is not affiliated with Lamassu Industries AG.
|
||||||
|
|
||||||
|
|
@ -250,15 +232,10 @@ TypeScript drivers in `packages/hal/`. Coverage by device class:
|
||||||
|
|
||||||
| Category | Drivers |
|
| Category | Drivers |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Validators | id003, ebds |
|
| Validators | id003, ccnet, cashflow_sc, bnr_advance, genmega, hcm2, gsr50 |
|
||||||
| Dispensers | f56, puloon |
|
| Dispensers | puloon, f56, genmega, hcm2, gsr50 |
|
||||||
| Recyclers | none — MEI SCR hardware in BATM3; a clean-room BNR Advance route exists via the MIT `bnr-xfs` crate (see the port backlog) |
|
| Recyclers | MEI SCR (planned — hardware in BATM3, no driver yet) |
|
||||||
| Printers | none — `packages/hal/src/printers/` does not exist |
|
| Printers | nippon, zebra, genmega |
|
||||||
|
|
||||||
This table previously listed ccnet, cashflow_sc, bnr_advance, genmega, hcm2, gsr50 and
|
|
||||||
three printers that are not in the tree (corrected 2026-10-09). `packages/hal/src` also
|
|
||||||
carries orphaned `*.rs` files (`lib.rs`, `error.rs`, `mod.rs`, `traits.rs`, `mock.rs`) from
|
|
||||||
an abandoned Rust HAL; they are not built.
|
|
||||||
|
|
||||||
### Sintra hardware specifics (Aaeon UP Board)
|
### Sintra hardware specifics (Aaeon UP Board)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
A Nostr-native Lightning ATM. KYC-free, open source, auditable. Talks to its Lightning backend over the nostr-native-transport (kind-21000 NIP-44 v2) on a relay — never HTTP — so the kiosk has no admin tokens to leak and no API surface to attack.
|
A Nostr-native Lightning ATM. KYC-free, open source, auditable. Talks to its Lightning backend over the nostr-native-transport (kind-21000 NIP-44 v2) on a relay — never HTTP — so the kiosk has no admin tokens to leak and no API surface to attack.
|
||||||
|
|
||||||
> Originally `lamassu-next`. Renamed during the LNbits-backend transition on the `dev` branch (commits leading up to 2026-05-13). Every live machine now runs `dev` against LNbits; `main` is the Lightning.Pub-era history (see CLAUDE.md → Branch model).
|
> Originally `lamassu-next`. Renamed during the LNbits-backend transition on the `dev` branch (commits leading up to 2026-05-13). Production ATMs (`batm3`, `douro`) still run from `main` against Lightning.Pub until cutover; this README describes the `dev` branch state.
|
||||||
|
|
||||||
## What the ATM actually does
|
## What the ATM actually does
|
||||||
|
|
||||||
|
|
@ -26,8 +26,8 @@ The `nostrrelay` extension inside LNbits is what the ATM connects to — there i
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone
|
# 1. Clone
|
||||||
git clone ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git
|
git clone ssh://forgejo@git.atitlan.io/aiolabs/lamassu-next.git
|
||||||
cd bitspire
|
cd lamassu-next # repo name kept for now — rename to bitSpire is a follow-up
|
||||||
git checkout dev
|
git checkout dev
|
||||||
|
|
||||||
# 2. Enter the dev environment
|
# 2. Enter the dev environment
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,17 @@
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Machine model preset (sintra, gaia, or custom)
|
# Machine model preset (sintra, gaia, or custom)
|
||||||
VITE_BITSPIRE_MACHINE_MODEL=sintra
|
VITE_LAMASSU_MACHINE_MODEL=sintra
|
||||||
|
|
||||||
# Fiat currency code (ISO 4217)
|
# Fiat currency code (ISO 4217)
|
||||||
VITE_BITSPIRE_FIAT_CODE=USD
|
VITE_LAMASSU_FIAT_CODE=USD
|
||||||
|
|
||||||
# Custom device paths (optional - uses preset defaults if not set)
|
# Custom device paths (optional - uses preset defaults if not set)
|
||||||
# VITE_BITSPIRE_VALIDATOR_DEVICE=/dev/ttyJ5
|
# VITE_LAMASSU_VALIDATOR_DEVICE=/dev/ttyJ5
|
||||||
# VITE_BITSPIRE_DISPENSER_DEVICE=/dev/ttyJ7
|
# VITE_LAMASSU_DISPENSER_DEVICE=/dev/ttyJ7
|
||||||
|
|
||||||
# Cassette configuration (optional - JSON array)
|
# Cassette configuration (optional - JSON array)
|
||||||
# VITE_BITSPIRE_CASSETTES='[{"denomination":20,"count":100}]'
|
# VITE_LAMASSU_CASSETTES='[{"denomination":20,"count":100}]'
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# LNbits Connection (dev override — normally seed-provided) — nostr-native-transport
|
# LNbits Connection (dev override — normally seed-provided) — nostr-native-transport
|
||||||
|
|
|
||||||
|
|
@ -355,11 +355,11 @@ ipcMain.handle('get-config', () => {
|
||||||
appId: process.env.VITE_APP_ID || '',
|
appId: process.env.VITE_APP_ID || '',
|
||||||
|
|
||||||
// Hardware configuration
|
// Hardware configuration
|
||||||
machineModel: process.env.VITE_BITSPIRE_MACHINE_MODEL || 'sintra',
|
machineModel: process.env.VITE_LAMASSU_MACHINE_MODEL || 'sintra',
|
||||||
fiatCode: process.env.VITE_BITSPIRE_FIAT_CODE || 'USD',
|
fiatCode: process.env.VITE_LAMASSU_FIAT_CODE || 'USD',
|
||||||
validatorDevice: process.env.VITE_BITSPIRE_VALIDATOR_DEVICE,
|
validatorDevice: process.env.VITE_LAMASSU_VALIDATOR_DEVICE,
|
||||||
dispenserDevice: process.env.VITE_BITSPIRE_DISPENSER_DEVICE,
|
dispenserDevice: process.env.VITE_LAMASSU_DISPENSER_DEVICE,
|
||||||
cassettes: process.env.VITE_BITSPIRE_CASSETTES,
|
cassettes: process.env.VITE_LAMASSU_CASSETTES,
|
||||||
// SECURITY: In production (packaged app), mock fallback is always disabled.
|
// SECURITY: In production (packaged app), mock fallback is always disabled.
|
||||||
// Only allow it in development mode, and only when explicitly opted in via env.
|
// Only allow it in development mode, and only when explicitly opted in via env.
|
||||||
allowMockFallback: isDev && process.env.VITE_ALLOW_MOCK_FALLBACK === 'true',
|
allowMockFallback: isDev && process.env.VITE_ALLOW_MOCK_FALLBACK === 'true',
|
||||||
|
|
@ -836,7 +836,7 @@ function startCommandPoller(): void {
|
||||||
const result = await halInstance.dispenseCash(parsed.bills)
|
const result = await halInstance.dispenseCash(parsed.bills)
|
||||||
const txid = `manual-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
const txid = `manual-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
const totalFiatCents = parsed.bills.reduce((s, b) => s + b.denomination * b.count * 100, 0)
|
const totalFiatCents = parsed.bills.reduce((s, b) => s + b.denomination * b.count * 100, 0)
|
||||||
const fiatCode = process.env.VITE_BITSPIRE_FIAT_CODE || 'USD'
|
const fiatCode = process.env.VITE_LAMASSU_FIAT_CODE || 'USD'
|
||||||
|
|
||||||
recordTransaction({
|
recordTransaction({
|
||||||
txid,
|
txid,
|
||||||
|
|
@ -908,19 +908,19 @@ app.whenReady().then(() => {
|
||||||
if (existing.length === 0) {
|
if (existing.length === 0) {
|
||||||
let seedCassettes: { denomination: number; count: number }[] = []
|
let seedCassettes: { denomination: number; count: number }[] = []
|
||||||
|
|
||||||
// Priority 1: explicit VITE_BITSPIRE_CASSETTES env var
|
// Priority 1: explicit VITE_LAMASSU_CASSETTES env var
|
||||||
const cassettesJson = process.env.VITE_BITSPIRE_CASSETTES
|
const cassettesJson = process.env.VITE_LAMASSU_CASSETTES
|
||||||
if (cassettesJson) {
|
if (cassettesJson) {
|
||||||
try {
|
try {
|
||||||
seedCassettes = JSON.parse(cassettesJson)
|
seedCassettes = JSON.parse(cassettesJson)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Electron] Failed to parse VITE_BITSPIRE_CASSETTES:', e)
|
console.warn('[Electron] Failed to parse VITE_LAMASSU_CASSETTES:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: default presets per model
|
// Priority 2: default presets per model
|
||||||
if (seedCassettes.length === 0) {
|
if (seedCassettes.length === 0) {
|
||||||
const model = process.env.VITE_BITSPIRE_MACHINE_MODEL || 'sintra'
|
const model = process.env.VITE_LAMASSU_MACHINE_MODEL || 'sintra'
|
||||||
const presets: Record<string, { denomination: number; count: number }[]> = {
|
const presets: Record<string, { denomination: number; count: number }[]> = {
|
||||||
douro: [
|
douro: [
|
||||||
{ denomination: 100, count: 50 },
|
{ denomination: 100, count: 50 },
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,8 @@ export const themes: ThemeOption[] = [
|
||||||
{ id: 'starrynight', label: 'Starry Night' },
|
{ id: 'starrynight', label: 'Starry Night' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const THEME_KEY = 'bitspire-theme'
|
const THEME_KEY = 'lamassu-theme'
|
||||||
const MODE_KEY = 'bitspire-color-mode'
|
const MODE_KEY = 'lamassu-color-mode'
|
||||||
|
|
||||||
const isElectron = !!(window as unknown as Record<string, unknown>).electronAPI
|
const isElectron = !!(window as unknown as Record<string, unknown>).electronAPI
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
*
|
*
|
||||||
* Configuration priority (highest to lowest):
|
* Configuration priority (highest to lowest):
|
||||||
* 1. Runtime overrides (passed directly to functions)
|
* 1. Runtime overrides (passed directly to functions)
|
||||||
* 2. Environment variables (BITSPIRE_*)
|
* 2. Environment variables (LAMASSU_*)
|
||||||
* 3. Machine preset defaults
|
* 3. Machine preset defaults
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -143,7 +143,7 @@ export const MACHINE_PRESETS: Record<MachineModel, Omit<DeviceConfig, 'fiatCode'
|
||||||
// stable udev symlink /dev/ttyMEI (batm3.nix, serial A9YW78OC). The old
|
// stable udev symlink /dev/ttyMEI (batm3.nix, serial A9YW78OC). The old
|
||||||
// /dev/ttyACM0 default assumed a CDC-ACM BNR; this hardware enumerates as
|
// /dev/ttyACM0 default assumed a CDC-ACM BNR; this hardware enumerates as
|
||||||
// ttyUSB* instead, so ACM0 never existed and cash-in was silently
|
// ttyUSB* instead, so ACM0 never existed and cash-in was silently
|
||||||
// disabled ("[HAL] No validator"). Per-box override: VITE_BITSPIRE_VALIDATOR_DEVICE.
|
// disabled ("[HAL] No validator"). Per-box override: VITE_LAMASSU_VALIDATOR_DEVICE.
|
||||||
device: '/dev/ttyMEI',
|
device: '/dev/ttyMEI',
|
||||||
},
|
},
|
||||||
dispenser: {
|
dispenser: {
|
||||||
|
|
@ -152,7 +152,7 @@ export const MACHINE_PRESETS: Record<MachineModel, Omit<DeviceConfig, 'fiatCode'
|
||||||
// /dev/ttyF56 (batm3.nix, serial DDDLb103Y23). Avoids the raw
|
// /dev/ttyF56 (batm3.nix, serial DDDLb103Y23). Avoids the raw
|
||||||
// /dev/ttyUSB0, which is enumeration-order dependent and could point at
|
// /dev/ttyUSB0, which is enumeration-order dependent and could point at
|
||||||
// the wrong adapter after a re-plug/reboot. Per-box override:
|
// the wrong adapter after a re-plug/reboot. Per-box override:
|
||||||
// VITE_BITSPIRE_DISPENSER_DEVICE.
|
// VITE_LAMASSU_DISPENSER_DEVICE.
|
||||||
device: '/dev/ttyF56',
|
device: '/dev/ttyF56',
|
||||||
cassettes: [
|
cassettes: [
|
||||||
{ denomination: 20, count: 400 },
|
{ denomination: 20, count: 400 },
|
||||||
|
|
@ -214,31 +214,31 @@ export function getDeviceConfig(
|
||||||
* Load device configuration from environment variables
|
* Load device configuration from environment variables
|
||||||
*
|
*
|
||||||
* Supported variables:
|
* Supported variables:
|
||||||
* - BITSPIRE_MACHINE_MODEL: Machine model preset (sintra, gaia, custom)
|
* - LAMASSU_MACHINE_MODEL: Machine model preset (sintra, gaia, custom)
|
||||||
* - BITSPIRE_FIAT_CODE: Fiat currency code (USD, EUR, etc.)
|
* - LAMASSU_FIAT_CODE: Fiat currency code (USD, EUR, etc.)
|
||||||
* - BITSPIRE_VALIDATOR_DEVICE: Bill validator serial device path
|
* - LAMASSU_VALIDATOR_DEVICE: Bill validator serial device path
|
||||||
* - BITSPIRE_DISPENSER_DEVICE: Bill dispenser serial device path
|
* - LAMASSU_DISPENSER_DEVICE: Bill dispenser serial device path
|
||||||
* - BITSPIRE_CASSETTES: JSON array of cassette configs
|
* - LAMASSU_CASSETTES: JSON array of cassette configs
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* BITSPIRE_MACHINE_MODEL=sintra
|
* LAMASSU_MACHINE_MODEL=sintra
|
||||||
* BITSPIRE_FIAT_CODE=USD
|
* LAMASSU_FIAT_CODE=USD
|
||||||
* BITSPIRE_CASSETTES='[{"denomination":20,"count":100},{"denomination":50,"count":50}]'
|
* LAMASSU_CASSETTES='[{"denomination":20,"count":100},{"denomination":50,"count":50}]'
|
||||||
*/
|
*/
|
||||||
export function loadDeviceConfigFromEnv(): DeviceConfig {
|
export function loadDeviceConfigFromEnv(): DeviceConfig {
|
||||||
const model = (import.meta.env.VITE_BITSPIRE_MACHINE_MODEL as MachineModel) || 'sintra'
|
const model = (import.meta.env.VITE_LAMASSU_MACHINE_MODEL as MachineModel) || 'sintra'
|
||||||
const fiatCode = import.meta.env.VITE_BITSPIRE_FIAT_CODE || 'USD'
|
const fiatCode = import.meta.env.VITE_LAMASSU_FIAT_CODE || 'USD'
|
||||||
|
|
||||||
const overrides: Partial<DeviceConfig> = {}
|
const overrides: Partial<DeviceConfig> = {}
|
||||||
|
|
||||||
// Validator device override (preserve validator type from preset)
|
// Validator device override (preserve validator type from preset)
|
||||||
const validatorDevice = import.meta.env.VITE_BITSPIRE_VALIDATOR_DEVICE
|
const validatorDevice = import.meta.env.VITE_LAMASSU_VALIDATOR_DEVICE
|
||||||
if (validatorDevice) {
|
if (validatorDevice) {
|
||||||
overrides.validator = { type: MACHINE_PRESETS[model].validator.type, device: validatorDevice }
|
overrides.validator = { type: MACHINE_PRESETS[model].validator.type, device: validatorDevice }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispenser device override
|
// Dispenser device override
|
||||||
const dispenserDevice = import.meta.env.VITE_BITSPIRE_DISPENSER_DEVICE
|
const dispenserDevice = import.meta.env.VITE_LAMASSU_DISPENSER_DEVICE
|
||||||
if (dispenserDevice) {
|
if (dispenserDevice) {
|
||||||
overrides.dispenser = {
|
overrides.dispenser = {
|
||||||
type: MACHINE_PRESETS[model].dispenser.type,
|
type: MACHINE_PRESETS[model].dispenser.type,
|
||||||
|
|
@ -248,7 +248,7 @@ export function loadDeviceConfigFromEnv(): DeviceConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cassettes override (JSON)
|
// Cassettes override (JSON)
|
||||||
const cassettesJson = import.meta.env.VITE_BITSPIRE_CASSETTES
|
const cassettesJson = import.meta.env.VITE_LAMASSU_CASSETTES
|
||||||
if (cassettesJson) {
|
if (cassettesJson) {
|
||||||
try {
|
try {
|
||||||
const cassettes = JSON.parse(cassettesJson) as CassetteConfig[]
|
const cassettes = JSON.parse(cassettesJson) as CassetteConfig[]
|
||||||
|
|
@ -262,7 +262,7 @@ export function loadDeviceConfigFromEnv(): DeviceConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Config] Failed to parse BITSPIRE_CASSETTES:', e)
|
console.error('[Config] Failed to parse LAMASSU_CASSETTES:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# atm-reconcile — Check each cassette's ledger count against recorded history
|
|
||||||
#
|
|
||||||
# The cassettes table is a running total maintained by the machine: operator
|
|
||||||
# ops add to it (refill) or set it (recount / empty), and dispenses subtract
|
|
||||||
# from it. That means the count can be re-derived, and a derived value that
|
|
||||||
# disagrees with the stored one is evidence of something the ledger never saw.
|
|
||||||
#
|
|
||||||
# Reconciliation runs forward from each bay's last ABSOLUTE truth point — a
|
|
||||||
# `recount` (someone opened the bay and counted it) or an `empty` (set to
|
|
||||||
# zero). Refills are deltas and cannot serve as a baseline: a refill applied
|
|
||||||
# on top of a wrong number just carries the error forward, which is exactly
|
|
||||||
# how a bad count survives for weeks.
|
|
||||||
#
|
|
||||||
# expected = base + refills_since_base - dispensed_since_base
|
|
||||||
# gap = ledger - expected
|
|
||||||
#
|
|
||||||
# A non-zero gap means either notes moved without an op recording it, or a
|
|
||||||
# dispense moved notes the dispenser's counters did not report. The second is
|
|
||||||
# real: a note that leaves the bay and jams in the transport completes neither
|
|
||||||
# the `dispensed` nor the `rejected` counter, so the bay silently reads one
|
|
||||||
# high while the transaction row says nothing was dispensed (aiolabs/bitspire#122).
|
|
||||||
#
|
|
||||||
# A bay with NO baseline cannot be reconciled at all — its starting number
|
|
||||||
# came from somewhere unrecorded. Publish a recount for it; that is the only
|
|
||||||
# op that establishes ground truth (and the only one that clears the
|
|
||||||
# counts-uncertain flag).
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# atm-reconcile # reconcile every bay
|
|
||||||
# atm-reconcile --csv # machine-readable
|
|
||||||
# atm-reconcile --quiet # exit status only, no output
|
|
||||||
#
|
|
||||||
# Exit status:
|
|
||||||
# 0 every bay reconciles
|
|
||||||
# 1 at least one bay has a non-zero gap or no baseline
|
|
||||||
# 2 database missing / unreadable
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
DB="${ATM_STATE_DB:-/var/lib/bitspire/state.db}"
|
|
||||||
|
|
||||||
FORMAT="-column -header"
|
|
||||||
QUIET=false
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--csv) FORMAT="-csv -header"; shift ;;
|
|
||||||
--quiet) QUIET=true; shift ;;
|
|
||||||
-h|--help)
|
|
||||||
sed -n '2,36p' "$0" | sed 's/^# \{0,1\}//'
|
|
||||||
exit 0 ;;
|
|
||||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ ! -r "$DB" ]; then
|
|
||||||
echo "ERROR: state database not readable at $DB" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Per-bay baseline, then the deltas since it. Written as scalar subqueries
|
|
||||||
# rather than joins on purpose: joining transaction_bills to cassettes fans
|
|
||||||
# out across bays, and a LEFT JOIN whose rows are all filtered out by the
|
|
||||||
# baseline cutoff collapses to NULL and poisons the arithmetic downstream.
|
|
||||||
RECONCILE_SQL="
|
|
||||||
WITH bay AS (
|
|
||||||
SELECT
|
|
||||||
c.position AS position,
|
|
||||||
c.denomination AS denomination,
|
|
||||||
c.count AS ledger,
|
|
||||||
(SELECT o.op_at FROM cassette_ops o
|
|
||||||
WHERE o.position = c.position AND o.op_type IN ('recount','empty')
|
|
||||||
ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_at,
|
|
||||||
(SELECT CASE o.op_type WHEN 'empty' THEN 0 ELSE o.count END FROM cassette_ops o
|
|
||||||
WHERE o.position = c.position AND o.op_type IN ('recount','empty')
|
|
||||||
ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_count
|
|
||||||
FROM cassettes c
|
|
||||||
),
|
|
||||||
delta AS (
|
|
||||||
SELECT
|
|
||||||
bay.*,
|
|
||||||
(SELECT COALESCE(SUM(o.bills), 0) FROM cassette_ops o
|
|
||||||
WHERE o.position = bay.position AND o.op_type = 'refill'
|
|
||||||
AND o.op_at > COALESCE(bay.base_at, -1)) AS added,
|
|
||||||
(SELECT COALESCE(SUM(tb.count), 0)
|
|
||||||
FROM transaction_bills tb
|
|
||||||
JOIN transactions t ON t.txid = tb.txid
|
|
||||||
WHERE tb.denomination = bay.denomination
|
|
||||||
AND t.type IN ('cash_out','manual_dispense')
|
|
||||||
AND t.created_at / 1000 > COALESCE(bay.base_at, -1)) AS dispensed
|
|
||||||
FROM bay
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
position AS 'Bay',
|
|
||||||
denomination AS 'Denom',
|
|
||||||
CASE WHEN base_at IS NULL THEN '(none)' ELSE datetime(base_at,'unixepoch') END AS 'Baseline',
|
|
||||||
CASE WHEN base_at IS NULL THEN NULL ELSE base_count END AS 'Base',
|
|
||||||
added AS 'Refilled',
|
|
||||||
dispensed AS 'Dispensed',
|
|
||||||
ledger AS 'Ledger',
|
|
||||||
CASE WHEN base_at IS NULL THEN NULL
|
|
||||||
ELSE base_count + added - dispensed END AS 'Expected',
|
|
||||||
CASE WHEN base_at IS NULL THEN 'NO BASELINE'
|
|
||||||
ELSE printf('%+d', ledger - (base_count + added - dispensed)) END AS 'Gap'
|
|
||||||
FROM delta
|
|
||||||
ORDER BY position;
|
|
||||||
"
|
|
||||||
|
|
||||||
# Bays sharing a denomination break per-bay attribution: transaction_bills
|
|
||||||
# records what denomination went out, never which bay it came from, so the
|
|
||||||
# dispensed figure lands on every matching bay.
|
|
||||||
AMBIGUOUS=$(sqlite3 "$DB" \
|
|
||||||
"SELECT group_concat(denomination) FROM (
|
|
||||||
SELECT denomination FROM cassettes GROUP BY denomination HAVING COUNT(*) > 1);")
|
|
||||||
|
|
||||||
PROBLEMS=$(sqlite3 "$DB" "
|
|
||||||
WITH bay AS (
|
|
||||||
SELECT c.position AS position, c.denomination AS denomination, c.count AS ledger,
|
|
||||||
(SELECT o.op_at FROM cassette_ops o
|
|
||||||
WHERE o.position = c.position AND o.op_type IN ('recount','empty')
|
|
||||||
ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_at,
|
|
||||||
(SELECT CASE o.op_type WHEN 'empty' THEN 0 ELSE o.count END FROM cassette_ops o
|
|
||||||
WHERE o.position = c.position AND o.op_type IN ('recount','empty')
|
|
||||||
ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_count
|
|
||||||
FROM cassettes c
|
|
||||||
)
|
|
||||||
SELECT COUNT(*) FROM bay WHERE base_at IS NULL OR ledger <> (
|
|
||||||
base_count
|
|
||||||
+ (SELECT COALESCE(SUM(o.bills),0) FROM cassette_ops o
|
|
||||||
WHERE o.position = bay.position AND o.op_type = 'refill' AND o.op_at > bay.base_at)
|
|
||||||
- (SELECT COALESCE(SUM(tb.count),0) FROM transaction_bills tb
|
|
||||||
JOIN transactions t ON t.txid = tb.txid
|
|
||||||
WHERE tb.denomination = bay.denomination
|
|
||||||
AND t.type IN ('cash_out','manual_dispense')
|
|
||||||
AND t.created_at / 1000 > bay.base_at));")
|
|
||||||
|
|
||||||
if ! $QUIET; then
|
|
||||||
# shellcheck disable=SC2086 # $FORMAT must word-split into two sqlite3 flags
|
|
||||||
sqlite3 $FORMAT "$DB" "$RECONCILE_SQL"
|
|
||||||
echo
|
|
||||||
|
|
||||||
UNCERTAIN=$(sqlite3 "$DB" \
|
|
||||||
"SELECT COALESCE(NULLIF(value,''),'') FROM meta WHERE key = 'countsUncertainSince';")
|
|
||||||
if [ -n "$UNCERTAIN" ]; then
|
|
||||||
echo "Counts flagged UNVERIFIED since $(date -u -d "@$UNCERTAIN" '+%F %T UTC' 2>/dev/null || echo "$UNCERTAIN")"
|
|
||||||
echo " A dispense ended without a trustworthy report. Only a recount clears this."
|
|
||||||
else
|
|
||||||
echo "Counts not flagged unverified."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "=== Unresolved dispense failures ==="
|
|
||||||
# shellcheck disable=SC2086 # $FORMAT must word-split into two sqlite3 flags
|
|
||||||
sqlite3 $FORMAT "$DB" "
|
|
||||||
SELECT txid AS 'TX ID', status AS 'Status',
|
|
||||||
printf('%.2f', fiat_cents / 100.0) AS 'Fiat', currency AS 'Cur',
|
|
||||||
COALESCE(error,'') AS 'Error',
|
|
||||||
datetime(created_at / 1000,'unixepoch') AS 'Time (UTC)'
|
|
||||||
FROM transactions
|
|
||||||
WHERE status IN ('dispense_error','partial')
|
|
||||||
ORDER BY created_at DESC;"
|
|
||||||
|
|
||||||
if [ -n "$AMBIGUOUS" ]; then
|
|
||||||
echo
|
|
||||||
echo "WARNING: denomination(s) $AMBIGUOUS are loaded in more than one bay."
|
|
||||||
echo " Dispenses are recorded by denomination, not by bay, so the Dispensed"
|
|
||||||
echo " column double-counts across those bays. Reconcile them as a group."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
[ "${PROBLEMS:-0}" -eq 0 ] || exit 1
|
|
||||||
|
|
@ -141,7 +141,7 @@ sql "SELECT
|
||||||
t.fiat_cents / 100 AS 'Fiat',
|
t.fiat_cents / 100 AS 'Fiat',
|
||||||
t.sats AS 'Sats',
|
t.sats AS 'Sats',
|
||||||
t.fee_sats AS 'Fee Sats',
|
t.fee_sats AS 'Fee Sats',
|
||||||
printf('%.1f%%', t.fee_fraction * 100) AS 'Fee %',
|
printf('%.1f%%', t.fee_percent * 100) AS 'Fee %',
|
||||||
CASE WHEN t.exchange_rate > 0 THEN printf('%.0f', t.exchange_rate) ELSE '-' END AS 'Rate',
|
CASE WHEN t.exchange_rate > 0 THEN printf('%.0f', t.exchange_rate) ELSE '-' END AS 'Rate',
|
||||||
datetime(t.created_at / 1000, 'unixepoch') AS 'Time (UTC)',
|
datetime(t.created_at / 1000, 'unixepoch') AS 'Time (UTC)',
|
||||||
group_concat('Q' || tb.denomination || 'x' || tb.count) AS 'Bills'
|
group_concat('Q' || tb.denomination || 'x' || tb.count) AS 'Bills'
|
||||||
|
|
|
||||||
|
|
@ -376,7 +376,6 @@ in
|
||||||
# ATM operations
|
# ATM operations
|
||||||
sqlite
|
sqlite
|
||||||
(writeShellScriptBin "atm-transactions" (builtins.readFile ./atm-transactions.sh))
|
(writeShellScriptBin "atm-transactions" (builtins.readFile ./atm-transactions.sh))
|
||||||
(writeShellScriptBin "atm-reconcile" (builtins.readFile ./atm-reconcile.sh))
|
|
||||||
];
|
];
|
||||||
|
|
||||||
# Enable SSH for remote administration
|
# Enable SSH for remote administration
|
||||||
|
|
@ -479,22 +478,6 @@ in
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
# In-place rename migration: VITE_LAMASSU_* → VITE_BITSPIRE_* in the
|
|
||||||
# provisioned .env. The machine reads MACHINE_MODEL / FIAT_CODE / CASSETTES
|
|
||||||
# from this file on every boot; renaming the keys in code without renaming
|
|
||||||
# them here would boot a live machine on preset defaults (wrong bays, wrong
|
|
||||||
# fiat) at the next nightly pull. Idempotent: a migrated file has nothing
|
|
||||||
# left to match. Runs before bitspire.service starts.
|
|
||||||
system.activationScripts.bitspire-env-migration = {
|
|
||||||
deps = [ "users" ];
|
|
||||||
text = ''
|
|
||||||
if [ -f /var/lib/bitspire/.env ] && grep -q '^VITE_LAMASSU_' /var/lib/bitspire/.env; then
|
|
||||||
sed -i 's/^VITE_LAMASSU_/VITE_BITSPIRE_/' /var/lib/bitspire/.env
|
|
||||||
echo "bitspire: migrated VITE_LAMASSU_* keys in /var/lib/bitspire/.env"
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
# Journal configuration
|
# Journal configuration
|
||||||
services.journald = {
|
services.journald = {
|
||||||
extraConfig = ''
|
extraConfig = ''
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,8 @@ ENV=/var/lib/bitspire/.env
|
||||||
DB=/var/lib/bitspire/state.db
|
DB=/var/lib/bitspire/state.db
|
||||||
|
|
||||||
# Preserve model + fiat from the existing .env (fall back to sintra/EUR).
|
# Preserve model + fiat from the existing .env (fall back to sintra/EUR).
|
||||||
model=$(grep -E '^VITE_BITSPIRE_MACHINE_MODEL=' "$ENV" 2>/dev/null | cut -d= -f2- || true)
|
model=$(grep -E '^VITE_LAMASSU_MACHINE_MODEL=' "$ENV" 2>/dev/null | cut -d= -f2- || true)
|
||||||
fiat=$(grep -E '^VITE_BITSPIRE_FIAT_CODE=' "$ENV" 2>/dev/null | cut -d= -f2- || true)
|
fiat=$(grep -E '^VITE_LAMASSU_FIAT_CODE=' "$ENV" 2>/dev/null | cut -d= -f2- || true)
|
||||||
model=${model:-sintra}
|
model=${model:-sintra}
|
||||||
fiat=${fiat:-EUR}
|
fiat=${fiat:-EUR}
|
||||||
|
|
||||||
|
|
@ -52,8 +52,8 @@ rm -f "$DB" "$DB-wal" "$DB-shm"
|
||||||
|
|
||||||
# Truncate .env to the minimal image-baked template.
|
# Truncate .env to the minimal image-baked template.
|
||||||
cat > "$ENV" <<EOF
|
cat > "$ENV" <<EOF
|
||||||
VITE_BITSPIRE_MACHINE_MODEL=$model
|
VITE_LAMASSU_MACHINE_MODEL=$model
|
||||||
VITE_BITSPIRE_FIAT_CODE=$fiat
|
VITE_LAMASSU_FIAT_CODE=$fiat
|
||||||
VITE_SPIRE_SEED=
|
VITE_SPIRE_SEED=
|
||||||
ELECTRON_FORCE_PROD=1
|
ELECTRON_FORCE_PROD=1
|
||||||
DISPLAY=:0
|
DISPLAY=:0
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ let
|
||||||
# mask its source). VITE_SPIRE_SEED is written by the wizard / provision-atm.sh;
|
# mask its source). VITE_SPIRE_SEED is written by the wizard / provision-atm.sh;
|
||||||
# the dev-only VITE_ATM_PRIVATE_KEY fallback is omitted on purpose.
|
# the dev-only VITE_ATM_PRIVATE_KEY fallback is omitted on purpose.
|
||||||
envTemplate = pkgs.writeText "bitspire-env" ''
|
envTemplate = pkgs.writeText "bitspire-env" ''
|
||||||
VITE_BITSPIRE_MACHINE_MODEL=${machineModel}
|
VITE_LAMASSU_MACHINE_MODEL=${machineModel}
|
||||||
VITE_BITSPIRE_FIAT_CODE=${fiatCodeForModel}
|
VITE_LAMASSU_FIAT_CODE=${fiatCodeForModel}
|
||||||
VITE_SPIRE_SEED=
|
VITE_SPIRE_SEED=
|
||||||
ELECTRON_FORCE_PROD=1
|
ELECTRON_FORCE_PROD=1
|
||||||
DISPLAY=:0
|
DISPLAY=:0
|
||||||
|
|
|
||||||
|
|
@ -132,8 +132,8 @@ $TRANSPORT_LINES
|
||||||
$IDENTITY_LINES
|
$IDENTITY_LINES
|
||||||
|
|
||||||
# Machine configuration
|
# Machine configuration
|
||||||
VITE_BITSPIRE_MACHINE_MODEL=$MODEL
|
VITE_LAMASSU_MACHINE_MODEL=$MODEL
|
||||||
VITE_BITSPIRE_FIAT_CODE=$FIAT_CODE
|
VITE_LAMASSU_FIAT_CODE=$FIAT_CODE
|
||||||
|
|
||||||
# Force production mode
|
# Force production mode
|
||||||
ELECTRON_FORCE_PROD=1
|
ELECTRON_FORCE_PROD=1
|
||||||
|
|
|
||||||
491
devenv.nix
491
devenv.nix
|
|
@ -1,7 +1,8 @@
|
||||||
{ pkgs, lib, config, ... }:
|
{ pkgs, lib, config, ... }:
|
||||||
|
|
||||||
{
|
{
|
||||||
name = "bitspire";
|
# Project metadata
|
||||||
|
name = "lamassu-next";
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Languages
|
# Languages
|
||||||
|
|
@ -18,11 +19,9 @@
|
||||||
|
|
||||||
languages.typescript.enable = true;
|
languages.typescript.enable = true;
|
||||||
|
|
||||||
|
# Use nixpkgs rust (simpler, no overlay needed)
|
||||||
packages = with pkgs; [
|
packages = with pkgs; [
|
||||||
# Rust toolchain — only the orphaned Rust HAL under packages/hal/src
|
# Rust toolchain from nixpkgs
|
||||||
# uses it (not built; see CLAUDE.md → Hardware drivers). Kept so the
|
|
||||||
# rustfmt git hook and `cargo check` still work until that crate is
|
|
||||||
# removed outright.
|
|
||||||
rustc
|
rustc
|
||||||
cargo
|
cargo
|
||||||
clippy
|
clippy
|
||||||
|
|
@ -34,22 +33,42 @@
|
||||||
openssl
|
openssl
|
||||||
openssl.dev
|
openssl.dev
|
||||||
|
|
||||||
# Python for node-gyp (native module builds: serialport, better-sqlite3)
|
# Electron dependencies (Linux)
|
||||||
|
# electron # Use npm-installed electron instead
|
||||||
|
|
||||||
|
# Python for node-gyp (native module builds)
|
||||||
(python3.withPackages (ps: [ ps.setuptools ]))
|
(python3.withPackages (ps: [ ps.setuptools ]))
|
||||||
|
|
||||||
# Hardware access
|
# Hardware access
|
||||||
libusb1
|
libusb1
|
||||||
udev
|
udev
|
||||||
|
|
||||||
# Serial port access (talk to a validator / dispenser by hand)
|
# Serial port access
|
||||||
picocom
|
picocom
|
||||||
|
|
||||||
# Development utilities
|
# Development utilities
|
||||||
|
just # Task runner
|
||||||
jq # JSON processing
|
jq # JSON processing
|
||||||
websocat # WebSocket client — relay-test below
|
websocat # WebSocket client
|
||||||
qrencode # QR code generation for terminal
|
qrencode # QR code generation for terminal
|
||||||
|
|
||||||
|
# Database tools
|
||||||
|
pgcli
|
||||||
|
|
||||||
|
# Container tools (for Lightning.Pub, strfry)
|
||||||
|
docker-compose
|
||||||
];
|
];
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Services
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
services.postgres = {
|
||||||
|
enable = true;
|
||||||
|
initialDatabases = [{ name = "lamassu_dev"; }];
|
||||||
|
listen_addresses = "127.0.0.1";
|
||||||
|
};
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Git Hooks (formerly pre-commit)
|
# Git Hooks (formerly pre-commit)
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -78,24 +97,21 @@
|
||||||
|
|
||||||
env = {
|
env = {
|
||||||
RUST_BACKTRACE = "1";
|
RUST_BACKTRACE = "1";
|
||||||
|
DATABASE_URL = "postgresql://localhost/lamassu_dev";
|
||||||
|
|
||||||
# The dev relay is LNbits's bundled nostrrelay extension on the native
|
# Nostr development relay
|
||||||
# instance (see CLAUDE.md → Environment variables); there is no
|
NOSTR_RELAY_URL = "ws://localhost:7777";
|
||||||
# in-repo relay container any more. Override per shell as needed.
|
|
||||||
NOSTR_RELAY_URL = "ws://localhost:5001/nostrrelay/test";
|
|
||||||
|
|
||||||
|
# Lightning.Pub development
|
||||||
|
LIGHTNING_PUB_URL = "http://localhost:1776";
|
||||||
|
|
||||||
|
# For Tauri
|
||||||
PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig";
|
PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig";
|
||||||
};
|
};
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Scripts (available as commands in shell)
|
# Scripts (available as commands in shell)
|
||||||
# ============================================
|
# ============================================
|
||||||
#
|
|
||||||
# The Lightning.Pub-era regtest stack (docker/ + the infra-*/lncli/btccli/
|
|
||||||
# mine-blocks/setup-channel/fund-atm/test-* commands that drove it) was
|
|
||||||
# removed 2026-10-09. Development runs against LNbits: FakeWallet needs
|
|
||||||
# nothing; bohm's native instance answers on :5001; the shared regtest
|
|
||||||
# stack lives at ~/dev/local/docker/regtest (not in this repo).
|
|
||||||
|
|
||||||
scripts = {
|
scripts = {
|
||||||
dev.exec = "pnpm turbo dev";
|
dev.exec = "pnpm turbo dev";
|
||||||
|
|
@ -103,16 +119,421 @@
|
||||||
test.exec = "pnpm turbo test";
|
test.exec = "pnpm turbo test";
|
||||||
lint.exec = "pnpm turbo lint";
|
lint.exec = "pnpm turbo lint";
|
||||||
|
|
||||||
# Test the dev relay connection
|
# Start infrastructure
|
||||||
|
infra-up.exec = ''
|
||||||
|
echo "Starting development infrastructure..."
|
||||||
|
docker compose -f docker/docker-compose.dev.yml up -d
|
||||||
|
echo ""
|
||||||
|
echo "Waiting for services to be healthy..."
|
||||||
|
echo "(This may take 30-60 seconds on first run)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Wait for strfry
|
||||||
|
echo -n "strfry relay: "
|
||||||
|
until docker exec lamassu-relay nc -z localhost 7777 2>/dev/null; do
|
||||||
|
echo -n "."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo " ready"
|
||||||
|
|
||||||
|
# Wait for bitcoind
|
||||||
|
echo -n "bitcoind: "
|
||||||
|
until docker exec lamassu-bitcoind bitcoin-cli -regtest -rpcuser=lamassu -rpcpassword=lamassu getblockchaininfo >/dev/null 2>&1; do
|
||||||
|
echo -n "."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo " ready"
|
||||||
|
|
||||||
|
# Wait for LND
|
||||||
|
echo -n "LND: "
|
||||||
|
until docker exec lamassu-lnd lncli --network=regtest getinfo >/dev/null 2>&1; do
|
||||||
|
echo -n "."
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
echo " ready"
|
||||||
|
|
||||||
|
# Wait for Alice's LND
|
||||||
|
echo -n "LND (Alice): "
|
||||||
|
until docker exec lamassu-lnd-alice lncli --network=regtest getinfo >/dev/null 2>&1; do
|
||||||
|
echo -n "."
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
echo " ready"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Infrastructure ready!"
|
||||||
|
echo " - Nostr relay: ws://localhost:7777"
|
||||||
|
echo " - Bitcoin RPC: localhost:18443 (regtest)"
|
||||||
|
echo " - LND gRPC: localhost:10009"
|
||||||
|
echo " - LND Alice gRPC: localhost:10010"
|
||||||
|
echo " - Lightning.Pub: http://localhost:1776"
|
||||||
|
echo " - PostgreSQL: localhost:5432"
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo " 1. mine-blocks 101 # Fund the wallet (if first run)"
|
||||||
|
echo " 2. setup-channel # Open channel between Alice and LND"
|
||||||
|
echo ""
|
||||||
|
echo "Use 'infra-logs' to view logs, 'infra-status' to check status"
|
||||||
|
'';
|
||||||
|
|
||||||
|
infra-down.exec = ''
|
||||||
|
echo "Stopping development infrastructure..."
|
||||||
|
docker compose -f docker/docker-compose.dev.yml down
|
||||||
|
echo "Infrastructure stopped"
|
||||||
|
'';
|
||||||
|
|
||||||
|
infra-logs.exec = ''
|
||||||
|
docker compose -f docker/docker-compose.dev.yml logs -f "$@"
|
||||||
|
'';
|
||||||
|
|
||||||
|
infra-status.exec = ''
|
||||||
|
echo "Infrastructure Status:"
|
||||||
|
echo ""
|
||||||
|
docker compose -f docker/docker-compose.dev.yml ps
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Mine regtest blocks (useful for testing)
|
||||||
|
mine-blocks.exec = ''
|
||||||
|
BLOCKS=''${1:-1}
|
||||||
|
echo "Mining $BLOCKS regtest block(s)..."
|
||||||
|
docker exec lamassu-bitcoind bitcoin-cli -regtest -rpcuser=lamassu -rpcpassword=lamassu -generate "$BLOCKS"
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Start auto-miner (mines 1 block every 30 seconds)
|
||||||
|
auto-mine.exec = ''
|
||||||
|
echo "Starting auto-miner (1 block every 30 seconds)..."
|
||||||
|
docker compose -f docker/docker-compose.dev.yml --profile mining up -d miner
|
||||||
|
echo ""
|
||||||
|
echo "Auto-miner started. This keeps LND in sync."
|
||||||
|
echo "Use 'auto-mine-stop' to stop it."
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Stop auto-miner
|
||||||
|
auto-mine-stop.exec = ''
|
||||||
|
echo "Stopping auto-miner..."
|
||||||
|
docker stop lamassu-miner 2>/dev/null || true
|
||||||
|
docker rm lamassu-miner 2>/dev/null || true
|
||||||
|
echo "Auto-miner stopped."
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Connect to LND CLI
|
||||||
|
lncli.exec = ''
|
||||||
|
docker exec -it lamassu-lnd lncli --network=regtest "$@"
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Connect to Bitcoin CLI
|
||||||
|
btccli.exec = ''
|
||||||
|
docker exec -it lamassu-bitcoind bitcoin-cli -regtest -rpcuser=lamassu -rpcpassword=lamassu "$@"
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Test relay connection
|
||||||
relay-test.exec = ''
|
relay-test.exec = ''
|
||||||
echo "Testing Nostr relay connection to $NOSTR_RELAY_URL ..."
|
echo "Testing Nostr relay connection..."
|
||||||
echo '["REQ", "test", {"kinds": [0], "limit": 1}]' | websocat "$NOSTR_RELAY_URL"
|
echo '["REQ", "test", {"kinds": [0], "limit": 1}]' | websocat ws://localhost:7777
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Connect to Alice's LND CLI (second node for testing payments)
|
||||||
|
lncli-alice.exec = ''
|
||||||
|
docker exec -it lamassu-lnd-alice lncli --network=regtest "$@"
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Setup Lightning channel between Alice and the main LND node
|
||||||
|
setup-channel.exec = ''
|
||||||
|
echo "Setting up Lightning channel between Alice and LND..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Get LND's pubkey and address
|
||||||
|
LND_INFO=$(docker exec lamassu-lnd lncli --network=regtest getinfo 2>/dev/null)
|
||||||
|
LND_PUBKEY=$(echo "$LND_INFO" | jq -r '.identity_pubkey')
|
||||||
|
echo "LND pubkey: $LND_PUBKEY"
|
||||||
|
|
||||||
|
# Connect Alice to LND
|
||||||
|
echo "Connecting Alice to LND..."
|
||||||
|
docker exec lamassu-lnd-alice lncli --network=regtest connect "$LND_PUBKEY@lnd:9735" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Check if Alice has enough funds
|
||||||
|
ALICE_BALANCE=$(docker exec lamassu-lnd-alice lncli --network=regtest walletbalance 2>/dev/null | jq -r '.confirmed_balance')
|
||||||
|
echo "Alice's on-chain balance: $ALICE_BALANCE sats"
|
||||||
|
|
||||||
|
if [ "$ALICE_BALANCE" -lt 1000000 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "Alice needs funds. Getting new address..."
|
||||||
|
ALICE_ADDR=$(docker exec lamassu-lnd-alice lncli --network=regtest newaddress p2wkh | jq -r '.address')
|
||||||
|
echo "Alice's address: $ALICE_ADDR"
|
||||||
|
echo ""
|
||||||
|
echo "Sending 5 BTC to Alice..."
|
||||||
|
docker exec lamassu-bitcoind bitcoin-cli -regtest -rpcuser=lamassu -rpcpassword=lamassu sendtoaddress "$ALICE_ADDR" 5
|
||||||
|
echo "Mining 6 blocks for confirmation..."
|
||||||
|
docker exec lamassu-bitcoind bitcoin-cli -regtest -rpcuser=lamassu -rpcpassword=lamassu -generate 6 >/dev/null
|
||||||
|
sleep 2
|
||||||
|
ALICE_BALANCE=$(docker exec lamassu-lnd-alice lncli --network=regtest walletbalance 2>/dev/null | jq -r '.confirmed_balance')
|
||||||
|
echo "Alice's new balance: $ALICE_BALANCE sats"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Open channel from Alice to LND (1M sats)
|
||||||
|
echo ""
|
||||||
|
echo "Opening 1M sat channel from Alice to LND..."
|
||||||
|
docker exec lamassu-lnd-alice lncli --network=regtest openchannel --node_key="$LND_PUBKEY" --local_amt=1000000
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Mining 6 blocks to confirm channel..."
|
||||||
|
docker exec lamassu-bitcoind bitcoin-cli -regtest -rpcuser=lamassu -rpcpassword=lamassu -generate 6 >/dev/null
|
||||||
|
|
||||||
|
sleep 3
|
||||||
|
echo ""
|
||||||
|
echo "Channel status:"
|
||||||
|
docker exec lamassu-lnd-alice lncli --network=regtest listchannels | jq '.channels[] | {remote_pubkey, capacity, local_balance, remote_balance, active}'
|
||||||
|
echo ""
|
||||||
|
echo "Channel setup complete! Alice can now pay invoices to Lightning.Pub."
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Pay an invoice from Alice's node
|
||||||
|
alice-pay.exec = ''
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
echo "Usage: alice-pay <invoice>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Paying invoice from Alice's node..."
|
||||||
|
docker exec lamassu-lnd-alice lncli --network=regtest payinvoice --force "$1"
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Create invoice on Alice's node (for testing ATM payouts)
|
||||||
|
alice-invoice.exec = ''
|
||||||
|
AMOUNT=''${1:-1000}
|
||||||
|
MEMO=''${2:-"Test invoice"}
|
||||||
|
docker exec lamassu-lnd-alice lncli --network=regtest addinvoice --amt="$AMOUNT" --memo="$MEMO" | jq -r '.payment_request'
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Fund ATM account in Lightning.Pub
|
||||||
|
fund-atm.exec = ''
|
||||||
|
AMOUNT=''${1:-100000}
|
||||||
|
echo "Funding ATM account with $AMOUNT sats..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Run the funding script from nostr-client package
|
||||||
|
cd packages/nostr-client
|
||||||
|
FUND_AMOUNT=$AMOUNT node fund-dev.mjs 2>&1 | tee /tmp/fund-atm-output.txt
|
||||||
|
|
||||||
|
# Extract the invoice
|
||||||
|
INVOICE=$(grep -o 'lnbcrt[a-zA-Z0-9]*' /tmp/fund-atm-output.txt | head -1)
|
||||||
|
|
||||||
|
if [ -z "$INVOICE" ]; then
|
||||||
|
echo "Failed to create invoice. Check the output above."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Invoice created. Paying from Alice..."
|
||||||
|
docker exec lamassu-lnd-alice lncli --network=regtest payinvoice --force "$INVOICE"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "ATM account funded with $AMOUNT sats!"
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Validate entire test setup
|
||||||
|
test-setup.exec = ''
|
||||||
|
echo ""
|
||||||
|
echo "═══════════════════════════════════════════════════════════"
|
||||||
|
echo " Lamassu Next - Test Setup Validation"
|
||||||
|
echo "═══════════════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Mine a block to wake up LND sync (regtest quirk: LND reports
|
||||||
|
# "not synced" when no blocks mined recently)
|
||||||
|
echo "Mining block to sync nodes..."
|
||||||
|
docker exec lamassu-bitcoind bitcoin-cli -regtest -rpcuser=lamassu -rpcpassword=lamassu -generate 1 >/dev/null 2>&1
|
||||||
|
sleep 1
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
PASSED=0
|
||||||
|
FAILED=0
|
||||||
|
|
||||||
|
check() {
|
||||||
|
if [ $1 -eq 0 ]; then
|
||||||
|
echo " ✓ $2"
|
||||||
|
PASSED=$((PASSED + 1))
|
||||||
|
else
|
||||||
|
echo " ✗ $2"
|
||||||
|
FAILED=$((FAILED + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "1. Services"
|
||||||
|
echo "───────────────────────────────────────────────────────────"
|
||||||
|
|
||||||
|
# Check containers
|
||||||
|
docker ps --format '{{.Names}}' | grep -q lamassu-relay
|
||||||
|
check $? "strfry relay running"
|
||||||
|
|
||||||
|
docker ps --format '{{.Names}}' | grep -q lamassu-bitcoind
|
||||||
|
check $? "bitcoind running"
|
||||||
|
|
||||||
|
docker ps --format '{{.Names}}' | grep -q lamassu-lnd
|
||||||
|
check $? "LND running"
|
||||||
|
|
||||||
|
docker ps --format '{{.Names}}' | grep -q lamassu-lnd-alice
|
||||||
|
check $? "LND Alice running"
|
||||||
|
|
||||||
|
docker ps --format '{{.Names}}' | grep -q lamassu-lightning-pub
|
||||||
|
check $? "Lightning.Pub running"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "2. Connectivity"
|
||||||
|
echo "───────────────────────────────────────────────────────────"
|
||||||
|
|
||||||
|
# Check relay port is open
|
||||||
|
timeout 2 bash -c 'echo > /dev/tcp/localhost/7777' 2>/dev/null
|
||||||
|
check $? "Nostr relay port open"
|
||||||
|
|
||||||
|
# Check bitcoind RPC
|
||||||
|
docker exec lamassu-bitcoind bitcoin-cli -regtest -rpcuser=lamassu -rpcpassword=lamassu getblockchaininfo >/dev/null 2>&1
|
||||||
|
check $? "Bitcoin RPC responding"
|
||||||
|
|
||||||
|
# Check LND
|
||||||
|
docker exec lamassu-lnd lncli --network=regtest getinfo >/dev/null 2>&1
|
||||||
|
check $? "LND RPC responding"
|
||||||
|
|
||||||
|
# Check Alice
|
||||||
|
docker exec lamassu-lnd-alice lncli --network=regtest getinfo >/dev/null 2>&1
|
||||||
|
check $? "LND Alice RPC responding"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "3. Blockchain State"
|
||||||
|
echo "───────────────────────────────────────────────────────────"
|
||||||
|
|
||||||
|
BLOCKS=$(docker exec lamassu-bitcoind bitcoin-cli -regtest -rpcuser=lamassu -rpcpassword=lamassu getblockcount 2>/dev/null)
|
||||||
|
[ "$BLOCKS" -ge 100 ]
|
||||||
|
check $? "Block height >= 100 (current: $BLOCKS)"
|
||||||
|
|
||||||
|
# Check LND synced
|
||||||
|
LND_SYNCED=$(docker exec lamassu-lnd lncli --network=regtest getinfo 2>/dev/null | jq -r '.synced_to_chain')
|
||||||
|
[ "$LND_SYNCED" = "true" ]
|
||||||
|
check $? "LND synced to chain"
|
||||||
|
|
||||||
|
# Check Alice synced
|
||||||
|
ALICE_SYNCED=$(docker exec lamassu-lnd-alice lncli --network=regtest getinfo 2>/dev/null | jq -r '.synced_to_chain')
|
||||||
|
[ "$ALICE_SYNCED" = "true" ]
|
||||||
|
check $? "LND Alice synced to chain"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "4. Lightning Channels"
|
||||||
|
echo "───────────────────────────────────────────────────────────"
|
||||||
|
|
||||||
|
# Check Alice has active channels
|
||||||
|
ALICE_CHANNELS=$(docker exec lamassu-lnd-alice lncli --network=regtest listchannels 2>/dev/null | jq '.channels | length')
|
||||||
|
[ "$ALICE_CHANNELS" -ge 1 ]
|
||||||
|
check $? "Alice has active channels (count: $ALICE_CHANNELS)"
|
||||||
|
|
||||||
|
# Check channel is active
|
||||||
|
ACTIVE_CHANNEL=$(docker exec lamassu-lnd-alice lncli --network=regtest listchannels 2>/dev/null | jq '.channels[0].active')
|
||||||
|
[ "$ACTIVE_CHANNEL" = "true" ]
|
||||||
|
check $? "Channel is active"
|
||||||
|
|
||||||
|
# Check Alice has outbound capacity
|
||||||
|
ALICE_LOCAL=$(docker exec lamassu-lnd-alice lncli --network=regtest listchannels 2>/dev/null | jq -r '.channels[0].local_balance // 0')
|
||||||
|
[ "$ALICE_LOCAL" -ge 10000 ]
|
||||||
|
check $? "Alice has outbound capacity ($ALICE_LOCAL sats)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "5. Payment Test"
|
||||||
|
echo "───────────────────────────────────────────────────────────"
|
||||||
|
|
||||||
|
# Create test invoice on LND and pay from Alice
|
||||||
|
TEST_INVOICE=$(docker exec lamassu-lnd lncli --network=regtest addinvoice --amt=100 --memo="test-setup validation" 2>/dev/null | jq -r '.payment_request')
|
||||||
|
if [ -n "$TEST_INVOICE" ]; then
|
||||||
|
check 0 "Created 100 sat test invoice"
|
||||||
|
|
||||||
|
# Pay it
|
||||||
|
PAY_RESULT=$(docker exec lamassu-lnd-alice lncli --network=regtest payinvoice --force "$TEST_INVOICE" 2>&1)
|
||||||
|
if echo "$PAY_RESULT" | grep -q "SUCCEEDED"; then
|
||||||
|
check 0 "Payment Alice → LND succeeded"
|
||||||
|
else
|
||||||
|
check 1 "Payment Alice → LND failed"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
check 1 "Failed to create test invoice"
|
||||||
|
check 1 "Payment test skipped"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "═══════════════════════════════════════════════════════════"
|
||||||
|
echo " Results: $PASSED passed, $FAILED failed"
|
||||||
|
echo "═══════════════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ $FAILED -gt 0 ]; then
|
||||||
|
echo "Some checks failed. Run 'infra-up' and 'setup-channel' first."
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "All checks passed! Ready for testing."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Quick e2e payment test (ATM pays customer invoice)
|
||||||
|
test-payment.exec = ''
|
||||||
|
AMOUNT=''${1:-1000}
|
||||||
|
echo "Testing e2e payment flow ($AMOUNT sats)..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Create invoice on Alice (simulating customer's wallet)
|
||||||
|
echo "1. Creating invoice on Alice's wallet..."
|
||||||
|
INVOICE=$(docker exec lamassu-lnd-alice lncli --network=regtest addinvoice --amt="$AMOUNT" --memo="E2E test" | jq -r '.payment_request')
|
||||||
|
echo " Invoice: ''${INVOICE:0:40}..."
|
||||||
|
|
||||||
|
# Use test-pay.mjs to pay via Lightning.Pub
|
||||||
|
echo ""
|
||||||
|
echo "2. Paying via Lightning.Pub (ATM flow)..."
|
||||||
|
cd packages/nostr-client
|
||||||
|
node test-pay.mjs "$INVOICE"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "3. Verifying payment on Alice..."
|
||||||
|
sleep 2
|
||||||
|
PAID=$(docker exec lamassu-lnd-alice lncli --network=regtest listinvoices 2>/dev/null | jq '.invoices[-1].settled')
|
||||||
|
if [ "$PAID" = "true" ]; then
|
||||||
|
echo " ✓ Payment received!"
|
||||||
|
else
|
||||||
|
echo " ✗ Payment not received"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Show node info
|
||||||
|
node-info.exec = ''
|
||||||
|
echo ""
|
||||||
|
echo "Node Information"
|
||||||
|
echo "════════════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "LND (Lightning.Pub's node):"
|
||||||
|
docker exec lamassu-lnd lncli --network=regtest getinfo 2>/dev/null | jq '{pubkey: .identity_pubkey, alias: .alias, channels: .num_active_channels, peers: .num_peers}'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "LND Alice (Payment source):"
|
||||||
|
docker exec lamassu-lnd-alice lncli --network=regtest getinfo 2>/dev/null | jq '{pubkey: .identity_pubkey, alias: .alias, channels: .num_active_channels, peers: .num_peers}'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Channel Details:"
|
||||||
|
docker exec lamassu-lnd-alice lncli --network=regtest listchannels 2>/dev/null | jq '.channels[] | {peer: .remote_pubkey[0:16], capacity, local: .local_balance, remote: .remote_balance, active}'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Lightning.Pub Nostr pubkey:"
|
||||||
|
echo " f454c5eec4ec4474128e19b57b45e49c3d0851d01eea960b39ce391cdba76fc6"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "ATM Dev Identity:"
|
||||||
|
echo " 4646ae5047316b4230d0086c8acec687f00b1cd9d1dc634f6cb358ac0a9a8fff"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Shell Hook
|
||||||
|
# ============================================
|
||||||
|
|
||||||
enterShell = ''
|
enterShell = ''
|
||||||
echo ""
|
echo ""
|
||||||
echo " ⚡ bitSpire - Nostr-Native Lightning ATM"
|
echo " ⚡ Lamassu Next - Nostr-Native Lightning ATM"
|
||||||
echo " ─────────────────────────────────────────────"
|
echo " ─────────────────────────────────────────────"
|
||||||
echo " Node.js: $(node --version)"
|
echo " Node.js: $(node --version)"
|
||||||
echo " Rust: $(rustc --version | cut -d' ' -f2)"
|
echo " Rust: $(rustc --version | cut -d' ' -f2)"
|
||||||
|
|
@ -122,8 +543,30 @@
|
||||||
echo " dev Start development servers"
|
echo " dev Start development servers"
|
||||||
echo " build Build all packages"
|
echo " build Build all packages"
|
||||||
echo " test Run tests"
|
echo " test Run tests"
|
||||||
echo " lint Lint all packages"
|
echo ""
|
||||||
echo " relay-test Test the dev relay ($NOSTR_RELAY_URL)"
|
echo " Infrastructure:"
|
||||||
|
echo " infra-up Start Docker services (strfry, bitcoind, LND, Lightning.Pub)"
|
||||||
|
echo " infra-down Stop Docker services"
|
||||||
|
echo " infra-status Show service status"
|
||||||
|
echo " infra-logs Follow service logs"
|
||||||
|
echo ""
|
||||||
|
echo " Bitcoin/Lightning:"
|
||||||
|
echo " btccli Bitcoin CLI (regtest)"
|
||||||
|
echo " lncli LND CLI (Lightning.Pub's node)"
|
||||||
|
echo " lncli-alice LND CLI (Alice's node for testing payments)"
|
||||||
|
echo " mine-blocks Mine regtest blocks (default: 1)"
|
||||||
|
echo " auto-mine Start auto-miner (1 block/30s, keeps LND synced)"
|
||||||
|
echo " auto-mine-stop Stop auto-miner"
|
||||||
|
echo " setup-channel Setup channel between Alice and LND"
|
||||||
|
echo " alice-pay Pay invoice from Alice's node"
|
||||||
|
echo " relay-test Test Nostr relay connection"
|
||||||
|
echo ""
|
||||||
|
echo " Testing (E2E):"
|
||||||
|
echo " test-setup Validate test environment (services, channels, payments)"
|
||||||
|
echo " test-payment Quick e2e payment test (ATM → customer)"
|
||||||
|
echo " fund-atm Fund ATM account (default: 100k sats)"
|
||||||
|
echo " alice-invoice Create invoice on Alice's node"
|
||||||
|
echo " node-info Show node pubkeys and channel info"
|
||||||
echo ""
|
echo ""
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1087
docker/dev.sh
Executable file
1087
docker/dev.sh
Executable file
File diff suppressed because it is too large
Load diff
226
docker/docker-compose.dev.yml
Normal file
226
docker/docker-compose.dev.yml
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
# Lamassu Next Development Infrastructure
|
||||||
|
# Usage: docker compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Bitcoin Core in regtest mode
|
||||||
|
bitcoind:
|
||||||
|
image: lncm/bitcoind:v27.0
|
||||||
|
container_name: lamassu-bitcoind
|
||||||
|
volumes:
|
||||||
|
- bitcoind-data:/data/.bitcoin
|
||||||
|
environment:
|
||||||
|
BITCOIN_NETWORK: regtest
|
||||||
|
command:
|
||||||
|
- -regtest
|
||||||
|
- -server
|
||||||
|
- -rpcuser=lamassu
|
||||||
|
- -rpcpassword=lamassu
|
||||||
|
- -rpcallowip=0.0.0.0/0
|
||||||
|
- -rpcbind=0.0.0.0
|
||||||
|
- -zmqpubrawblock=tcp://0.0.0.0:28332
|
||||||
|
- -zmqpubrawtx=tcp://0.0.0.0:28333
|
||||||
|
- -fallbackfee=0.00001
|
||||||
|
- -txindex=1
|
||||||
|
ports:
|
||||||
|
- '18443:18443' # RPC
|
||||||
|
- '28332:28332' # ZMQ blocks
|
||||||
|
- '28333:28333' # ZMQ tx
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
'CMD',
|
||||||
|
'bitcoin-cli',
|
||||||
|
'-regtest',
|
||||||
|
'-rpcuser=lamassu',
|
||||||
|
'-rpcpassword=lamassu',
|
||||||
|
'getblockchaininfo',
|
||||||
|
]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# LND Lightning node
|
||||||
|
lnd:
|
||||||
|
image: lightninglabs/lnd:v0.18.0-beta
|
||||||
|
container_name: lamassu-lnd
|
||||||
|
depends_on:
|
||||||
|
bitcoind:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- lnd-data:/root/.lnd
|
||||||
|
environment:
|
||||||
|
- NETWORK=regtest
|
||||||
|
command:
|
||||||
|
- --bitcoin.active
|
||||||
|
- --bitcoin.regtest
|
||||||
|
- --bitcoin.node=bitcoind
|
||||||
|
- --bitcoind.rpchost=bitcoind:18443
|
||||||
|
- --bitcoind.rpcuser=lamassu
|
||||||
|
- --bitcoind.rpcpass=lamassu
|
||||||
|
- --bitcoind.zmqpubrawblock=tcp://bitcoind:28332
|
||||||
|
- --bitcoind.zmqpubrawtx=tcp://bitcoind:28333
|
||||||
|
- --rpclisten=0.0.0.0:10009
|
||||||
|
- --restlisten=0.0.0.0:8080
|
||||||
|
- --tlsextradomain=lnd
|
||||||
|
- --tlsextraip=0.0.0.0
|
||||||
|
- --noseedbackup
|
||||||
|
- --accept-keysend
|
||||||
|
- --accept-amp
|
||||||
|
ports:
|
||||||
|
- '10009:10009' # gRPC
|
||||||
|
- '8080:8080' # REST
|
||||||
|
- '9735:9735' # P2P
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'lncli', '--network=regtest', 'getinfo']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 30
|
||||||
|
start_period: 30s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Lightning.Pub - Nostr-native Lightning account system
|
||||||
|
# Note: Lightning.Pub connects to LND for actual Lightning operations
|
||||||
|
# Using patched image with Kind 0 profile publishing support
|
||||||
|
lightning-pub:
|
||||||
|
image: lightning-pub-patched:latest
|
||||||
|
container_name: lamassu-lightning-pub
|
||||||
|
depends_on:
|
||||||
|
lnd:
|
||||||
|
condition: service_healthy
|
||||||
|
extra_hosts:
|
||||||
|
- 'host.docker.internal:host-gateway' # Enable host.docker.internal on Linux
|
||||||
|
ports:
|
||||||
|
- '1776:1776'
|
||||||
|
volumes:
|
||||||
|
- lightning-pub-data:/root/lightning_pub
|
||||||
|
- lnd-data:/root/.lnd:ro # Read-only access to LND data for macaroons/certs
|
||||||
|
environment:
|
||||||
|
- NETWORK=regtest
|
||||||
|
- LND_ADDRESS=lnd:10009
|
||||||
|
- LND_CERT_PATH=/root/.lnd/tls.cert
|
||||||
|
- LND_MACAROON_PATH=/root/.lnd/data/chain/bitcoin/regtest/admin.macaroon
|
||||||
|
# Relay URLs (space-separated). Docker-internal relay FIRST for publishing,
|
||||||
|
# then backup. Mock ATM rewrites ndebit relay for browser access.
|
||||||
|
- 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
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Second LND node (Alice) for payment testing
|
||||||
|
# This node can pay invoices to Lightning.Pub's LND
|
||||||
|
lnd-alice:
|
||||||
|
image: lightninglabs/lnd:v0.18.0-beta
|
||||||
|
container_name: lamassu-lnd-alice
|
||||||
|
depends_on:
|
||||||
|
bitcoind:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- lnd-alice-data:/root/.lnd
|
||||||
|
environment:
|
||||||
|
- NETWORK=regtest
|
||||||
|
command:
|
||||||
|
- --bitcoin.active
|
||||||
|
- --bitcoin.regtest
|
||||||
|
- --bitcoin.node=bitcoind
|
||||||
|
- --bitcoind.rpchost=bitcoind:18443
|
||||||
|
- --bitcoind.rpcuser=lamassu
|
||||||
|
- --bitcoind.rpcpass=lamassu
|
||||||
|
- --bitcoind.zmqpubrawblock=tcp://bitcoind:28332
|
||||||
|
- --bitcoind.zmqpubrawtx=tcp://bitcoind:28333
|
||||||
|
- --rpclisten=0.0.0.0:10009
|
||||||
|
- --restlisten=0.0.0.0:8080
|
||||||
|
- --tlsextradomain=lnd-alice
|
||||||
|
- --tlsextraip=0.0.0.0
|
||||||
|
- --noseedbackup
|
||||||
|
- --accept-keysend
|
||||||
|
- --accept-amp
|
||||||
|
- --alias=alice
|
||||||
|
ports:
|
||||||
|
- '10010:10009' # gRPC (different host port)
|
||||||
|
- '8081:8080' # REST (different host port)
|
||||||
|
- '9736:9735' # P2P (different host port)
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'lncli', '--network=regtest', 'getinfo']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 30
|
||||||
|
start_period: 30s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Automatic block miner for regtest (keeps LND synced)
|
||||||
|
# Mines 1 block every 30 seconds
|
||||||
|
miner:
|
||||||
|
image: alpine:latest
|
||||||
|
container_name: lamassu-miner
|
||||||
|
depends_on:
|
||||||
|
bitcoind:
|
||||||
|
condition: service_healthy
|
||||||
|
entrypoint: /bin/sh
|
||||||
|
command:
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
apk add --no-cache curl jq
|
||||||
|
echo "Starting auto-miner (1 block every 30 seconds)..."
|
||||||
|
while true; do
|
||||||
|
# Create wallet if not exists
|
||||||
|
curl -s --user lamassu:lamassu --data-binary '{"jsonrpc":"1.0","method":"createwallet","params":["miner"]}' http://bitcoind:18443/ > /dev/null 2>&1
|
||||||
|
# Mine a block
|
||||||
|
ADDR=$$(curl -s --user lamassu:lamassu --data-binary '{"jsonrpc":"1.0","method":"getnewaddress","params":[]}' http://bitcoind:18443/ | jq -r '.result // empty')
|
||||||
|
if [ -n "$$ADDR" ]; then
|
||||||
|
curl -s --user lamassu:lamassu --data-binary "{\"jsonrpc\":\"1.0\",\"method\":\"generatetoaddress\",\"params\":[1,\"$$ADDR\"]}" http://bitcoind:18443/ > /dev/null
|
||||||
|
fi
|
||||||
|
sleep 30
|
||||||
|
done
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- mining # Only starts with: docker compose --profile mining up -d
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
strfry-data:
|
||||||
|
bitcoind-data:
|
||||||
|
lnd-data:
|
||||||
|
lnd-alice-data:
|
||||||
|
lightning-pub-data:
|
||||||
|
postgres-data:
|
||||||
166
docker/docker-compose.regtest.yml
Normal file
166
docker/docker-compose.regtest.yml
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
# Lamassu Next - Regtest Integration
|
||||||
|
#
|
||||||
|
# This overlay connects lamassu-next services to the comprehensive regtest
|
||||||
|
# environment at ~/dev/local/docker/regtest
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# 1. Start regtest (minimal — only what LP needs):
|
||||||
|
# cd ~/dev/local/docker/regtest && docker compose up -d bitcoind lnd-1 lnd-4
|
||||||
|
#
|
||||||
|
# 2. Start lamassu services:
|
||||||
|
# cd docker && docker compose -f docker-compose.regtest.yml up -d
|
||||||
|
#
|
||||||
|
# 3. Bootstrap (funds LND, opens channels, creates LP app):
|
||||||
|
# cd docker && ./regtest-bootstrap.sh
|
||||||
|
#
|
||||||
|
# 4. Configure apps/machine/.env:
|
||||||
|
# VITE_RELAY_URL=ws://localhost:7777
|
||||||
|
# VITE_EXTENSION_API_URL=http://localhost:1777
|
||||||
|
#
|
||||||
|
# Phone wallet testing (LNURL callbacks need LAN IP):
|
||||||
|
# HOST_IP=192.168.1.100 docker compose -f docker-compose.regtest.yml up -d
|
||||||
|
#
|
||||||
|
# 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:
|
||||||
|
# Check port 7777 (0x1E61) is listening via procfs — BusyBox nc lacks -z flag
|
||||||
|
test: ['CMD-SHELL', 'grep -q ":1E61 " /proc/net/tcp']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
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:-localhost}:1777
|
||||||
|
depends_on:
|
||||||
|
strfry:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'curl -so /dev/null -w "%{http_code}" http://localhost:1776 | grep -q .']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 30
|
||||||
|
start_period: 30s
|
||||||
|
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
|
||||||
|
# Ensure a wallet is loaded (-generate requires one)
|
||||||
|
bitcoin-cli -regtest -rpcconnect=bitcoind createwallet default 2>/dev/null \
|
||||||
|
|| bitcoin-cli -regtest -rpcconnect=bitcoind loadwallet default 2>/dev/null \
|
||||||
|
|| true
|
||||||
|
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:-30}
|
||||||
|
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: regtest_bitcoin-data
|
||||||
|
|
||||||
|
networks:
|
||||||
|
regtest:
|
||||||
|
name: regtest_default
|
||||||
|
external: true
|
||||||
273
docker/regtest-bootstrap.sh
Executable file
273
docker/regtest-bootstrap.sh
Executable file
|
|
@ -0,0 +1,273 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# regtest-bootstrap.sh — Bootstrap a working LP+LND regtest environment
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# 1. Regtest running (minimal — only needs bitcoind + lnd-1 + lnd-4):
|
||||||
|
# cd ~/dev/local/docker/regtest && docker compose up -d bitcoind lnd-1 lnd-4
|
||||||
|
# 2. Lamassu services running:
|
||||||
|
# cd docker && docker compose -f docker-compose.regtest.yml up -d
|
||||||
|
#
|
||||||
|
# What this script does:
|
||||||
|
# 1. Waits for bitcoind and LND nodes to be synced
|
||||||
|
# 2. Funds LND-1 (1 BTC from bitcoind)
|
||||||
|
# 3. Opens a 5M sat channel from LND-1 → LND-4 (LP's backend)
|
||||||
|
# 4. Mines blocks to confirm and announce the channel
|
||||||
|
# 5. Waits for LP to be healthy
|
||||||
|
# 6. Creates an LP app and funds it (10k sats via invoice from LND-1)
|
||||||
|
# 7. Prints summary with pubkeys, app token, and channel status
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# -- Colors --
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
|
||||||
|
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||||
|
err() { echo -e "${RED}[ERROR]${NC} $*"; }
|
||||||
|
|
||||||
|
# -- Auto-detect container name prefix --
|
||||||
|
# The regtest compose uses project name "lnbits" (via start-regtest) or "regtest" (direct).
|
||||||
|
# Detect which prefix the RUNNING bitcoind container has (filter=running avoids stale containers).
|
||||||
|
if docker ps -f name=lnbits-bitcoind-1 -f status=running --format '{{.Names}}' | grep -q .; then
|
||||||
|
PREFIX="lnbits"
|
||||||
|
elif docker ps -f name=regtest-bitcoind-1 -f status=running --format '{{.Names}}' | grep -q .; then
|
||||||
|
PREFIX="regtest"
|
||||||
|
else
|
||||||
|
err "No running bitcoind container found (tried lnbits-bitcoind-1 and regtest-bitcoind-1)"
|
||||||
|
err "Start regtest first: cd ~/dev/local/docker/regtest && docker compose up -d bitcoind lnd-1 lnd-4"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
info "Using container prefix: ${PREFIX}"
|
||||||
|
|
||||||
|
# -- Container helpers --
|
||||||
|
bitcoin_cli() {
|
||||||
|
docker exec "${PREFIX}-bitcoind-1" bitcoin-cli -regtest "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
lncli_1() {
|
||||||
|
docker exec "${PREFIX}-lnd-1-1" lncli --network regtest --rpcserver=lnd-1:10009 "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
lncli_4() {
|
||||||
|
docker exec "${PREFIX}-lnd-4-1" lncli --network regtest --rpcserver=lnd-4:10009 "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
LP_URL="http://localhost:1776"
|
||||||
|
LP_EXT_URL="http://localhost:1777"
|
||||||
|
ADMIN_TOKEN="lamassu-dev-admin-token"
|
||||||
|
CHANNEL_SIZE=5000000 # 5M sats
|
||||||
|
FUND_AMOUNT=10000 # 10k sats for LP app
|
||||||
|
|
||||||
|
# -- Step 1: Wait for bitcoind --
|
||||||
|
info "Waiting for bitcoind..."
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if bitcoin_cli getblockchaininfo > /dev/null 2>&1; then
|
||||||
|
ok "bitcoind ready (block $(bitcoin_cli getblockcount))"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" -eq 60 ]; then
|
||||||
|
err "bitcoind not ready after 60s — is regtest running?"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Ensure a wallet exists
|
||||||
|
bitcoin_cli createwallet default 2>/dev/null \
|
||||||
|
|| bitcoin_cli loadwallet default 2>/dev/null \
|
||||||
|
|| true
|
||||||
|
|
||||||
|
# Mine initial blocks if needed (coinbase needs 100 confirmations to be spendable)
|
||||||
|
BLOCKS=$(bitcoin_cli getblockcount)
|
||||||
|
if [ "$BLOCKS" -lt 101 ]; then
|
||||||
|
NEEDED=$((101 - BLOCKS))
|
||||||
|
info "Mining ${NEEDED} initial blocks (coinbase needs 100 confirmations)..."
|
||||||
|
bitcoin_cli -generate "$NEEDED" > /dev/null
|
||||||
|
ok "Mined to block $(bitcoin_cli getblockcount)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- Step 2: Wait for LND-1 and LND-4 to sync --
|
||||||
|
wait_lnd_sync() {
|
||||||
|
local name=$1
|
||||||
|
local cli_fn=$2
|
||||||
|
info "Waiting for ${name} to sync..."
|
||||||
|
for i in $(seq 1 120); do
|
||||||
|
if [ "$($cli_fn getinfo 2>/dev/null | jq -r '.synced_to_chain' 2>/dev/null)" = "true" ]; then
|
||||||
|
ok "${name} synced"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$i" -eq 120 ]; then
|
||||||
|
err "${name} not synced after 120s"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_lnd_sync "LND-1" lncli_1
|
||||||
|
wait_lnd_sync "LND-4" lncli_4
|
||||||
|
|
||||||
|
# -- Step 3: Fund LND-1 --
|
||||||
|
info "Funding LND-1..."
|
||||||
|
LND1_ADDR=$(lncli_1 newaddress p2wkh | jq -r '.address')
|
||||||
|
bitcoin_cli -named sendtoaddress address="$LND1_ADDR" amount=1 fee_rate=100 > /dev/null
|
||||||
|
info "Mining 6 blocks to confirm funding..."
|
||||||
|
bitcoin_cli -generate 6 > /dev/null
|
||||||
|
|
||||||
|
# Wait for LND-1 to see the confirmed balance
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
BALANCE=$(lncli_1 walletbalance | jq -r '.confirmed_balance')
|
||||||
|
if [ "$BALANCE" != "0" ] && [ -n "$BALANCE" ]; then
|
||||||
|
ok "LND-1 funded: ${BALANCE} sats"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# -- Step 4: Connect LND-1 → LND-4 and open channel --
|
||||||
|
LND4_PUBKEY=$(lncli_4 getinfo | jq -r '.identity_pubkey')
|
||||||
|
info "Connecting LND-1 → LND-4 (${LND4_PUBKEY:0:16}...)..."
|
||||||
|
lncli_1 connect "${LND4_PUBKEY}@${PREFIX}-lnd-4-1:9735" > /dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Check if channel already exists
|
||||||
|
EXISTING=$(lncli_1 listchannels --peer "$LND4_PUBKEY" 2>/dev/null | jq '.channels | length')
|
||||||
|
if [ "${EXISTING:-0}" -gt 0 ]; then
|
||||||
|
warn "Channel to LND-4 already exists, skipping open"
|
||||||
|
else
|
||||||
|
info "Opening ${CHANNEL_SIZE} sat channel LND-1 → LND-4..."
|
||||||
|
lncli_1 openchannel "$LND4_PUBKEY" "$CHANNEL_SIZE" > /dev/null
|
||||||
|
|
||||||
|
info "Mining 6 blocks to confirm channel..."
|
||||||
|
bitcoin_cli -generate 6 > /dev/null
|
||||||
|
|
||||||
|
# Wait for channel to leave pending
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
PENDING=$(lncli_1 pendingchannels | jq '.pending_open_channels | length')
|
||||||
|
if [ "$PENDING" = "0" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- Step 5: Verify channel is active and in graph --
|
||||||
|
info "Waiting for channel to become active..."
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
ACTIVE=$(lncli_1 listchannels --peer "$LND4_PUBKEY" 2>/dev/null | jq '[.channels[] | select(.active == true)] | length')
|
||||||
|
if [ "${ACTIVE:-0}" -gt 0 ]; then
|
||||||
|
ok "Channel active"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" -eq 60 ]; then
|
||||||
|
warn "Channel not active after 60s — may need more blocks"
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Mine a few more blocks to ensure graph announcement propagates
|
||||||
|
bitcoin_cli -generate 3 > /dev/null
|
||||||
|
|
||||||
|
info "Checking graph..."
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
GRAPH_NODES=$(lncli_1 describegraph 2>/dev/null | jq '.nodes | length')
|
||||||
|
GRAPH_EDGES=$(lncli_1 describegraph 2>/dev/null | jq '.edges | length')
|
||||||
|
if [ "${GRAPH_EDGES:-0}" -gt 0 ]; then
|
||||||
|
ok "Graph: ${GRAPH_NODES} nodes, ${GRAPH_EDGES} edges"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" -eq 30 ]; then
|
||||||
|
warn "Graph has no edges yet — channel may not be announced"
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# -- Step 6: Wait for Lightning.Pub to be healthy --
|
||||||
|
info "Waiting for Lightning.Pub..."
|
||||||
|
for i in $(seq 1 120); do
|
||||||
|
if curl -so /dev/null "${LP_URL}" 2>/dev/null; then
|
||||||
|
ok "Lightning.Pub ready"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" -eq 120 ]; then
|
||||||
|
err "Lightning.Pub not ready after 120s"
|
||||||
|
err "Check: docker logs lamassu-lightning-pub"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Small delay to ensure LP has fully initialized its LND connection
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# -- Step 7: Create LP app --
|
||||||
|
info "Creating LP app 'regtest-atm'..."
|
||||||
|
APP_RESPONSE=$(curl -sf "${LP_URL}/api/admin/app/add" \
|
||||||
|
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name": "regtest-atm", "allow_user_creation": true}')
|
||||||
|
|
||||||
|
if [ -z "$APP_RESPONSE" ]; then
|
||||||
|
err "Failed to create LP app — check LP logs"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP_ID=$(echo "$APP_RESPONSE" | jq -r '.app.id')
|
||||||
|
APP_NPUB=$(echo "$APP_RESPONSE" | jq -r '.app.npub')
|
||||||
|
APP_TOKEN=$(echo "$APP_RESPONSE" | jq -r '.auth_token')
|
||||||
|
|
||||||
|
if [ "$APP_ID" = "null" ] || [ -z "$APP_ID" ]; then
|
||||||
|
err "App creation returned unexpected response:"
|
||||||
|
echo "$APP_RESPONSE" | jq .
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ok "App created: id=${APP_ID}"
|
||||||
|
|
||||||
|
# -- Step 8: Fund the app (create invoice via LP, pay from LND-1) --
|
||||||
|
info "Funding app with ${FUND_AMOUNT} sats..."
|
||||||
|
INVOICE_RESPONSE=$(curl -sf "${LP_URL}/api/app/add/invoice" \
|
||||||
|
-H "Authorization: Bearer ${APP_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"payer_identifier\": \"bootstrap\", \"http_callback_url\": \"\", \"invoice_req\": {\"amountSats\": ${FUND_AMOUNT}, \"memo\": \"regtest bootstrap\"}}")
|
||||||
|
|
||||||
|
INVOICE=$(echo "$INVOICE_RESPONSE" | jq -r '.invoice')
|
||||||
|
if [ "$INVOICE" = "null" ] || [ -z "$INVOICE" ]; then
|
||||||
|
warn "Failed to create invoice — app has 0 balance"
|
||||||
|
warn "Response: $INVOICE_RESPONSE"
|
||||||
|
else
|
||||||
|
# Pay from LND-1
|
||||||
|
PAY_RESULT=$(lncli_1 payinvoice --force "$INVOICE" 2>&1) || true
|
||||||
|
PAY_STATUS=$(echo "$PAY_RESULT" | jq -r '.status' 2>/dev/null || echo "")
|
||||||
|
if [ "$PAY_STATUS" = "SUCCEEDED" ] || echo "$PAY_RESULT" | grep -q "SUCCEEDED"; then
|
||||||
|
ok "App funded with ${FUND_AMOUNT} sats"
|
||||||
|
else
|
||||||
|
warn "Payment may have failed — check manually"
|
||||||
|
warn "Result: $(echo "$PAY_RESULT" | tail -3)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- Summary --
|
||||||
|
# LP uses LND-4 as its backend, so LP pubkey = LND-4 pubkey
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
echo -e "${GREEN} Regtest Bootstrap Complete${NC}"
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${CYAN}LND-1 pubkey:${NC} $(lncli_1 getinfo | jq -r '.identity_pubkey')"
|
||||||
|
echo -e " ${CYAN}LP (LND-4):${NC} ${LND4_PUBKEY}"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${CYAN}LP app ID:${NC} ${APP_ID}"
|
||||||
|
echo -e " ${CYAN}LP app token:${NC} ${APP_TOKEN}"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${CYAN}LP URL:${NC} ${LP_URL}"
|
||||||
|
echo -e " ${CYAN}Extension URL:${NC} ${LP_EXT_URL}"
|
||||||
|
echo -e " ${CYAN}Relay URL:${NC} ws://localhost:7777"
|
||||||
|
echo ""
|
||||||
|
CHAN_INFO=$(lncli_1 listchannels --peer "$LND4_PUBKEY" 2>/dev/null | jq -r '.channels[0] | "\(.local_balance) / \(.capacity) sats (active: \(.active))"' 2>/dev/null || echo "unknown")
|
||||||
|
echo -e " ${CYAN}Channel:${NC} LND-1 → LND-4: ${CHAN_INFO}"
|
||||||
|
echo ""
|
||||||
142
docker/regtest.sh
Executable file
142
docker/regtest.sh
Executable file
|
|
@ -0,0 +1,142 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# regtest.sh — Manage the LP+LND regtest development environment
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./regtest.sh up Start everything + bootstrap
|
||||||
|
# ./regtest.sh down Stop everything
|
||||||
|
# ./regtest.sh reset Full teardown (volumes + data) and fresh start
|
||||||
|
# ./regtest.sh status Show container status
|
||||||
|
# ./regtest.sh logs [svc] Tail logs (lp, relay, miner, lnd1, lnd4, bitcoind)
|
||||||
|
# ./regtest.sh lncli N Run lncli on LND-N (e.g. ./regtest.sh lncli 1 getinfo)
|
||||||
|
# ./regtest.sh bitcoin Run bitcoin-cli (e.g. ./regtest.sh bitcoin getblockcount)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REGTEST_DIR="${REGTEST_DIR:-$HOME/dev/local/docker/regtest}"
|
||||||
|
COMPOSE_FILE="${SCRIPT_DIR}/docker-compose.regtest.yml"
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# -- Detect container prefix (regtest- or lnbits-) --
|
||||||
|
detect_prefix() {
|
||||||
|
if docker ps -f name=regtest-bitcoind-1 -f status=running --format '{{.Names}}' | grep -q .; then
|
||||||
|
echo "regtest"
|
||||||
|
elif docker ps -f name=lnbits-bitcoind-1 -f status=running --format '{{.Names}}' | grep -q .; then
|
||||||
|
echo "lnbits"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_up() {
|
||||||
|
echo -e "${CYAN}Starting regtest (bitcoind + lnd-1 + lnd-4)...${NC}"
|
||||||
|
cd "$REGTEST_DIR" && docker compose up -d bitcoind lnd-1 lnd-4
|
||||||
|
|
||||||
|
echo -e "${CYAN}Starting lamassu services...${NC}"
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d
|
||||||
|
|
||||||
|
echo -e "${CYAN}Running bootstrap...${NC}"
|
||||||
|
"$SCRIPT_DIR/regtest-bootstrap.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_down() {
|
||||||
|
echo -e "${CYAN}Stopping lamassu services...${NC}"
|
||||||
|
docker compose -f "$COMPOSE_FILE" down 2>/dev/null || true
|
||||||
|
|
||||||
|
echo -e "${CYAN}Stopping regtest...${NC}"
|
||||||
|
cd "$REGTEST_DIR" && docker compose down 2>/dev/null || true
|
||||||
|
|
||||||
|
echo -e "${GREEN}All stopped.${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_reset() {
|
||||||
|
echo -e "${CYAN}Full reset: tearing down everything + wiping data...${NC}"
|
||||||
|
|
||||||
|
docker compose -f "$COMPOSE_FILE" down -v 2>/dev/null || true
|
||||||
|
cd "$REGTEST_DIR" && docker compose down -v 2>/dev/null || true
|
||||||
|
|
||||||
|
# Clean LND data (root-owned from containers)
|
||||||
|
docker run --rm -v "$REGTEST_DIR/data:/data" alpine sh -c 'rm -rf /data/lnd-1/* /data/lnd-4/*' 2>/dev/null || true
|
||||||
|
|
||||||
|
echo -e "${GREEN}Clean. Starting fresh...${NC}"
|
||||||
|
cmd_up
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_status() {
|
||||||
|
echo -e "${CYAN}Containers:${NC}"
|
||||||
|
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | grep -E 'regtest|lamassu|NAMES' | sort
|
||||||
|
|
||||||
|
PREFIX=$(detect_prefix)
|
||||||
|
if [ -n "$PREFIX" ]; then
|
||||||
|
echo ""
|
||||||
|
BLOCK=$(docker exec "${PREFIX}-bitcoind-1" bitcoin-cli -regtest getblockcount 2>/dev/null || echo "?")
|
||||||
|
echo -e " ${CYAN}Block height:${NC} ${BLOCK}"
|
||||||
|
|
||||||
|
for N in 1 4; do
|
||||||
|
SYNCED=$(docker exec "${PREFIX}-lnd-${N}-1" lncli --network regtest --rpcserver="lnd-${N}:10009" getinfo 2>/dev/null | jq -r '.synced_to_chain' 2>/dev/null || echo "?")
|
||||||
|
echo -e " ${CYAN}LND-${N} synced:${NC} ${SYNCED}"
|
||||||
|
done
|
||||||
|
|
||||||
|
LP_STATUS=$(curl -so /dev/null -w "%{http_code}" http://localhost:1776 2>/dev/null || echo "down")
|
||||||
|
echo -e " ${CYAN}LP status:${NC} ${LP_STATUS}"
|
||||||
|
|
||||||
|
EXT_STATUS=$(curl -so /dev/null -w "%{http_code}" http://localhost:1777 2>/dev/null || echo "down")
|
||||||
|
echo -e " ${CYAN}Extension:${NC} ${EXT_STATUS}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_logs() {
|
||||||
|
local svc="${1:-lp}"
|
||||||
|
case "$svc" in
|
||||||
|
lp|lightning-pub) docker logs -f --tail 50 lamassu-lightning-pub ;;
|
||||||
|
relay|strfry) docker logs -f --tail 50 lamassu-relay ;;
|
||||||
|
miner) docker logs -f --tail 50 lamassu-miner ;;
|
||||||
|
lnd1|lnd-1) PREFIX=$(detect_prefix); docker logs -f --tail 50 "${PREFIX}-lnd-1-1" ;;
|
||||||
|
lnd4|lnd-4) PREFIX=$(detect_prefix); docker logs -f --tail 50 "${PREFIX}-lnd-4-1" ;;
|
||||||
|
bitcoind) PREFIX=$(detect_prefix); docker logs -f --tail 50 "${PREFIX}-bitcoind-1" ;;
|
||||||
|
*) echo "Unknown service: $svc (try: lp, relay, miner, lnd1, lnd4, bitcoind)"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_lncli() {
|
||||||
|
local N="$1"; shift
|
||||||
|
PREFIX=$(detect_prefix)
|
||||||
|
if [ -z "$PREFIX" ]; then
|
||||||
|
echo -e "${RED}No running regtest found${NC}"; exit 1
|
||||||
|
fi
|
||||||
|
docker exec "${PREFIX}-lnd-${N}-1" lncli --network regtest --rpcserver="lnd-${N}:10009" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_bitcoin() {
|
||||||
|
PREFIX=$(detect_prefix)
|
||||||
|
if [ -z "$PREFIX" ]; then
|
||||||
|
echo -e "${RED}No running regtest found${NC}"; exit 1
|
||||||
|
fi
|
||||||
|
docker exec "${PREFIX}-bitcoind-1" bitcoin-cli -regtest "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- Main --
|
||||||
|
case "${1:-help}" in
|
||||||
|
up) cmd_up ;;
|
||||||
|
down) cmd_down ;;
|
||||||
|
reset) cmd_reset ;;
|
||||||
|
status) cmd_status ;;
|
||||||
|
logs) shift; cmd_logs "${1:-lp}" ;;
|
||||||
|
lncli) shift; cmd_lncli "$@" ;;
|
||||||
|
bitcoin) shift; cmd_bitcoin "$@" ;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 {up|down|reset|status|logs|lncli|bitcoin}"
|
||||||
|
echo ""
|
||||||
|
echo " up Start everything + bootstrap"
|
||||||
|
echo " down Stop everything"
|
||||||
|
echo " reset Full teardown + fresh start"
|
||||||
|
echo " status Show container & service status"
|
||||||
|
echo " logs [s] Tail logs (lp, relay, miner, lnd1, lnd4, bitcoind)"
|
||||||
|
echo " lncli N Run lncli on LND-N (e.g. $0 lncli 1 getinfo)"
|
||||||
|
echo " bitcoin Run bitcoin-cli (e.g. $0 bitcoin getblockcount)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
335
docker/start-with-regtest.sh
Executable file
335
docker/start-with-regtest.sh
Executable file
|
|
@ -0,0 +1,335 @@
|
||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Start lamassu-next development environment with comprehensive regtest
|
||||||
|
#
|
||||||
|
# This script:
|
||||||
|
# 1. Connects to the regtest environment at ~/dev/local/docker/regtest
|
||||||
|
# 2. Starts Lightning.Pub with the specified image/worktree
|
||||||
|
# 3. Creates and funds an ATM account
|
||||||
|
# 4. Displays connection info for Zeus wallet and Lightning.Pub nprofile
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# ~/dev/local/docker/regtest must be running:
|
||||||
|
# cd ~/dev/local/docker/regtest && ./start-regtest
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./start-with-regtest.sh # Start with default image
|
||||||
|
# ./start-with-regtest.sh --image myimage # Use specific Docker image
|
||||||
|
# ./start-with-regtest.sh --worktree ~/path # Build from worktree
|
||||||
|
# ./start-with-regtest.sh down # Stop services
|
||||||
|
# ./start-with-regtest.sh logs # Follow logs
|
||||||
|
# ./start-with-regtest.sh status # Show connection info
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REGTEST_DIR="${REGTEST_DIR:-$HOME/dev/local/docker/regtest}"
|
||||||
|
DEFAULT_IMAGE="lightning-pub-withdraw:latest"
|
||||||
|
ATM_FUNDING_SATS=100000
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
log() { echo -e "${GREEN}[lamassu]${NC} $1"; }
|
||||||
|
warn() { echo -e "${YELLOW}[lamassu]${NC} $1"; }
|
||||||
|
error() { echo -e "${RED}[lamassu]${NC} $1"; }
|
||||||
|
info() { echo -e "${CYAN}[lamassu]${NC} $1"; }
|
||||||
|
|
||||||
|
# Get local IP for external wallet access
|
||||||
|
get_local_ip() {
|
||||||
|
ip route get 1 2>/dev/null | awk '{print $7; exit}' || hostname -I | awk '{print $1}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if regtest is running
|
||||||
|
check_regtest() {
|
||||||
|
if ! docker network inspect lnbits_default &>/dev/null; then
|
||||||
|
error "Regtest network not found!"
|
||||||
|
echo ""
|
||||||
|
echo "Start the regtest environment first:"
|
||||||
|
echo " cd $REGTEST_DIR && ./start-regtest"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if lnd-4 is running (Lightning.Pub's backend)
|
||||||
|
if ! docker exec lnbits-lnd-4-1 lncli --network=regtest getinfo &>/dev/null 2>&1; then
|
||||||
|
warn "lnd-4 not responding. Waiting..."
|
||||||
|
sleep 5
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build Lightning.Pub from worktree
|
||||||
|
build_from_worktree() {
|
||||||
|
local worktree="$1"
|
||||||
|
local image_name="lightning-pub-custom:latest"
|
||||||
|
|
||||||
|
if [[ ! -d "$worktree" ]]; then
|
||||||
|
error "Worktree not found: $worktree"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Building Lightning.Pub from $worktree..."
|
||||||
|
docker build -t "$image_name" "$worktree"
|
||||||
|
echo "$image_name"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Wait for Lightning.Pub to be ready and get nprofile
|
||||||
|
wait_for_lightning_pub() {
|
||||||
|
log "Waiting for Lightning.Pub to start..."
|
||||||
|
local max_attempts=30
|
||||||
|
local attempt=0
|
||||||
|
|
||||||
|
while [[ $attempt -lt $max_attempts ]]; do
|
||||||
|
if docker logs lamassu-lightning-pub 2>&1 | grep -q "LightningPub listening"; then
|
||||||
|
sleep 2 # Extra time for Nostr middleware
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
error "Lightning.Pub failed to start within 60 seconds"
|
||||||
|
docker logs lamassu-lightning-pub 2>&1 | tail -20
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get Lightning.Pub nprofile from logs
|
||||||
|
get_nprofile() {
|
||||||
|
docker logs lamassu-lightning-pub 2>&1 | grep -oP 'nprofile:\s*\K\S+' | tail -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get Lightning.Pub pubkey from logs
|
||||||
|
get_pubkey() {
|
||||||
|
docker logs lamassu-lightning-pub 2>&1 | grep -oP 'pubkey:\s*\K[a-f0-9]+' | tail -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate lndconnect URL for Zeus (using lnd-3 which has REST exposed)
|
||||||
|
generate_lndconnect() {
|
||||||
|
local lnd_container="lnbits-lnd-3-1"
|
||||||
|
local lnd_data="$REGTEST_DIR/data/lnd-3"
|
||||||
|
local local_ip=$(get_local_ip)
|
||||||
|
local rest_port=8082 # lnd-3's REST port
|
||||||
|
|
||||||
|
# Get cert and macaroon
|
||||||
|
local cert_path="$lnd_data/tls.cert"
|
||||||
|
local mac_path="$lnd_data/data/chain/bitcoin/regtest/admin.macaroon"
|
||||||
|
|
||||||
|
if [[ ! -f "$cert_path" ]] || [[ ! -f "$mac_path" ]]; then
|
||||||
|
warn "lnd-3 credentials not found. Zeus connection string unavailable."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Base64url encode (replace + with -, / with _, remove =)
|
||||||
|
local cert_b64=$(base64 -w0 "$cert_path" | tr '+/' '-_' | tr -d '=')
|
||||||
|
local mac_b64=$(base64 -w0 "$mac_path" | tr '+/' '-_' | tr -d '=')
|
||||||
|
|
||||||
|
echo "lndconnect://${local_ip}:${rest_port}?cert=${cert_b64}&macaroon=${mac_b64}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create and fund ATM account
|
||||||
|
setup_atm_account() {
|
||||||
|
log "Setting up ATM account..."
|
||||||
|
|
||||||
|
# Create app
|
||||||
|
local app_response=$(curl -s -X POST http://localhost:1776/api/admin/app/add \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer lamassu-dev-admin-token" \
|
||||||
|
-d '{"name":"atm-app","allow_user_creation":true}' 2>/dev/null)
|
||||||
|
|
||||||
|
if echo "$app_response" | grep -q '"status":"OK"'; then
|
||||||
|
local app_id=$(echo "$app_response" | grep -oP '"id":"\K[^"]+')
|
||||||
|
local app_token=$(echo "$app_response" | grep -oP '"auth_token":"\K[^"]+')
|
||||||
|
log "Created ATM app: $app_id"
|
||||||
|
|
||||||
|
# Store app token for later use
|
||||||
|
echo "$app_token" > "$SCRIPT_DIR/.atm-app-token"
|
||||||
|
echo "$app_id" > "$SCRIPT_DIR/.atm-app-id"
|
||||||
|
|
||||||
|
# TODO: Fund the app by creating an invoice and paying from lnd-3
|
||||||
|
# This requires the app to support invoice creation via HTTP API
|
||||||
|
# For now, the app starts with 0 balance
|
||||||
|
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
warn "Failed to create ATM app: $app_response"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Display connection information
|
||||||
|
show_connection_info() {
|
||||||
|
local nprofile=$(get_nprofile)
|
||||||
|
local pubkey=$(get_pubkey)
|
||||||
|
local local_ip=$(get_local_ip)
|
||||||
|
local lndconnect=$(generate_lndconnect 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}═══════════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo -e "${BOLD} LAMASSU REGTEST ENVIRONMENT ${NC}"
|
||||||
|
echo -e "${BOLD}═══════════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo -e "${CYAN}Lightning.Pub:${NC}"
|
||||||
|
echo -e " Pubkey: ${GREEN}$pubkey${NC}"
|
||||||
|
echo -e " nprofile: ${GREEN}$nprofile${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo -e "${CYAN}Service URLs:${NC}"
|
||||||
|
echo " Nostr Relay: ws://localhost:7777"
|
||||||
|
echo " Lightning.Pub: http://localhost:1776"
|
||||||
|
echo " Withdraw API: http://localhost:1777"
|
||||||
|
echo " PostgreSQL: localhost:5432"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo -e "${CYAN}External Access (from phone/other devices):${NC}"
|
||||||
|
echo " Nostr Relay: ws://${local_ip}:7777"
|
||||||
|
echo " Withdraw API: http://${local_ip}:1777"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ -n "$lndconnect" ]]; then
|
||||||
|
echo -e "${CYAN}Zeus Wallet Connection (lnd-3):${NC}"
|
||||||
|
echo -e " ${GREEN}$lndconnect${NC}"
|
||||||
|
echo ""
|
||||||
|
echo " Scan this with Zeus to connect to lnd-3 for testing payments."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${CYAN}ATM Configuration (apps/machine/.env):${NC}"
|
||||||
|
echo " VITE_RELAY_URL=ws://localhost:7777"
|
||||||
|
echo " VITE_LIGHTNING_PUB_PUBKEY=$pubkey"
|
||||||
|
echo " VITE_EXTENSION_API_URL=http://localhost:1777"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo -e "${CYAN}CLI Helpers:${NC}"
|
||||||
|
echo " source $REGTEST_DIR/docker-scripts.sh"
|
||||||
|
echo " bitcoin-cli-sim -generate 1 # Mine blocks"
|
||||||
|
echo " lncli-sim 4 getinfo # lnd-4 (Lightning.Pub)"
|
||||||
|
echo " lncli-sim 3 getinfo # lnd-3 (Zeus wallet)"
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}═══════════════════════════════════════════════════════════════${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start services
|
||||||
|
start() {
|
||||||
|
local image="$DEFAULT_IMAGE"
|
||||||
|
local worktree=""
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--image)
|
||||||
|
image="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--worktree)
|
||||||
|
worktree="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
log "Checking prerequisites..."
|
||||||
|
check_regtest
|
||||||
|
|
||||||
|
# Build from worktree if specified
|
||||||
|
if [[ -n "$worktree" ]]; then
|
||||||
|
image=$(build_from_worktree "$worktree")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if image exists
|
||||||
|
if ! docker image inspect "$image" &>/dev/null; then
|
||||||
|
error "Docker image not found: $image"
|
||||||
|
echo ""
|
||||||
|
echo "Either:"
|
||||||
|
echo " 1. Build the image: docker build -t $image <path>"
|
||||||
|
echo " 2. Use --worktree: ./start-with-regtest.sh --worktree ~/path/to/lightning-pub"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Using Lightning.Pub image: $image"
|
||||||
|
|
||||||
|
# Export environment variables
|
||||||
|
export REGTEST_DATA_DIR="$REGTEST_DIR/data"
|
||||||
|
export LIGHTNING_PUB_IMAGE="$image"
|
||||||
|
export HOST_IP=$(get_local_ip)
|
||||||
|
|
||||||
|
log "Starting lamassu services..."
|
||||||
|
docker compose -f "$SCRIPT_DIR/docker-compose.regtest.yml" up -d
|
||||||
|
|
||||||
|
# Wait for Lightning.Pub and setup
|
||||||
|
if wait_for_lightning_pub; then
|
||||||
|
setup_atm_account || true
|
||||||
|
show_connection_info
|
||||||
|
else
|
||||||
|
error "Failed to start Lightning.Pub. Check logs with: $0 logs lightning-pub"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stop services
|
||||||
|
stop() {
|
||||||
|
log "Stopping lamassu services..."
|
||||||
|
docker compose -f "$SCRIPT_DIR/docker-compose.regtest.yml" down
|
||||||
|
rm -f "$SCRIPT_DIR/.atm-app-token" "$SCRIPT_DIR/.atm-app-id"
|
||||||
|
log "Services stopped."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show logs
|
||||||
|
logs() {
|
||||||
|
docker compose -f "$SCRIPT_DIR/docker-compose.regtest.yml" logs -f "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show status/connection info
|
||||||
|
status() {
|
||||||
|
if ! docker ps --format '{{.Names}}' | grep -q lamassu-lightning-pub; then
|
||||||
|
error "Services not running. Start with: $0 start"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
show_connection_info
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main
|
||||||
|
case "${1:-start}" in
|
||||||
|
start)
|
||||||
|
shift || true
|
||||||
|
start "$@"
|
||||||
|
;;
|
||||||
|
stop|down)
|
||||||
|
stop
|
||||||
|
;;
|
||||||
|
logs)
|
||||||
|
shift
|
||||||
|
logs "$@"
|
||||||
|
;;
|
||||||
|
status|info)
|
||||||
|
status
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 [command] [options]"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " start Start services (default)"
|
||||||
|
echo " stop, down Stop services"
|
||||||
|
echo " logs [service] Follow logs"
|
||||||
|
echo " status, info Show connection info"
|
||||||
|
echo ""
|
||||||
|
echo "Options for 'start':"
|
||||||
|
echo " --image <name> Use specific Docker image"
|
||||||
|
echo " --worktree <path> Build from Lightning.Pub worktree"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 # Start with default image"
|
||||||
|
echo " $0 --worktree ~/dev/lightning-pub/withdraw # Build and use worktree"
|
||||||
|
echo " $0 --image lightning-pub-custom:v1 # Use specific image"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
65
docker/strfry.conf
Normal file
65
docker/strfry.conf
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
##
|
||||||
|
## strfry configuration for Lamassu ATM development
|
||||||
|
##
|
||||||
|
|
||||||
|
relay {
|
||||||
|
bind = "0.0.0.0"
|
||||||
|
port = 7777
|
||||||
|
|
||||||
|
# Set to 0 to skip configuring nofiles limit (avoids container ulimit issues)
|
||||||
|
nofiles = 0
|
||||||
|
|
||||||
|
info {
|
||||||
|
name = "Lamassu Dev Relay"
|
||||||
|
description = "Private Nostr relay for ATM development and testing"
|
||||||
|
pubkey = ""
|
||||||
|
contact = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Maximum message size in bytes
|
||||||
|
maxWebsocketPayloadSize = 131072
|
||||||
|
|
||||||
|
# Connection limits
|
||||||
|
maxWebsockets = 100
|
||||||
|
maxConnections = 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
# Event policies
|
||||||
|
events {
|
||||||
|
# Maximum event size
|
||||||
|
maxEventSize = 65536
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
rejectEventsNewerThanSeconds = 900
|
||||||
|
rejectEventsOlderThanSeconds = 94608000
|
||||||
|
|
||||||
|
# Event kinds we care about
|
||||||
|
# 14: Private DMs (NIP-17)
|
||||||
|
# 21000: Lightning.Pub RPC (generic request/response)
|
||||||
|
# 21001-21003: CLINK events (Offer, Debit, Manage)
|
||||||
|
# 22242: NIP-42 auth
|
||||||
|
# 30078: Service Beacon (replaceable, service discovery)
|
||||||
|
# 30079: Transaction records (replaceable)
|
||||||
|
|
||||||
|
# TODO: Review if these ephemeral event settings actually do anything useful.
|
||||||
|
# CLINK events (21000-21003) are in the ephemeral range but need to persist
|
||||||
|
# long enough for request/response flows. These settings may not be recognized
|
||||||
|
# by strfry - verify against strfry documentation.
|
||||||
|
# Allow ephemeral events up to 5 minutes old to be accepted
|
||||||
|
rejectEphemeralEventsOlderThanSeconds = 300
|
||||||
|
# Keep ephemeral events for 5 minutes before deletion
|
||||||
|
ephemeralEventsLifetimeSeconds = 300
|
||||||
|
}
|
||||||
|
|
||||||
|
# Negentropy sync (for relay federation)
|
||||||
|
negentropy {
|
||||||
|
enabled = true
|
||||||
|
syncOnConnect = false
|
||||||
|
}
|
||||||
|
|
||||||
|
# Plugins (for NIP-42 auth in production)
|
||||||
|
# plugins {
|
||||||
|
# authRequired = true
|
||||||
|
# writePolicy = "accept"
|
||||||
|
# readPolicy = "accept"
|
||||||
|
# }
|
||||||
|
|
@ -1,484 +0,0 @@
|
||||||
# ADR-005: Cash-Out Dispense Outcome and Settlement Capture
|
|
||||||
|
|
||||||
**Status:** Proposed
|
|
||||||
**Date:** 2026-10-09
|
|
||||||
**Context:** On 2026-10-09 a customer paid a 40 EUR cash-out on sintra, a note jammed at the
|
|
||||||
cassette exit, and the operator dashboard showed the settlement as `processed` with no sign
|
|
||||||
anything was wrong (aiolabs/bitspire#122). The machine had recorded the failure correctly and
|
|
||||||
in detail. Nothing it knew ever reached anyone. This ADR specifies how a dispense outcome
|
|
||||||
becomes a first-class fact on both sides of the wire, and fixes the structural reason the
|
|
||||||
existing remediation tool could not have helped.
|
|
||||||
|
|
||||||
## The problem
|
|
||||||
|
|
||||||
A cash-out moves value in two steps that today are not connected:
|
|
||||||
|
|
||||||
1. **Payment.** The customer pays the ATM's BOLT11 invoice. LNbits lands it in the machine
|
|
||||||
wallet, `spirekeeper._handle_payment` verifies attribution, inserts a `dca_settlements` row,
|
|
||||||
and — in the same breath — spawns `process_settlement` as a background task.
|
|
||||||
2. **Dispense.** The machine, which learns of the payment through `watchInvoice`, commands the
|
|
||||||
dispenser. The hardware reports per-bay `dispensed` / `rejected` counts and, on failure, an
|
|
||||||
error code.
|
|
||||||
|
|
||||||
Step 1 does not wait for step 2. `process_settlement` pays the super fee, the operator's
|
|
||||||
commission splits, and the DCA legs the moment the payment lands, which on an LNbits-internal
|
|
||||||
transfer is sub-second. The dispense begins afterwards. So by the time the F56 reported
|
|
||||||
`78 42` at T+2 s, the settlement's legs were already `completed` and its status was
|
|
||||||
`processed` — which is what the dashboard faithfully displayed. `processed` means *all
|
|
||||||
distribution legs paid*. It has never meant *cash reached a hand*, because the server has no
|
|
||||||
input that could tell it.
|
|
||||||
|
|
||||||
The tool built for this situation, `apply_partial_dispense_and_redistribute`, carries a hard
|
|
||||||
guard: it refuses once any leg has completed, because a Lightning payment cannot be clawed
|
|
||||||
back. Under the current ordering that guard is reached on every real failure. The remediation
|
|
||||||
is structurally unreachable for the exact case it was written for, except by winning a race
|
|
||||||
against a sub-second transfer.
|
|
||||||
|
|
||||||
Downstream of that, four smaller gaps compound it (all in #122):
|
|
||||||
|
|
||||||
- The machine's `dispenseError` state is terminal and local. No report, no notification. The
|
|
||||||
only record that a customer is owed money lives in `state.db` on the ATM.
|
|
||||||
- The dispenser's counters do not see a note that leaves the bay and stops in the transport —
|
|
||||||
it is neither `dispensed` nor `rejected`. The machine trusts the resulting `dispensed: 0`,
|
|
||||||
leaves the bay count untouched, and republishes it as fact. The `countsUncertainSince`
|
|
||||||
safety net fires only when the report is *absent*, not when it is present and wrong.
|
|
||||||
- Nothing reads dispenser health. The availability beacon derives `cash_out` from
|
|
||||||
`totalBills > 0` alone and kept advertising a jammed machine as available. (Nothing
|
|
||||||
consumes that beacon today, so it could not have been the enforcement point in any case.)
|
|
||||||
- The customer sees a 30-second countdown and a txid QR, then the idle screen. There is no
|
|
||||||
claim reference, no statement that they have paid, and nothing distinguishes a mechanical
|
|
||||||
fault from an out-of-cash condition.
|
|
||||||
|
|
||||||
## Prior art
|
|
||||||
|
|
||||||
lamassu-machine / lamassu-server ran this exact hardware in production for a decade. Their
|
|
||||||
model, which this ADR adopts where it fits and deviates from where it is wrong:
|
|
||||||
|
|
||||||
- **Three fields on the transaction:** `error` (human message), `error_code` (the error's
|
|
||||||
*name*, machine-readable), `dispense_confirmed` (boolean). `dispense_confirmed` is computed on
|
|
||||||
**value** — `tx.fiat.eq(Σ denomination × dispensed)` — not taken from the driver.
|
|
||||||
- **An append-only action log, `cash_out_actions`,** one row per dispense attempt with
|
|
||||||
per-bay `provisioned_N` / `denomination_N` / `dispensed_N` / `rejected_N`, written by
|
|
||||||
`logDispense` as `action: 'dispense'` or `'dispenseError'` purely on whether `error` is set.
|
|
||||||
- **The operator is notified in the same atomic block that logs the dispense**
|
|
||||||
(`notifyOperator`, cash-out-atomic.js). Push, not a worklist.
|
|
||||||
- **A mechanical fault is not "out of cash."** Their 2026-09-29 fix routes a
|
|
||||||
dispenser-reported error to the screen that asks the customer to photograph their receipt,
|
|
||||||
*"the right prompt when they have paid and are owed money,"* and reserves `outOfCash` for a
|
|
||||||
shortfall with no error. The same commit removed a borrowed `statusCode 570` from the F56
|
|
||||||
driver because 570 meant "insufficient funds" to the server — a jammed BDU was being
|
|
||||||
reported as a hot-wallet problem.
|
|
||||||
- **What they did not have:** any notion of disabling a machine on a dispenser fault.
|
|
||||||
`getMachineStatuses` is inferential — ping age, stuck-screen age — and `cashOut` is an
|
|
||||||
operator config toggle. A jammed dispenser on a responsive machine reads *Fully
|
|
||||||
functional*. That is sintra's beacon exactly, so the latch below is new work, not a port.
|
|
||||||
- **What they got wrong and we will not copy:** `dispenseOccurred(bills)` returns true if the
|
|
||||||
bill entries merely *have* `dispensed` and `rejected` keys, and `updateCassettes` then
|
|
||||||
decrements by those numbers. A jam reporting `dispensed: 0` passes and decrements by zero.
|
|
||||||
Their cassette counts drift the same way sintra's did.
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
### 1. Payment is authorization. Dispense confirmation is capture. Distribution waits for capture.
|
|
||||||
|
|
||||||
A `cash_out` settlement lands as `pending` exactly as now, but `_handle_payment` no longer
|
|
||||||
spawns `process_settlement` for it. The row moves to a new status, `awaiting_dispense`, and
|
|
||||||
stays there until the machine reports.
|
|
||||||
|
|
||||||
| Machine reports | Settlement becomes | Then |
|
|
||||||
| ----------------------------------------- | ------------------ | --------------------------------------- |
|
|
||||||
| `dispense_confirmed: true` | `pending` | claim + distribute → `processed` |
|
|
||||||
| partial (some notes out, value short) | `partial_pending` | operator confirms → distribute scaled |
|
|
||||||
| `dispense_confirmed: false`, nothing out | `cash_owed` | legs never run; funds stay in wallet |
|
|
||||||
| no report within `DISPENSE_REPORT_TTL` | `dispense_unreported` | worklist; operator investigates |
|
|
||||||
|
|
||||||
This is the card-processing shape — authorize, then capture — and it is the same ordering
|
|
||||||
lamassu-server enforces between `dispense_confirmed` and `updateCassettes`. The cost is that
|
|
||||||
operator and DCA legs land seconds later than they do today, which is the dispense time.
|
|
||||||
The benefit is that `apply_partial_dispense_and_redistribute` is always reachable, because
|
|
||||||
no leg has run yet, and `cash_owed` is a state the money has not left.
|
|
||||||
|
|
||||||
`cash_in` settlements are unaffected: there is no dispense to wait for, and
|
|
||||||
`_pay_dca_distributions` already branches on `tx_type` for exactly this kind of asymmetry.
|
|
||||||
|
|
||||||
**Rejected:** keeping immediate distribution and adding a compensating reversal. Internal
|
|
||||||
legs *are* reversible — they are LNbits-internal invoices, so a compensating internal
|
|
||||||
payment is mechanically possible and the guard's "Lightning can't be clawed back" is only
|
|
||||||
true of the `autoforward` leg. But undoing money movement is strictly harder than not
|
|
||||||
moving it yet, and the autoforward leg stays irreversible either way. Compensation is kept
|
|
||||||
as a secondary tool for settlements that distributed before this ADR landed.
|
|
||||||
|
|
||||||
### 2. The machine reports every cash-out outcome over a `report_dispense` RPC.
|
|
||||||
|
|
||||||
Not over the kind-30078 state document. ADR-004 established why: an addressable event gives
|
|
||||||
its publisher no failure signal, and a losing writer is never told. A per-transaction
|
|
||||||
outcome is an append-only fact that must be acknowledged, which is a request/reply.
|
|
||||||
|
|
||||||
The RPC follows `create_withdraw` and `get_machine_config`: `register_rpc` at
|
|
||||||
`AUTH_ACCOUNT`, identity taken from the **verified** `sender_pubkey`, never from the body.
|
|
||||||
It is sent on **success as well as failure** — a success report is what captures (Decision
|
|
||||||
1). Payload, adopting the lamassu field names:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"txid": "tx_mv0madw6_wdhtea1v",
|
|
||||||
"payment_hash": "6f216df32c36…",
|
|
||||||
"tx_type": "cash_out",
|
|
||||||
"dispense_confirmed": false, // value equality, Decision 3
|
|
||||||
"error": "Dispensing, code: 78 42", // human, null on success
|
|
||||||
"error_code": "F56DispenseError", // the error's NAME, null on success
|
|
||||||
"raw_code": "78 42", // driver-native, for the decode table
|
|
||||||
"error_class": "terminal", // "terminal" | "recoverable" | null, Decision 5
|
|
||||||
"fiat_cents": 4000,
|
|
||||||
"bills": [{ "denomination": 20, "requested": 2, "dispensed": 0, "rejected": 0 }],
|
|
||||||
"cassettes": [ // the machine's cassette_bills rows, verbatim
|
|
||||||
{ "position": 2, "denomination": 20, "provisioned": 2, "dispensed": 0, "rejected": 0 }
|
|
||||||
],
|
|
||||||
"counts_uncertain": true, // Decision 3
|
|
||||||
"at": 1791529353
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Delivery is at-least-once with a durable outbox.** The machine writes the report to
|
|
||||||
`state.db` in the same transaction as the `transactions` row (`dispense_reports`:
|
|
||||||
`txid PRIMARY KEY, payload, created_at, acked_at`), then sends. It resends on boot, on relay
|
|
||||||
reconnect, and on a timer until an `OK` reply sets `acked_at`. The server upserts on `txid`,
|
|
||||||
so a resend is a no-op. This is the cassette-ops idempotency pattern applied to the other
|
|
||||||
direction.
|
|
||||||
|
|
||||||
The server stores every report in an append-only `dispense_reports` table (one row per
|
|
||||||
attempt, lamassu's `cash_out_actions` shape, keyed to the settlement by `bitspire_txid`,
|
|
||||||
which is already populated from `extra.txid`), copies the per-bay detail into
|
|
||||||
`dca_settlements.bills_json` / `cassettes_json` (columns that exist today and are never
|
|
||||||
written), and sets `dispense_confirmed`, `error`, `error_code` on the settlement.
|
|
||||||
|
|
||||||
### 3. `dispense_confirmed` is computed on value, separately from `error`, and a zero report with an error is unverified.
|
|
||||||
|
|
||||||
On the machine, after the HAL returns:
|
|
||||||
|
|
||||||
```
|
|
||||||
confirmed = requestedFiatCents === Σ(denomination × dispensed) × 100
|
|
||||||
```
|
|
||||||
|
|
||||||
`error` is carried independently. The state machine's `dispensingCash.onDone` guard moves
|
|
||||||
from `output.dispensed === true` to `output.dispenseConfirmed`, and `DispenseCashResult`
|
|
||||||
gains `dispenseConfirmed`, `errorCode`, `rawCode`, `errorClass`.
|
|
||||||
|
|
||||||
**The deviation from both bitSpire-today and lamassu:** when a report arrives with `error`
|
|
||||||
set and `dispensed === 0` on every bay, the machine does *not* treat that zero as a count. A
|
|
||||||
note in the transport path completes neither counter. The machine sets `countsUncertainSince`
|
|
||||||
exactly as it already does for an absent report, carries `counts_uncertain: true` in the RPC,
|
|
||||||
and the bay stays flagged until a `recount` op clears it. `atm-reconcile` then shows the gap
|
|
||||||
instead of a clean ledger.
|
|
||||||
|
|
||||||
### 4. A dispenser fault is its own customer screen, with evidence, and it is not "out of cash."
|
|
||||||
|
|
||||||
Two distinct terminal states replace the single `dispenseError`:
|
|
||||||
|
|
||||||
- **`outOfCash`** — the request could not be met from inventory and the dispenser reported
|
|
||||||
**no error**. Nothing was charged beyond what was dispensed.
|
|
||||||
- **`dispenseFault`** — the dispenser reported an error. The customer **has paid** and is
|
|
||||||
owed the shortfall.
|
|
||||||
|
|
||||||
`dispenseFault` shows: the amount paid, the amount dispensed (per denomination, as now), the
|
|
||||||
txid as QR (as now) **and as text**, the first 12 characters of the payment hash, the time,
|
|
||||||
and the sentence *"You have paid. The operator has been notified and holds your transaction
|
|
||||||
record. Keep this reference."* The raw error code is **not** shown to the customer; it is in
|
|
||||||
the report. The 30-second auto-return is extended to 120 s and the screen offers "I've saved
|
|
||||||
this" rather than only "Return to Start." This is lamassu's `fiatTransactionError` prompt
|
|
||||||
without the receipt camera.
|
|
||||||
|
|
||||||
### 5. Terminal dispenser faults latch cash-out off. A recount or an explicit operator op clears it.
|
|
||||||
|
|
||||||
The HAL classifies each error as `terminal` or `recoverable` (#27's split: jam, motor stop,
|
|
||||||
diverter and sensor faults, dispense timeout are terminal; pickup error and bill-end are
|
|
||||||
recoverable). The machine persists `cashOutHeld: { reason, errorCode, since }` in `meta`
|
|
||||||
when a terminal fault lands, and `SELECT_CASH_OUT` is guarded on it. The idle screen shows
|
|
||||||
cash-out unavailable with the reason.
|
|
||||||
|
|
||||||
The hold is **not** cleared by re-initialising the dispenser. `dispenseCash` already re-inits
|
|
||||||
on the next attempt, and re-initialising does not move a note that is stuck. It is cleared
|
|
||||||
by:
|
|
||||||
|
|
||||||
- a `recount` operator op on any bay — the same "operator opened the machine" gesture that
|
|
||||||
clears `countsUncertainSince`, so one physical act resolves both; or
|
|
||||||
- a new `resume_cash_out` operator op, idempotent-id'd like the cassette ops, for the case
|
|
||||||
where the operator cleared the jam without touching a bay count.
|
|
||||||
|
|
||||||
The machine mirrors the hold into its cassettes-state document (`cash_out_held_since`,
|
|
||||||
`cash_out_held_reason`) and spirekeeper writes it onto `dca_machines` beside
|
|
||||||
`counts_uncertain_since`. The availability beacon reports `cash_out: false` while held.
|
|
||||||
|
|
||||||
Separately, the operator gets a manual switch: `cash_out_enabled` on `dca_machines`,
|
|
||||||
published as an operator op, defaulting true. This is lamassu's `cashOutConfig.active`
|
|
||||||
shape. Cash-out is offered only when the machine is not held **and** the switch is on.
|
|
||||||
`dca_machines.is_active` is **not** used for either — it is the roster filter in
|
|
||||||
`get_machine_by_wallet`, and flipping it makes the machine unknown to the RPC handlers
|
|
||||||
rather than pausing it.
|
|
||||||
|
|
||||||
### 6. Owed cash is a first-class state on both sides, with an off-machine settle path.
|
|
||||||
|
|
||||||
Server: `cash_owed` and `dispense_unreported` are two new buckets on
|
|
||||||
`StuckSettlementsResponse`. They are the only buckets whose meaning is *a customer is owed
|
|
||||||
money*, and they render first. Arrival in either bucket triggers the operator notification
|
|
||||||
path (whatever `notifyOperator` equivalent spirekeeper grows; at minimum the dashboard banner
|
|
||||||
— but the push is the point, and it belongs in the same transaction that writes the row).
|
|
||||||
|
|
||||||
Resolution closes **both** ledgers:
|
|
||||||
|
|
||||||
- **On-machine remediation.** `manual_dispense` with `ref_txid` already flips the machine row
|
|
||||||
to `remediated` via `remediateTransaction`. The machine sends a `report_dispense` for the
|
|
||||||
remediation with `remediates_txid`, and the server moves the settlement from `cash_owed` to
|
|
||||||
`pending` and distributes.
|
|
||||||
- **Off-machine settlement.** The operator paid the customer by hand. A new `settle_cash_owed`
|
|
||||||
operator action records provenance (free text, author, time) on the settlement, moves it to
|
|
||||||
`pending`, and publishes a `settle_transaction { txid, note }` operator op; the machine
|
|
||||||
applies it by setting `remediated_by` to the note and `status = 'remediated'`. Today there is
|
|
||||||
no way to record this at all, and the machine's ledger asserts the debt forever.
|
|
||||||
|
|
||||||
`PartialDispenseData` is pre-filled from the report's `bills` so the operator confirms a
|
|
||||||
number the hardware produced rather than typing one.
|
|
||||||
|
|
||||||
### 7. Raw codes get a decode table in the driver, built empirically.
|
|
||||||
|
|
||||||
`packages/hal` owns a `rawCode → { errorCode, errorClass, human }` table per dispenser. It is
|
|
||||||
seeded with what has been observed — `78 42` on an F56 is a note stopped at the cassette
|
|
||||||
exit (sintra, 2026-10-09) — and grows as codes occur; an unknown code reports as
|
|
||||||
`F56DispenseError` / `terminal` / `"unrecognised dispenser error <code>"`, failing safe. No
|
|
||||||
code is borrowed from another layer's vocabulary (the lamassu 570 lesson).
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- One cash-out now produces one `report_dispense`; the server's `dispense_reports` table is
|
|
||||||
the audit trail, and `atm-reconcile`'s natural sibling is a settlement↔transaction
|
|
||||||
reconciliation that joins on `txid` and flags any settlement without a report.
|
|
||||||
- Distribution for `cash_out` is delayed by the dispense (seconds). Operators watching the
|
|
||||||
dashboard will see `awaiting_dispense` briefly on every sale.
|
|
||||||
- `apply_partial_dispense_and_redistribute`'s hard guard still exists but is reached only for
|
|
||||||
settlements that pre-date this ADR; its message should say which leg type blocked it.
|
|
||||||
- The state machine gains `dispenseFault`, `outOfCash` and a `cashOutHeld` guard; tests in
|
|
||||||
`packages/state-machine` cover all three (cash-out is the critical path).
|
|
||||||
- A machine on an old build keeps working: the server treats a `cash_out` settlement with no
|
|
||||||
report after `DISPENSE_REPORT_TTL` as `dispense_unreported`, not as failed, and the operator
|
|
||||||
can capture manually. That is also the upgrade path.
|
|
||||||
|
|
||||||
## Rollout
|
|
||||||
|
|
||||||
1. **bitspire:** `DispenseCashResult` gains the new fields; HAL computes `dispenseConfirmed`,
|
|
||||||
classifies, decodes; `dispensingCash` guards on it; `dispenseFault` / `outOfCash` screens;
|
|
||||||
`dispense_reports` outbox + `report_dispense` client; `cashOutHeld` latch and guard;
|
|
||||||
`counts_uncertain` on zero-with-error. Ships first — with no server handler the RPC
|
|
||||||
returns an error and the outbox simply retries, so the machine is never blocked on it.
|
|
||||||
2. **spirekeeper:** `report_dispense` handler + `dispense_reports` table; `awaiting_dispense` /
|
|
||||||
`cash_owed` / `partial_pending` / `dispense_unreported` statuses; gate in `_handle_payment`;
|
|
||||||
worklist buckets + notification; `settle_cash_owed`; `cash_out_enabled` and the two new
|
|
||||||
operator ops; `dca_machines.cash_out_held_*`.
|
|
||||||
3. **Both:** `resume_cash_out` / `settle_transaction` ops on the machine; beacon reflects
|
|
||||||
held; docs: `docs/nostr-patterns` entry for the outbox-RPC pattern, this ADR → Accepted.
|
|
||||||
|
|
||||||
## Future directions
|
|
||||||
|
|
||||||
Recorded 2026-10-09 so the decisions above are made with the destination in view. None of
|
|
||||||
these are decided; several would change what "owed cash" even means.
|
|
||||||
|
|
||||||
### Hold invoices — capture at the Lightning layer instead of the application layer
|
|
||||||
|
|
||||||
Decision 1 implements authorize/capture in spirekeeper because a plain BOLT11 payment is
|
|
||||||
final the moment it lands. A **hold (HODL) invoice** moves that boundary into the protocol:
|
|
||||||
the payer's HTLC is accepted but not settled until the receiver reveals the preimage, and
|
|
||||||
can be cancelled instead, returning the funds with no second payment. The ATM would mint the
|
|
||||||
preimage, create the hold invoice, dispense, and then `settle` on `dispense_confirmed` or
|
|
||||||
`cancel` on a fault. A cancelled hold means nobody is owed anything — the customer's funds
|
|
||||||
were never taken.
|
|
||||||
|
|
||||||
What is already there: LNbits core has `create_hold_invoice`, `settle_hold_invoice(preimage)`
|
|
||||||
and `cancel_hold_invoice` (`lnbits/core/services/payments.py`), tagging `extra.hold_invoice`.
|
|
||||||
It is implemented for the **lndrest and lndgrpc** funding sources only; other backends raise
|
|
||||||
*"Hold invoices are not supported by the funding source."* None of the three is exposed over
|
|
||||||
the nostr-transport yet, so three RPCs are needed before the machine can use them. Spark's
|
|
||||||
`createLightningHodlInvoice({ amountSats, paymentHash, … })` and RoboSats' escrow bonds are
|
|
||||||
the reference shapes — the payer-visible behaviour (a pending payment that later settles or
|
|
||||||
cancels) is identical.
|
|
||||||
|
|
||||||
Two properties bound what this buys:
|
|
||||||
|
|
||||||
- **Settlement is all-or-nothing per HTLC.** A hold invoice cannot be partially settled. A
|
|
||||||
**full** fault (nothing dispensed) is cleanly cancelled. A **partial** fault — notes out,
|
|
||||||
value short — cannot be: cancelling would refund a customer who is holding cash, and
|
|
||||||
settling takes the full amount. Partial therefore still needs the voucher or refund path
|
|
||||||
below. Hold invoices eliminate owed-cash for the common full-fault case, not for every case.
|
|
||||||
- **The hold window locks the payer's funds and route liquidity**, and some wallets surface
|
|
||||||
a long-pending payment as a failure. The window should equal the dispense window — seconds,
|
|
||||||
capped at a minute or two — with an automatic `cancel` on timeout, never an open-ended hold.
|
|
||||||
|
|
||||||
Where it lands in this ADR: a settlement created from a held payment reaches
|
|
||||||
`_handle_payment` only on **settle** (`payment.success` is false while held), so for hold-paid
|
|
||||||
transactions the `awaiting_dispense` state in Decision 1 is unnecessary — the Lightning layer
|
|
||||||
is already waiting. Decision 1 stays for plain BOLT11 and for any CLINK path that resolves to
|
|
||||||
an ordinary invoice. The two coexist; `report_dispense` (Decision 2) is what triggers the
|
|
||||||
settle/cancel either way.
|
|
||||||
|
|
||||||
### Vouchers — a fiat-denominated claim instead of a debt
|
|
||||||
|
|
||||||
For the shortfall a hold invoice cannot refund, and for any dispense failure on a plain
|
|
||||||
invoice, the customer could be issued a **voucher**: a claim on the operator for *X fiat value
|
|
||||||
of notes*, redeemable at a machine at a later date **at the original exchange rate**,
|
|
||||||
independent of the BTC price at redemption and requiring no further Lightning payment. The
|
|
||||||
machine prints or displays it; redemption is a cash-out whose "payment" is the voucher.
|
|
||||||
|
|
||||||
Vouchers could also be a general product — buy X fiat value now, collect later — but the
|
|
||||||
first use is fault recovery. Accounting constraints that must hold whichever form ships:
|
|
||||||
|
|
||||||
- A voucher is a **liability** row on the server, linked to its origin `txid` / settlement
|
|
||||||
when it has one (nullable for the general case), with the fiat amount, the locked rate, the
|
|
||||||
issuing machine and an expiry.
|
|
||||||
- The sats the origin settlement received for the undispensed portion must **not** be
|
|
||||||
distributed while the voucher is open: redemption or expiry is what releases them, so the
|
|
||||||
operator never pays out commission on cash that has not left a machine. This is the same
|
|
||||||
rule as Decision 1 applied over a longer window.
|
|
||||||
- Redemption records a `cash_out` with `tx_type = 'voucher_redeem'`, `wire_sats = 0`, and a
|
|
||||||
reference to the voucher; the machine's own ledger treats it as a dispense like any other
|
|
||||||
(bays decrement, `cassette_bills` written, `report_dispense` sent).
|
|
||||||
- A voucher is a bearer instrument unless bound to a pubkey. Both are possible; the choice
|
|
||||||
decides whether a lost voucher is lost money.
|
|
||||||
|
|
||||||
Taken together, hold invoices plus vouchers could remove the *owed cash* state entirely:
|
|
||||||
full fault → cancel, nobody pays; partial fault → settle, voucher for the difference; and
|
|
||||||
`cash_owed` in Decision 6 becomes the fallback for a plain-invoice path, not the norm.
|
|
||||||
|
|
||||||
### CLINK — the protocol this machine is moving toward
|
|
||||||
|
|
||||||
bitSpire is a Nostr machine and the long-term direction is to implement, and eventually
|
|
||||||
favour, Shocknet's **CLINK** (Common Lightning Interface for Nostr Keys) for customer-facing
|
|
||||||
payment flows: kind 21001 Offers (`noffer`), 21002 Debits (`ndebit`), 21003 Manage, 21004
|
|
||||||
Enroll, and the **CLINK Beacon** — a kind-30078 service heartbeat carrying liveness, persona
|
|
||||||
and fee disclosure. Reference tree: `~/dev/refs/repos/shocknet/shocknet/{CLINK,ClinkSDK,
|
|
||||||
clink-demo,Lightning.Pub}`. The `@bitSpire/clink` package is in tree and dormant.
|
|
||||||
|
|
||||||
Two consequences for this ADR. First, BOLT11 stays alongside CLINK rather than being
|
|
||||||
replaced, so Decisions 1–2 remain load-bearing for the invoice path. Second, review finding 4
|
|
||||||
below — the availability beacon has no readers and overlaps the cassettes-state document —
|
|
||||||
should be resolved by aligning the beacon with the **CLINK Beacon** spec rather than by
|
|
||||||
inventing a third shape: a machine advertising itself to Nostr clients should do so in the
|
|
||||||
form those clients will read.
|
|
||||||
|
|
||||||
### Operator notification is Nostr-native
|
|
||||||
|
|
||||||
Decision 6 calls for the operator to be notified when a settlement reaches `cash_owed`.
|
|
||||||
That notification is **a Nostr event to the operator's pubkey**, not email or SMS (the
|
|
||||||
lamassu `notifyOperator` channel). The pieces exist:
|
|
||||||
|
|
||||||
- The operator's pubkey is already on their LNbits account — `get_machine_config` refuses to
|
|
||||||
run without it ("operator has no Nostr pubkey on file").
|
|
||||||
- The sender signs through the bunker (`sign_as_operator` / `resolve_operator_signer` in
|
|
||||||
`nostr_publish.py`), so no key is at rest on the server. A dedicated server identity for
|
|
||||||
alerts is preferable to the operator messaging themself.
|
|
||||||
- The operator does **not** need their private key to read it. nsecbunkerd supports
|
|
||||||
`nip44_encrypt` / `nip44_decrypt` for its users (see its ACL tests), so any NIP-46 client —
|
|
||||||
our webapp, Amber, nsec.app — decrypts the message through the bunker. nsecbunkerd does hold
|
|
||||||
a `decryptNsec` path, but it is the bunker *admin's*, gated on the passphrase; exposing it to
|
|
||||||
operators would reverse the "no nsec outside the bunker" principle this stack is built on.
|
|
||||||
- Operators may still want alerts on a phone identity that is not their LNbits pubkey. An
|
|
||||||
optional per-operator `alerts_pubkey` covers that without changing the default.
|
|
||||||
|
|
||||||
NIP-17 (kind 14 in a kind-1059 gift wrap) is the right wire for metadata privacy; NIP-04 is an
|
|
||||||
acceptable interim if the receiving clients are ours. Pick one in the notification issue.
|
|
||||||
|
|
||||||
### An operator-facing error glossary
|
|
||||||
|
|
||||||
Every `error_code` surfaced by Decisions 2 and 7 should link to a glossary entry: what the
|
|
||||||
code means, what the operator will find when they open the machine, what clears it, and
|
|
||||||
whether it is `terminal` or `recoverable`. The authoritative source for the F56 is the Fujitsu
|
|
||||||
Frontech **F56-BDU Error Code List** (K3KD03234–K3KD03236-0001, edition E02), per the Lamassu
|
|
||||||
port backlog; it is not held locally yet and should be obtained. Seed entries, from the
|
|
||||||
backlog and from this incident:
|
|
||||||
|
|
||||||
| raw | meaning (F56-BDU) | class | observed |
|
|
||||||
| ------- | --------------------------------- | ----------- | ------------------------- |
|
|
||||||
| `78 42` | note stopped at the cassette exit | terminal | sintra, 2026-10-09 |
|
|
||||||
| `82 00` | bill length — long | recoverable | Tejo GTQ, 2026-09-26 |
|
|
||||||
| `83 00` | bill length — short | recoverable | |
|
|
||||||
| `84 00` | bill thickness | recoverable | |
|
|
||||||
| `85 0n` | pick from another safe | recoverable | |
|
|
||||||
| `86 00` | bill spacing | recoverable | |
|
|
||||||
| `B5 ..` | reject box overflow | terminal | |
|
|
||||||
|
|
||||||
The glossary lives in `docs/` and is served by spirekeeper so the dashboard can deep-link
|
|
||||||
from a report. Neither lamassu codebase ever decoded these bytes.
|
|
||||||
|
|
||||||
### The Lamassu port backlog
|
|
||||||
|
|
||||||
A provenance-gated analysis of what bitSpire and spirekeeper can take from lamassu-machine
|
|
||||||
and lamassu-server exists as *Lamassu Port Backlog* (27 Sep 2026, claude.ai artifact
|
|
||||||
`61af38f6`). Items that bear directly on this ADR: **structured fault reporting**
|
|
||||||
(`routes/diagnosticsRoutes.js` → `machine-loader.updateDiagnostics` — a fault record carrying
|
|
||||||
the driver error, the raw device frame and cassette state at failure, which is `report_dispense`
|
|
||||||
by another name); the **interactive hardware test harness** (`lib/hardware-testing/`, an F56
|
|
||||||
dispense-and-count case is the obvious first addition); the **denomination solver**
|
|
||||||
(`lib/coin-change.js`, portable from its upstream `git.sr.ht/~siiky/coin-change`); and the
|
|
||||||
ID003 startup and hang fixes. The backlog also records that bitSpire carried lamassu's narrow
|
|
||||||
GTQ note-length window byte for byte — fixed on `dev` alongside this ADR, mirroring
|
|
||||||
lamassu-machine `b1cc3622`.
|
|
||||||
|
|
||||||
### Bay layout, machine identity, and the operator docs that do not exist yet
|
|
||||||
|
|
||||||
How many bays a machine has is **machine-authoritative**: on first boot the layout comes from
|
|
||||||
`VITE_LAMASSU_CASSETTES` in the machine's `.env` (documented in `docs/device-configuration.md`
|
|
||||||
— an installer document, with a stale `LAMASSU` prefix), after which `state.db` owns it and
|
|
||||||
the operator adjusts counts and denominations by publishing ops. spirekeeper adopts whatever
|
|
||||||
the machine reports: `apply_reported_state` treats the payload as the full bay set and
|
|
||||||
*deletes* positions the machine no longer reports ("the bay count is hardware-determined").
|
|
||||||
There is **no operator-facing document** describing any of this — spirekeeper's README still
|
|
||||||
describes the satmachineadmin era and has no setup section — so an operator with a two-bay
|
|
||||||
Tejo and one with a four-bay Tejo have no page telling them where the difference is set.
|
|
||||||
|
|
||||||
That gap is one face of a larger one: every fleet target in `flake.nix` is a **specific
|
|
||||||
machine** (fiat, upgrade timer, card reader, address are keyed by hostname), so a second Tejo
|
|
||||||
cannot join the fleet by pointing at the same flake. Generalising the install means splitting
|
|
||||||
**model** (the hardware preset: `sintra`, `tejo`, `douro`, `batm3`) from **identity** (the
|
|
||||||
per-install provisioning: seed, cassettes, fiat, network), with the latter arriving through
|
|
||||||
pairing and operator ops rather than through Nix. The operator docs should be written against
|
|
||||||
that split, not the current one.
|
|
||||||
|
|
||||||
## Review findings outside this ADR
|
|
||||||
|
|
||||||
Found while tracing the money path end to end for this document. Not decided here; each is
|
|
||||||
a candidate issue.
|
|
||||||
|
|
||||||
1. **The settlement push did not deliver — `[ATM Service] Invoice paid (poll)!`** The
|
|
||||||
`subscribe_payments` stream missed the payment and the polling fallback caught it. Worth
|
|
||||||
knowing before relying on push latency anywhere; related to #78.
|
|
||||||
2. **`waitingForCashTaken` auto-advances to `complete` after 30 s "assume taken."** A
|
|
||||||
transaction can be recorded complete with notes still in the slot. The HAL already blocks
|
|
||||||
in `waitForBillsRemoved` inside `dispenseCash`, so the state is doing a second, weaker
|
|
||||||
version of the same job.
|
|
||||||
3. **The machine and server keep two ledgers with no reconciliation.** `transactions` and
|
|
||||||
`dca_settlements` join on `txid` today and nothing ever joins them. Decision 2 gives the
|
|
||||||
server everything a reconciliation needs.
|
|
||||||
4. **The availability beacon has no consumers and overlaps the cassettes-state document.**
|
|
||||||
Two machine-authored state documents with overlapping fields is drift waiting to happen.
|
|
||||||
Either fold availability into the cassettes-state doc or give the beacon a reader.
|
|
||||||
5. **`is_active` reads as a service gate and is a roster flag.** Rename to something like
|
|
||||||
`enrolled`, or document at the column.
|
|
||||||
6. **`generateInvoice` carries a comment deferring `bills`/`cassettes` onto the invoice
|
|
||||||
`extra`.** With Decision 2 that would be the wrong place — provisioned is not dispensed —
|
|
||||||
and the comment invites a future contributor to wire it there. Remove it.
|
|
||||||
7. **The HAL's `dispensed` boolean is count-based (`totalRequested === totalDispensed`),
|
|
||||||
not value-based.** Equivalent only while each bay dispenses its own denomination.
|
|
||||||
Decision 3 replaces it.
|
|
||||||
8. **`_handle_payment` processes cash-in and cash-out through one path and only `tx_type`
|
|
||||||
tells them apart.** Decision 1 adds a second direction-specific branch. If a third
|
|
||||||
arrives, split the handler.
|
|
||||||
9. **The partial-dispense guard's message is wrong for internal legs.** "Lightning payments
|
|
||||||
can't be clawed back" is true of `autoforward` and false of the LNbits-internal legs,
|
|
||||||
which are compensatable. Make the guard leg-aware or correct the message.
|
|
||||||
10. **`packages/hal` has no tests.** `pnpm test` there exits 1 with "No test files found."
|
|
||||||
The F56 note-length table that produced a production fault on a GTQ Tejo was carried
|
|
||||||
byte for byte from lamassu with nothing over it; the fix landed the same way. A table test
|
|
||||||
asserting every currency's window is centred on its note length is a few lines, and
|
|
||||||
`dispenseConfirmed` (Decision 3) needs the harness to exist before it can be tested.
|
|
||||||
11. **Review scope.** This document traced the cash-out path: state machine → HAL → ledger →
|
|
||||||
transport → settlement → distribution → dashboard. The cash-in path shares the settlement
|
|
||||||
pipeline and has its own money-at-risk shape in `create_withdraw` (server-side amounts,
|
|
||||||
`max_cash_in_sats`); it has not been reviewed to the same depth and is the obvious next
|
|
||||||
slice. The HAL drivers, access layer and deploy module were not in scope.
|
|
||||||
|
|
@ -68,7 +68,7 @@ noffer1<bech32-encoded-data>
|
||||||
The ATM wants to receive payment from a customer:
|
The ATM wants to receive payment from a customer:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { encodeNoffer } from '@bitSpire/clink'
|
import { encodeNoffer } from '@lamassu/clink'
|
||||||
|
|
||||||
// ATM creates a noffer for receiving payment
|
// ATM creates a noffer for receiving payment
|
||||||
const noffer = encodeNoffer({
|
const noffer = encodeNoffer({
|
||||||
|
|
@ -134,7 +134,7 @@ ndebit1<bech32-encoded-data>
|
||||||
The ATM wants to pay the customer (customer inserted cash, wants Bitcoin):
|
The ATM wants to pay the customer (customer inserted cash, wants Bitcoin):
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { encodeNdebit, formatNdebitUri } from '@bitSpire/clink'
|
import { encodeNdebit, formatNdebitUri } from '@lamassu/clink'
|
||||||
|
|
||||||
// ATM creates an ndebit for the customer to authorize withdrawal
|
// ATM creates an ndebit for the customer to authorize withdrawal
|
||||||
const ndebit = encodeNdebit({
|
const ndebit = encodeNdebit({
|
||||||
|
|
@ -438,14 +438,14 @@ import {
|
||||||
CLINKClient,
|
CLINKClient,
|
||||||
createOfferSuccess,
|
createOfferSuccess,
|
||||||
createOfferError,
|
createOfferError,
|
||||||
} from '@bitSpire/clink'
|
} from '@lamassu/clink'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Reference Implementation
|
### Reference Implementation
|
||||||
|
|
||||||
- [CLINK Protocol Spec](https://github.com/shocknet/clink)
|
- [CLINK Protocol Spec](https://github.com/shocknet/clink)
|
||||||
- [Lightning.Pub](https://github.com/shocknet/Lightning.Pub)
|
- [Lightning.Pub](https://github.com/shocknet/Lightning.Pub)
|
||||||
- [@bitSpire/clink](../packages/clink/) - TypeScript implementation
|
- [@lamassu/clink](../packages/clink/) - TypeScript implementation
|
||||||
|
|
||||||
## Related NIPs
|
## Related NIPs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,24 +48,24 @@ All configuration can be overridden via environment variables. For Vite/Electron
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
| ------------------------------- | ---------------------------- | ------------- |
|
| ------------------------------- | ---------------------------- | ------------- |
|
||||||
| `VITE_BITSPIRE_MACHINE_MODEL` | Machine preset | `sintra` |
|
| `VITE_LAMASSU_MACHINE_MODEL` | Machine preset | `sintra` |
|
||||||
| `VITE_BITSPIRE_FIAT_CODE` | Fiat currency (ISO 4217) | `USD` |
|
| `VITE_LAMASSU_FIAT_CODE` | Fiat currency (ISO 4217) | `USD` |
|
||||||
| `VITE_BITSPIRE_VALIDATOR_DEVICE` | Validator serial device path | (from preset) |
|
| `VITE_LAMASSU_VALIDATOR_DEVICE` | Validator serial device path | (from preset) |
|
||||||
| `VITE_BITSPIRE_DISPENSER_DEVICE` | Dispenser serial device path | (from preset) |
|
| `VITE_LAMASSU_DISPENSER_DEVICE` | Dispenser serial device path | (from preset) |
|
||||||
| `VITE_BITSPIRE_CASSETTES` | JSON array of cassettes | (from preset) |
|
| `VITE_LAMASSU_CASSETTES` | JSON array of cassettes | (from preset) |
|
||||||
|
|
||||||
### Example: Custom Cassette Configuration
|
### Example: Custom Cassette Configuration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Two cassettes: $20 bills (100 count) and $50 bills (50 count)
|
# Two cassettes: $20 bills (100 count) and $50 bills (50 count)
|
||||||
export VITE_BITSPIRE_CASSETTES='[{"denomination":20,"count":100},{"denomination":50,"count":50}]'
|
export VITE_LAMASSU_CASSETTES='[{"denomination":20,"count":100},{"denomination":50,"count":50}]'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Example: Custom Device Paths
|
### Example: Custom Device Paths
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export VITE_BITSPIRE_VALIDATOR_DEVICE="/dev/ttyUSB0"
|
export VITE_LAMASSU_VALIDATOR_DEVICE="/dev/ttyUSB0"
|
||||||
export VITE_BITSPIRE_DISPENSER_DEVICE="/dev/ttyUSB1"
|
export VITE_LAMASSU_DISPENSER_DEVICE="/dev/ttyUSB1"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cassette Configuration
|
## Cassette Configuration
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ Conceptually:
|
||||||
| Layer | Source | Purpose |
|
| Layer | Source | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Kernel + initrd** | `nixpkgs` 24.11 + `upboard.nix` initrd modules | Boot the Sintra hardware (eMMC via `sdhci-acpi`, validator/dispenser at `ttyJ5`/`ttyJ7`) |
|
| **Kernel + initrd** | `nixpkgs` 24.11 + `upboard.nix` initrd modules | Boot the Sintra hardware (eMMC via `sdhci-acpi`, validator/dispenser at `ttyJ5`/`ttyJ7`) |
|
||||||
| **NixOS base** | `nixpkgs` 24.11 | systemd, Xorg, openbox, the `bitspire` user, sshd for provisioning |
|
| **NixOS base** | `nixpkgs` 24.11 | systemd, Xorg, openbox, the `lamassu` user, sshd for provisioning |
|
||||||
| **bitspire.service** | `deploy/nixos/bitspire-atm.nix` | systemd unit that launches the Electron kiosk |
|
| **bitspire.service** | `deploy/nixos/bitspire-atm.nix` | systemd unit that launches the Electron kiosk |
|
||||||
| **The Electron app** | `apps/machine` built into a nix derivation | The actual ATM UI + state machine + Lightning client |
|
| **The Electron app** | `apps/machine` built into a nix derivation | The actual ATM UI + state machine + Lightning client |
|
||||||
| **Hardware-specific config** | `deploy/nixos/hardware/upboard.nix` (or `douro.nix`, `batm3.nix`) | udev rules, kernel modules, panel calibration |
|
| **Hardware-specific config** | `deploy/nixos/hardware/upboard.nix` (or `douro.nix`, `batm3.nix`) | udev rules, kernel modules, panel calibration |
|
||||||
|
|
@ -82,11 +82,11 @@ Before flashing a Sintra you'll want:
|
||||||
|
|
||||||
Once the kiosk is up, useful things to know:
|
Once the kiosk is up, useful things to know:
|
||||||
|
|
||||||
- **Service status:** `ssh bitspire@<atm> 'sudo systemctl status bitspire'`
|
- **Service status:** `ssh lamassu@<atm> 'sudo systemctl status bitspire'`
|
||||||
- **Live log tail:** `ssh bitspire@<atm> 'sudo journalctl -u bitspire -f'`
|
- **Live log tail:** `ssh lamassu@<atm> 'sudo journalctl -u bitspire -f'`
|
||||||
- **Re-provision (e.g., wrong relay URL):** rerun `provision-atm.sh` from the dev box with the new env vars
|
- **Re-provision (e.g., wrong relay URL):** rerun `provision-atm.sh` from the dev box with the new env vars
|
||||||
- **Push a code change without reflashing:** `nixos-rebuild switch --flake .#sintra-installed --target-host bitspire@<atm> --use-remote-sudo`
|
- **Push a code change without reflashing:** `nixos-rebuild switch --flake .#sintra-installed --target-host lamassu@<atm> --use-remote-sudo`
|
||||||
- **Inspect transaction history:** `ssh bitspire@<atm> 'sudo bash /etc/nixos/atm-transactions.sh'` (queries `/var/lib/bitspire/state.db`)
|
- **Inspect transaction history:** `ssh lamassu@<atm> 'sudo bash /etc/nixos/atm-transactions.sh'` (queries `/var/lib/bitspire/state.db`)
|
||||||
|
|
||||||
## Related documentation
|
## Related documentation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,6 @@
|
||||||
> debit-approval listener and all kind-21002 handling were removed in
|
> debit-approval listener and all kind-21002 handling were removed in
|
||||||
> commit `3c14eea` (the 3b.4 cleanup of LP-paired infrastructure).
|
> commit `3c14eea` (the 3b.4 cleanup of LP-paired infrastructure).
|
||||||
>
|
>
|
||||||
> The Lightning.Pub regtest tooling this doc leans on — the `docker/`
|
|
||||||
> compose stack, the `fund-atm` / `lncli` devenv commands, and the
|
|
||||||
> `packages/nostr-client/dev/` agent scripts — was removed on 2026-10-09.
|
|
||||||
> Commands quoted below no longer exist in this repo.
|
|
||||||
>
|
|
||||||
> This doc is retained because it explains *why* the previous flow
|
> This doc is retained because it explains *why* the previous flow
|
||||||
> existed and what the ndebit/CLINK protocol surface looks like — useful
|
> existed and what the ndebit/CLINK protocol surface looks like — useful
|
||||||
> if the project ever wants to reintroduce nostr-native cash-in that
|
> if the project ever wants to reintroduce nostr-native cash-in that
|
||||||
|
|
@ -144,7 +139,7 @@ export const NDEBIT_REGEX = new RegExp(
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// In lib/types/parse.ts
|
// In lib/types/parse.ts
|
||||||
import type { DebitPointer } from '@bitSpire/clink'
|
import type { DebitPointer } from '@lamassu/clink'
|
||||||
import type { Satoshi } from './units'
|
import type { Satoshi } from './units'
|
||||||
|
|
||||||
export enum InputClassification {
|
export enum InputClassification {
|
||||||
|
|
@ -164,7 +159,7 @@ export interface ParsedNdebitInput {
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// In lib/parse.ts
|
// In lib/parse.ts
|
||||||
import { decodeNdebit } from '@bitSpire/clink'
|
import { decodeNdebit } from '@lamassu/clink'
|
||||||
|
|
||||||
// Add to VALIDATORS array:
|
// Add to VALIDATORS array:
|
||||||
{
|
{
|
||||||
|
|
@ -214,7 +209,7 @@ case InputClassification.NDEBIT: {
|
||||||
```typescript
|
```typescript
|
||||||
// In State/scoped/backups/sources/history/claimNdebitThunk.ts
|
// In State/scoped/backups/sources/history/claimNdebitThunk.ts
|
||||||
import { getNostrClient } from '@/Api/nostr'
|
import { getNostrClient } from '@/Api/nostr'
|
||||||
// Note: SendNdebitRequest is wallet-side code, not part of @bitSpire/clink
|
// Note: SendNdebitRequest is wallet-side code, not part of @lamassu/clink
|
||||||
// This example shows the wallet's implementation pattern
|
// This example shows the wallet's implementation pattern
|
||||||
import { finalizeEvent } from 'nostr-tools'
|
import { finalizeEvent } from 'nostr-tools'
|
||||||
import { SimplePool } from 'nostr-tools'
|
import { SimplePool } from 'nostr-tools'
|
||||||
|
|
@ -854,7 +849,7 @@ See "Single-Use Protection" section above for implementation details.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"@bitSpire/clink": "workspace:*",
|
"@lamassu/clink": "workspace:*",
|
||||||
"nostr-tools": "^2.x.x",
|
"nostr-tools": "^2.x.x",
|
||||||
"@noble/hashes": "^1.x.x",
|
"@noble/hashes": "^1.x.x",
|
||||||
"qrcode": "^1.x.x"
|
"qrcode": "^1.x.x"
|
||||||
|
|
|
||||||
19
flake.nix
19
flake.nix
|
|
@ -303,8 +303,9 @@
|
||||||
# upgrade source to ?ref=dev so any ATM flashed from `dev` stays on
|
# upgrade source to ?ref=dev so any ATM flashed from `dev` stays on
|
||||||
# `dev`. Without the explicit ?ref=dev, nix would resolve the repo's
|
# `dev`. Without the explicit ?ref=dev, nix would resolve the repo's
|
||||||
# default branch and could silently change a dev-deployed Sintra at
|
# default branch and could silently change a dev-deployed Sintra at
|
||||||
# 04:00. (Every live machine now pulls from this repo; the
|
# 04:00. The legacy `aiolabs/lamassu-next` repo still feeds the
|
||||||
# pre-rename `aiolabs/lamassu-next` repo no longer feeds anything.)
|
# not-yet-converted production ATMs (batm3, douro) from its own
|
||||||
|
# branches; it is retired once those machines migrate to bitspire.
|
||||||
# To update manually: sudo nixos-rebuild switch --flake git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=dev#<model>-installed
|
# To update manually: sudo nixos-rebuild switch --flake git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=dev#<model>-installed
|
||||||
system.autoUpgrade = {
|
system.autoUpgrade = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
|
@ -334,8 +335,8 @@
|
||||||
mkdir -p /var/lib/bitspire
|
mkdir -p /var/lib/bitspire
|
||||||
if [ ! -f /var/lib/bitspire/.env ]; then
|
if [ ! -f /var/lib/bitspire/.env ]; then
|
||||||
cp ${pkgs.writeText "bitspire-env-default" (''
|
cp ${pkgs.writeText "bitspire-env-default" (''
|
||||||
VITE_BITSPIRE_MACHINE_MODEL=${machineModel}
|
VITE_LAMASSU_MACHINE_MODEL=${machineModel}
|
||||||
VITE_BITSPIRE_FIAT_CODE=${fiatCode}
|
VITE_LAMASSU_FIAT_CODE=${fiatCode}
|
||||||
VITE_SPIRE_SEED=
|
VITE_SPIRE_SEED=
|
||||||
ELECTRON_FORCE_PROD=1
|
ELECTRON_FORCE_PROD=1
|
||||||
DISPLAY=:0
|
DISPLAY=:0
|
||||||
|
|
@ -538,12 +539,18 @@
|
||||||
sintra = mkLiveConfig "sintra";
|
sintra = mkLiveConfig "sintra";
|
||||||
batm3 = mkLiveConfig "batm3";
|
batm3 = mkLiveConfig "batm3";
|
||||||
|
|
||||||
# Branded aliases for the live configs above. The lamassu-live-* names
|
# Backwards-compat aliases. Renamed lamassu-live-* → bitSpire-live-*
|
||||||
# were dropped 2026-10-09; nothing referenced them.
|
# for the brand transition; both styles available until callers
|
||||||
|
# (CI / scripts / docs) catch up. Drop the lamassu-* names once
|
||||||
|
# nothing references them.
|
||||||
bitSpire-live-douro = mkLiveConfig "douro";
|
bitSpire-live-douro = mkLiveConfig "douro";
|
||||||
bitSpire-live-tejo = mkLiveConfig "tejo";
|
bitSpire-live-tejo = mkLiveConfig "tejo";
|
||||||
bitSpire-live-sintra = mkLiveConfig "sintra";
|
bitSpire-live-sintra = mkLiveConfig "sintra";
|
||||||
bitSpire-live = mkLiveConfig "douro";
|
bitSpire-live = mkLiveConfig "douro";
|
||||||
|
lamassu-live-douro = mkLiveConfig "douro";
|
||||||
|
lamassu-live-tejo = mkLiveConfig "tejo";
|
||||||
|
lamassu-live-sintra = mkLiveConfig "sintra";
|
||||||
|
lamassu-live = mkLiveConfig "douro";
|
||||||
|
|
||||||
# Installed-to-disk configs (proper GPT + systemd-boot, supports nixos-rebuild)
|
# Installed-to-disk configs (proper GPT + systemd-boot, supports nixos-rebuild)
|
||||||
douro-installed = mkInstalledConfig "douro" ./deploy/nixos/hardware/douro.nix;
|
douro-installed = mkInstalledConfig "douro" ./deploy/nixos/hardware/douro.nix;
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,8 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
||||||
env = {
|
env = {
|
||||||
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
||||||
# Vite compile-time variables (baked into the frontend bundle)
|
# Vite compile-time variables (baked into the frontend bundle)
|
||||||
VITE_BITSPIRE_MACHINE_MODEL = model;
|
VITE_LAMASSU_MACHINE_MODEL = model;
|
||||||
VITE_BITSPIRE_FIAT_CODE = fiatCode;
|
VITE_LAMASSU_FIAT_CODE = fiatCode;
|
||||||
};
|
};
|
||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
[package]
|
[package]
|
||||||
name = "bitspire-hal"
|
name = "lamassu-hal"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Hardware Abstraction Layer for the bitSpire ATM (orphaned Rust HAL; not built)"
|
description = "Hardware Abstraction Layer for Lamassu ATM"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.atitlan.io/aiolabs/bitspire"
|
repository = "https://github.com/lamassu/lamassu-next"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
crate-type = ["cdylib"]
|
crate-type = ["cdylib"]
|
||||||
|
|
|
||||||
|
|
@ -77,20 +77,13 @@ export const bills: Record<string, BillConfig> = {
|
||||||
},
|
},
|
||||||
GTQ: {
|
GTQ: {
|
||||||
thickness: 0x0c,
|
thickness: 0x0c,
|
||||||
// Every quetzal note is 156 x 67 mm — physically the same as USD and
|
|
||||||
// HNL, which accept 146–166 (±10). This table inherited lamassu's ±5
|
|
||||||
// window (151–161) with Q5/Q20 centred on 158, and a Tejo in GTQ rejected
|
|
||||||
// 5/5 notes on every Q100 dispense with F56 error 82 00 (bill length,
|
|
||||||
// long): the BDU read >161 mm on every pick, and the reject tray held
|
|
||||||
// single notes, not pairs — so it was the window, not a double-pick.
|
|
||||||
// Widened to match USD/HNL, mirroring lamassu-machine b1cc3622.
|
|
||||||
lengths: {
|
lengths: {
|
||||||
5: [0xa6, 0x92],
|
5: [0xa3, 0x99],
|
||||||
10: [0xa6, 0x92],
|
10: [0xa1, 0x97],
|
||||||
20: [0xa6, 0x92],
|
20: [0xa3, 0x99],
|
||||||
50: [0xa6, 0x92],
|
50: [0xa1, 0x97],
|
||||||
100: [0xa6, 0x92],
|
100: [0xa1, 0x97],
|
||||||
200: [0xa6, 0x92],
|
200: [0xa1, 0x97],
|
||||||
},
|
},
|
||||||
polymer: false,
|
polymer: false,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//! bitSpire Hardware Abstraction Layer (orphaned Rust HAL — not built; the TypeScript HAL is the real one)
|
//! Lamassu Hardware Abstraction Layer
|
||||||
//!
|
//!
|
||||||
//! This crate provides Rust implementations of hardware drivers for bill validators,
|
//! This crate provides Rust implementations of hardware drivers for bill validators,
|
||||||
//! dispensers, printers, and other ATM peripherals. These are exposed to Node.js
|
//! dispensers, printers, and other ATM peripherals. These are exposed to Node.js
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
//! # Usage
|
//! # Usage
|
||||||
//!
|
//!
|
||||||
//! ```typescript
|
//! ```typescript
|
||||||
//! import { BillValidatorWrapper, ValidatorDriver } from '@bitSpire/hal'
|
//! import { BillValidatorWrapper, ValidatorDriver } from '@lamassu/hal'
|
||||||
//!
|
//!
|
||||||
//! const validator = new BillValidatorWrapper(ValidatorDriver.Id003, '/dev/ttyUSB0', 'USD')
|
//! const validator = new BillValidatorWrapper(ValidatorDriver.Id003, '/dev/ttyUSB0', 'USD')
|
||||||
//! await validator.connect()
|
//! await validator.connect()
|
||||||
|
|
|
||||||
270
packages/nostr-client/dev/atm-debit-agent.mjs
Normal file
270
packages/nostr-client/dev/atm-debit-agent.mjs
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
/**
|
||||||
|
* ATM Debit Authorization Agent
|
||||||
|
*
|
||||||
|
* This script demonstrates how an ATM can act as the authorization authority
|
||||||
|
* for CLINK debit requests, similar to an "admin macaroon" for Lightning.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Generate keypair for ATM (or load from secure storage)
|
||||||
|
* 2. Link keypair to Lightning.Pub user account
|
||||||
|
* 3. Subscribe to live debit requests
|
||||||
|
* 4. Auto-approve requests (within configured limits)
|
||||||
|
*
|
||||||
|
* Prerequisites:
|
||||||
|
* - Get a linking token from Lightning.Pub HTTP API
|
||||||
|
* - Run: curl -X POST "http://localhost:1776/api/app/user/npub/token/reset" \
|
||||||
|
* -H "Authorization: Bearer $APP_TOKEN" \
|
||||||
|
* -H "Content-Type: application/json" \
|
||||||
|
* -d '{"user_identifier": "YOUR_USER_IDENTIFIER"}'
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Relay } from 'nostr-tools/relay'
|
||||||
|
import { finalizeEvent, getPublicKey, generateSecretKey } from 'nostr-tools'
|
||||||
|
import * as nip44v1 from './nip44v1.mjs'
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const LINKING_TOKEN = process.argv[2]
|
||||||
|
const LIGHTNING_PUB_PUBKEY = '6c59284e3da31b776cb1c06324c25f4a0b0308177af9f8aec5ebef07b44c3fdf'
|
||||||
|
const RELAY_URL = process.env.RELAY_URL || 'ws://localhost:7777'
|
||||||
|
|
||||||
|
// Generate ATM keypair (in production, this would be stored securely)
|
||||||
|
const ATM_PRIVATE_KEY = generateSecretKey()
|
||||||
|
const ATM_PUBLIC_KEY = getPublicKey(ATM_PRIVATE_KEY)
|
||||||
|
const ATM_PRIVATE_KEY_HEX = Buffer.from(ATM_PRIVATE_KEY).toString('hex')
|
||||||
|
|
||||||
|
if (!LINKING_TOKEN) {
|
||||||
|
console.log('ATM Debit Authorization Agent')
|
||||||
|
console.log('==============================')
|
||||||
|
console.log('')
|
||||||
|
console.log('Usage: node atm-debit-agent.mjs <linking-token>')
|
||||||
|
console.log('')
|
||||||
|
console.log('Get a linking token:')
|
||||||
|
console.log(' curl -X POST "http://localhost:1776/api/app/user/npub/token/reset" \\')
|
||||||
|
console.log(' -H "Authorization: Bearer $APP_TOKEN" \\')
|
||||||
|
console.log(' -H "Content-Type: application/json" \\')
|
||||||
|
console.log(' -d \'{"user_identifier": "YOUR_USER_IDENTIFIER"}\'')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== ATM Debit Authorization Agent ===')
|
||||||
|
console.log('')
|
||||||
|
console.log('ATM Pubkey:', ATM_PUBLIC_KEY)
|
||||||
|
console.log('Lightning.Pub Pubkey:', LIGHTNING_PUB_PUBKEY)
|
||||||
|
console.log('Linking Token:', LINKING_TOKEN.substring(0, 16) + '...')
|
||||||
|
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 (used by Kind 21000 RPC)
|
||||||
|
const conversationKey = nip44v1.getConversationKey(ATM_PRIVATE_KEY_HEX, LIGHTNING_PUB_PUBKEY)
|
||||||
|
|
||||||
|
// Step 1: Link NPub through token
|
||||||
|
console.log('Step 1: Linking ATM keypair to user account...')
|
||||||
|
|
||||||
|
const linkRequest = {
|
||||||
|
rpcName: 'LinkNPubThroughToken',
|
||||||
|
authIdentifier: ATM_PUBLIC_KEY,
|
||||||
|
body: {
|
||||||
|
token: LINKING_TOKEN,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkEvent = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 21000,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [['p', LIGHTNING_PUB_PUBKEY]],
|
||||||
|
content: nip44v1.encrypt(JSON.stringify(linkRequest), conversationKey),
|
||||||
|
},
|
||||||
|
ATM_PRIVATE_KEY
|
||||||
|
)
|
||||||
|
|
||||||
|
// Subscribe for response
|
||||||
|
let linkingComplete = false
|
||||||
|
const linkSub = relay.subscribe(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kinds: [21000],
|
||||||
|
authors: [LIGHTNING_PUB_PUBKEY],
|
||||||
|
'#p': [ATM_PUBLIC_KEY],
|
||||||
|
since: Math.floor(Date.now() / 1000) - 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
onevent(evt) {
|
||||||
|
try {
|
||||||
|
const decrypted = nip44v1.decrypt(evt.content, conversationKey)
|
||||||
|
const response = JSON.parse(decrypted)
|
||||||
|
console.log('Link response:', JSON.stringify(response))
|
||||||
|
if (response.status === 'OK') {
|
||||||
|
linkingComplete = true
|
||||||
|
console.log('Keypair linked successfully!')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log('Failed to decrypt link response:', err.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await relay.publish(linkEvent)
|
||||||
|
console.log(
|
||||||
|
'Link request sent (event id:',
|
||||||
|
linkEvent.id.substring(0, 16) + '...), waiting for confirmation...'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wait for linking to complete
|
||||||
|
for (let i = 0; i < 10 && !linkingComplete; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 1000))
|
||||||
|
if (i % 3 === 2) console.log('Still waiting for link confirmation...')
|
||||||
|
}
|
||||||
|
linkSub.close()
|
||||||
|
|
||||||
|
if (!linkingComplete) {
|
||||||
|
console.log('Warning: Did not receive linking confirmation, continuing anyway...')
|
||||||
|
}
|
||||||
|
console.log('')
|
||||||
|
|
||||||
|
// Step 2: Subscribe to live debit requests
|
||||||
|
console.log('Step 2: 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
|
||||||
|
)
|
||||||
|
|
||||||
|
// Subscribe for debit requests and responses
|
||||||
|
console.log('Listening for debit requests...')
|
||||||
|
console.log('(Scan the ndebit QR code with ShockWallet to test)')
|
||||||
|
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 debit request (has request_id and debit fields)
|
||||||
|
if (message.requestId === 'GetLiveDebitRequests' && message.debit) {
|
||||||
|
console.log('')
|
||||||
|
console.log('========================================')
|
||||||
|
console.log('Received debit request!')
|
||||||
|
console.log(' Request ID:', message.request_id)
|
||||||
|
console.log(' From npub:', message.npub)
|
||||||
|
console.log(' Debit type:', message.debit.type)
|
||||||
|
|
||||||
|
if (message.debit.type === 'invoice' && 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...')
|
||||||
|
|
||||||
|
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('Approval sent! (event id:', approveEvent.id.substring(0, 16) + '...)')
|
||||||
|
} else if (message.debit.type === 'budget') {
|
||||||
|
console.log(' Budget request - ignoring for now')
|
||||||
|
} else if (message.debit.type === 'fullAccess') {
|
||||||
|
console.log(' Full access request - ignoring for now')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('========================================')
|
||||||
|
console.log('')
|
||||||
|
} else if (message.rpcName) {
|
||||||
|
// This is a response to our RPC request
|
||||||
|
console.log('RPC response:', message.rpcName, ':', message.status || 'received')
|
||||||
|
} else {
|
||||||
|
// Log other messages for debugging
|
||||||
|
console.log('Message received:', JSON.stringify(message).substring(0, 100))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore decryption failures (may be messages for other clients)
|
||||||
|
if (!err.message.includes('Unsupported')) {
|
||||||
|
console.log('Error processing message:', err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await relay.publish(subEvent)
|
||||||
|
console.log('Subscription request sent (event id:', subEvent.id.substring(0, 16) + '...)')
|
||||||
|
console.log('')
|
||||||
|
|
||||||
|
// Keep running
|
||||||
|
console.log('ATM Debit Agent running. Press Ctrl+C to exit.')
|
||||||
|
console.log('')
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('\nShutting down...')
|
||||||
|
debitSub.close()
|
||||||
|
relay.close()
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Keep alive with heartbeat
|
||||||
|
let heartbeatCount = 0
|
||||||
|
while (true) {
|
||||||
|
await new Promise((r) => setTimeout(r, 10000))
|
||||||
|
heartbeatCount++
|
||||||
|
if (heartbeatCount % 6 === 0) {
|
||||||
|
// Every minute
|
||||||
|
console.log('Still listening... (' + heartbeatCount * 10 + 's)')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Error:', err.message)
|
||||||
|
console.error(err.stack)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
133
packages/nostr-client/dev/fund-dev.mjs
Normal file
133
packages/nostr-client/dev/fund-dev.mjs
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Fund the ATM's Lightning.Pub account via Nostr RPC
|
||||||
|
*
|
||||||
|
* This creates an invoice for the ATM user (authenticated via Nostr),
|
||||||
|
* so the funds go directly to the user that will be queried for balance.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* VITE_ATM_PRIVATE_KEY=xxx VITE_LIGHTNING_PUB_PUBKEY=yyy node fund-dev.mjs [amount]
|
||||||
|
*/
|
||||||
|
import { NostrClient, loadIdentityFromHex, encryptContent, decryptJSON } from '../dist/index.js'
|
||||||
|
import { finalizeEvent } from 'nostr-tools'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
|
const ATM_PRIVATE_KEY = process.env.VITE_ATM_PRIVATE_KEY
|
||||||
|
const LIGHTNING_PUB_PUBKEY = process.env.VITE_LIGHTNING_PUB_PUBKEY
|
||||||
|
const RELAY_URL = process.env.VITE_RELAY_URL || 'ws://localhost:7777'
|
||||||
|
const APP_ID = process.env.VITE_APP_ID || ''
|
||||||
|
const FUND_AMOUNT = parseInt(process.argv[2] || process.env.FUND_AMOUNT || '100000', 10)
|
||||||
|
|
||||||
|
if (!ATM_PRIVATE_KEY) {
|
||||||
|
console.error('Error: VITE_ATM_PRIVATE_KEY environment variable required')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!LIGHTNING_PUB_PUBKEY) {
|
||||||
|
console.error('Error: VITE_LIGHTNING_PUB_PUBKEY environment variable required')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!APP_ID) {
|
||||||
|
console.error('Error: VITE_APP_ID environment variable required')
|
||||||
|
console.error('This ensures funds go to the same user as LNURL-withdraw uses')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const identity = loadIdentityFromHex(ATM_PRIVATE_KEY)
|
||||||
|
console.error('[fund-dev] ATM pubkey:', identity.publicKey)
|
||||||
|
console.error('[fund-dev] Lightning.Pub pubkey:', LIGHTNING_PUB_PUBKEY)
|
||||||
|
console.error('[fund-dev] Creating invoice for', FUND_AMOUNT, 'sats')
|
||||||
|
|
||||||
|
const client = new NostrClient({
|
||||||
|
relays: [{ url: RELAY_URL }],
|
||||||
|
identity,
|
||||||
|
})
|
||||||
|
|
||||||
|
await client.connect()
|
||||||
|
console.error('[fund-dev] Connected to relay:', RELAY_URL)
|
||||||
|
|
||||||
|
const requestId = randomUUID()
|
||||||
|
|
||||||
|
console.error('[fund-dev] App ID:', APP_ID)
|
||||||
|
|
||||||
|
// Correct RPC structure per Lightning.Pub documentation
|
||||||
|
// appId ensures the Nostr user is created under the same app as HTTP API users
|
||||||
|
const rpcRequest = {
|
||||||
|
rpcName: 'NewInvoice',
|
||||||
|
params: {},
|
||||||
|
query: {},
|
||||||
|
body: {
|
||||||
|
amountSats: FUND_AMOUNT,
|
||||||
|
memo: `ATM funding (${FUND_AMOUNT} sats)`,
|
||||||
|
},
|
||||||
|
authIdentifier: identity.publicKey,
|
||||||
|
requestId,
|
||||||
|
appId: APP_ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedContent = encryptContent(identity, LIGHTNING_PUB_PUBKEY, rpcRequest)
|
||||||
|
|
||||||
|
const event = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 21000,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [['p', LIGHTNING_PUB_PUBKEY]],
|
||||||
|
content: encryptedContent,
|
||||||
|
},
|
||||||
|
identity.privateKey
|
||||||
|
)
|
||||||
|
|
||||||
|
// Subscribe to responses before publishing
|
||||||
|
let invoice = null
|
||||||
|
const subId = client.subscribe(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kinds: [21000],
|
||||||
|
authors: [LIGHTNING_PUB_PUBKEY],
|
||||||
|
'#p': [identity.publicKey],
|
||||||
|
since: Math.floor(Date.now() / 1000) - 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
onEvent: (evt) => {
|
||||||
|
try {
|
||||||
|
const response = decryptJSON(identity, LIGHTNING_PUB_PUBKEY, evt.content)
|
||||||
|
if (response.requestId === requestId && response.invoice) {
|
||||||
|
invoice = response.invoice
|
||||||
|
console.error('[fund-dev] Got invoice!')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore decrypt errors for other messages
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await client.publish(event)
|
||||||
|
console.error('[fund-dev] Request published, waiting for invoice...')
|
||||||
|
|
||||||
|
// Wait up to 15 seconds for response
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
if (invoice) break
|
||||||
|
}
|
||||||
|
|
||||||
|
client.unsubscribe(subId)
|
||||||
|
client.disconnect()
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
console.error('[fund-dev] Error: No invoice received within timeout')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output just the invoice to stdout (for piping to payment command)
|
||||||
|
console.log(invoice)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('[fund-dev] Error:', err.message)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
84
packages/nostr-client/dev/generate-ndebit.mjs
Normal file
84
packages/nostr-client/dev/generate-ndebit.mjs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
/**
|
||||||
|
* Generate an ndebit string for ShockWallet to scan
|
||||||
|
*
|
||||||
|
* Usage: node generate-ndebit.mjs [pointer]
|
||||||
|
*
|
||||||
|
* The ndebit allows ShockWallet to send an invoice that Lightning.Pub will pay.
|
||||||
|
* This is the LNURL-withdraw equivalent for CLINK.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { bech32 } from '@scure/base'
|
||||||
|
|
||||||
|
const LIGHTNING_PUB_PUBKEY =
|
||||||
|
process.env.LIGHTNING_PUB_PUBKEY ||
|
||||||
|
'4a72e400a254bf74a70cc711ab97e461b8d4fd9738b1ac3b5194cdeb3192ab91'
|
||||||
|
const RELAY_URL = process.env.NOSTR_RELAY_URL || 'wss://strfry.shock.network'
|
||||||
|
const POINTER = process.argv[2] || 'atm-cashin-' + Date.now()
|
||||||
|
|
||||||
|
function hexToBytes(hex) {
|
||||||
|
const bytes = new Uint8Array(hex.length / 2)
|
||||||
|
for (let i = 0; i < hex.length; i += 2) {
|
||||||
|
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16)
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeTLV(tlv) {
|
||||||
|
const entries = []
|
||||||
|
Object.entries(tlv)
|
||||||
|
.reverse()
|
||||||
|
.forEach(([t, vs]) => {
|
||||||
|
vs.forEach((v) => {
|
||||||
|
const entry = new Uint8Array(v.length + 2)
|
||||||
|
entry.set([parseInt(t)], 0)
|
||||||
|
entry.set([v.length], 1)
|
||||||
|
entry.set(v, 2)
|
||||||
|
entries.push(entry)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Concatenate all entries
|
||||||
|
const totalLength = entries.reduce((sum, e) => sum + e.length, 0)
|
||||||
|
const result = new Uint8Array(totalLength)
|
||||||
|
let offset = 0
|
||||||
|
for (const entry of entries) {
|
||||||
|
result.set(entry, offset)
|
||||||
|
offset += entry.length
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function ndebitEncode(debit) {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
|
||||||
|
const tlv = {
|
||||||
|
0: [hexToBytes(debit.pubkey)],
|
||||||
|
1: [encoder.encode(debit.relay)],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debit.pointer) {
|
||||||
|
tlv[2] = [encoder.encode(debit.pointer)]
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = encodeTLV(tlv)
|
||||||
|
const words = bech32.toWords(data)
|
||||||
|
return bech32.encode('ndebit', words, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate ndebit
|
||||||
|
const ndebit = ndebitEncode({
|
||||||
|
pubkey: LIGHTNING_PUB_PUBKEY,
|
||||||
|
relay: RELAY_URL,
|
||||||
|
pointer: POINTER,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('=== NDEBIT for ShockWallet ===')
|
||||||
|
console.log('')
|
||||||
|
console.log('Pubkey:', LIGHTNING_PUB_PUBKEY)
|
||||||
|
console.log('Relay:', RELAY_URL)
|
||||||
|
console.log('Pointer:', POINTER)
|
||||||
|
console.log('')
|
||||||
|
console.log('ndebit string:')
|
||||||
|
console.log(ndebit)
|
||||||
|
console.log('')
|
||||||
|
console.log('ShockWallet should scan this QR code to receive sats from the ATM.')
|
||||||
833
packages/nostr-client/dev/mock-machine.mjs
Normal file
833
packages/nostr-client/dev/mock-machine.mjs
Normal file
|
|
@ -0,0 +1,833 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Mock ATM Machine - Simulates the ATM cash-in flow with CLINK
|
||||||
|
*
|
||||||
|
* Usage: node mock-machine.mjs
|
||||||
|
*
|
||||||
|
* This creates a web server that:
|
||||||
|
* 1. Displays the ndebit QR code for customers to scan
|
||||||
|
* 2. Runs the ATM debit agent to authorize payments
|
||||||
|
* 3. Shows real-time status updates
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from 'http'
|
||||||
|
import { WebSocketServer } from 'ws'
|
||||||
|
import { Relay } from 'nostr-tools/relay'
|
||||||
|
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||||
|
import { getConversationKey, encrypt, decrypt } from './nip44v1.mjs'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
import { decodeBech32, ndebitEncode } from '@shocknet/clink-sdk'
|
||||||
|
|
||||||
|
const PORT = 3456
|
||||||
|
const RELAY_URL = 'ws://localhost:7777'
|
||||||
|
// Relay URL for browser access (different from Docker internal strfry:7777)
|
||||||
|
const BROWSER_RELAY_URL = 'ws://localhost:7777'
|
||||||
|
const LIGHTNING_PUB_HTTP = 'http://localhost:1776'
|
||||||
|
const ADMIN_TOKEN = 'lamassu-dev-admin-token'
|
||||||
|
|
||||||
|
// Lightning.Pub pubkey - fetched dynamically from the ATM user's ndebit
|
||||||
|
let LIGHTNING_PUB_PUBKEY = null
|
||||||
|
|
||||||
|
// ATM keypair (persistent for this session)
|
||||||
|
const ATM_PRIVATE_KEY = generateSecretKey()
|
||||||
|
const ATM_PUBLIC_KEY = getPublicKey(ATM_PRIVATE_KEY)
|
||||||
|
|
||||||
|
// Fake exchange rate: sats per USD (approximately $100k/BTC)
|
||||||
|
const SATS_PER_USD = 1000
|
||||||
|
|
||||||
|
// ATM states
|
||||||
|
const ATM_STATE = {
|
||||||
|
IDLE: 'idle',
|
||||||
|
CASH_INSERTED: 'cash_inserted',
|
||||||
|
WAITING_FOR_SCAN: 'waiting_for_scan',
|
||||||
|
PROCESSING: 'processing',
|
||||||
|
COMPLETE: 'complete',
|
||||||
|
}
|
||||||
|
|
||||||
|
// State
|
||||||
|
let relay = null
|
||||||
|
let appToken = null
|
||||||
|
let ndebit = null
|
||||||
|
let ndebitQR = null
|
||||||
|
let atmBalance = 0
|
||||||
|
let wsClients = []
|
||||||
|
let isLinked = false
|
||||||
|
let withdrawAmount = 0 // Amount in sats (calculated from cash)
|
||||||
|
let cashInserted = 0 // Amount in USD
|
||||||
|
let atmState = ATM_STATE.IDLE
|
||||||
|
|
||||||
|
// Rewrite ndebit relay for browser access (strfry:7777 -> localhost:7777)
|
||||||
|
function rewriteNdebitRelay(ndebitString) {
|
||||||
|
try {
|
||||||
|
const decoded = decodeBech32(ndebitString)
|
||||||
|
if (decoded.type !== 'ndebit') return ndebitString
|
||||||
|
|
||||||
|
// Replace Docker internal relay with browser-accessible relay
|
||||||
|
const data = {
|
||||||
|
pubkey: decoded.data.pubkey,
|
||||||
|
relay: BROWSER_RELAY_URL,
|
||||||
|
pointer: decoded.data.pointer,
|
||||||
|
}
|
||||||
|
return ndebitEncode(data)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to rewrite ndebit relay:', e)
|
||||||
|
return ndebitString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format ndebit with clink: prefix and amount parameter
|
||||||
|
// Using clink: instead of lightning: because CLINK is protocol-agnostic
|
||||||
|
// (could work with Cashu/Fedimint, not just Lightning)
|
||||||
|
function formatNdebitUri(ndebitString, amount) {
|
||||||
|
const rewritten = rewriteNdebitRelay(ndebitString)
|
||||||
|
return `clink:${rewritten}?amount=${amount}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast(type, data) {
|
||||||
|
const msg = JSON.stringify({ type, ...data })
|
||||||
|
wsClients.forEach((ws) => {
|
||||||
|
if (ws.readyState === 1) ws.send(msg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(message) {
|
||||||
|
const timestamp = new Date().toLocaleTimeString()
|
||||||
|
console.log(`[${timestamp}] ${message}`)
|
||||||
|
broadcast('log', { message: `[${timestamp}] ${message}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAppToken() {
|
||||||
|
const res = await fetch(`${LIGHTNING_PUB_HTTP}/api/admin/app/auth`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${ADMIN_TOKEN}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: 'wallet' }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
return data.auth_token
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAtmUser() {
|
||||||
|
const res = await fetch(`${LIGHTNING_PUB_HTTP}/api/app/user/get`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${appToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ user_identifier: 'atm' }),
|
||||||
|
})
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLinkingToken() {
|
||||||
|
const res = await fetch(`${LIGHTNING_PUB_HTTP}/api/app/user/npub/token/reset`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${appToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ user_identifier: 'atm' }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
return data.token
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendRPC(rpcName, body) {
|
||||||
|
const requestId = randomUUID()
|
||||||
|
const request = {
|
||||||
|
rpcName,
|
||||||
|
authIdentifier: ATM_PUBLIC_KEY,
|
||||||
|
body,
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversationKey = getConversationKey(ATM_PRIVATE_KEY, LIGHTNING_PUB_PUBKEY)
|
||||||
|
const encryptedContent = encrypt(JSON.stringify(request), conversationKey)
|
||||||
|
|
||||||
|
const event = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 21000,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [['p', LIGHTNING_PUB_PUBKEY]],
|
||||||
|
content: encryptedContent,
|
||||||
|
},
|
||||||
|
ATM_PRIVATE_KEY
|
||||||
|
)
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => reject(new Error('RPC timeout')), 30000)
|
||||||
|
|
||||||
|
const sub = relay.subscribe(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kinds: [21000],
|
||||||
|
authors: [LIGHTNING_PUB_PUBKEY],
|
||||||
|
since: Math.floor(Date.now() / 1000) - 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
onevent(evt) {
|
||||||
|
const pTags = evt.tags.filter((t) => t[0] === 'p')
|
||||||
|
if (!pTags.some((t) => t[1] === ATM_PUBLIC_KEY)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(decrypt(evt.content, conversationKey))
|
||||||
|
clearTimeout(timeout)
|
||||||
|
sub.close()
|
||||||
|
resolve(response)
|
||||||
|
} catch (e) {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
relay.publish(event)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkKeypair(token) {
|
||||||
|
log('Linking ATM keypair to user account...')
|
||||||
|
const response = await sendRPC('LinkNPubThroughToken', { token })
|
||||||
|
if (response.status === 'OK') {
|
||||||
|
log('[OK] Keypair linked successfully!')
|
||||||
|
isLinked = true
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
log(`[ERROR] Link failed: ${response.reason}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subscribeToDebitRequests() {
|
||||||
|
log('Subscribing to debit requests...')
|
||||||
|
|
||||||
|
const conversationKey = getConversationKey(ATM_PRIVATE_KEY, LIGHTNING_PUB_PUBKEY)
|
||||||
|
|
||||||
|
// Subscribe to Kind 21000 messages from Lightning.Pub
|
||||||
|
relay.subscribe(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kinds: [21000],
|
||||||
|
authors: [LIGHTNING_PUB_PUBKEY],
|
||||||
|
since: Math.floor(Date.now() / 1000) - 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
onevent(evt) {
|
||||||
|
const pTags = evt.tags.filter((t) => t[0] === 'p')
|
||||||
|
if (!pTags.some((t) => t[1] === ATM_PUBLIC_KEY)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(decrypt(evt.content, conversationKey))
|
||||||
|
|
||||||
|
if (message.debit) {
|
||||||
|
handleDebitRequest(message, conversationKey)
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send GetLiveDebitRequests to start the stream
|
||||||
|
const request = {
|
||||||
|
rpcName: 'GetLiveDebitRequests',
|
||||||
|
authIdentifier: ATM_PUBLIC_KEY,
|
||||||
|
body: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedContent = encrypt(JSON.stringify(request), conversationKey)
|
||||||
|
const event = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 21000,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [['p', LIGHTNING_PUB_PUBKEY]],
|
||||||
|
content: encryptedContent,
|
||||||
|
},
|
||||||
|
ATM_PRIVATE_KEY
|
||||||
|
)
|
||||||
|
|
||||||
|
await relay.publish(event)
|
||||||
|
log('[OK] Listening for debit requests...')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDebitRequest(message, conversationKey) {
|
||||||
|
const { debit } = message
|
||||||
|
atmState = ATM_STATE.PROCESSING
|
||||||
|
broadcastState()
|
||||||
|
|
||||||
|
log(`[ALERT] DEBIT REQUEST RECEIVED!`)
|
||||||
|
log(` Amount: ${debit.amount || 'invoice amount'} sats`)
|
||||||
|
log(` Type: ${debit.type}`)
|
||||||
|
|
||||||
|
broadcast('debit_request', { debit })
|
||||||
|
|
||||||
|
// Auto-approve after 1 second
|
||||||
|
setTimeout(async () => {
|
||||||
|
log('[OK] Auto-approving debit request...')
|
||||||
|
|
||||||
|
const approval = {
|
||||||
|
rpcName: 'RespondToDebit',
|
||||||
|
authIdentifier: ATM_PUBLIC_KEY,
|
||||||
|
body: {
|
||||||
|
npub: message.npub,
|
||||||
|
request_id: message.request_id,
|
||||||
|
response: {
|
||||||
|
type: 'invoice',
|
||||||
|
invoice: debit.invoice,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedContent = encrypt(JSON.stringify(approval), conversationKey)
|
||||||
|
const event = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 21000,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [['p', LIGHTNING_PUB_PUBKEY]],
|
||||||
|
content: encryptedContent,
|
||||||
|
},
|
||||||
|
ATM_PRIVATE_KEY
|
||||||
|
)
|
||||||
|
|
||||||
|
await relay.publish(event)
|
||||||
|
log('[OK] Approval sent! Payment complete.')
|
||||||
|
|
||||||
|
// Mark as complete
|
||||||
|
atmState = ATM_STATE.COMPLETE
|
||||||
|
broadcastState()
|
||||||
|
|
||||||
|
// Update balance and reset after delay
|
||||||
|
setTimeout(async () => {
|
||||||
|
await updateBalance()
|
||||||
|
// Auto-reset after showing success
|
||||||
|
setTimeout(resetAtm, 3000)
|
||||||
|
}, 2000)
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateBalance() {
|
||||||
|
const user = await getAtmUser()
|
||||||
|
if (user.status === 'OK') {
|
||||||
|
atmBalance = user.info.balance
|
||||||
|
ndebit = user.info.ndebit
|
||||||
|
broadcastState()
|
||||||
|
log(`Balance updated: ${atmBalance} sats`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenerateQR() {
|
||||||
|
if (ndebit) {
|
||||||
|
const uri = formatNdebitUri(ndebit, withdrawAmount)
|
||||||
|
ndebitQR = await QRCode.toDataURL(uri, { width: 300, margin: 2 })
|
||||||
|
log(`QR code generated for ${withdrawAmount} sats`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWithdrawAmount(amount) {
|
||||||
|
withdrawAmount = amount
|
||||||
|
regenerateQR()
|
||||||
|
broadcastState()
|
||||||
|
log(`Withdrawal amount set to ${amount} sats`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertCash(usdAmount) {
|
||||||
|
cashInserted = usdAmount
|
||||||
|
withdrawAmount = usdAmount * SATS_PER_USD
|
||||||
|
atmState = ATM_STATE.CASH_INSERTED
|
||||||
|
log(`[CASH] $${usdAmount} inserted → ${withdrawAmount.toLocaleString()} sats`)
|
||||||
|
|
||||||
|
// Brief delay then show QR
|
||||||
|
await new Promise((r) => setTimeout(r, 500))
|
||||||
|
atmState = ATM_STATE.WAITING_FOR_SCAN
|
||||||
|
await regenerateQR()
|
||||||
|
broadcastState()
|
||||||
|
log(`[QR] Scan to receive ${withdrawAmount.toLocaleString()} sats`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAtm() {
|
||||||
|
atmState = ATM_STATE.IDLE
|
||||||
|
cashInserted = 0
|
||||||
|
withdrawAmount = 0
|
||||||
|
ndebitQR = null
|
||||||
|
broadcastState()
|
||||||
|
log('[RESET] ATM ready for next customer')
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastState() {
|
||||||
|
broadcast('status', {
|
||||||
|
balance: atmBalance,
|
||||||
|
ndebit,
|
||||||
|
ndebitQR,
|
||||||
|
ndebitUri: withdrawAmount > 0 ? formatNdebitUri(ndebit, withdrawAmount) : null,
|
||||||
|
withdrawAmount,
|
||||||
|
cashInserted,
|
||||||
|
atmState,
|
||||||
|
satsPerUsd: SATS_PER_USD,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initialize() {
|
||||||
|
log('Starting Mock ATM Machine...')
|
||||||
|
|
||||||
|
// Get app token
|
||||||
|
appToken = await getAppToken()
|
||||||
|
log('Got app token')
|
||||||
|
|
||||||
|
// Get ATM user info
|
||||||
|
const user = await getAtmUser()
|
||||||
|
if (user.status === 'OK') {
|
||||||
|
atmBalance = user.info.balance
|
||||||
|
ndebit = user.info.ndebit
|
||||||
|
|
||||||
|
// Extract Lightning.Pub pubkey from ndebit
|
||||||
|
const decoded = decodeBech32(ndebit)
|
||||||
|
LIGHTNING_PUB_PUBKEY = decoded.data.pubkey
|
||||||
|
log(`ATM user found: ${atmBalance} sats balance`)
|
||||||
|
log(`Lightning.Pub pubkey: ${LIGHTNING_PUB_PUBKEY.substring(0, 16)}...`)
|
||||||
|
} else {
|
||||||
|
log('ATM user not found - please create one first')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to relay
|
||||||
|
log('Connecting to relay...')
|
||||||
|
relay = await Relay.connect(RELAY_URL)
|
||||||
|
log('Connected to relay')
|
||||||
|
|
||||||
|
// Get linking token and link keypair
|
||||||
|
const token = await getLinkingToken()
|
||||||
|
await linkKeypair(token)
|
||||||
|
|
||||||
|
// Subscribe to debit requests
|
||||||
|
await subscribeToDebitRequests()
|
||||||
|
|
||||||
|
// Start in IDLE state (no QR until cash inserted)
|
||||||
|
atmState = ATM_STATE.IDLE
|
||||||
|
broadcastState()
|
||||||
|
|
||||||
|
log('[READY] Mock ATM Machine ready!')
|
||||||
|
log(` Open http://localhost:${PORT} to use the ATM`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML page
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Mock ATM Machine</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
color: #fff;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.container { max-width: 500px; margin: 0 auto; }
|
||||||
|
h1 { text-align: center; margin-bottom: 8px; color: #00d4ff; font-size: 28px; }
|
||||||
|
.subtitle { text-align: center; opacity: 0.6; margin-bottom: 20px; font-size: 14px; }
|
||||||
|
.card {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
.balance-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.balance-bar .label { opacity: 0.7; }
|
||||||
|
.balance-bar .value { color: #00ff88; font-weight: bold; }
|
||||||
|
.exchange-rate { font-size: 12px; opacity: 0.5; }
|
||||||
|
|
||||||
|
/* ATM Screen */
|
||||||
|
.atm-screen {
|
||||||
|
min-height: 400px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.screen-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.screen-subtitle {
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cash buttons */
|
||||||
|
.cash-buttons {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
.cash-btn {
|
||||||
|
background: linear-gradient(135deg, #2d5a27 0%, #1e3d1a 100%);
|
||||||
|
border: 2px solid #3d7a35;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px 16px;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.cash-btn:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
border-color: #00ff88;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,255,136,0.3);
|
||||||
|
}
|
||||||
|
.cash-btn:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
.cash-btn .amount {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #00ff88;
|
||||||
|
}
|
||||||
|
.cash-btn .sats {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* QR Display */
|
||||||
|
#qr-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
#qr-container img { max-width: 250px; }
|
||||||
|
.amount-display {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #00ff88;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.amount-usd {
|
||||||
|
font-size: 18px;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.ndebit-code {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
word-break: break-all;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.cancel-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 2px solid rgba(255,255,255,0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 32px;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.cancel-btn:hover {
|
||||||
|
border-color: #ff6b6b;
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Processing */
|
||||||
|
.spinner {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border: 4px solid rgba(255,255,255,0.2);
|
||||||
|
border-top-color: #00d4ff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success */
|
||||||
|
.success-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: #00ff88;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logs */
|
||||||
|
.logs-card { margin-top: 8px; }
|
||||||
|
.logs-card h3 { font-size: 14px; margin-bottom: 8px; opacity: 0.7; }
|
||||||
|
.logs {
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.log-entry { margin: 4px 0; }
|
||||||
|
.log-entry.alert { color: #00d4ff; }
|
||||||
|
.log-entry.success { color: #00ff88; }
|
||||||
|
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Mock ATM</h1>
|
||||||
|
<p class="subtitle">Simulated Bitcoin ATM for testing</p>
|
||||||
|
|
||||||
|
<div class="balance-bar">
|
||||||
|
<span class="label">ATM Balance:</span>
|
||||||
|
<span class="value"><span id="balance">0</span> sats</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="atm-screen">
|
||||||
|
<!-- IDLE State -->
|
||||||
|
<div id="screen-idle">
|
||||||
|
<div class="screen-title">Insert Cash</div>
|
||||||
|
<div class="screen-subtitle">Select amount to withdraw as Bitcoin</div>
|
||||||
|
<div class="cash-buttons">
|
||||||
|
<button class="cash-btn" data-usd="20">
|
||||||
|
<div class="amount">$20</div>
|
||||||
|
<div class="sats" id="sats-20">20,000 sats</div>
|
||||||
|
</button>
|
||||||
|
<button class="cash-btn" data-usd="50">
|
||||||
|
<div class="amount">$50</div>
|
||||||
|
<div class="sats" id="sats-50">50,000 sats</div>
|
||||||
|
</button>
|
||||||
|
<button class="cash-btn" data-usd="100">
|
||||||
|
<div class="amount">$100</div>
|
||||||
|
<div class="sats" id="sats-100">100,000 sats</div>
|
||||||
|
</button>
|
||||||
|
<button class="cash-btn" data-usd="200">
|
||||||
|
<div class="amount">$200</div>
|
||||||
|
<div class="sats" id="sats-200">200,000 sats</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="exchange-rate">Rate: <span id="rate">1,000</span> sats/$</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WAITING_FOR_SCAN State -->
|
||||||
|
<div id="screen-scan" class="hidden">
|
||||||
|
<div class="screen-title">Scan QR Code</div>
|
||||||
|
<div class="screen-subtitle">Open your wallet and scan to receive</div>
|
||||||
|
<div class="amount-display"><span id="display-sats">0</span> sats</div>
|
||||||
|
<div class="amount-usd">($<span id="display-usd">0</span>)</div>
|
||||||
|
<div id="qr-container">
|
||||||
|
<img id="qr" alt="QR Code" />
|
||||||
|
</div>
|
||||||
|
<div class="ndebit-code" id="ndebit-code"></div>
|
||||||
|
<button class="cancel-btn" id="cancel-btn">Cancel Transaction</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PROCESSING State -->
|
||||||
|
<div id="screen-processing" class="hidden">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div class="screen-title">Processing...</div>
|
||||||
|
<div class="screen-subtitle">Verifying payment request</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- COMPLETE State -->
|
||||||
|
<div id="screen-complete" class="hidden">
|
||||||
|
<div class="success-icon">✓</div>
|
||||||
|
<div class="screen-title">Success!</div>
|
||||||
|
<div class="screen-subtitle">
|
||||||
|
<span id="complete-sats">0</span> sats sent to your wallet
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card logs-card">
|
||||||
|
<h3>Activity Log</h3>
|
||||||
|
<div class="logs" id="logs"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const ws = new WebSocket('ws://localhost:3456')
|
||||||
|
const logs = document.getElementById('logs')
|
||||||
|
|
||||||
|
// Screen elements
|
||||||
|
const screens = {
|
||||||
|
idle: document.getElementById('screen-idle'),
|
||||||
|
scan: document.getElementById('screen-scan'),
|
||||||
|
processing: document.getElementById('screen-processing'),
|
||||||
|
complete: document.getElementById('screen-complete'),
|
||||||
|
}
|
||||||
|
|
||||||
|
function showScreen(name) {
|
||||||
|
Object.values(screens).forEach(s => s.classList.add('hidden'))
|
||||||
|
if (screens[name]) screens[name].classList.remove('hidden')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cash buttons
|
||||||
|
document.querySelectorAll('.cash-btn').forEach(btn => {
|
||||||
|
btn.onclick = () => {
|
||||||
|
const usd = parseInt(btn.dataset.usd, 10)
|
||||||
|
ws.send(JSON.stringify({ type: 'insert_cash', amount: usd }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cancel button
|
||||||
|
document.getElementById('cancel-btn').onclick = () => {
|
||||||
|
ws.send(JSON.stringify({ type: 'reset' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLog(message, type = '') {
|
||||||
|
const entry = document.createElement('div')
|
||||||
|
entry.className = 'log-entry' + (type ? ' ' + type : '')
|
||||||
|
entry.textContent = message
|
||||||
|
logs.appendChild(entry)
|
||||||
|
logs.scrollTop = logs.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
|
||||||
|
if (data.type === 'log') {
|
||||||
|
const isAlert = data.message.includes('[ALERT]')
|
||||||
|
const isSuccess = data.message.includes('[OK]') || data.message.includes('Success')
|
||||||
|
addLog(data.message, isAlert ? 'alert' : isSuccess ? 'success' : '')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === 'status') {
|
||||||
|
// Update balance
|
||||||
|
document.getElementById('balance').textContent = data.balance.toLocaleString()
|
||||||
|
|
||||||
|
// Update exchange rate display
|
||||||
|
if (data.satsPerUsd) {
|
||||||
|
document.getElementById('rate').textContent = data.satsPerUsd.toLocaleString()
|
||||||
|
document.getElementById('sats-20').textContent = (20 * data.satsPerUsd).toLocaleString() + ' sats'
|
||||||
|
document.getElementById('sats-50').textContent = (50 * data.satsPerUsd).toLocaleString() + ' sats'
|
||||||
|
document.getElementById('sats-100').textContent = (100 * data.satsPerUsd).toLocaleString() + ' sats'
|
||||||
|
document.getElementById('sats-200').textContent = (200 * data.satsPerUsd).toLocaleString() + ' sats'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update amounts
|
||||||
|
if (data.withdrawAmount !== undefined) {
|
||||||
|
document.getElementById('display-sats').textContent = data.withdrawAmount.toLocaleString()
|
||||||
|
document.getElementById('complete-sats').textContent = data.withdrawAmount.toLocaleString()
|
||||||
|
}
|
||||||
|
if (data.cashInserted !== undefined) {
|
||||||
|
document.getElementById('display-usd').textContent = data.cashInserted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update QR
|
||||||
|
if (data.ndebitQR) {
|
||||||
|
document.getElementById('qr').src = data.ndebitQR
|
||||||
|
}
|
||||||
|
if (data.ndebitUri) {
|
||||||
|
document.getElementById('ndebit-code').textContent = data.ndebitUri
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show correct screen based on state
|
||||||
|
switch (data.atmState) {
|
||||||
|
case 'idle':
|
||||||
|
showScreen('idle')
|
||||||
|
break
|
||||||
|
case 'cash_inserted':
|
||||||
|
case 'waiting_for_scan':
|
||||||
|
showScreen('scan')
|
||||||
|
break
|
||||||
|
case 'processing':
|
||||||
|
showScreen('processing')
|
||||||
|
break
|
||||||
|
case 'complete':
|
||||||
|
showScreen('complete')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === 'debit_request') {
|
||||||
|
addLog('[DEBIT] Request received from wallet', 'alert')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
addLog('Connected to ATM server')
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
addLog('Connection error - is the server running?')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
// Create HTTP server
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' })
|
||||||
|
res.end(html)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create WebSocket server
|
||||||
|
const wss = new WebSocketServer({ server })
|
||||||
|
|
||||||
|
wss.on('connection', (ws) => {
|
||||||
|
wsClients.push(ws)
|
||||||
|
ws.on('close', () => {
|
||||||
|
wsClients = wsClients.filter((c) => c !== ws)
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(data)
|
||||||
|
if (msg.type === 'insert_cash' && typeof msg.amount === 'number') {
|
||||||
|
insertCash(msg.amount)
|
||||||
|
} else if (msg.type === 'reset') {
|
||||||
|
resetAtm()
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send current state
|
||||||
|
if (ndebit) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'status',
|
||||||
|
balance: atmBalance,
|
||||||
|
ndebit,
|
||||||
|
ndebitQR,
|
||||||
|
ndebitUri: withdrawAmount > 0 ? formatNdebitUri(ndebit, withdrawAmount) : null,
|
||||||
|
withdrawAmount,
|
||||||
|
cashInserted,
|
||||||
|
atmState,
|
||||||
|
satsPerUsd: SATS_PER_USD,
|
||||||
|
isLinked,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
server.listen(PORT, async () => {
|
||||||
|
console.log(`Mock ATM Machine running at http://localhost:${PORT}`)
|
||||||
|
await initialize()
|
||||||
|
})
|
||||||
111
packages/nostr-client/dev/nip44v1.mjs
Normal file
111
packages/nostr-client/dev/nip44v1.mjs
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
/**
|
||||||
|
* NIP-44 v1 Implementation
|
||||||
|
*
|
||||||
|
* This is the XChaCha20-based encryption used by Lightning.Pub for Kind 21000 RPC events.
|
||||||
|
* It differs from standard NIP-44 v2 (used in nostr-tools) which uses ChaCha20-Poly1305.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { base64 } from '@scure/base'
|
||||||
|
import { randomBytes } from '@noble/hashes/utils.js'
|
||||||
|
import { streamXOR as xchacha20 } from '@stablelib/xchacha20'
|
||||||
|
import { secp256k1 } from '@noble/curves/secp256k1.js'
|
||||||
|
import { sha256 } from '@noble/hashes/sha2.js'
|
||||||
|
|
||||||
|
const XCHACHA20_VERSION = 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert hex string to Uint8Array
|
||||||
|
* @param {string} hex - Hex string
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
function hexToBytes(hex) {
|
||||||
|
const bytes = new Uint8Array(hex.length / 2)
|
||||||
|
for (let i = 0; i < hex.length; i += 2) {
|
||||||
|
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16)
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get shared secret for NIP-44 v1 encryption
|
||||||
|
* @param {Uint8Array|string} privateKey - Private key (32 bytes or hex string)
|
||||||
|
* @param {string} publicKey - Public key (32 bytes hex string, no prefix)
|
||||||
|
* @returns {Uint8Array} - 32-byte shared secret
|
||||||
|
*/
|
||||||
|
export function getConversationKey(privateKey, publicKey) {
|
||||||
|
// Convert private key to Uint8Array if it's a hex string
|
||||||
|
const privKeyBytes = typeof privateKey === 'string' ? hexToBytes(privateKey) : privateKey
|
||||||
|
|
||||||
|
// Convert public key (with 02 prefix) to Uint8Array
|
||||||
|
const pubKeyBytes = hexToBytes('02' + publicKey)
|
||||||
|
|
||||||
|
// Get ECDH shared point
|
||||||
|
const sharedPoint = secp256k1.getSharedSecret(privKeyBytes, pubKeyBytes)
|
||||||
|
|
||||||
|
// Hash the x-coordinate of the shared point
|
||||||
|
return sha256(sharedPoint.slice(1, 33))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt content using NIP-44 v1 (XChaCha20)
|
||||||
|
* @param {string} content - Plaintext content to encrypt
|
||||||
|
* @param {Uint8Array} conversationKey - 32-byte conversation key from getConversationKey
|
||||||
|
* @returns {string} - Base64-encoded encrypted payload
|
||||||
|
*/
|
||||||
|
export function encrypt(content, conversationKey) {
|
||||||
|
const nonce = randomBytes(24)
|
||||||
|
const plaintext = new TextEncoder().encode(content)
|
||||||
|
|
||||||
|
// XChaCha20 stream cipher - encrypts in place
|
||||||
|
const ciphertext = new Uint8Array(plaintext.length)
|
||||||
|
xchacha20(conversationKey, nonce, plaintext, ciphertext)
|
||||||
|
|
||||||
|
// Encode: version byte + nonce + ciphertext
|
||||||
|
return base64.encode(new Uint8Array([XCHACHA20_VERSION, ...nonce, ...ciphertext]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt content using NIP-44 v1 (XChaCha20)
|
||||||
|
* @param {string} content - Base64-encoded encrypted payload
|
||||||
|
* @param {Uint8Array} conversationKey - 32-byte conversation key from getConversationKey
|
||||||
|
* @returns {string} - Decrypted plaintext
|
||||||
|
*/
|
||||||
|
export function decrypt(content, conversationKey) {
|
||||||
|
const payload = decodePayload(content)
|
||||||
|
|
||||||
|
// XChaCha20 stream cipher - decrypts in place
|
||||||
|
const plaintext = new Uint8Array(payload.ciphertext.length)
|
||||||
|
xchacha20(conversationKey, payload.nonce, payload.ciphertext, plaintext)
|
||||||
|
|
||||||
|
return new TextDecoder().decode(plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode encrypted payload (supports both formats)
|
||||||
|
* @param {string} content - Base64-encoded or JSON-encoded payload
|
||||||
|
* @returns {{nonce: Uint8Array, ciphertext: Uint8Array}}
|
||||||
|
*/
|
||||||
|
function decodePayload(content) {
|
||||||
|
// Check for JSON format
|
||||||
|
if (content.startsWith('{') && content.endsWith('}')) {
|
||||||
|
const parsed = JSON.parse(content)
|
||||||
|
if (parsed.v !== XCHACHA20_VERSION) {
|
||||||
|
throw new Error(`Unsupported encryption version: ${parsed.v}`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
nonce: base64.decode(parsed.nonce),
|
||||||
|
ciphertext: base64.decode(parsed.ciphertext),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binary format: version byte + nonce (24 bytes) + ciphertext
|
||||||
|
const buf = base64.decode(content)
|
||||||
|
if (buf[0] !== XCHACHA20_VERSION) {
|
||||||
|
throw new Error(`Unsupported encryption version: ${buf[0]}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nonce: buf.subarray(1, 25),
|
||||||
|
ciphertext: buf.subarray(25),
|
||||||
|
}
|
||||||
|
}
|
||||||
221
packages/nostr-client/dev/run-debit-agent.mjs
Normal file
221
packages/nostr-client/dev/run-debit-agent.mjs
Normal file
|
|
@ -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=<hex-private-key>
|
||||||
|
* export LIGHTNING_PUB_PUBKEY=<hex-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)
|
||||||
|
})
|
||||||
182
packages/nostr-client/dev/test-debit.mjs
Normal file
182
packages/nostr-client/dev/test-debit.mjs
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
/**
|
||||||
|
* Test CLINK Debit flow
|
||||||
|
*
|
||||||
|
* This simulates what ShockWallet does when scanning an ndebit:
|
||||||
|
* 1. Decode the ndebit to get pubkey, relay, pointer
|
||||||
|
* 2. Create a bolt11 invoice (we'll get one from Alice)
|
||||||
|
* 3. Send Kind 21002 debit request to Lightning.Pub
|
||||||
|
* 4. Wait for payment response
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Relay } from 'nostr-tools/relay'
|
||||||
|
import { finalizeEvent, getPublicKey } from 'nostr-tools'
|
||||||
|
import { nip44 } from 'nostr-tools'
|
||||||
|
import { bech32 } from '@scure/base'
|
||||||
|
import { randomBytes } from 'crypto'
|
||||||
|
|
||||||
|
// Generate a random keypair for this test (simulates ShockWallet)
|
||||||
|
const WALLET_PRIVATE_KEY = randomBytes(32)
|
||||||
|
const WALLET_PUBLIC_KEY = getPublicKey(WALLET_PRIVATE_KEY)
|
||||||
|
|
||||||
|
// The ndebit to test
|
||||||
|
const NDEBIT = process.argv[2]
|
||||||
|
// The bolt11 invoice to be paid
|
||||||
|
const BOLT11 = process.argv[3]
|
||||||
|
|
||||||
|
if (!NDEBIT || !BOLT11) {
|
||||||
|
console.log('Usage: node test-debit.mjs <ndebit> <bolt11>')
|
||||||
|
console.log('')
|
||||||
|
console.log('Example:')
|
||||||
|
console.log(' # First create an invoice on Alice:')
|
||||||
|
console.log(' docker exec lamassu-lnd-alice lncli --network=regtest addinvoice --amt 1000')
|
||||||
|
console.log('')
|
||||||
|
console.log(' # Then test the debit:')
|
||||||
|
console.log(' node test-debit.mjs ndebit1... lnbcrt...')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeNdebit(ndebit) {
|
||||||
|
const { prefix, words } = bech32.decode(ndebit, 5000)
|
||||||
|
if (prefix !== 'ndebit') throw new Error('Invalid ndebit prefix')
|
||||||
|
|
||||||
|
const data = new Uint8Array(bech32.fromWords(words))
|
||||||
|
|
||||||
|
let pubkey, relay, pointer
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
while (offset < data.length) {
|
||||||
|
const type = data[offset]
|
||||||
|
const length = data[offset + 1]
|
||||||
|
const value = data.slice(offset + 2, offset + 2 + length)
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 0:
|
||||||
|
pubkey = Buffer.from(value).toString('hex')
|
||||||
|
break
|
||||||
|
case 1:
|
||||||
|
relay = new TextDecoder().decode(value)
|
||||||
|
break
|
||||||
|
case 2:
|
||||||
|
pointer = new TextDecoder().decode(value)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += 2 + length
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pubkey, relay, pointer }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('=== CLINK Debit Test ===')
|
||||||
|
console.log('')
|
||||||
|
console.log('Test wallet pubkey:', WALLET_PUBLIC_KEY)
|
||||||
|
console.log('')
|
||||||
|
|
||||||
|
// Decode ndebit
|
||||||
|
const debit = decodeNdebit(NDEBIT)
|
||||||
|
console.log('Decoded ndebit:')
|
||||||
|
console.log(' Pubkey:', debit.pubkey)
|
||||||
|
console.log(' Relay:', debit.relay)
|
||||||
|
console.log(' Pointer:', debit.pointer || '(none)')
|
||||||
|
console.log('')
|
||||||
|
|
||||||
|
// Connect to relay (override Docker internal hostnames with localhost for local testing)
|
||||||
|
const relayUrl = debit.relay
|
||||||
|
.replace('host.docker.internal', 'localhost')
|
||||||
|
.replace('ws://strfry:', 'ws://localhost:')
|
||||||
|
console.log('Connecting to relay:', relayUrl)
|
||||||
|
const relay = await Relay.connect(relayUrl)
|
||||||
|
console.log('Connected!')
|
||||||
|
console.log('')
|
||||||
|
|
||||||
|
// Build debit request payload
|
||||||
|
const requestPayload = {
|
||||||
|
pointer: debit.pointer,
|
||||||
|
bolt11: BOLT11,
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Request payload:', JSON.stringify(requestPayload, null, 2))
|
||||||
|
console.log('')
|
||||||
|
|
||||||
|
// Encrypt with NIP-44
|
||||||
|
const conversationKey = nip44.getConversationKey(WALLET_PRIVATE_KEY, debit.pubkey)
|
||||||
|
const encryptedContent = nip44.encrypt(JSON.stringify(requestPayload), conversationKey)
|
||||||
|
|
||||||
|
// Create Kind 21002 event
|
||||||
|
const event = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 21002,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['p', debit.pubkey],
|
||||||
|
['clink_version', '1'],
|
||||||
|
],
|
||||||
|
content: encryptedContent,
|
||||||
|
},
|
||||||
|
WALLET_PRIVATE_KEY
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('Publishing debit request (event id:', event.id.substring(0, 16) + '...)...')
|
||||||
|
|
||||||
|
// Subscribe to responses
|
||||||
|
let responseReceived = false
|
||||||
|
const sub = relay.subscribe(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kinds: [21002],
|
||||||
|
authors: [debit.pubkey],
|
||||||
|
'#p': [WALLET_PUBLIC_KEY],
|
||||||
|
'#e': [event.id],
|
||||||
|
since: Math.floor(Date.now() / 1000) - 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
onevent(evt) {
|
||||||
|
console.log('')
|
||||||
|
console.log('Got response event:', evt.id.substring(0, 16) + '...')
|
||||||
|
try {
|
||||||
|
const decrypted = nip44.decrypt(evt.content, conversationKey)
|
||||||
|
const response = JSON.parse(decrypted)
|
||||||
|
console.log('Response:', JSON.stringify(response, null, 2))
|
||||||
|
|
||||||
|
if (response.res === 'ok') {
|
||||||
|
console.log('')
|
||||||
|
console.log('✅ DEBIT SUCCESS!')
|
||||||
|
if (response.preimage) {
|
||||||
|
console.log('Preimage:', response.preimage)
|
||||||
|
}
|
||||||
|
} else if (response.res === 'GFY') {
|
||||||
|
console.log('')
|
||||||
|
console.log('❌ DEBIT FAILED:', response.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
responseReceived = true
|
||||||
|
} catch (err) {
|
||||||
|
console.log('Failed to decrypt:', err.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Publish request
|
||||||
|
await relay.publish(event)
|
||||||
|
console.log('Request published, waiting for response...')
|
||||||
|
|
||||||
|
// Wait for response
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 1000))
|
||||||
|
if (responseReceived) break
|
||||||
|
if (i % 5 === 4) console.log('Still waiting... (' + (i + 1) + 's)')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!responseReceived) {
|
||||||
|
console.log('')
|
||||||
|
console.log('❌ No response received within timeout')
|
||||||
|
}
|
||||||
|
|
||||||
|
sub.close()
|
||||||
|
relay.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error)
|
||||||
181
packages/nostr-client/dev/test-ndebit.mjs
Normal file
181
packages/nostr-client/dev/test-ndebit.mjs
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Test script to simulate a wallet sending an ndebit claim request
|
||||||
|
* This tests whether Lightning.Pub sends Kind 21002 responses after the fix
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Relay } from 'nostr-tools/relay'
|
||||||
|
import { nip44, finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||||
|
import { decodeBech32 } from '@shocknet/clink-sdk'
|
||||||
|
|
||||||
|
const { getConversationKey, encrypt, decrypt } = nip44
|
||||||
|
|
||||||
|
const NDEBIT =
|
||||||
|
'ndebit1qgpkzardqyg8wue69uhhxarjvee8jw3hxumnwqpqf05wyqarxsdm9d62fh9lsa6wqc2r0a37cgd00mp3gnydpf5w9uusavytcn'
|
||||||
|
const RELAY_URL = 'ws://localhost:7777'
|
||||||
|
const AMOUNT_SATS = 5000 // Small test amount
|
||||||
|
|
||||||
|
// Generate a wallet keypair for this test
|
||||||
|
const WALLET_PRIVATE_KEY = generateSecretKey()
|
||||||
|
const WALLET_PUBLIC_KEY = getPublicKey(WALLET_PRIVATE_KEY)
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🔧 Test: ndebit claim flow (NIP-44 v2)')
|
||||||
|
console.log('='.repeat(50))
|
||||||
|
|
||||||
|
// Decode ndebit to get Lightning.Pub pubkey and pointer
|
||||||
|
const decoded = decodeBech32(NDEBIT)
|
||||||
|
const LPUB_PUBKEY = decoded.data.pubkey
|
||||||
|
const POINTER = decoded.data.pointer
|
||||||
|
console.log(`\n📍 Lightning.Pub pubkey: ${LPUB_PUBKEY.slice(0, 16)}...`)
|
||||||
|
console.log(`🔑 Pointer (user ID): ${POINTER.slice(0, 16)}...`)
|
||||||
|
console.log(`👛 Test wallet pubkey: ${WALLET_PUBLIC_KEY.slice(0, 16)}...`)
|
||||||
|
console.log(`💰 Amount: ${AMOUNT_SATS} sats`)
|
||||||
|
|
||||||
|
// Connect to relay
|
||||||
|
console.log(`\n🔌 Connecting to relay: ${RELAY_URL}`)
|
||||||
|
const relay = await Relay.connect(RELAY_URL)
|
||||||
|
console.log('✅ Connected!')
|
||||||
|
|
||||||
|
// Build the debit request data (NdebitData format)
|
||||||
|
// Using newNdebitFullAccessRequest format with amount
|
||||||
|
const debitData = {
|
||||||
|
amount_sats: AMOUNT_SATS,
|
||||||
|
pointer: POINTER,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt using NIP-44 v2
|
||||||
|
const conversationKey = getConversationKey(WALLET_PRIVATE_KEY, LPUB_PUBKEY)
|
||||||
|
const encryptedContent = encrypt(JSON.stringify(debitData), conversationKey)
|
||||||
|
|
||||||
|
// Build event with correct tags (including clink_version)
|
||||||
|
const event = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 21002,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['p', LPUB_PUBKEY],
|
||||||
|
['clink_version', '1'],
|
||||||
|
],
|
||||||
|
content: encryptedContent,
|
||||||
|
},
|
||||||
|
WALLET_PRIVATE_KEY
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(`\n📤 Sending Kind 21002 debit request`)
|
||||||
|
console.log(` Event ID: ${event.id.slice(0, 16)}...`)
|
||||||
|
console.log(` Content length: ${encryptedContent.length} chars`)
|
||||||
|
|
||||||
|
// Subscribe for responses BEFORE sending the request
|
||||||
|
let responseReceived = false
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
// Filter for Kind 21002 responses from Lightning.Pub that reference our event
|
||||||
|
const sub = relay.subscribe(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kinds: [21002],
|
||||||
|
authors: [LPUB_PUBKEY],
|
||||||
|
'#p': [WALLET_PUBLIC_KEY],
|
||||||
|
'#e': [event.id],
|
||||||
|
since: Math.floor(Date.now() / 1000) - 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
onevent(evt) {
|
||||||
|
console.log(`\n📥 Received Kind 21002 response!`)
|
||||||
|
console.log(` Event ID: ${evt.id.slice(0, 16)}...`)
|
||||||
|
console.log(` Author: ${evt.pubkey.slice(0, 16)}...`)
|
||||||
|
|
||||||
|
// Check #e tag (should reference our original event)
|
||||||
|
const eTag = evt.tags.find((t) => t[0] === 'e')
|
||||||
|
if (eTag) {
|
||||||
|
console.log(` #e tag: ${eTag[1].slice(0, 16)}...`)
|
||||||
|
if (eTag[1] === event.id) {
|
||||||
|
console.log(' ✅ Correctly references our original event!')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(' ⚠️ No #e tag found')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(decrypt(evt.content, conversationKey))
|
||||||
|
console.log(`\n📋 Response content:`)
|
||||||
|
console.log(JSON.stringify(response, null, 2))
|
||||||
|
|
||||||
|
if (response.res === 'OK') {
|
||||||
|
console.log('\n✅✅✅ SUCCESS! Lightning.Pub sent Kind 21002 response correctly!')
|
||||||
|
console.log(' The fix is working!')
|
||||||
|
} else if (response.res === 'GFY' || response.error) {
|
||||||
|
console.log(`\n⚠️ Response indicates error: ${response.error || 'unknown'}`)
|
||||||
|
console.log(' (Expected if payment denied or auth required)')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' ❌ Could not decrypt response:', e.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
responseReceived = true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Publish the debit request
|
||||||
|
await relay.publish(event)
|
||||||
|
console.log('✅ Request published!')
|
||||||
|
|
||||||
|
// Wait for response with timeout
|
||||||
|
console.log('\n⏳ Waiting for Kind 21002 response (30s timeout)...')
|
||||||
|
const timeout = 30000
|
||||||
|
const checkInterval = 1000
|
||||||
|
|
||||||
|
while (!responseReceived && Date.now() - startTime < timeout) {
|
||||||
|
await new Promise((r) => setTimeout(r, checkInterval))
|
||||||
|
const elapsed = Math.floor((Date.now() - startTime) / 1000)
|
||||||
|
process.stdout.write(`\r ${elapsed}s elapsed...`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('')
|
||||||
|
|
||||||
|
if (!responseReceived) {
|
||||||
|
console.log('\n❌❌❌ TIMEOUT! No Kind 21002 response received.')
|
||||||
|
console.log(' This means the fix did NOT work or there was another issue.')
|
||||||
|
|
||||||
|
// Let's check what Kind 21002 events exist
|
||||||
|
console.log('\n🔍 Checking for any Kind 21002 events on relay...')
|
||||||
|
let foundEvents = 0
|
||||||
|
const allDebitSub = relay.subscribe(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kinds: [21002],
|
||||||
|
limit: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
onevent(evt) {
|
||||||
|
foundEvents++
|
||||||
|
const pTags = evt.tags.filter((t) => t[0] === 'p').map((t) => t[1].slice(0, 8) + '...')
|
||||||
|
const eTags = evt.tags.filter((t) => t[0] === 'e').map((t) => t[1].slice(0, 8) + '...')
|
||||||
|
console.log(
|
||||||
|
` ${foundEvents}. id=${evt.id.slice(0, 12)}... by=${evt.pubkey.slice(0, 8)}... #p=${pTags.join(',')} #e=${eTags.join(',')}`
|
||||||
|
)
|
||||||
|
},
|
||||||
|
oneose() {
|
||||||
|
console.log(` (Found ${foundEvents} Kind 21002 events total)`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 3000))
|
||||||
|
allDebitSub.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
sub.close()
|
||||||
|
relay.close()
|
||||||
|
console.log('\n🏁 Test complete')
|
||||||
|
process.exit(responseReceived ? 0 : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('Fatal error:', e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
116
packages/nostr-client/dev/test-pay.mjs
Normal file
116
packages/nostr-client/dev/test-pay.mjs
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { NostrClient, loadIdentityFromHex, encryptContent, decryptJSON } from '../dist/index.js'
|
||||||
|
import { Relay } from 'nostr-tools/relay'
|
||||||
|
import { finalizeEvent } from 'nostr-tools'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
|
const DEV_PRIVATE_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
|
||||||
|
const LIGHTNING_PUB_PUBKEY =
|
||||||
|
process.env.LIGHTNING_PUB_PUBKEY ||
|
||||||
|
'4a72e400a254bf74a70cc711ab97e461b8d4fd9738b1ac3b5194cdeb3192ab91'
|
||||||
|
const RELAY_URL = process.env.NOSTR_RELAY_URL || 'ws://localhost:7777'
|
||||||
|
|
||||||
|
// Get a fresh invoice from Alice first:
|
||||||
|
// docker exec lamassu-lnd-alice lncli --network=regtest addinvoice --amt 1000
|
||||||
|
const TEST_INVOICE = process.argv[2]
|
||||||
|
|
||||||
|
if (!TEST_INVOICE) {
|
||||||
|
console.log('Usage: node test-pay.mjs <invoice>')
|
||||||
|
console.log(
|
||||||
|
'Generate invoice: docker exec lamassu-lnd-alice lncli --network=regtest addinvoice --amt 1000'
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const identity = loadIdentityFromHex(DEV_PRIVATE_KEY)
|
||||||
|
console.log('Using identity:', identity.publicKey)
|
||||||
|
|
||||||
|
console.log('Connecting to relay...')
|
||||||
|
const relay = await Relay.connect(RELAY_URL)
|
||||||
|
console.log('Connected!')
|
||||||
|
|
||||||
|
const requestId = randomUUID()
|
||||||
|
|
||||||
|
// Check if invoice has amount (look for pattern before '1' separator)
|
||||||
|
const amountMatch = TEST_INVOICE.toLowerCase().match(/ln(?:bc|tb|bcrt)(\d+)?([munp])?1/)
|
||||||
|
const hasAmount = amountMatch && amountMatch[1]
|
||||||
|
console.log('Invoice amount match:', amountMatch ? amountMatch.slice(0, 3) : null)
|
||||||
|
console.log('Has embedded amount:', hasAmount)
|
||||||
|
|
||||||
|
// Build body - amount is always required (use 0 for invoices with embedded amounts)
|
||||||
|
const body = {
|
||||||
|
invoice: TEST_INVOICE,
|
||||||
|
amount: hasAmount ? 0 : 2000, // 0 means "use invoice amount"
|
||||||
|
}
|
||||||
|
console.log('Body amount:', body.amount, hasAmount ? '(use invoice amount)' : '(explicit amount)')
|
||||||
|
|
||||||
|
const rpcRequest = {
|
||||||
|
rpcName: 'PayInvoice',
|
||||||
|
params: {},
|
||||||
|
query: {},
|
||||||
|
body,
|
||||||
|
authIdentifier: identity.publicKey,
|
||||||
|
requestId,
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nRequest structure:', JSON.stringify(rpcRequest, null, 2))
|
||||||
|
|
||||||
|
const encryptedContent = encryptContent(identity, LIGHTNING_PUB_PUBKEY, rpcRequest)
|
||||||
|
|
||||||
|
const event = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 21000,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [['p', LIGHTNING_PUB_PUBKEY]],
|
||||||
|
content: encryptedContent,
|
||||||
|
},
|
||||||
|
identity.privateKey
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('\nPublishing PayInvoice request (event id:', event.id.substring(0, 16) + '...)...')
|
||||||
|
|
||||||
|
// Subscribe to responses
|
||||||
|
const filter = {
|
||||||
|
kinds: [21000],
|
||||||
|
authors: [LIGHTNING_PUB_PUBKEY],
|
||||||
|
since: Math.floor(Date.now() / 1000) - 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
let responseReceived = false
|
||||||
|
const sub = relay.subscribe([filter], {
|
||||||
|
onevent(evt) {
|
||||||
|
// Check if for us
|
||||||
|
const pTags = evt.tags.filter((t) => t[0] === 'p')
|
||||||
|
if (!pTags.some((t) => t[1] === identity.publicKey)) return
|
||||||
|
|
||||||
|
console.log('\nGot response event:', evt.id.substring(0, 16) + '...')
|
||||||
|
try {
|
||||||
|
const response = decryptJSON(identity, LIGHTNING_PUB_PUBKEY, evt.content)
|
||||||
|
console.log('Response:', JSON.stringify(response, null, 2))
|
||||||
|
if (response.requestId === requestId) {
|
||||||
|
responseReceived = true
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log('Failed to decrypt:', err.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await relay.publish(event)
|
||||||
|
console.log('Request published, waiting for response...')
|
||||||
|
|
||||||
|
// Wait for response
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 500))
|
||||||
|
if (responseReceived) break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!responseReceived) {
|
||||||
|
console.log('\nNo response received for our requestId within timeout')
|
||||||
|
}
|
||||||
|
|
||||||
|
sub.close()
|
||||||
|
relay.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error)
|
||||||
|
|
@ -21,14 +21,20 @@
|
||||||
"validate-schemas": "tsx scripts/validate-schemas.ts"
|
"validate-schemas": "tsx scripts/validate-schemas.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@noble/curves": "^2.0.1",
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
|
"@scure/base": "^1.2.6",
|
||||||
|
"@shocknet/clink-sdk": "^1.5.4",
|
||||||
|
"@stablelib/xchacha20": "^2.0.1",
|
||||||
"nostr-tools": "^2.10.0"
|
"nostr-tools": "^2.10.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.19.7",
|
"@types/node": "^22.19.7",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vitest": "^2.1.0"
|
"vitest": "^2.1.0",
|
||||||
|
"ws": "^8.19.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
|
||||||
import { generateIdentity } from '../identity.js'
|
import { generateIdentity } from '../identity.js'
|
||||||
import { LocalSigner } from '../signer.js'
|
import { LocalSigner } from '../signer.js'
|
||||||
import { createSignedEvent, createAuthEvent, validateEvent, generateTxId } from '../events.js'
|
import { createSignedEvent, createAuthEvent, validateEvent, generateTxId } from '../events.js'
|
||||||
import { BitSpireEventKind } from '../types.js'
|
import { LamassuEventKind } from '../types.js'
|
||||||
|
|
||||||
describe('events', () => {
|
describe('events', () => {
|
||||||
describe('createSignedEvent', () => {
|
describe('createSignedEvent', () => {
|
||||||
|
|
@ -33,7 +33,7 @@ describe('events', () => {
|
||||||
|
|
||||||
const event = await createAuthEvent(signer, relayUrl, challenge)
|
const event = await createAuthEvent(signer, relayUrl, challenge)
|
||||||
|
|
||||||
expect(event.kind).toBe(BitSpireEventKind.Auth)
|
expect(event.kind).toBe(LamassuEventKind.Auth)
|
||||||
expect(event.content).toBe('')
|
expect(event.content).toBe('')
|
||||||
expect(event.tags).toContainEqual(['relay', relayUrl])
|
expect(event.tags).toContainEqual(['relay', relayUrl])
|
||||||
expect(event.tags).toContainEqual(['challenge', challenge])
|
expect(event.tags).toContainEqual(['challenge', challenge])
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
import { type Event, type EventTemplate, type VerifiedEvent, getEventHash } from 'nostr-tools'
|
import { type Event, type EventTemplate, type VerifiedEvent, getEventHash } from 'nostr-tools'
|
||||||
import type { Signer } from './signer.js'
|
import type { Signer } from './signer.js'
|
||||||
import { BitSpireEventKind } from './types.js'
|
import { LamassuEventKind } from './types.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign an event template with the given signer.
|
* Sign an event template with the given signer.
|
||||||
|
|
@ -29,7 +29,7 @@ export function createAuthEvent(
|
||||||
challenge: string
|
challenge: string
|
||||||
): Promise<VerifiedEvent> {
|
): Promise<VerifiedEvent> {
|
||||||
return signer.signEvent({
|
return signer.signEvent({
|
||||||
kind: BitSpireEventKind.Auth,
|
kind: LamassuEventKind.Auth,
|
||||||
content: '',
|
content: '',
|
||||||
tags: [
|
tags: [
|
||||||
['relay', relayUrl],
|
['relay', relayUrl],
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ export type {
|
||||||
ClientEvents,
|
ClientEvents,
|
||||||
} from './types.js'
|
} from './types.js'
|
||||||
|
|
||||||
export { BitSpireEventKind } from './types.js'
|
export { LamassuEventKind } from './types.js'
|
||||||
|
|
||||||
// Re-export useful nostr-tools types
|
// Re-export useful nostr-tools types
|
||||||
export type { Event, UnsignedEvent, Filter } from 'nostr-tools'
|
export type { Event, UnsignedEvent, Filter } from 'nostr-tools'
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ export interface SubscriptionOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ATM-specific event kinds */
|
/** ATM-specific event kinds */
|
||||||
export enum BitSpireEventKind {
|
export enum LamassuEventKind {
|
||||||
/** CLINK Offer Request/Response */
|
/** CLINK Offer Request/Response */
|
||||||
ClinkOffer = 21001,
|
ClinkOffer = 21001,
|
||||||
/** CLINK Debit Request/Response */
|
/** CLINK Debit Request/Response */
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export default defineConfig({
|
||||||
build: {
|
build: {
|
||||||
lib: {
|
lib: {
|
||||||
entry: resolve(__dirname, 'src/index.ts'),
|
entry: resolve(__dirname, 'src/index.ts'),
|
||||||
name: 'BitSpireUIShared',
|
name: 'LamassuUIShared',
|
||||||
fileName: 'index',
|
fileName: 'index',
|
||||||
},
|
},
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
|
|
|
||||||
272
pnpm-lock.yaml
generated
272
pnpm-lock.yaml
generated
|
|
@ -211,9 +211,21 @@ importers:
|
||||||
|
|
||||||
packages/nostr-client:
|
packages/nostr-client:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@noble/curves':
|
||||||
|
specifier: ^2.0.1
|
||||||
|
version: 2.0.1
|
||||||
'@noble/hashes':
|
'@noble/hashes':
|
||||||
specifier: ^2.0.1
|
specifier: ^2.0.1
|
||||||
version: 2.0.1
|
version: 2.0.1
|
||||||
|
'@scure/base':
|
||||||
|
specifier: ^1.2.6
|
||||||
|
version: 1.2.6
|
||||||
|
'@shocknet/clink-sdk':
|
||||||
|
specifier: ^1.5.4
|
||||||
|
version: 1.5.4
|
||||||
|
'@stablelib/xchacha20':
|
||||||
|
specifier: ^2.0.1
|
||||||
|
version: 2.0.1
|
||||||
nostr-tools:
|
nostr-tools:
|
||||||
specifier: ^2.10.0
|
specifier: ^2.10.0
|
||||||
version: 2.19.4(typescript@5.9.3)
|
version: 2.19.4(typescript@5.9.3)
|
||||||
|
|
@ -221,6 +233,9 @@ importers:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.19.7
|
specifier: ^22.19.7
|
||||||
version: 22.19.7
|
version: 22.19.7
|
||||||
|
qrcode:
|
||||||
|
specifier: ^1.5.4
|
||||||
|
version: 1.5.4
|
||||||
tsx:
|
tsx:
|
||||||
specifier: ^4.19.0
|
specifier: ^4.19.0
|
||||||
version: 4.21.0
|
version: 4.21.0
|
||||||
|
|
@ -230,6 +245,9 @@ importers:
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.1.9(@types/node@22.19.7)(lightningcss@1.30.2)
|
version: 2.1.9(@types/node@22.19.7)(lightningcss@1.30.2)
|
||||||
|
ws:
|
||||||
|
specifier: ^8.19.0
|
||||||
|
version: 8.19.0
|
||||||
|
|
||||||
packages/state-machine:
|
packages/state-machine:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -1089,10 +1107,28 @@ packages:
|
||||||
resolution: {integrity: sha512-9On64rhzuqKdOQyiYLYv2lQOh3TZU/D3+IWCR5gk0alPel2nwpp4YwDEGiUBfrQZEdQ6xww0PWkzqth4wqwX3Q==}
|
resolution: {integrity: sha512-9On64rhzuqKdOQyiYLYv2lQOh3TZU/D3+IWCR5gk0alPel2nwpp4YwDEGiUBfrQZEdQ6xww0PWkzqth4wqwX3Q==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
'@shocknet/clink-sdk@1.5.4':
|
||||||
|
resolution: {integrity: sha512-YrKR7oFjzUmBhm4p8pT6mP3YC7UdWbIlh5PMXHSwSLfJhL8Ddx91NLIfOHUTcQa1cDf27uvillMFXYrVUMCRuQ==}
|
||||||
|
|
||||||
'@sindresorhus/is@4.6.0':
|
'@sindresorhus/is@4.6.0':
|
||||||
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
'@stablelib/binary@2.0.1':
|
||||||
|
resolution: {integrity: sha512-U9iAO8lXgEDONsA0zPPSgcf3HUBNAqHiJmSHgZz62OvC3Hi2Bhc5kTnQ3S1/L+sthDTHtCMhcEiklmIly6uQ3w==}
|
||||||
|
|
||||||
|
'@stablelib/chacha@2.0.1':
|
||||||
|
resolution: {integrity: sha512-lS1FqtNqofxe2vLkRsLli2m3x/XanUyAYRphLhdHumKeIsLbjbCXdCq3Pf/eWiO7G3QlSG5ViqnoVjktzfLWMg==}
|
||||||
|
|
||||||
|
'@stablelib/int@2.0.1':
|
||||||
|
resolution: {integrity: sha512-Ht63fQp3wz/F8U4AlXEPb7hfJOIILs8Lq55jgtD7KueWtyjhVuzcsGLSTAWtZs3XJDZYdF1WcSKn+kBtbzupww==}
|
||||||
|
|
||||||
|
'@stablelib/wipe@2.0.1':
|
||||||
|
resolution: {integrity: sha512-1eU2K9EgOcV4qc9jcP6G72xxZxEm5PfeI5H55l08W95b4oRJaqhmlWRc4xZAm6IVSKhVNxMi66V67hCzzuMTAg==}
|
||||||
|
|
||||||
|
'@stablelib/xchacha20@2.0.1':
|
||||||
|
resolution: {integrity: sha512-k55pNv7gIM4mUPU00+nJYTxKiUVNwAtsgrridC0aIU5cVbw9u6qP99x8ENu5eiwOEhZUNg+p3tTOLooCeAOJQA==}
|
||||||
|
|
||||||
'@swc/helpers@0.5.18':
|
'@swc/helpers@0.5.18':
|
||||||
resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==}
|
resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==}
|
||||||
|
|
||||||
|
|
@ -1554,6 +1590,10 @@ packages:
|
||||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
camelcase@5.3.1:
|
||||||
|
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
chai@5.3.3:
|
chai@5.3.3:
|
||||||
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -1599,6 +1639,9 @@ packages:
|
||||||
resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==}
|
resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
cliui@6.0.0:
|
||||||
|
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||||
|
|
||||||
cliui@8.0.1:
|
cliui@8.0.1:
|
||||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
@ -1701,6 +1744,10 @@ packages:
|
||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decamelize@1.2.0:
|
||||||
|
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
decompress-response@6.0.0:
|
decompress-response@6.0.0:
|
||||||
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -1745,6 +1792,9 @@ packages:
|
||||||
detect-node@2.1.0:
|
detect-node@2.1.0:
|
||||||
resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==}
|
resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3:
|
||||||
|
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||||
|
|
||||||
dir-compare@4.2.0:
|
dir-compare@4.2.0:
|
||||||
resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==}
|
resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==}
|
||||||
|
|
||||||
|
|
@ -1915,6 +1965,10 @@ packages:
|
||||||
filelist@1.0.4:
|
filelist@1.0.4:
|
||||||
resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
|
resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
|
||||||
|
|
||||||
|
find-up@4.1.0:
|
||||||
|
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
foreground-child@3.3.1:
|
foreground-child@3.3.1:
|
||||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
@ -1989,6 +2043,10 @@ packages:
|
||||||
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
|
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
|
hasBin: true
|
||||||
|
|
||||||
|
glob@13.0.0:
|
||||||
|
resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==}
|
||||||
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
glob@7.2.3:
|
glob@7.2.3:
|
||||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||||
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
|
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
|
||||||
|
|
@ -2259,6 +2317,10 @@ packages:
|
||||||
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
|
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
|
|
||||||
|
locate-path@5.0.0:
|
||||||
|
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
lodash-es@4.17.23:
|
lodash-es@4.17.23:
|
||||||
resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==}
|
resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==}
|
||||||
|
|
||||||
|
|
@ -2294,6 +2356,10 @@ packages:
|
||||||
lru-cache@10.4.3:
|
lru-cache@10.4.3:
|
||||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||||
|
|
||||||
|
lru-cache@11.2.4:
|
||||||
|
resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
|
||||||
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
lru-cache@6.0.0:
|
lru-cache@6.0.0:
|
||||||
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
|
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -2477,6 +2543,14 @@ packages:
|
||||||
resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
|
resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
nostr-tools@2.15.1:
|
||||||
|
resolution: {integrity: sha512-LpetHDR9ltnkpJDkva/SONgyKBbsoV+5yLB8DWc0/U3lCWGtoWJw6Nbc2vR2Ai67RIQYrBQeZLyMlhwVZRK/9A==}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '>=5.0.0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
|
||||||
nostr-tools@2.19.4:
|
nostr-tools@2.19.4:
|
||||||
resolution: {integrity: sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg==}
|
resolution: {integrity: sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -2515,20 +2589,36 @@ packages:
|
||||||
resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==}
|
resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
p-limit@2.3.0:
|
||||||
|
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
p-limit@3.1.0:
|
p-limit@3.1.0:
|
||||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
p-locate@4.1.0:
|
||||||
|
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
p-map@4.0.0:
|
p-map@4.0.0:
|
||||||
resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==}
|
resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
p-try@2.2.0:
|
||||||
|
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
package-json-from-dist@1.0.1:
|
package-json-from-dist@1.0.1:
|
||||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||||
|
|
||||||
path-browserify@1.0.1:
|
path-browserify@1.0.1:
|
||||||
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
||||||
|
|
||||||
|
path-exists@4.0.0:
|
||||||
|
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
path-is-absolute@1.0.1:
|
path-is-absolute@1.0.1:
|
||||||
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
|
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -2541,6 +2631,10 @@ packages:
|
||||||
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
|
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
|
||||||
engines: {node: '>=16 || 14 >=14.18'}
|
engines: {node: '>=16 || 14 >=14.18'}
|
||||||
|
|
||||||
|
path-scurry@2.0.1:
|
||||||
|
resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==}
|
||||||
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
pathe@1.1.2:
|
pathe@1.1.2:
|
||||||
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
|
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
|
||||||
|
|
||||||
|
|
@ -2575,6 +2669,10 @@ packages:
|
||||||
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
|
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
|
||||||
engines: {node: '>=10.4.0'}
|
engines: {node: '>=10.4.0'}
|
||||||
|
|
||||||
|
pngjs@5.0.0:
|
||||||
|
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
postcss@8.5.6:
|
postcss@8.5.6:
|
||||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
@ -2625,6 +2723,11 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: ^3.0.0
|
vue: ^3.0.0
|
||||||
|
|
||||||
|
qrcode@1.5.4:
|
||||||
|
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
quick-lru@5.1.1:
|
quick-lru@5.1.1:
|
||||||
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
|
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -2656,6 +2759,9 @@ packages:
|
||||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0:
|
||||||
|
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||||
|
|
||||||
resedit@1.7.2:
|
resedit@1.7.2:
|
||||||
resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==}
|
resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==}
|
||||||
engines: {node: '>=12', npm: '>=6'}
|
engines: {node: '>=12', npm: '>=6'}
|
||||||
|
|
@ -2682,6 +2788,11 @@ packages:
|
||||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
rimraf@6.1.2:
|
||||||
|
resolution: {integrity: sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==}
|
||||||
|
engines: {node: 20 || >=22}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
roarr@2.15.4:
|
roarr@2.15.4:
|
||||||
resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==}
|
resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==}
|
||||||
engines: {node: '>=8.0'}
|
engines: {node: '>=8.0'}
|
||||||
|
|
@ -3134,6 +3245,9 @@ packages:
|
||||||
wcwidth@1.0.1:
|
wcwidth@1.0.1:
|
||||||
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
|
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
|
||||||
|
|
||||||
|
which-module@2.0.1:
|
||||||
|
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
@ -3147,6 +3261,10 @@ packages:
|
||||||
wide-align@1.1.5:
|
wide-align@1.1.5:
|
||||||
resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==}
|
resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==}
|
||||||
|
|
||||||
|
wrap-ansi@6.2.0:
|
||||||
|
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -3158,6 +3276,18 @@ packages:
|
||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||||
|
|
||||||
|
ws@8.19.0:
|
||||||
|
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
bufferutil: ^4.0.1
|
||||||
|
utf-8-validate: '>=5.0.2'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
bufferutil:
|
||||||
|
optional: true
|
||||||
|
utf-8-validate:
|
||||||
|
optional: true
|
||||||
|
|
||||||
xmlbuilder@15.1.1:
|
xmlbuilder@15.1.1:
|
||||||
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
|
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
|
||||||
engines: {node: '>=8.0'}
|
engines: {node: '>=8.0'}
|
||||||
|
|
@ -3165,6 +3295,9 @@ packages:
|
||||||
xstate@5.25.1:
|
xstate@5.25.1:
|
||||||
resolution: {integrity: sha512-oyvsNH5pF2qkHmiHEMdWqc3OjDtoZOH2MTAI35r01f/ZQWOD+VLOiYqo65UgQET0XMA5s9eRm8fnsIo+82biEw==}
|
resolution: {integrity: sha512-oyvsNH5pF2qkHmiHEMdWqc3OjDtoZOH2MTAI35r01f/ZQWOD+VLOiYqo65UgQET0XMA5s9eRm8fnsIo+82biEw==}
|
||||||
|
|
||||||
|
y18n@4.0.3:
|
||||||
|
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||||
|
|
||||||
y18n@5.0.8:
|
y18n@5.0.8:
|
||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -3172,10 +3305,18 @@ packages:
|
||||||
yallist@4.0.0:
|
yallist@4.0.0:
|
||||||
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
|
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
|
||||||
|
|
||||||
|
yargs-parser@18.1.3:
|
||||||
|
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
yargs-parser@21.1.1:
|
yargs-parser@21.1.1:
|
||||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
yargs@15.4.1:
|
||||||
|
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
yargs@17.7.2:
|
yargs@17.7.2:
|
||||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
@ -3786,8 +3927,35 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@shocknet/clink-sdk@1.5.4':
|
||||||
|
dependencies:
|
||||||
|
'@noble/hashes': 1.8.0
|
||||||
|
'@scure/base': 1.2.6
|
||||||
|
nostr-tools: 2.15.1(typescript@5.9.3)
|
||||||
|
rimraf: 6.1.2
|
||||||
|
typescript: 5.9.3
|
||||||
|
|
||||||
'@sindresorhus/is@4.6.0': {}
|
'@sindresorhus/is@4.6.0': {}
|
||||||
|
|
||||||
|
'@stablelib/binary@2.0.1':
|
||||||
|
dependencies:
|
||||||
|
'@stablelib/int': 2.0.1
|
||||||
|
|
||||||
|
'@stablelib/chacha@2.0.1':
|
||||||
|
dependencies:
|
||||||
|
'@stablelib/binary': 2.0.1
|
||||||
|
'@stablelib/wipe': 2.0.1
|
||||||
|
|
||||||
|
'@stablelib/int@2.0.1': {}
|
||||||
|
|
||||||
|
'@stablelib/wipe@2.0.1': {}
|
||||||
|
|
||||||
|
'@stablelib/xchacha20@2.0.1':
|
||||||
|
dependencies:
|
||||||
|
'@stablelib/binary': 2.0.1
|
||||||
|
'@stablelib/chacha': 2.0.1
|
||||||
|
'@stablelib/wipe': 2.0.1
|
||||||
|
|
||||||
'@swc/helpers@0.5.18':
|
'@swc/helpers@0.5.18':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
@ -4371,6 +4539,8 @@ snapshots:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
|
|
||||||
|
camelcase@5.3.1: {}
|
||||||
|
|
||||||
chai@5.3.3:
|
chai@5.3.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
assertion-error: 2.0.1
|
assertion-error: 2.0.1
|
||||||
|
|
@ -4412,6 +4582,12 @@ snapshots:
|
||||||
string-width: 4.2.3
|
string-width: 4.2.3
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
cliui@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
string-width: 4.2.3
|
||||||
|
strip-ansi: 6.0.1
|
||||||
|
wrap-ansi: 6.2.0
|
||||||
|
|
||||||
cliui@8.0.1:
|
cliui@8.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 4.2.3
|
string-width: 4.2.3
|
||||||
|
|
@ -4502,6 +4678,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
decamelize@1.2.0: {}
|
||||||
|
|
||||||
decompress-response@6.0.0:
|
decompress-response@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
mimic-response: 3.1.0
|
mimic-response: 3.1.0
|
||||||
|
|
@ -4541,6 +4719,8 @@ snapshots:
|
||||||
detect-node@2.1.0:
|
detect-node@2.1.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3: {}
|
||||||
|
|
||||||
dir-compare@4.2.0:
|
dir-compare@4.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
minimatch: 3.1.2
|
minimatch: 3.1.2
|
||||||
|
|
@ -4815,6 +4995,11 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
minimatch: 5.1.6
|
minimatch: 5.1.6
|
||||||
|
|
||||||
|
find-up@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
locate-path: 5.0.0
|
||||||
|
path-exists: 4.0.0
|
||||||
|
|
||||||
foreground-child@3.3.1:
|
foreground-child@3.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
|
|
@ -4916,6 +5101,12 @@ snapshots:
|
||||||
package-json-from-dist: 1.0.1
|
package-json-from-dist: 1.0.1
|
||||||
path-scurry: 1.11.1
|
path-scurry: 1.11.1
|
||||||
|
|
||||||
|
glob@13.0.0:
|
||||||
|
dependencies:
|
||||||
|
minimatch: 10.1.1
|
||||||
|
minipass: 7.1.2
|
||||||
|
path-scurry: 2.0.1
|
||||||
|
|
||||||
glob@7.2.3:
|
glob@7.2.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
fs.realpath: 1.0.0
|
fs.realpath: 1.0.0
|
||||||
|
|
@ -5177,6 +5368,10 @@ snapshots:
|
||||||
lightningcss-win32-arm64-msvc: 1.30.2
|
lightningcss-win32-arm64-msvc: 1.30.2
|
||||||
lightningcss-win32-x64-msvc: 1.30.2
|
lightningcss-win32-x64-msvc: 1.30.2
|
||||||
|
|
||||||
|
locate-path@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
p-locate: 4.1.0
|
||||||
|
|
||||||
lodash-es@4.17.23: {}
|
lodash-es@4.17.23: {}
|
||||||
|
|
||||||
lodash.defaults@4.2.0: {}
|
lodash.defaults@4.2.0: {}
|
||||||
|
|
@ -5202,6 +5397,8 @@ snapshots:
|
||||||
|
|
||||||
lru-cache@10.4.3: {}
|
lru-cache@10.4.3: {}
|
||||||
|
|
||||||
|
lru-cache@11.2.4: {}
|
||||||
|
|
||||||
lru-cache@6.0.0:
|
lru-cache@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
yallist: 4.0.0
|
yallist: 4.0.0
|
||||||
|
|
@ -5378,6 +5575,18 @@ snapshots:
|
||||||
|
|
||||||
normalize-url@6.1.0: {}
|
normalize-url@6.1.0: {}
|
||||||
|
|
||||||
|
nostr-tools@2.15.1(typescript@5.9.3):
|
||||||
|
dependencies:
|
||||||
|
'@noble/ciphers': 0.5.3
|
||||||
|
'@noble/curves': 1.2.0
|
||||||
|
'@noble/hashes': 1.3.1
|
||||||
|
'@scure/base': 1.1.1
|
||||||
|
'@scure/bip32': 1.3.1
|
||||||
|
'@scure/bip39': 1.2.1
|
||||||
|
nostr-wasm: 0.1.0
|
||||||
|
optionalDependencies:
|
||||||
|
typescript: 5.9.3
|
||||||
|
|
||||||
nostr-tools@2.19.4(typescript@5.9.3):
|
nostr-tools@2.19.4(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@noble/ciphers': 0.5.3
|
'@noble/ciphers': 0.5.3
|
||||||
|
|
@ -5426,18 +5635,30 @@ snapshots:
|
||||||
|
|
||||||
p-cancelable@2.1.1: {}
|
p-cancelable@2.1.1: {}
|
||||||
|
|
||||||
|
p-limit@2.3.0:
|
||||||
|
dependencies:
|
||||||
|
p-try: 2.2.0
|
||||||
|
|
||||||
p-limit@3.1.0:
|
p-limit@3.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
yocto-queue: 0.1.0
|
yocto-queue: 0.1.0
|
||||||
|
|
||||||
|
p-locate@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
p-limit: 2.3.0
|
||||||
|
|
||||||
p-map@4.0.0:
|
p-map@4.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
aggregate-error: 3.1.0
|
aggregate-error: 3.1.0
|
||||||
|
|
||||||
|
p-try@2.2.0: {}
|
||||||
|
|
||||||
package-json-from-dist@1.0.1: {}
|
package-json-from-dist@1.0.1: {}
|
||||||
|
|
||||||
path-browserify@1.0.1: {}
|
path-browserify@1.0.1: {}
|
||||||
|
|
||||||
|
path-exists@4.0.0: {}
|
||||||
|
|
||||||
path-is-absolute@1.0.1: {}
|
path-is-absolute@1.0.1: {}
|
||||||
|
|
||||||
path-key@3.1.1: {}
|
path-key@3.1.1: {}
|
||||||
|
|
@ -5447,6 +5668,11 @@ snapshots:
|
||||||
lru-cache: 10.4.3
|
lru-cache: 10.4.3
|
||||||
minipass: 7.1.2
|
minipass: 7.1.2
|
||||||
|
|
||||||
|
path-scurry@2.0.1:
|
||||||
|
dependencies:
|
||||||
|
lru-cache: 11.2.4
|
||||||
|
minipass: 7.1.2
|
||||||
|
|
||||||
pathe@1.1.2: {}
|
pathe@1.1.2: {}
|
||||||
|
|
||||||
pathval@2.0.1: {}
|
pathval@2.0.1: {}
|
||||||
|
|
@ -5475,6 +5701,8 @@ snapshots:
|
||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
xmlbuilder: 15.1.1
|
xmlbuilder: 15.1.1
|
||||||
|
|
||||||
|
pngjs@5.0.0: {}
|
||||||
|
|
||||||
postcss@8.5.6:
|
postcss@8.5.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.11
|
nanoid: 3.3.11
|
||||||
|
|
@ -5522,6 +5750,12 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.27(typescript@5.9.3)
|
vue: 3.5.27(typescript@5.9.3)
|
||||||
|
|
||||||
|
qrcode@1.5.4:
|
||||||
|
dependencies:
|
||||||
|
dijkstrajs: 1.0.3
|
||||||
|
pngjs: 5.0.0
|
||||||
|
yargs: 15.4.1
|
||||||
|
|
||||||
quick-lru@5.1.1: {}
|
quick-lru@5.1.1: {}
|
||||||
|
|
||||||
rc@1.2.8:
|
rc@1.2.8:
|
||||||
|
|
@ -5576,6 +5810,8 @@ snapshots:
|
||||||
|
|
||||||
require-directory@2.1.1: {}
|
require-directory@2.1.1: {}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0: {}
|
||||||
|
|
||||||
resedit@1.7.2:
|
resedit@1.7.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
pe-library: 0.4.1
|
pe-library: 0.4.1
|
||||||
|
|
@ -5599,6 +5835,11 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
glob: 7.2.3
|
glob: 7.2.3
|
||||||
|
|
||||||
|
rimraf@6.1.2:
|
||||||
|
dependencies:
|
||||||
|
glob: 13.0.0
|
||||||
|
package-json-from-dist: 1.0.1
|
||||||
|
|
||||||
roarr@2.15.4:
|
roarr@2.15.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
boolean: 3.2.0
|
boolean: 3.2.0
|
||||||
|
|
@ -6050,6 +6291,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
defaults: 1.0.4
|
defaults: 1.0.4
|
||||||
|
|
||||||
|
which-module@2.0.1: {}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
|
|
@ -6063,6 +6306,12 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 4.2.3
|
string-width: 4.2.3
|
||||||
|
|
||||||
|
wrap-ansi@6.2.0:
|
||||||
|
dependencies:
|
||||||
|
ansi-styles: 4.3.0
|
||||||
|
string-width: 4.2.3
|
||||||
|
strip-ansi: 6.0.1
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
|
|
@ -6077,16 +6326,39 @@ snapshots:
|
||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
|
ws@8.19.0: {}
|
||||||
|
|
||||||
xmlbuilder@15.1.1: {}
|
xmlbuilder@15.1.1: {}
|
||||||
|
|
||||||
xstate@5.25.1: {}
|
xstate@5.25.1: {}
|
||||||
|
|
||||||
|
y18n@4.0.3: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|
||||||
yallist@4.0.0: {}
|
yallist@4.0.0: {}
|
||||||
|
|
||||||
|
yargs-parser@18.1.3:
|
||||||
|
dependencies:
|
||||||
|
camelcase: 5.3.1
|
||||||
|
decamelize: 1.2.0
|
||||||
|
|
||||||
yargs-parser@21.1.1: {}
|
yargs-parser@21.1.1: {}
|
||||||
|
|
||||||
|
yargs@15.4.1:
|
||||||
|
dependencies:
|
||||||
|
cliui: 6.0.0
|
||||||
|
decamelize: 1.2.0
|
||||||
|
find-up: 4.1.0
|
||||||
|
get-caller-file: 2.0.5
|
||||||
|
require-directory: 2.1.1
|
||||||
|
require-main-filename: 2.0.0
|
||||||
|
set-blocking: 2.0.0
|
||||||
|
string-width: 4.2.3
|
||||||
|
which-module: 2.0.1
|
||||||
|
y18n: 4.0.3
|
||||||
|
yargs-parser: 18.1.3
|
||||||
|
|
||||||
yargs@17.7.2:
|
yargs@17.7.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
cliui: 8.0.1
|
cliui: 8.0.1
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue