Compare commits

..

No commits in common. "fa4858ed66673d7b576cbf8e7092ec309e574a5a" and "695a8bf98d95b5f3d7ac99f81684123937f8176b" have entirely different histories.

51 changed files with 5806 additions and 845 deletions

View file

@ -175,7 +175,7 @@ From git commits since last release:
### Suggested Updates
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
- `packages/cashu/src/wallet.ts` - No API docs

View file

@ -2,9 +2,9 @@
## 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.
@ -16,7 +16,7 @@ Validate HAL driver implementations in `packages/hal/` against published hardwar
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
- `safety` — Type safety, error handling, hardware safety review
- `mock` — Validate mock implementation completeness
@ -31,9 +31,9 @@ Drivers:
### 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/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):
```javascript
// lamassu-machine c0b69d1 — lib/id003/id003rs232.js
// lamassu-machine v8.1.5 — lib/id003/id003rs232.js
function buildPacket(data) {
const buf = Buffer.alloc(data.length + 4)
buf[0] = 0x02 // SYNC
@ -234,6 +234,6 @@ A discrepancy here (different CRC polynomial, different framing, different endia
## 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`.
- 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.
- 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.
- "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.

View file

@ -17,7 +17,7 @@ Where `target` can be:
## Relevant NIPs for bitSpire
### Core NIPs (Must Implement)
| NIP | Description | Usage in bitSpire |
| NIP | Description | Usage in Lamassu |
|-----|-------------|------------------|
| NIP-01 | Basic protocol | Event structure, relay communication |
| NIP-19 | bech32 entities | npub, nsec, nprofile encoding |
@ -26,7 +26,7 @@ Where `target` can be:
| NIP-59 | Gift wrapping | Anonymous message delivery |
### Application NIPs
| NIP | Description | Usage in bitSpire |
| NIP | Description | Usage in Lamassu |
|-----|-------------|------------------|
| NIP-17 | Private DMs | Receipt delivery |
| NIP-47 | Nostr Wallet Connect | Potential wallet integration |

513
.devenv.flake.nix Normal file
View file

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

5
.gitignore vendored
View file

@ -55,10 +55,13 @@ apps/machine/src/services/*.js
# devenv
.devenv/
.devenv.flake.nix
.direnv/
.pre-commit-config.yaml
# Docker
docker/**/data/
docker/.state/
# Nix build outputs
result
result-*

View file

@ -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.
**Reference permission (2026-10-09).** The maintainer taking over the Lamassu
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.
**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.
**Boundaries, for the record.** lamassu-machine's last public-domain commit is `c0b69d1`
(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.
The `lamassu-server` boundary has not been re-verified against its own history and may differ — check its license-change commit before referencing it.
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 |
|---|---|
| Validators | id003, ebds |
| Dispensers | f56, puloon |
| Recyclers | none — MEI SCR hardware in BATM3; a clean-room BNR Advance route exists via the MIT `bnr-xfs` crate (see the port backlog) |
| Printers | none — `packages/hal/src/printers/` does not exist |
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.
| Validators | id003, ccnet, cashflow_sc, bnr_advance, genmega, hcm2, gsr50 |
| Dispensers | puloon, f56, genmega, hcm2, gsr50 |
| Recyclers | MEI SCR (planned — hardware in BATM3, no driver yet) |
| Printers | nippon, zebra, genmega |
### Sintra hardware specifics (Aaeon UP Board)

View file

@ -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.
> 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
@ -26,8 +26,8 @@ The `nostrrelay` extension inside LNbits is what the ATM connects to — there i
```bash
# 1. Clone
git clone ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git
cd bitspire
git clone ssh://forgejo@git.atitlan.io/aiolabs/lamassu-next.git
cd lamassu-next # repo name kept for now — rename to bitSpire is a follow-up
git checkout dev
# 2. Enter the dev environment

View file

@ -6,17 +6,17 @@
# =============================================================================
# Machine model preset (sintra, gaia, or custom)
VITE_BITSPIRE_MACHINE_MODEL=sintra
VITE_LAMASSU_MACHINE_MODEL=sintra
# 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)
# VITE_BITSPIRE_VALIDATOR_DEVICE=/dev/ttyJ5
# VITE_BITSPIRE_DISPENSER_DEVICE=/dev/ttyJ7
# VITE_LAMASSU_VALIDATOR_DEVICE=/dev/ttyJ5
# VITE_LAMASSU_DISPENSER_DEVICE=/dev/ttyJ7
# 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

View file

@ -355,11 +355,11 @@ ipcMain.handle('get-config', () => {
appId: process.env.VITE_APP_ID || '',
// Hardware configuration
machineModel: process.env.VITE_BITSPIRE_MACHINE_MODEL || 'sintra',
fiatCode: process.env.VITE_BITSPIRE_FIAT_CODE || 'USD',
validatorDevice: process.env.VITE_BITSPIRE_VALIDATOR_DEVICE,
dispenserDevice: process.env.VITE_BITSPIRE_DISPENSER_DEVICE,
cassettes: process.env.VITE_BITSPIRE_CASSETTES,
machineModel: process.env.VITE_LAMASSU_MACHINE_MODEL || 'sintra',
fiatCode: process.env.VITE_LAMASSU_FIAT_CODE || 'USD',
validatorDevice: process.env.VITE_LAMASSU_VALIDATOR_DEVICE,
dispenserDevice: process.env.VITE_LAMASSU_DISPENSER_DEVICE,
cassettes: process.env.VITE_LAMASSU_CASSETTES,
// SECURITY: In production (packaged app), mock fallback is always disabled.
// Only allow it in development mode, and only when explicitly opted in via env.
allowMockFallback: isDev && process.env.VITE_ALLOW_MOCK_FALLBACK === 'true',
@ -836,7 +836,7 @@ function startCommandPoller(): void {
const result = await halInstance.dispenseCash(parsed.bills)
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 fiatCode = process.env.VITE_BITSPIRE_FIAT_CODE || 'USD'
const fiatCode = process.env.VITE_LAMASSU_FIAT_CODE || 'USD'
recordTransaction({
txid,
@ -908,19 +908,19 @@ app.whenReady().then(() => {
if (existing.length === 0) {
let seedCassettes: { denomination: number; count: number }[] = []
// Priority 1: explicit VITE_BITSPIRE_CASSETTES env var
const cassettesJson = process.env.VITE_BITSPIRE_CASSETTES
// Priority 1: explicit VITE_LAMASSU_CASSETTES env var
const cassettesJson = process.env.VITE_LAMASSU_CASSETTES
if (cassettesJson) {
try {
seedCassettes = JSON.parse(cassettesJson)
} 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
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 }[]> = {
douro: [
{ denomination: 100, count: 50 },

View file

@ -35,8 +35,8 @@ export const themes: ThemeOption[] = [
{ id: 'starrynight', label: 'Starry Night' },
]
const THEME_KEY = 'bitspire-theme'
const MODE_KEY = 'bitspire-color-mode'
const THEME_KEY = 'lamassu-theme'
const MODE_KEY = 'lamassu-color-mode'
const isElectron = !!(window as unknown as Record<string, unknown>).electronAPI

View file

@ -6,7 +6,7 @@
*
* Configuration priority (highest to lowest):
* 1. Runtime overrides (passed directly to functions)
* 2. Environment variables (BITSPIRE_*)
* 2. Environment variables (LAMASSU_*)
* 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
// /dev/ttyACM0 default assumed a CDC-ACM BNR; this hardware enumerates as
// 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',
},
dispenser: {
@ -152,7 +152,7 @@ export const MACHINE_PRESETS: Record<MachineModel, Omit<DeviceConfig, 'fiatCode'
// /dev/ttyF56 (batm3.nix, serial DDDLb103Y23). Avoids the raw
// /dev/ttyUSB0, which is enumeration-order dependent and could point at
// the wrong adapter after a re-plug/reboot. Per-box override:
// VITE_BITSPIRE_DISPENSER_DEVICE.
// VITE_LAMASSU_DISPENSER_DEVICE.
device: '/dev/ttyF56',
cassettes: [
{ denomination: 20, count: 400 },
@ -214,31 +214,31 @@ export function getDeviceConfig(
* Load device configuration from environment variables
*
* Supported variables:
* - BITSPIRE_MACHINE_MODEL: Machine model preset (sintra, gaia, custom)
* - BITSPIRE_FIAT_CODE: Fiat currency code (USD, EUR, etc.)
* - BITSPIRE_VALIDATOR_DEVICE: Bill validator serial device path
* - BITSPIRE_DISPENSER_DEVICE: Bill dispenser serial device path
* - BITSPIRE_CASSETTES: JSON array of cassette configs
* - LAMASSU_MACHINE_MODEL: Machine model preset (sintra, gaia, custom)
* - LAMASSU_FIAT_CODE: Fiat currency code (USD, EUR, etc.)
* - LAMASSU_VALIDATOR_DEVICE: Bill validator serial device path
* - LAMASSU_DISPENSER_DEVICE: Bill dispenser serial device path
* - LAMASSU_CASSETTES: JSON array of cassette configs
*
* @example
* BITSPIRE_MACHINE_MODEL=sintra
* BITSPIRE_FIAT_CODE=USD
* BITSPIRE_CASSETTES='[{"denomination":20,"count":100},{"denomination":50,"count":50}]'
* LAMASSU_MACHINE_MODEL=sintra
* LAMASSU_FIAT_CODE=USD
* LAMASSU_CASSETTES='[{"denomination":20,"count":100},{"denomination":50,"count":50}]'
*/
export function loadDeviceConfigFromEnv(): DeviceConfig {
const model = (import.meta.env.VITE_BITSPIRE_MACHINE_MODEL as MachineModel) || 'sintra'
const fiatCode = import.meta.env.VITE_BITSPIRE_FIAT_CODE || 'USD'
const model = (import.meta.env.VITE_LAMASSU_MACHINE_MODEL as MachineModel) || 'sintra'
const fiatCode = import.meta.env.VITE_LAMASSU_FIAT_CODE || 'USD'
const overrides: Partial<DeviceConfig> = {}
// Validator device override (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) {
overrides.validator = { type: MACHINE_PRESETS[model].validator.type, device: validatorDevice }
}
// Dispenser device override
const dispenserDevice = import.meta.env.VITE_BITSPIRE_DISPENSER_DEVICE
const dispenserDevice = import.meta.env.VITE_LAMASSU_DISPENSER_DEVICE
if (dispenserDevice) {
overrides.dispenser = {
type: MACHINE_PRESETS[model].dispenser.type,
@ -248,7 +248,7 @@ export function loadDeviceConfigFromEnv(): DeviceConfig {
}
// Cassettes override (JSON)
const cassettesJson = import.meta.env.VITE_BITSPIRE_CASSETTES
const cassettesJson = import.meta.env.VITE_LAMASSU_CASSETTES
if (cassettesJson) {
try {
const cassettes = JSON.parse(cassettesJson) as CassetteConfig[]
@ -262,7 +262,7 @@ export function loadDeviceConfigFromEnv(): DeviceConfig {
}
}
} catch (e) {
console.error('[Config] Failed to parse BITSPIRE_CASSETTES:', e)
console.error('[Config] Failed to parse LAMASSU_CASSETTES:', e)
}
}

View file

@ -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

View file

@ -141,7 +141,7 @@ sql "SELECT
t.fiat_cents / 100 AS 'Fiat',
t.sats AS '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',
datetime(t.created_at / 1000, 'unixepoch') AS 'Time (UTC)',
group_concat('Q' || tb.denomination || 'x' || tb.count) AS 'Bills'

View file

@ -376,7 +376,6 @@ in
# ATM operations
sqlite
(writeShellScriptBin "atm-transactions" (builtins.readFile ./atm-transactions.sh))
(writeShellScriptBin "atm-reconcile" (builtins.readFile ./atm-reconcile.sh))
];
# 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
services.journald = {
extraConfig = ''

View file

@ -40,8 +40,8 @@ ENV=/var/lib/bitspire/.env
DB=/var/lib/bitspire/state.db
# 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)
fiat=$(grep -E '^VITE_BITSPIRE_FIAT_CODE=' "$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_LAMASSU_FIAT_CODE=' "$ENV" 2>/dev/null | cut -d= -f2- || true)
model=${model:-sintra}
fiat=${fiat:-EUR}
@ -52,8 +52,8 @@ rm -f "$DB" "$DB-wal" "$DB-shm"
# Truncate .env to the minimal image-baked template.
cat > "$ENV" <<EOF
VITE_BITSPIRE_MACHINE_MODEL=$model
VITE_BITSPIRE_FIAT_CODE=$fiat
VITE_LAMASSU_MACHINE_MODEL=$model
VITE_LAMASSU_FIAT_CODE=$fiat
VITE_SPIRE_SEED=
ELECTRON_FORCE_PROD=1
DISPLAY=:0

View file

@ -29,8 +29,8 @@ let
# 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.
envTemplate = pkgs.writeText "bitspire-env" ''
VITE_BITSPIRE_MACHINE_MODEL=${machineModel}
VITE_BITSPIRE_FIAT_CODE=${fiatCodeForModel}
VITE_LAMASSU_MACHINE_MODEL=${machineModel}
VITE_LAMASSU_FIAT_CODE=${fiatCodeForModel}
VITE_SPIRE_SEED=
ELECTRON_FORCE_PROD=1
DISPLAY=:0

View file

@ -132,8 +132,8 @@ $TRANSPORT_LINES
$IDENTITY_LINES
# Machine configuration
VITE_BITSPIRE_MACHINE_MODEL=$MODEL
VITE_BITSPIRE_FIAT_CODE=$FIAT_CODE
VITE_LAMASSU_MACHINE_MODEL=$MODEL
VITE_LAMASSU_FIAT_CODE=$FIAT_CODE
# Force production mode
ELECTRON_FORCE_PROD=1

View file

@ -1,7 +1,8 @@
{ pkgs, lib, config, ... }:
{
name = "bitspire";
# Project metadata
name = "lamassu-next";
# ============================================
# Languages
@ -18,11 +19,9 @@
languages.typescript.enable = true;
# Use nixpkgs rust (simpler, no overlay needed)
packages = with pkgs; [
# Rust toolchain — only the orphaned Rust HAL under packages/hal/src
# 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.
# Rust toolchain from nixpkgs
rustc
cargo
clippy
@ -34,22 +33,42 @@
openssl
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 ]))
# Hardware access
libusb1
udev
# Serial port access (talk to a validator / dispenser by hand)
# Serial port access
picocom
# Development utilities
just # Task runner
jq # JSON processing
websocat # WebSocket client — relay-test below
websocat # WebSocket client
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)
# ============================================
@ -78,24 +97,21 @@
env = {
RUST_BACKTRACE = "1";
DATABASE_URL = "postgresql://localhost/lamassu_dev";
# The dev relay is LNbits's bundled nostrrelay extension on the native
# instance (see CLAUDE.md → Environment variables); there is no
# in-repo relay container any more. Override per shell as needed.
NOSTR_RELAY_URL = "ws://localhost:5001/nostrrelay/test";
# Nostr development relay
NOSTR_RELAY_URL = "ws://localhost:7777";
# Lightning.Pub development
LIGHTNING_PUB_URL = "http://localhost:1776";
# For Tauri
PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig";
};
# ============================================
# 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 = {
dev.exec = "pnpm turbo dev";
@ -103,16 +119,421 @@
test.exec = "pnpm turbo test";
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 = ''
echo "Testing Nostr relay connection to $NOSTR_RELAY_URL ..."
echo '["REQ", "test", {"kinds": [0], "limit": 1}]' | websocat "$NOSTR_RELAY_URL"
echo "Testing Nostr relay connection..."
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 = ''
echo ""
echo " ⚡ bitSpire - Nostr-Native Lightning ATM"
echo " ⚡ Lamassu Next - Nostr-Native Lightning ATM"
echo " ─────────────────────────────────────────────"
echo " Node.js: $(node --version)"
echo " Rust: $(rustc --version | cut -d' ' -f2)"
@ -122,8 +543,30 @@
echo " dev Start development servers"
echo " build Build all packages"
echo " test Run tests"
echo " lint Lint all packages"
echo " relay-test Test the dev relay ($NOSTR_RELAY_URL)"
echo ""
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 ""
'';
}

1087
docker/dev.sh Executable file

File diff suppressed because it is too large Load diff

View 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:

View 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
View 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
View 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
View file

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

65
docker/strfry.conf Normal file
View 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"
# }

View file

@ -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.

View file

@ -68,7 +68,7 @@ noffer1<bech32-encoded-data>
The ATM wants to receive payment from a customer:
```typescript
import { encodeNoffer } from '@bitSpire/clink'
import { encodeNoffer } from '@lamassu/clink'
// ATM creates a noffer for receiving payment
const noffer = encodeNoffer({
@ -134,7 +134,7 @@ ndebit1<bech32-encoded-data>
The ATM wants to pay the customer (customer inserted cash, wants Bitcoin):
```typescript
import { encodeNdebit, formatNdebitUri } from '@bitSpire/clink'
import { encodeNdebit, formatNdebitUri } from '@lamassu/clink'
// ATM creates an ndebit for the customer to authorize withdrawal
const ndebit = encodeNdebit({
@ -438,14 +438,14 @@ import {
CLINKClient,
createOfferSuccess,
createOfferError,
} from '@bitSpire/clink'
} from '@lamassu/clink'
```
### Reference Implementation
- [CLINK Protocol Spec](https://github.com/shocknet/clink)
- [Lightning.Pub](https://github.com/shocknet/Lightning.Pub)
- [@bitSpire/clink](../packages/clink/) - TypeScript implementation
- [@lamassu/clink](../packages/clink/) - TypeScript implementation
## Related NIPs

View file

@ -48,24 +48,24 @@ All configuration can be overridden via environment variables. For Vite/Electron
| Variable | Description | Default |
| ------------------------------- | ---------------------------- | ------------- |
| `VITE_BITSPIRE_MACHINE_MODEL` | Machine preset | `sintra` |
| `VITE_BITSPIRE_FIAT_CODE` | Fiat currency (ISO 4217) | `USD` |
| `VITE_BITSPIRE_VALIDATOR_DEVICE` | Validator serial device path | (from preset) |
| `VITE_BITSPIRE_DISPENSER_DEVICE` | Dispenser serial device path | (from preset) |
| `VITE_BITSPIRE_CASSETTES` | JSON array of cassettes | (from preset) |
| `VITE_LAMASSU_MACHINE_MODEL` | Machine preset | `sintra` |
| `VITE_LAMASSU_FIAT_CODE` | Fiat currency (ISO 4217) | `USD` |
| `VITE_LAMASSU_VALIDATOR_DEVICE` | Validator serial device path | (from preset) |
| `VITE_LAMASSU_DISPENSER_DEVICE` | Dispenser serial device path | (from preset) |
| `VITE_LAMASSU_CASSETTES` | JSON array of cassettes | (from preset) |
### Example: Custom Cassette Configuration
```bash
# 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
```bash
export VITE_BITSPIRE_VALIDATOR_DEVICE="/dev/ttyUSB0"
export VITE_BITSPIRE_DISPENSER_DEVICE="/dev/ttyUSB1"
export VITE_LAMASSU_VALIDATOR_DEVICE="/dev/ttyUSB0"
export VITE_LAMASSU_DISPENSER_DEVICE="/dev/ttyUSB1"
```
## Cassette Configuration

View file

@ -52,7 +52,7 @@ Conceptually:
| 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`) |
| **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 |
| **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 |
@ -82,11 +82,11 @@ Before flashing a Sintra you'll want:
Once the kiosk is up, useful things to know:
- **Service status:** `ssh bitspire@<atm> 'sudo systemctl status bitspire'`
- **Live log tail:** `ssh bitspire@<atm> 'sudo journalctl -u bitspire -f'`
- **Service status:** `ssh lamassu@<atm> 'sudo systemctl status bitspire'`
- **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
- **Push a code change without reflashing:** `nixos-rebuild switch --flake .#sintra-installed --target-host bitspire@<atm> --use-remote-sudo`
- **Inspect transaction history:** `ssh bitspire@<atm> 'sudo bash /etc/nixos/atm-transactions.sh'` (queries `/var/lib/bitspire/state.db`)
- **Push a code change without reflashing:** `nixos-rebuild switch --flake .#sintra-installed --target-host lamassu@<atm> --use-remote-sudo`
- **Inspect transaction history:** `ssh lamassu@<atm> 'sudo bash /etc/nixos/atm-transactions.sh'` (queries `/var/lib/bitspire/state.db`)
## Related documentation

View file

@ -9,11 +9,6 @@
> debit-approval listener and all kind-21002 handling were removed in
> 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
> existed and what the ndebit/CLINK protocol surface looks like — useful
> if the project ever wants to reintroduce nostr-native cash-in that
@ -144,7 +139,7 @@ export const NDEBIT_REGEX = new RegExp(
```typescript
// In lib/types/parse.ts
import type { DebitPointer } from '@bitSpire/clink'
import type { DebitPointer } from '@lamassu/clink'
import type { Satoshi } from './units'
export enum InputClassification {
@ -164,7 +159,7 @@ export interface ParsedNdebitInput {
```typescript
// In lib/parse.ts
import { decodeNdebit } from '@bitSpire/clink'
import { decodeNdebit } from '@lamassu/clink'
// Add to VALIDATORS array:
{
@ -214,7 +209,7 @@ case InputClassification.NDEBIT: {
```typescript
// In State/scoped/backups/sources/history/claimNdebitThunk.ts
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
import { finalizeEvent } from 'nostr-tools'
import { SimplePool } from 'nostr-tools'
@ -854,7 +849,7 @@ See "Single-Use Protection" section above for implementation details.
```json
{
"@bitSpire/clink": "workspace:*",
"@lamassu/clink": "workspace:*",
"nostr-tools": "^2.x.x",
"@noble/hashes": "^1.x.x",
"qrcode": "^1.x.x"

View file

@ -303,8 +303,9 @@
# 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
# default branch and could silently change a dev-deployed Sintra at
# 04:00. (Every live machine now pulls from this repo; the
# pre-rename `aiolabs/lamassu-next` repo no longer feeds anything.)
# 04:00. The legacy `aiolabs/lamassu-next` repo still feeds the
# 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
system.autoUpgrade = {
enable = true;
@ -334,8 +335,8 @@
mkdir -p /var/lib/bitspire
if [ ! -f /var/lib/bitspire/.env ]; then
cp ${pkgs.writeText "bitspire-env-default" (''
VITE_BITSPIRE_MACHINE_MODEL=${machineModel}
VITE_BITSPIRE_FIAT_CODE=${fiatCode}
VITE_LAMASSU_MACHINE_MODEL=${machineModel}
VITE_LAMASSU_FIAT_CODE=${fiatCode}
VITE_SPIRE_SEED=
ELECTRON_FORCE_PROD=1
DISPLAY=:0
@ -538,12 +539,18 @@
sintra = mkLiveConfig "sintra";
batm3 = mkLiveConfig "batm3";
# Branded aliases for the live configs above. The lamassu-live-* names
# were dropped 2026-10-09; nothing referenced them.
# Backwards-compat aliases. Renamed lamassu-live-* → bitSpire-live-*
# 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-tejo = mkLiveConfig "tejo";
bitSpire-live-sintra = mkLiveConfig "sintra";
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)
douro-installed = mkInstalledConfig "douro" ./deploy/nixos/hardware/douro.nix;

View file

@ -67,8 +67,8 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
env = {
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
# Vite compile-time variables (baked into the frontend bundle)
VITE_BITSPIRE_MACHINE_MODEL = model;
VITE_BITSPIRE_FIAT_CODE = fiatCode;
VITE_LAMASSU_MACHINE_MODEL = model;
VITE_LAMASSU_FIAT_CODE = fiatCode;
};
buildPhase = ''

View file

@ -1,10 +1,10 @@
[package]
name = "bitspire-hal"
name = "lamassu-hal"
version = "0.1.0"
edition = "2021"
description = "Hardware Abstraction Layer for the bitSpire ATM (orphaned Rust HAL; not built)"
description = "Hardware Abstraction Layer for Lamassu ATM"
license = "MIT"
repository = "https://git.atitlan.io/aiolabs/bitspire"
repository = "https://github.com/lamassu/lamassu-next"
[lib]
crate-type = ["cdylib"]

View file

@ -77,20 +77,13 @@ export const bills: Record<string, BillConfig> = {
},
GTQ: {
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: {
5: [0xa6, 0x92],
10: [0xa6, 0x92],
20: [0xa6, 0x92],
50: [0xa6, 0x92],
100: [0xa6, 0x92],
200: [0xa6, 0x92],
5: [0xa3, 0x99],
10: [0xa1, 0x97],
20: [0xa3, 0x99],
50: [0xa1, 0x97],
100: [0xa1, 0x97],
200: [0xa1, 0x97],
},
polymer: false,
},

View file

@ -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,
//! dispensers, printers, and other ATM peripherals. These are exposed to Node.js
@ -30,7 +30,7 @@
//! # Usage
//!
//! ```typescript
//! import { BillValidatorWrapper, ValidatorDriver } from '@bitSpire/hal'
//! import { BillValidatorWrapper, ValidatorDriver } from '@lamassu/hal'
//!
//! const validator = new BillValidatorWrapper(ValidatorDriver.Id003, '/dev/ttyUSB0', 'USD')
//! await validator.connect()

View 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)
})

View 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)
})

View 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.')

View 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()
})

View 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),
}
}

View 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)
})

View 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)

View 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)
})

View 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)

View file

@ -21,14 +21,20 @@
"validate-schemas": "tsx scripts/validate-schemas.ts"
},
"dependencies": {
"@noble/curves": "^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"
},
"devDependencies": {
"@types/node": "^22.19.7",
"qrcode": "^1.5.4",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^2.1.0"
"vitest": "^2.1.0",
"ws": "^8.19.0"
},
"peerDependencies": {
"typescript": "^5.0.0"

View file

@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
import { generateIdentity } from '../identity.js'
import { LocalSigner } from '../signer.js'
import { createSignedEvent, createAuthEvent, validateEvent, generateTxId } from '../events.js'
import { BitSpireEventKind } from '../types.js'
import { LamassuEventKind } from '../types.js'
describe('events', () => {
describe('createSignedEvent', () => {
@ -33,7 +33,7 @@ describe('events', () => {
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.tags).toContainEqual(['relay', relayUrl])
expect(event.tags).toContainEqual(['challenge', challenge])

View file

@ -4,7 +4,7 @@
import { type Event, type EventTemplate, type VerifiedEvent, getEventHash } from 'nostr-tools'
import type { Signer } from './signer.js'
import { BitSpireEventKind } from './types.js'
import { LamassuEventKind } from './types.js'
/**
* Sign an event template with the given signer.
@ -29,7 +29,7 @@ export function createAuthEvent(
challenge: string
): Promise<VerifiedEvent> {
return signer.signEvent({
kind: BitSpireEventKind.Auth,
kind: LamassuEventKind.Auth,
content: '',
tags: [
['relay', relayUrl],

View file

@ -96,7 +96,7 @@ export type {
ClientEvents,
} from './types.js'
export { BitSpireEventKind } from './types.js'
export { LamassuEventKind } from './types.js'
// Re-export useful nostr-tools types
export type { Event, UnsignedEvent, Filter } from 'nostr-tools'

View file

@ -86,7 +86,7 @@ export interface SubscriptionOptions {
}
/** ATM-specific event kinds */
export enum BitSpireEventKind {
export enum LamassuEventKind {
/** CLINK Offer Request/Response */
ClinkOffer = 21001,
/** CLINK Debit Request/Response */

View file

@ -7,7 +7,7 @@ export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'BitSpireUIShared',
name: 'LamassuUIShared',
fileName: 'index',
},
rollupOptions: {

272
pnpm-lock.yaml generated
View file

@ -211,9 +211,21 @@ importers:
packages/nostr-client:
dependencies:
'@noble/curves':
specifier: ^2.0.1
version: 2.0.1
'@noble/hashes':
specifier: ^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:
specifier: ^2.10.0
version: 2.19.4(typescript@5.9.3)
@ -221,6 +233,9 @@ importers:
'@types/node':
specifier: ^22.19.7
version: 22.19.7
qrcode:
specifier: ^1.5.4
version: 1.5.4
tsx:
specifier: ^4.19.0
version: 4.21.0
@ -230,6 +245,9 @@ importers:
vitest:
specifier: ^2.1.0
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:
dependencies:
@ -1089,10 +1107,28 @@ packages:
resolution: {integrity: sha512-9On64rhzuqKdOQyiYLYv2lQOh3TZU/D3+IWCR5gk0alPel2nwpp4YwDEGiUBfrQZEdQ6xww0PWkzqth4wqwX3Q==}
engines: {node: '>=12.0.0'}
'@shocknet/clink-sdk@1.5.4':
resolution: {integrity: sha512-YrKR7oFjzUmBhm4p8pT6mP3YC7UdWbIlh5PMXHSwSLfJhL8Ddx91NLIfOHUTcQa1cDf27uvillMFXYrVUMCRuQ==}
'@sindresorhus/is@4.6.0':
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
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':
resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==}
@ -1554,6 +1590,10 @@ packages:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
@ -1599,6 +1639,9 @@ packages:
resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==}
engines: {node: '>=8'}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@ -1701,6 +1744,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
@ -1745,6 +1792,9 @@ packages:
detect-node@2.1.0:
resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dir-compare@4.2.0:
resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==}
@ -1915,6 +1965,10 @@ packages:
filelist@1.0.4:
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:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
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
hasBin: true
glob@13.0.0:
resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==}
engines: {node: 20 || >=22}
glob@7.2.3:
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
@ -2259,6 +2317,10 @@ packages:
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
engines: {node: '>= 12.0.0'}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
lodash-es@4.17.23:
resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==}
@ -2294,6 +2356,10 @@ packages:
lru-cache@10.4.3:
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:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
@ -2477,6 +2543,14 @@ packages:
resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
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:
resolution: {integrity: sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg==}
peerDependencies:
@ -2515,20 +2589,36 @@ packages:
resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==}
engines: {node: '>=8'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-map@4.0.0:
resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==}
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:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
path-browserify@1.0.1:
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:
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
engines: {node: '>=0.10.0'}
@ -2541,6 +2631,10 @@ packages:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
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:
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
@ -2575,6 +2669,10 @@ packages:
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
engines: {node: '>=10.4.0'}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
@ -2625,6 +2723,11 @@ packages:
peerDependencies:
vue: ^3.0.0
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
quick-lru@5.1.1:
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
engines: {node: '>=10'}
@ -2656,6 +2759,9 @@ packages:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resedit@1.7.2:
resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==}
engines: {node: '>=12', npm: '>=6'}
@ -2682,6 +2788,11 @@ packages:
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
rimraf@6.1.2:
resolution: {integrity: sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==}
engines: {node: 20 || >=22}
hasBin: true
roarr@2.15.4:
resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==}
engines: {node: '>=8.0'}
@ -3134,6 +3245,9 @@ packages:
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@ -3147,6 +3261,10 @@ packages:
wide-align@1.1.5:
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:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@ -3158,6 +3276,18 @@ packages:
wrappy@1.0.2:
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:
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
engines: {node: '>=8.0'}
@ -3165,6 +3295,9 @@ packages:
xstate@5.25.1:
resolution: {integrity: sha512-oyvsNH5pF2qkHmiHEMdWqc3OjDtoZOH2MTAI35r01f/ZQWOD+VLOiYqo65UgQET0XMA5s9eRm8fnsIo+82biEw==}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@ -3172,10 +3305,18 @@ packages:
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
@ -3786,8 +3927,35 @@ snapshots:
transitivePeerDependencies:
- 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': {}
'@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':
dependencies:
tslib: 2.8.1
@ -4371,6 +4539,8 @@ snapshots:
es-errors: 1.3.0
function-bind: 1.1.2
camelcase@5.3.1: {}
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
@ -4412,6 +4582,12 @@ snapshots:
string-width: 4.2.3
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:
dependencies:
string-width: 4.2.3
@ -4502,6 +4678,8 @@ snapshots:
dependencies:
ms: 2.1.3
decamelize@1.2.0: {}
decompress-response@6.0.0:
dependencies:
mimic-response: 3.1.0
@ -4541,6 +4719,8 @@ snapshots:
detect-node@2.1.0:
optional: true
dijkstrajs@1.0.3: {}
dir-compare@4.2.0:
dependencies:
minimatch: 3.1.2
@ -4815,6 +4995,11 @@ snapshots:
dependencies:
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:
dependencies:
cross-spawn: 7.0.6
@ -4916,6 +5101,12 @@ snapshots:
package-json-from-dist: 1.0.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:
dependencies:
fs.realpath: 1.0.0
@ -5177,6 +5368,10 @@ snapshots:
lightningcss-win32-arm64-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.defaults@4.2.0: {}
@ -5202,6 +5397,8 @@ snapshots:
lru-cache@10.4.3: {}
lru-cache@11.2.4: {}
lru-cache@6.0.0:
dependencies:
yallist: 4.0.0
@ -5378,6 +5575,18 @@ snapshots:
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):
dependencies:
'@noble/ciphers': 0.5.3
@ -5426,18 +5635,30 @@ snapshots:
p-cancelable@2.1.1: {}
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-map@4.0.0:
dependencies:
aggregate-error: 3.1.0
p-try@2.2.0: {}
package-json-from-dist@1.0.1: {}
path-browserify@1.0.1: {}
path-exists@4.0.0: {}
path-is-absolute@1.0.1: {}
path-key@3.1.1: {}
@ -5447,6 +5668,11 @@ snapshots:
lru-cache: 10.4.3
minipass: 7.1.2
path-scurry@2.0.1:
dependencies:
lru-cache: 11.2.4
minipass: 7.1.2
pathe@1.1.2: {}
pathval@2.0.1: {}
@ -5475,6 +5701,8 @@ snapshots:
base64-js: 1.5.1
xmlbuilder: 15.1.1
pngjs@5.0.0: {}
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
@ -5522,6 +5750,12 @@ snapshots:
dependencies:
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: {}
rc@1.2.8:
@ -5576,6 +5810,8 @@ snapshots:
require-directory@2.1.1: {}
require-main-filename@2.0.0: {}
resedit@1.7.2:
dependencies:
pe-library: 0.4.1
@ -5599,6 +5835,11 @@ snapshots:
dependencies:
glob: 7.2.3
rimraf@6.1.2:
dependencies:
glob: 13.0.0
package-json-from-dist: 1.0.1
roarr@2.15.4:
dependencies:
boolean: 3.2.0
@ -6050,6 +6291,8 @@ snapshots:
dependencies:
defaults: 1.0.4
which-module@2.0.1: {}
which@2.0.2:
dependencies:
isexe: 2.0.0
@ -6063,6 +6306,12 @@ snapshots:
dependencies:
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:
dependencies:
ansi-styles: 4.3.0
@ -6077,16 +6326,39 @@ snapshots:
wrappy@1.0.2: {}
ws@8.19.0: {}
xmlbuilder@15.1.1: {}
xstate@5.25.1: {}
y18n@4.0.3: {}
y18n@5.0.8: {}
yallist@4.0.0: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
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:
dependencies:
cliui: 8.0.1