Compare commits
No commits in common. "dev" and "main" have entirely different histories.
158 changed files with 7662 additions and 13532 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
513
.devenv.flake.nix
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
{
|
||||
inputs =
|
||||
let
|
||||
vars = {
|
||||
version = "1.11.2";
|
||||
system = "x86_64-linux";
|
||||
devenv_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next";
|
||||
project_input_ref = "path:/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next";
|
||||
devenv_dotfile = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next/.devenv";
|
||||
devenv_dotfile_path = ./.devenv;
|
||||
devenv_tmpdir = "/run/user/1000";
|
||||
devenv_runtime = "/run/user/1000/devenv-f4ba770";
|
||||
devenv_istesting = false;
|
||||
devenv_direnvrc_latest_version = 1;
|
||||
container_name = null;
|
||||
active_profiles = [
|
||||
];
|
||||
hostname = "gizmo";
|
||||
username = "padreug";
|
||||
git_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages";
|
||||
secretspec = null;
|
||||
};
|
||||
in
|
||||
{
|
||||
git-hooks.url = "github:cachix/git-hooks.nix";
|
||||
git-hooks.inputs.nixpkgs.follows = "nixpkgs";
|
||||
pre-commit-hooks.follows = "git-hooks";
|
||||
nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling";
|
||||
devenv.url = "github:cachix/devenv?dir=src/modules";
|
||||
}
|
||||
// (
|
||||
if builtins.pathExists (vars.devenv_dotfile_path + "/flake.json") then
|
||||
builtins.fromJSON (builtins.readFile (vars.devenv_dotfile_path + "/flake.json"))
|
||||
else
|
||||
{ }
|
||||
);
|
||||
|
||||
outputs =
|
||||
{ nixpkgs, ... }@inputs:
|
||||
let
|
||||
vars = {
|
||||
version = "1.11.2";
|
||||
system = "x86_64-linux";
|
||||
devenv_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next";
|
||||
project_input_ref = "path:/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next";
|
||||
devenv_dotfile = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next/.devenv";
|
||||
devenv_dotfile_path = ./.devenv;
|
||||
devenv_tmpdir = "/run/user/1000";
|
||||
devenv_runtime = "/run/user/1000/devenv-f4ba770";
|
||||
devenv_istesting = false;
|
||||
devenv_direnvrc_latest_version = 1;
|
||||
container_name = null;
|
||||
active_profiles = [
|
||||
];
|
||||
hostname = "gizmo";
|
||||
username = "padreug";
|
||||
git_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages";
|
||||
secretspec = null;
|
||||
};
|
||||
devenv =
|
||||
if builtins.pathExists (vars.devenv_dotfile_path + "/devenv.json") then
|
||||
builtins.fromJSON (builtins.readFile (vars.devenv_dotfile_path + "/devenv.json"))
|
||||
else
|
||||
{ };
|
||||
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
"x86_64-darwin"
|
||||
"aarch64-darwin"
|
||||
];
|
||||
|
||||
# Function to create devenv configuration for a specific system with profiles support
|
||||
mkDevenvForSystem =
|
||||
targetSystem:
|
||||
let
|
||||
getOverlays =
|
||||
inputName: inputAttrs:
|
||||
map (
|
||||
overlay:
|
||||
let
|
||||
input =
|
||||
inputs.${inputName} or (throw "No such input `${inputName}` while trying to configure overlays.");
|
||||
in
|
||||
input.overlays.${overlay}
|
||||
or (throw "Input `${inputName}` has no overlay called `${overlay}`. Supported overlays: ${nixpkgs.lib.concatStringsSep ", " (builtins.attrNames input.overlays)}")
|
||||
) inputAttrs.overlays or [ ];
|
||||
overlays = nixpkgs.lib.flatten (nixpkgs.lib.mapAttrsToList getOverlays (devenv.inputs or { }));
|
||||
permittedUnfreePackages =
|
||||
devenv.nixpkgs.per-platform."${targetSystem}".permittedUnfreePackages
|
||||
or devenv.nixpkgs.permittedUnfreePackages or [ ];
|
||||
pkgs = import nixpkgs {
|
||||
system = targetSystem;
|
||||
config = {
|
||||
allowUnfree =
|
||||
devenv.nixpkgs.per-platform."${targetSystem}".allowUnfree or devenv.nixpkgs.allowUnfree
|
||||
or devenv.allowUnfree or false;
|
||||
allowBroken =
|
||||
devenv.nixpkgs.per-platform."${targetSystem}".allowBroken or devenv.nixpkgs.allowBroken
|
||||
or devenv.allowBroken or false;
|
||||
cudaSupport =
|
||||
devenv.nixpkgs.per-platform."${targetSystem}".cudaSupport or devenv.nixpkgs.cudaSupport or false;
|
||||
cudaCapabilities =
|
||||
devenv.nixpkgs.per-platform."${targetSystem}".cudaCapabilities or devenv.nixpkgs.cudaCapabilities
|
||||
or [ ];
|
||||
permittedInsecurePackages =
|
||||
devenv.nixpkgs.per-platform."${targetSystem}".permittedInsecurePackages
|
||||
or devenv.nixpkgs.permittedInsecurePackages or devenv.permittedInsecurePackages or [ ];
|
||||
allowUnfreePredicate =
|
||||
if (permittedUnfreePackages != [ ]) then
|
||||
(pkg: builtins.elem (nixpkgs.lib.getName pkg) permittedUnfreePackages)
|
||||
else
|
||||
(_: false);
|
||||
};
|
||||
inherit overlays;
|
||||
};
|
||||
inherit (pkgs) lib;
|
||||
importModule =
|
||||
path:
|
||||
if lib.hasPrefix "./" path then
|
||||
if lib.hasSuffix ".nix" path then
|
||||
./. + (builtins.substring 1 255 path)
|
||||
else
|
||||
./. + (builtins.substring 1 255 path) + "/devenv.nix"
|
||||
else if lib.hasPrefix "../" path then
|
||||
# For parent directory paths, concatenate with /.
|
||||
# ./. refers to the directory containing this file (project root)
|
||||
# So ./. + "/../shared" = <project-root>/../shared
|
||||
if lib.hasSuffix ".nix" path then ./. + "/${path}" else ./. + "/${path}/devenv.nix"
|
||||
else
|
||||
let
|
||||
paths = lib.splitString "/" path;
|
||||
name = builtins.head paths;
|
||||
input = inputs.${name} or (throw "Unknown input ${name}");
|
||||
subpath = "/${lib.concatStringsSep "/" (builtins.tail paths)}";
|
||||
devenvpath = "${input}" + subpath;
|
||||
devenvdefaultpath = devenvpath + "/devenv.nix";
|
||||
in
|
||||
if lib.hasSuffix ".nix" devenvpath then
|
||||
devenvpath
|
||||
else if builtins.pathExists devenvdefaultpath then
|
||||
devenvdefaultpath
|
||||
else
|
||||
throw (devenvdefaultpath + " file does not exist for input ${name}.");
|
||||
|
||||
# Phase 1: Base evaluation to extract profile definitions
|
||||
baseProject = pkgs.lib.evalModules {
|
||||
specialArgs = inputs // {
|
||||
inherit inputs;
|
||||
};
|
||||
modules = [
|
||||
(
|
||||
{ config, ... }:
|
||||
{
|
||||
_module.args.pkgs = pkgs.appendOverlays (config.overlays or [ ]);
|
||||
}
|
||||
)
|
||||
(inputs.devenv.modules + /top-level.nix)
|
||||
(
|
||||
{ options, ... }:
|
||||
{
|
||||
config.devenv = lib.mkMerge [
|
||||
{
|
||||
cliVersion = vars.version;
|
||||
root = vars.devenv_root;
|
||||
dotfile = vars.devenv_dotfile;
|
||||
}
|
||||
(pkgs.lib.optionalAttrs (builtins.hasAttr "tmpdir" options.devenv) {
|
||||
tmpdir = vars.devenv_tmpdir;
|
||||
})
|
||||
(pkgs.lib.optionalAttrs (builtins.hasAttr "isTesting" options.devenv) {
|
||||
isTesting = vars.devenv_istesting;
|
||||
})
|
||||
(pkgs.lib.optionalAttrs (builtins.hasAttr "runtime" options.devenv) {
|
||||
runtime = vars.devenv_runtime;
|
||||
})
|
||||
(pkgs.lib.optionalAttrs (builtins.hasAttr "direnvrcLatestVersion" options.devenv) {
|
||||
direnvrcLatestVersion = vars.devenv_direnvrc_latest_version;
|
||||
})
|
||||
];
|
||||
}
|
||||
)
|
||||
(
|
||||
{ options, ... }:
|
||||
{
|
||||
config = lib.mkMerge [
|
||||
(pkgs.lib.optionalAttrs (builtins.hasAttr "git" options) {
|
||||
git.root = vars.git_root;
|
||||
})
|
||||
];
|
||||
}
|
||||
)
|
||||
(pkgs.lib.optionalAttrs (vars.container_name != null) {
|
||||
container.isBuilding = pkgs.lib.mkForce true;
|
||||
containers.${vars.container_name}.isBuilding = true;
|
||||
})
|
||||
]
|
||||
++ (map importModule (devenv.imports or [ ]))
|
||||
++ [
|
||||
(if builtins.pathExists ./devenv.nix then ./devenv.nix else { })
|
||||
(devenv.devenv or { })
|
||||
(if builtins.pathExists ./devenv.local.nix then ./devenv.local.nix else { })
|
||||
(
|
||||
if builtins.pathExists (vars.devenv_dotfile_path + "/cli-options.nix") then
|
||||
import (vars.devenv_dotfile_path + "/cli-options.nix")
|
||||
else
|
||||
{ }
|
||||
)
|
||||
];
|
||||
};
|
||||
|
||||
# Phase 2: Extract and apply profiles using extendModules with priority overrides
|
||||
project =
|
||||
let
|
||||
# Build ordered list of profile names: hostname -> user -> manual
|
||||
manualProfiles = vars.active_profiles;
|
||||
currentHostname = vars.hostname;
|
||||
currentUsername = vars.username;
|
||||
hostnameProfiles = lib.optional (
|
||||
currentHostname != ""
|
||||
&& builtins.hasAttr currentHostname (baseProject.config.profiles.hostname or { })
|
||||
) "hostname.${currentHostname}";
|
||||
userProfiles = lib.optional (
|
||||
currentUsername != "" && builtins.hasAttr currentUsername (baseProject.config.profiles.user or { })
|
||||
) "user.${currentUsername}";
|
||||
|
||||
# Ordered list of profiles to activate
|
||||
orderedProfiles = hostnameProfiles ++ userProfiles ++ manualProfiles;
|
||||
|
||||
# Resolve profile extends with cycle detection
|
||||
resolveProfileExtends =
|
||||
profileName: visited:
|
||||
if builtins.elem profileName visited then
|
||||
throw "Circular dependency detected in profile extends: ${lib.concatStringsSep " -> " visited} -> ${profileName}"
|
||||
else
|
||||
let
|
||||
profile = getProfileConfig profileName;
|
||||
extends = profile.extends or [ ];
|
||||
newVisited = visited ++ [ profileName ];
|
||||
extendedProfiles = lib.flatten (map (name: resolveProfileExtends name newVisited) extends);
|
||||
in
|
||||
extendedProfiles ++ [ profileName ];
|
||||
|
||||
# Get profile configuration by name from baseProject
|
||||
getProfileConfig =
|
||||
profileName:
|
||||
if lib.hasPrefix "hostname." profileName then
|
||||
let
|
||||
name = lib.removePrefix "hostname." profileName;
|
||||
in
|
||||
baseProject.config.profiles.hostname.${name}
|
||||
else if lib.hasPrefix "user." profileName then
|
||||
let
|
||||
name = lib.removePrefix "user." profileName;
|
||||
in
|
||||
baseProject.config.profiles.user.${name}
|
||||
else
|
||||
let
|
||||
availableProfiles = builtins.attrNames (baseProject.config.profiles or { });
|
||||
hostnameProfiles = map (n: "hostname.${n}") (
|
||||
builtins.attrNames (baseProject.config.profiles.hostname or { })
|
||||
);
|
||||
userProfiles = map (n: "user.${n}") (builtins.attrNames (baseProject.config.profiles.user or { }));
|
||||
allAvailableProfiles = availableProfiles ++ hostnameProfiles ++ userProfiles;
|
||||
in
|
||||
baseProject.config.profiles.${profileName}
|
||||
or (throw "Profile '${profileName}' not found. Available profiles: ${lib.concatStringsSep ", " allAvailableProfiles}");
|
||||
|
||||
# Fold over ordered profiles to build final list with extends
|
||||
expandedProfiles = lib.foldl' (
|
||||
acc: profileName:
|
||||
let
|
||||
allProfileNames = resolveProfileExtends profileName [ ];
|
||||
in
|
||||
acc ++ allProfileNames
|
||||
) [ ] orderedProfiles;
|
||||
|
||||
# Map over expanded profiles and apply priorities
|
||||
allPrioritizedModules = lib.imap0 (
|
||||
index: profileName:
|
||||
let
|
||||
# Decrement priority for each profile (lower = higher precedence)
|
||||
# Start with the next lowest priority after the default priority for values (100)
|
||||
profilePriority = (lib.modules.defaultOverridePriority - 1) - index;
|
||||
profileConfig = getProfileConfig profileName;
|
||||
|
||||
# Check if an option type needs explicit override to resolve conflicts
|
||||
# Only apply overrides to LEAF values (scalars), not collection types that can merge
|
||||
typeNeedsOverride =
|
||||
type:
|
||||
if type == null then
|
||||
false
|
||||
else
|
||||
let
|
||||
typeName = type.name or type._type or "";
|
||||
|
||||
# True leaf types that need priority resolution when they conflict
|
||||
isLeafType = builtins.elem typeName [
|
||||
"str"
|
||||
"int"
|
||||
"bool"
|
||||
"enum"
|
||||
"path"
|
||||
"package"
|
||||
"float"
|
||||
"anything"
|
||||
];
|
||||
in
|
||||
if isLeafType then
|
||||
true
|
||||
else if typeName == "nullOr" then
|
||||
# For nullOr, check the wrapped type recursively
|
||||
let
|
||||
innerType =
|
||||
type.elemType
|
||||
or (if type ? nestedTypes && type.nestedTypes ? elemType then type.nestedTypes.elemType else null);
|
||||
in
|
||||
if innerType != null then typeNeedsOverride innerType else false
|
||||
else
|
||||
# Everything else (collections, submodules, etc.) should merge naturally
|
||||
false;
|
||||
|
||||
# Check if a config path needs explicit override
|
||||
pathNeedsOverride =
|
||||
optionPath:
|
||||
let
|
||||
# Try direct option first
|
||||
directOption = lib.attrByPath optionPath null baseProject.options;
|
||||
in
|
||||
if directOption != null && lib.isOption directOption then
|
||||
typeNeedsOverride directOption.type
|
||||
else if optionPath != [ ] then
|
||||
# Check parent for freeform type
|
||||
let
|
||||
parentPath = lib.init optionPath;
|
||||
parentOption = lib.attrByPath parentPath null baseProject.options;
|
||||
in
|
||||
if parentOption != null && lib.isOption parentOption then
|
||||
let
|
||||
# Look for freeform type:
|
||||
# 1. Standard location: type.freeformType (primary)
|
||||
# 2. Nested location: type.nestedTypes.freeformType (evaluated form)
|
||||
freeformType = parentOption.type.freeformType or parentOption.type.nestedTypes.freeformType or null;
|
||||
elementType =
|
||||
if freeformType ? elemType then
|
||||
freeformType.elemType
|
||||
else if freeformType ? nestedTypes && freeformType.nestedTypes ? elemType then
|
||||
freeformType.nestedTypes.elemType
|
||||
else
|
||||
freeformType;
|
||||
in
|
||||
typeNeedsOverride elementType
|
||||
else
|
||||
false
|
||||
else
|
||||
false;
|
||||
|
||||
# Support overriding both plain attrset modules and functions
|
||||
applyModuleOverride =
|
||||
config:
|
||||
if builtins.isFunction config then
|
||||
let
|
||||
wrapper = args: applyOverrideRecursive (config args) [ ];
|
||||
in
|
||||
lib.mirrorFunctionArgs config wrapper
|
||||
else
|
||||
applyOverrideRecursive config [ ];
|
||||
|
||||
# Apply overrides recursively based on option types
|
||||
applyOverrideRecursive =
|
||||
config: optionPath:
|
||||
if lib.isAttrs config && config ? _type then
|
||||
config # Don't touch values with existing type metadata
|
||||
else if lib.isAttrs config then
|
||||
lib.mapAttrs (name: value: applyOverrideRecursive value (optionPath ++ [ name ])) config
|
||||
else if pathNeedsOverride optionPath then
|
||||
lib.mkOverride profilePriority config
|
||||
else
|
||||
config;
|
||||
|
||||
# Apply priority overrides recursively to the deferredModule imports structure
|
||||
prioritizedConfig = (
|
||||
profileConfig.module
|
||||
// {
|
||||
imports = lib.map (
|
||||
importItem:
|
||||
importItem
|
||||
// {
|
||||
imports = lib.map (nestedImport: applyModuleOverride nestedImport) (importItem.imports or [ ]);
|
||||
}
|
||||
) (profileConfig.module.imports or [ ]);
|
||||
}
|
||||
);
|
||||
in
|
||||
prioritizedConfig
|
||||
) expandedProfiles;
|
||||
in
|
||||
if allPrioritizedModules == [ ] then
|
||||
baseProject
|
||||
else
|
||||
baseProject.extendModules { modules = allPrioritizedModules; };
|
||||
|
||||
config = project.config;
|
||||
|
||||
options = pkgs.nixosOptionsDoc {
|
||||
options = builtins.removeAttrs project.options [ "_module" ];
|
||||
warningsAreErrors = false;
|
||||
# Unpack Nix types, e.g. literalExpression, mDoc.
|
||||
transformOptions =
|
||||
let
|
||||
isDocType =
|
||||
v:
|
||||
builtins.elem v [
|
||||
"literalDocBook"
|
||||
"literalExpression"
|
||||
"literalMD"
|
||||
"mdDoc"
|
||||
];
|
||||
in
|
||||
lib.attrsets.mapAttrs (
|
||||
_: v:
|
||||
if v ? _type && isDocType v._type then
|
||||
v.text
|
||||
else if v ? _type && v._type == "derivation" then
|
||||
v.name
|
||||
else
|
||||
v
|
||||
);
|
||||
};
|
||||
|
||||
# Recursively search for outputs in the config.
|
||||
# This is used when not building a specific output by attrpath.
|
||||
build =
|
||||
options: config:
|
||||
lib.concatMapAttrs (
|
||||
name: option:
|
||||
if lib.isOption option then
|
||||
let
|
||||
typeName = option.type.name or "";
|
||||
in
|
||||
if
|
||||
builtins.elem typeName [
|
||||
"output"
|
||||
"outputOf"
|
||||
]
|
||||
then
|
||||
{ ${name} = config.${name}; }
|
||||
else
|
||||
{ }
|
||||
else if builtins.isAttrs option && !lib.isDerivation option then
|
||||
let
|
||||
v = build option config.${name};
|
||||
in
|
||||
if v != { } then
|
||||
{
|
||||
${name} = v;
|
||||
}
|
||||
else
|
||||
{ }
|
||||
else
|
||||
{ }
|
||||
) options;
|
||||
in
|
||||
{
|
||||
inherit
|
||||
config
|
||||
options
|
||||
build
|
||||
project
|
||||
;
|
||||
shell = config.shell;
|
||||
packages = {
|
||||
optionsJSON = options.optionsJSON;
|
||||
# deprecated
|
||||
inherit (config)
|
||||
info
|
||||
procfileScript
|
||||
procfileEnv
|
||||
procfile
|
||||
;
|
||||
ci = config.ciDerivation;
|
||||
};
|
||||
};
|
||||
|
||||
# Generate per-system devenv configurations
|
||||
perSystem = nixpkgs.lib.genAttrs systems mkDevenvForSystem;
|
||||
|
||||
# Default devenv for the current system
|
||||
currentSystemDevenv = perSystem.${vars.system};
|
||||
in
|
||||
{
|
||||
devShell = nixpkgs.lib.genAttrs systems (s: perSystem.${s}.shell);
|
||||
packages = nixpkgs.lib.genAttrs systems (s: perSystem.${s}.packages);
|
||||
|
||||
# Per-system devenv configurations
|
||||
devenv = {
|
||||
# Default devenv for the current system
|
||||
inherit (currentSystemDevenv)
|
||||
config
|
||||
options
|
||||
build
|
||||
shell
|
||||
packages
|
||||
project
|
||||
;
|
||||
# Per-system devenv configurations
|
||||
inherit perSystem;
|
||||
};
|
||||
|
||||
# Legacy build output
|
||||
build = currentSystemDevenv.build currentSystemDevenv.options currentSystemDevenv.config;
|
||||
};
|
||||
}
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -55,10 +55,13 @@ apps/machine/src/services/*.js
|
|||
|
||||
# devenv
|
||||
.devenv/
|
||||
.devenv.flake.nix
|
||||
.direnv/
|
||||
.pre-commit-config.yaml
|
||||
|
||||
# Docker
|
||||
docker/**/data/
|
||||
docker/.state/
|
||||
|
||||
# Nix build outputs
|
||||
result
|
||||
result-*
|
||||
|
|
|
|||
139
CLAUDE.md
139
CLAUDE.md
|
|
@ -4,7 +4,7 @@ Guidance for Claude Code when working in this repo. Read this before touching co
|
|||
|
||||
## Project Overview
|
||||
|
||||
**bitSpire** is a Nostr-native Lightning ATM running **LNbits over the nostr-native-transport**. The `dev` branch, which this file describes, is what the machines run.
|
||||
**bitSpire** is a Nostr-native Lightning ATM. Production ATMs (`batm3`, `douro`) currently run from `main` against Lightning.Pub; the `dev` branch — which is what this file describes — has been migrated to **LNbits over the nostr-native-transport**.
|
||||
|
||||
Core principles:
|
||||
|
||||
|
|
@ -15,100 +15,16 @@ Core principles:
|
|||
|
||||
## Provenance + legal status
|
||||
|
||||
The HAL drivers (validators / dispensers / printers) and the cash-flow state machine derive from Lamassu Industries AG's `lamassu-machine` repository, **only up to commit `c0b69d1ed196d396c5f057478c2ea290babd58ab`** ("chore: v8.6.0-beta.9", 2023-09-19) — the last commit published into the public domain (`UNLICENSE` in tree). The very next commit, `a9234d124d` ("chore: add LICENSE (#1019)", 2023-09-19), removed `UNLICENSE` and added Lamassu's proprietary "Appendix A SLA". **The `v8.1.5` tag (2023-09-21) already ships the Appendix A license** — the previously documented "8.1.5 is the open boundary" was wrong (verified against GitHub history 2026-07-04). Note the public-domain boundary sits on the 8.6-beta line, which is *further along* than 8.1.5 feature-wise.
|
||||
The HAL drivers (validators / dispensers / printers) and the cash-flow state machine derive from Lamassu Industries AG's open-source `lamassu-machine` and `lamassu-server` repositories, **only up to v8.1.5** — the last release published under a fully-open license. Lamassu transitioned to a proprietary, source-available license (their custom "Appendix A SLA") on 2024-01-26 and gated v8.1.6+ behind a paid OSA subscription.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**Hard rule when working in this repo:** do not pull, port, or copy code from lamassu-machine / lamassu-server at v8.1.6 or later. If a HAL bug fix or feature exists upstream past 8.1.5, either (a) reimplement from protocol docs / hardware specs without looking at v8.1.6+ source, or (b) raise the question with the maintainer first. The 8.1.5 tree is fair game; everything after is licensed code we have no rights to.
|
||||
|
||||
bitSpire is an independent project under AGPL-3.0 and is not affiliated with Lamassu Industries AG.
|
||||
|
||||
## Branch model
|
||||
|
||||
- `dev` — **what every live machine runs.** Not a staging branch any more. Verified
|
||||
2026-09-24 on batm3, whose `nixos-upgrade` unit pulls
|
||||
`git+ssh://…/bitspire.git?ref=dev#batm3-installed` daily at 04:00. "Push freely
|
||||
to dev" is no longer safe advice: a bad commit reaches production hardware the
|
||||
next morning, unattended.
|
||||
- `main` — Lightning.Pub era, historical. Tag `pre-bitspire-cutover` is the
|
||||
rollback target if the migration ever has to be reverted.
|
||||
|
||||
> This section previously said the production ATMs ran `main` against
|
||||
> Lightning.Pub and that only Sintra was on `dev`. That was stale and it was
|
||||
> repeatedly taken at face value. Check the machine, not this file, before
|
||||
> relying on which stack a given box runs: `systemctl cat nixos-upgrade` gives
|
||||
> the branch, `/var/lib/bitspire` vs `/var/lib/lamassu-atm` gives the era.
|
||||
|
||||
### Fleet state (surveyed 2026-09-24)
|
||||
|
||||
| Machine | Reachable | Stack | GPU | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `sintra` | LAN `192.168.0.252` | dev / LNbits | Braswell `8086:22b0` → crocus | dev unit; ethernet `r8169`. **Its nightly upgrade failed every night 2026-10-06 → 10-09** on the same 60 s timeout as batm3 (below); nothing merged that week reached it until a cachix push on 10-10 |
|
||||
| `batm3` | wg `10.0.0.5` | dev / LNbits | Haswell GT2 `8086:0412` → crocus | **networks over WiFi**, `iwlwifi` 7260; ethernet down |
|
||||
| `douro` | **down** | — | Bay Trail (Gen7) | needs reflashing with the current image and reconnecting to WireGuard |
|
||||
| `tejo` | wg `10.0.0.3` | **Debian** (`ubilinux4`, kernel 4.9) | Braswell `8086:22b0` | never had bitspire installed; a flake target, not a deployment |
|
||||
|
||||
Two consequences worth holding onto. Every GPU in the fleet binds **crocus**, not
|
||||
iris — sintra's Braswell does so despite being Gen8. And batm3's only working
|
||||
network path is Intel WiFi, so `intel/iwlwifi` firmware is load-bearing there;
|
||||
trimming it would strand the machine with no way back in.
|
||||
|
||||
### The nightly upgrade fails on any machine that has to build the app (batm3, and sintra too)
|
||||
|
||||
Confirmed on batm3 2026-09-24 and on sintra 2026-10-10 (failing since 10-06). The run dies at:
|
||||
|
||||
```
|
||||
04:03:26 building '…-bitspire-atm-app-0.1.0.drv'...
|
||||
04:04:28 error: timed out after 60 seconds
|
||||
```
|
||||
|
||||
The ATM app is built in-house and is **not in `aiolabs.cachix.org` or
|
||||
`cache.nixos.org`**, so batm3 has to build it locally, and `nix.settings.timeout
|
||||
= 60` in `flake.nix` kills it. The comment there assumes heavy derivations are
|
||||
"effectively cache-only … upstream-cached", which is true of nixpkgs and false of
|
||||
our own app.
|
||||
|
||||
So the machine is pinned to whatever generation last succeeded, and nothing
|
||||
merged to `dev` reaches it. This is the same class of silent-updater failure as
|
||||
#98, in a new form. The fix is pushing `atm-app-*` to the aiolabs cachix as part
|
||||
of releasing, not raising the timeout — a 60s ceiling on ATM hardware is correct.
|
||||
|
||||
**Interim rule (2026-10-10): every push to `dev` is followed by
|
||||
`./deploy/push-cache.sh sintra && ./deploy/push-cache.sh batm3` from bohm**
|
||||
(cachix is authenticated there). `mkAtmApp` takes `src = self` — the whole flake
|
||||
tree — so *any* commit, docs included, changes the app derivation and a cached
|
||||
toplevel no longer matches what `?ref=dev` resolves to. Three more things that
|
||||
bit on 10-10:
|
||||
|
||||
- **`pnpm-lock.yaml` changes require re-deriving `pnpmDeps.hash` in
|
||||
`nix/mkAtmApp.nix`** (blank it, build, paste the `got:` value). Nix reuses the
|
||||
stale fixed-output store otherwise and the sandboxed `pnpm install --offline`
|
||||
fails with `ERR_PNPM_NO_OFFLINE_TARBALL`. A passing local `pnpm build` says
|
||||
nothing about the nix build.
|
||||
- **Activation scripts run with a minimal PATH** — coreutils yes, `grep`/`sed`
|
||||
no. Reference `${pkgs.gnugrep}/bin/grep` / `${pkgs.gnused}/bin/sed` by store
|
||||
path; the `.env` migration printed success and then died 127.
|
||||
- `nixos-rebuild switch --flake .#<model>-installed --target-host <model> --sudo
|
||||
--use-substitutes` from bohm is the fast manual path once the cache has the
|
||||
toplevel: store hit here, closure copied, nothing built on the UP board.
|
||||
- `main` — production. Lightning.Pub backend. The two production ATMs auto-pull from here daily at 04:00 (`flake.nix:152-160`). **DO NOT** push to `main` casually — a wrong commit gets baked into prod ATMs the next morning.
|
||||
- `dev` — staging. LNbits backend. The Sintra dev unit auto-pulls from here (`?ref=dev` pin on this branch's `flake.nix`). Push freely; tag `pre-bitspire-cutover` is the rollback target if the migration ever needs to be reverted on prod.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
@ -166,34 +82,13 @@ Renderer reads (Electron IPC or Vite `import.meta.env`):
|
|||
|
||||
| Var | Required | Notes |
|
||||
|---|---|---|
|
||||
| `VITE_RELAY_URL` | no (seed-provided) | Relay both ATM and LNbits subscribe to. **Comes from the pairing seed** (aiolabs/bitspire#70); set this only as an override — it WINS over the seed via env-first precedence. Dev override: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) |
|
||||
| `VITE_LNBITS_SERVER_PUBKEY` | no (seed-provided) | 64-char hex transport pubkey. **Comes from the seed's `lnbits_npub`** (#70); env override only. LNbits prints it on startup (`docker logs lnbits \| grep 'Public key (share this)'`) |
|
||||
| `VITE_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:<base64url>`) from spirekeeper. Carries the relay(s), the LNbits transport pubkey (`lnbits_npub`), the spire signing pubkey (`spire_npub`), and a one-shot NIP-46 connect token (#70 slimmed the shape). First boot redeems it and persists the binding to `state.db`; later boots resume by fingerprint. A changed seed re-pairs. Provisioning it up front is optional — an unpaired machine renders an on-screen QR-pairing wizard that scans the seed off the camera (see below). See aiolabs/bitspire#52. |
|
||||
| `VITE_ATM_PRIVATE_KEY` | dev only | 64-char hex raw nsec fallback for running without a bunker. Ignored when `VITE_SPIRE_SEED` or a stored binding exists. |
|
||||
| `VITE_RELAY_URL` | yes | `ws://...` of the relay both ATM and LNbits subscribe to. Dev: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) |
|
||||
| `VITE_LNBITS_SERVER_PUBKEY` | yes | 64-char hex pubkey LNbits prints on startup (`docker logs lnbits \| grep 'Public key (share this)'`) |
|
||||
| `VITE_ATM_PRIVATE_KEY` | yes (prod) | 64-char hex. The ATM's nostr identity. Generates ephemeral on first boot if unset (dev only) |
|
||||
| `VITE_OPERATOR_PUBKEYS` | optional | Comma-separated hex pubkeys allowed to send kind-21003 management commands |
|
||||
|
||||
The LP-era vars (`VITE_LIGHTNING_PUB_PUBKEY`, `VITE_LIGHTNING_PUB_API_URL`, `VITE_EXTENSION_API_URL`, `VITE_ADMIN_TOKEN`) are gone from the dev branch's `.env.example` and `LightningConfig` interface.
|
||||
|
||||
## Pairing (on-machine QR wizard)
|
||||
|
||||
A machine with no seed **and** no stored binding boots `unpaired` and, under
|
||||
Electron, renders an interactive wizard (`src/components/PairingWizard.vue`)
|
||||
instead of a dead-end fault screen. The operator displays the `spire-seed`
|
||||
QR (minted by spirekeeper's `/pair`) to the machine's camera; the wizard:
|
||||
|
||||
1. captures + decodes via a `PairingSource` (`src/services/pairing/`) — camera
|
||||
today (decode through `qr`, paulmillr's zero-dep lib), NFC scaffolded;
|
||||
2. validates the scan parses as a spire-seed (`ingestScannedSeed`), rejecting
|
||||
a stray QR;
|
||||
3. persists it as `VITE_SPIRE_SEED` via the `state:save-spire-seed` IPC and
|
||||
relaunches (`app:relaunch`).
|
||||
|
||||
Pairing itself is **not** done in the wizard — relaunch lets the normal boot
|
||||
path (`signer-resolver` → `connectNewSeed`) redeem the one-shot token, so
|
||||
there's one tested pairing path. A revoked/expired binding lands on the same
|
||||
wizard (re-pair = scan a fresh seed). Provisioning `VITE_SPIRE_SEED` up front
|
||||
still works and skips the wizard.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
|
|
@ -269,15 +164,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)
|
||||
|
||||
|
|
@ -298,7 +188,7 @@ UP Board enumerates its eMMC controller via ACPI, not PCI. `upboard.nix` force-l
|
|||
|
||||
## Security priorities
|
||||
|
||||
1. **Private keys** — Never log nsec. In production the ATM holds no signing nsec: `VITE_SPIRE_SEED` (in `/var/lib/bitspire/.env`, mode 0600) carries a one-shot connect token, and the ATM's own NIP-46 *transport* key (`client_secret_hex`) lives in `state.db` (`bunker_binding`). The operator's signing key stays in the bunker. The legacy `VITE_ATM_PRIVATE_KEY` is a dev-only fallback.
|
||||
1. **Private keys** — Never log nsec. The ATM's `VITE_ATM_PRIVATE_KEY` lives in `/var/lib/bitspire/.env` with mode 0600, owned by `bitspire:bitspire`.
|
||||
2. **Payments** — Validate the bolt11 amount on cash-out before exposing the QR. Decode `payment_hash` from the bolt11 (cheap, avoids a roundtrip) and use it as the `subscribe_payments` filter.
|
||||
3. **Replay** — LNURL-withdraw links use `uses:1` and are deleted on session abort.
|
||||
4. **Encryption** — All RPC content is NIP-44 v2. NIP-04 is forbidden.
|
||||
|
|
@ -306,8 +196,7 @@ UP Board enumerates its eMMC controller via ACPI, not PCI. `upboard.nix` force-l
|
|||
## Useful invariants when debugging
|
||||
|
||||
- The renderer logs prefix every line with a tag: `[Lightning]`, `[ATM]`, `[ATM Service]`, `[LNURL Session]`, `[CLINK]`, `[StateStore]`. `journalctl -u bitspire | grep '\['` is your friend.
|
||||
- **Never pass an object as a console argument in the renderer.** Electron's console bridge stringifies each argument, so `console.log('msg:', { a, b })` reaches the journal as `msg: [object Object]` and every field is lost. Interpolate instead. Cost a debugging session on 2026-09-23, when a cassette publish that had worked looked like it had done nothing.
|
||||
- `bitspire.service` runs as the `bitspire` user (verified on sintra 2026-09-24; this line used to say `lamassu`, left over from the rename in `46e52f6`); `/var/lib/bitspire` is its `dataDir` (ReadWritePaths). DB lives at `/var/lib/bitspire/state.db` (we previously had `/var/lib/lamassu-atm` — that path is gone on dev, see commit `9c455d6`).
|
||||
- `bitspire.service` runs as the `lamassu` user; `/var/lib/bitspire` is its `dataDir` (ReadWritePaths). DB lives at `/var/lib/bitspire/state.db` (we previously had `/var/lib/lamassu-atm` — that path is gone on dev, see commit `9c455d6`).
|
||||
- The `lightning.lightningPub` field on `LightningServices` is a `LightningBackend` *adapter*, not a `LightningPubClient`. Don't try to call LP-only methods on it.
|
||||
|
||||
## Related documentation
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,28 +6,24 @@
|
|||
# =============================================================================
|
||||
|
||||
# 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
|
||||
# LNbits Connection (Required) — nostr-native-transport
|
||||
# =============================================================================
|
||||
# On a real machine the pairing SEED (VITE_SPIRE_SEED) carries the relay AND the
|
||||
# server pubkey (aiolabs/bitspire#70), so leave both blank there. Set them here
|
||||
# only for browser dev without a seed/bunker — they WIN over the seed.
|
||||
|
||||
# Nostr relay WebSocket URL. Dev stack uses LNbits's bundled nostrrelay:
|
||||
# VITE_RELAY_URL=ws://localhost:5001/nostrrelay/test
|
||||
VITE_RELAY_URL=
|
||||
# Nostr relay WebSocket URL — relay LNbits is subscribed to.
|
||||
VITE_RELAY_URL=ws://localhost:7777
|
||||
|
||||
# LNbits nostr-transport server pubkey (hex, 64 chars).
|
||||
# Printed by the LNbits server on startup:
|
||||
|
|
@ -40,23 +36,16 @@ VITE_LNBITS_SERVER_PUBKEY=
|
|||
# aiolabs/withdraw#1 / commit e9d911e.)
|
||||
|
||||
# =============================================================================
|
||||
# ATM Identity — spire pairing seed (NIP-46 bunker; aiolabs/bitspire#52)
|
||||
# ATM Identity
|
||||
# =============================================================================
|
||||
|
||||
# The spire pairing seed produced by the operator dashboard (spirekeeper):
|
||||
# spire-seed:v1:<base64url>
|
||||
# It carries a one-shot NIP-46 connect token + the spire's signing pubkey +
|
||||
# the bunker URL. On first boot the ATM redeems the token, generates its own
|
||||
# transport key, and persists the binding to state.db; thereafter it resumes
|
||||
# from the binding (the seed can stay set — it's matched by fingerprint).
|
||||
# A changed seed re-pairs (and re-publishes the cassette-state hello).
|
||||
VITE_SPIRE_SEED=
|
||||
|
||||
# pragma: allowlist secret
|
||||
# DEV ONLY fallback — a raw Nostr private key (hex, 64 chars) for running
|
||||
# without a bunker. Ignored when VITE_SPIRE_SEED or a stored binding exists.
|
||||
# ATM's Nostr private key (hex format, 64 characters). This signing
|
||||
# key IS the credential — LNbits derives the account from it on first
|
||||
# contact (issue aiolabs/lnbits#9 alignment).
|
||||
# Generate with: openssl rand -hex 32
|
||||
# VITE_ATM_PRIVATE_KEY=
|
||||
# If not set, generates ephemeral identity on each restart (dev only).
|
||||
VITE_ATM_PRIVATE_KEY=
|
||||
|
||||
# =============================================================================
|
||||
# Operator Identity
|
||||
|
|
@ -73,18 +62,6 @@ VITE_SPIRE_SEED=
|
|||
# Show "Under Service" screen and block all transactions
|
||||
# VITE_MAINTENANCE_MODE=true
|
||||
|
||||
# =============================================================================
|
||||
# Public Web Demo
|
||||
# =============================================================================
|
||||
|
||||
# Set ONLY for the browser demo build (atm.demo.aiolabs.dev). Leave blank on
|
||||
# every real machine. When set it:
|
||||
# - keeps the mouse cursor visible (kiosk builds hide it)
|
||||
# - mints one extra, never-used LNbits wallet named with this exact string,
|
||||
# so the throwaway accounts the demo creates (one per page load, each with
|
||||
# its own ephemeral identity) can be swept by name instead of guessed at.
|
||||
# VITE_DEMO_TAG=bitspire-web-demo
|
||||
|
||||
# =============================================================================
|
||||
# Mock Fallback (Production Safety)
|
||||
# =============================================================================
|
||||
|
|
@ -93,31 +70,3 @@ VITE_SPIRE_SEED=
|
|||
# Set to 'true' for development/demo environments only
|
||||
# When false (production default), initialization failures show a maintenance screen
|
||||
# VITE_ALLOW_MOCK_FALLBACK=true
|
||||
|
||||
# =============================================================================
|
||||
# Access Control (ADR-003)
|
||||
# =============================================================================
|
||||
|
||||
# Tap-to-enter gate. When disabled (default), the machine boots straight to
|
||||
# idle exactly as before. When enabled, it boots into a locked screen and a
|
||||
# Bolt Card tap (read by the main-process NFC service over pcscd) unlocks it
|
||||
# and loads the card for the session, so buy/sell finish with one Complete.
|
||||
# ACCESS_CONTROL_ENABLED=true
|
||||
|
||||
# Admit ANY Bolt Card when the allow-list has no match. With this on the gate
|
||||
# only keeps casual users off the menu — any NDEF tag with a /scan/<id> URL
|
||||
# unlocks it; money still moves only on a valid SUN at Complete. Turn OFF once
|
||||
# a real allow-list (/var/lib/bitspire/access.json) is provisioned.
|
||||
# ACCESS_OPEN_ENROLLMENT=true
|
||||
|
||||
# Show the on-screen runtime dev/operator unlock button on the locked screen.
|
||||
# Default OFF — it bypasses the gate, so enable only on a bench/dev machine.
|
||||
# ACCESS_DEV_UNLOCK=true
|
||||
|
||||
# Per-machine salt for hashing credentials/PINs. Provision a real value in
|
||||
# production (or in access.json); a fixed default is used if unset.
|
||||
# ACCESS_SALT=change-me-per-machine
|
||||
|
||||
# Build/dev bypass — forces the gate OPEN even when enabled (browser dev / CI).
|
||||
# Renderer-side (Vite) flag, never set in a production image.
|
||||
# VITE_SKIP_ACCESS_GATE=true
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
/**
|
||||
* Tests for bunker-binding persistence in state-store (aiolabs/bitspire#52,
|
||||
* transport config added in #70).
|
||||
*
|
||||
* Validates the round-trip of the binding singleton, including the v11→v12
|
||||
* transport columns (relays JSON + lnbits_server_pubkey) and their absence on
|
||||
* a pre-#70 binding.
|
||||
*
|
||||
* Uses an in-memory SQLite database — fresh per test, no on-disk artifacts.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
clearBunkerBinding,
|
||||
closeDatabase,
|
||||
getBunkerBinding,
|
||||
initDatabase,
|
||||
saveBunkerBinding,
|
||||
type StoredBunkerBinding,
|
||||
} from '../state-store.js'
|
||||
|
||||
const BASE: StoredBunkerBinding = {
|
||||
clientSecretHex: 'aa'.repeat(32),
|
||||
spirePubkey: 'bb'.repeat(32),
|
||||
bunkerUrl: 'bunker://bb?relay=wss%3A%2F%2Fr%2F&secret=deadbeef',
|
||||
seedFingerprint: 'cc'.repeat(32),
|
||||
pairedAt: 1_780_000_000,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
initDatabase(':memory:')
|
||||
})
|
||||
afterEach(() => {
|
||||
closeDatabase()
|
||||
})
|
||||
|
||||
describe('bunker binding persistence', () => {
|
||||
it('round-trips a binding carrying transport config (#70)', () => {
|
||||
const binding: StoredBunkerBinding = {
|
||||
...BASE,
|
||||
relays: ['wss://one.relay/', 'wss://two.relay/'],
|
||||
lnbitsServerPubkey: 'dd'.repeat(32),
|
||||
}
|
||||
saveBunkerBinding(binding)
|
||||
expect(getBunkerBinding()).toEqual(binding)
|
||||
})
|
||||
|
||||
it('round-trips a pre-#70 binding (no transport config) as undefined fields', () => {
|
||||
saveBunkerBinding(BASE)
|
||||
const got = getBunkerBinding()
|
||||
expect(got).toEqual(BASE)
|
||||
expect(got?.relays).toBeUndefined()
|
||||
expect(got?.lnbitsServerPubkey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('upserts transport config in place (re-pair overwrites)', () => {
|
||||
saveBunkerBinding({ ...BASE, relays: ['wss://old/'], lnbitsServerPubkey: 'ee'.repeat(32) })
|
||||
saveBunkerBinding({ ...BASE, relays: ['wss://new/'], lnbitsServerPubkey: 'ff'.repeat(32) })
|
||||
const got = getBunkerBinding()
|
||||
expect(got?.relays).toEqual(['wss://new/'])
|
||||
expect(got?.lnbitsServerPubkey).toBe('ff'.repeat(32))
|
||||
})
|
||||
|
||||
it('returns null after clear', () => {
|
||||
saveBunkerBinding(BASE)
|
||||
clearBunkerBinding()
|
||||
expect(getBunkerBinding()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,495 +0,0 @@
|
|||
/**
|
||||
* Tests for recordTransaction inventory accounting.
|
||||
*
|
||||
* Regression coverage for the position-vs-denomination decrement bug:
|
||||
* position is the cassettes PK (v9) and duplicate denominations across
|
||||
* bays are legal, so cash-out decrements MUST address bays by position.
|
||||
* A denomination-keyed UPDATE would drain every matching bay at once.
|
||||
*
|
||||
* Uses an in-memory SQLite database — fresh per test, no on-disk
|
||||
* artifacts, no parallel-test interference.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyOperatorCassetteOps,
|
||||
closeDatabase,
|
||||
getAppliedOpIds,
|
||||
getCassetteStateSeq,
|
||||
getCashbox,
|
||||
getCountsUncertainSince,
|
||||
getInventory,
|
||||
initDatabase,
|
||||
loadCassettes,
|
||||
markCountsUncertain,
|
||||
recordTransaction,
|
||||
setCassettes,
|
||||
} from '../state-store.js'
|
||||
|
||||
const TX_BASE = {
|
||||
fiatCents: 4000,
|
||||
sats: 100_000,
|
||||
feeSats: 5_000,
|
||||
feeFraction: 0.05,
|
||||
exchangeRate: 2500,
|
||||
currency: 'USD',
|
||||
}
|
||||
|
||||
/** Two $20 bays plus one $50 bay — the duplicate-denomination layout. */
|
||||
function seedDuplicateDenomBays() {
|
||||
setCassettes([
|
||||
{ position: 1, denomination: 20, count: 50 },
|
||||
{ position: 2, denomination: 20, count: 50 },
|
||||
{ position: 3, denomination: 50, count: 30 },
|
||||
])
|
||||
}
|
||||
|
||||
function countsByPosition(): Record<number, number> {
|
||||
const out: Record<number, number> = {}
|
||||
for (const row of loadCassettes()) out[row.position] = row.count
|
||||
return out
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
initDatabase(':memory:')
|
||||
seedDuplicateDenomBays()
|
||||
})
|
||||
afterEach(() => {
|
||||
closeDatabase()
|
||||
})
|
||||
|
||||
describe('state-store: recordTransaction cash_out inventory', () => {
|
||||
it('decrements only the bay that actually dispensed (duplicate denominations)', () => {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-single-bay',
|
||||
type: 'cash_out',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 20, count: 3 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette1',
|
||||
position: 1,
|
||||
denomination: 20,
|
||||
provisioned: 3,
|
||||
dispensed: 3,
|
||||
rejected: 0,
|
||||
},
|
||||
{
|
||||
name: 'cassette2',
|
||||
position: 2,
|
||||
denomination: 20,
|
||||
provisioned: 0,
|
||||
dispensed: 0,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(countsByPosition()).toEqual({ 1: 47, 2: 50, 3: 30 })
|
||||
})
|
||||
|
||||
it('decrements each bay by its own dispensed count on a split dispense', () => {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-split-bays',
|
||||
type: 'cash_out',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 20, count: 60 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette1',
|
||||
position: 1,
|
||||
denomination: 20,
|
||||
provisioned: 50,
|
||||
dispensed: 50,
|
||||
rejected: 0,
|
||||
},
|
||||
{
|
||||
name: 'cassette2',
|
||||
position: 2,
|
||||
denomination: 20,
|
||||
provisioned: 10,
|
||||
dispensed: 10,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(countsByPosition()).toEqual({ 1: 0, 2: 40, 3: 30 })
|
||||
})
|
||||
|
||||
it('fallback without cassette results drains matching bays greedily by position', () => {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-fallback',
|
||||
type: 'cash_out',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 20, count: 60 }],
|
||||
})
|
||||
|
||||
// Bay 1 (50 bills) drains fully, bay 2 covers the remaining 10.
|
||||
expect(countsByPosition()).toEqual({ 1: 0, 2: 40, 3: 30 })
|
||||
})
|
||||
|
||||
it('never drives a bay count below zero', () => {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-overdispense',
|
||||
type: 'cash_out',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 50, count: 35 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette3',
|
||||
position: 3,
|
||||
denomination: 50,
|
||||
provisioned: 35,
|
||||
dispensed: 35,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(countsByPosition()).toEqual({ 1: 50, 2: 50, 3: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('state-store: recordTransaction cash_in cashbox', () => {
|
||||
it('adds inserted bills to the cashbox and leaves cassettes untouched', () => {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-cash-in',
|
||||
type: 'cash_in',
|
||||
status: 'complete',
|
||||
bills: [
|
||||
{ denomination: 20, count: 2 },
|
||||
{ denomination: 50, count: 1 },
|
||||
],
|
||||
})
|
||||
|
||||
const cashbox = getCashbox()
|
||||
expect(cashbox.totalBills).toBe(3)
|
||||
expect(cashbox.totalFiatCents).toBe(TX_BASE.fiatCents)
|
||||
expect(countsByPosition()).toEqual({ 1: 50, 2: 50, 3: 30 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('state-store: recordTransaction manual_dispense inventory (#76)', () => {
|
||||
it('decrements the bays an operator remediation actually emptied', () => {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-manual',
|
||||
type: 'manual_dispense',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 20, count: 2 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette1',
|
||||
position: 1,
|
||||
denomination: 20,
|
||||
provisioned: 2,
|
||||
dispensed: 2,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
// Bills physically left bay 1; before #76 this row was untouched and the
|
||||
// inflated count became truth on the next boot.
|
||||
expect(countsByPosition()).toEqual({ 1: 48, 2: 50, 3: 30 })
|
||||
})
|
||||
|
||||
it('decrements again when remediating a partly-dispensed cash-out', () => {
|
||||
// Original cash-out managed 1 of the 2 notes it provisioned.
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-partial',
|
||||
type: 'cash_out',
|
||||
status: 'partial',
|
||||
bills: [{ denomination: 50, count: 1 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette3',
|
||||
position: 3,
|
||||
denomination: 50,
|
||||
provisioned: 2,
|
||||
dispensed: 1,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(countsByPosition()[3]).toBe(29)
|
||||
|
||||
// The operator dispenses the missing note by hand. That is a second lot of
|
||||
// bills leaving the bay, so it debits again — the original only ever
|
||||
// debited what physically left.
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-remediate',
|
||||
type: 'manual_dispense',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 50, count: 1 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette3',
|
||||
position: 3,
|
||||
denomination: 50,
|
||||
provisioned: 1,
|
||||
dispensed: 1,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(countsByPosition()[3]).toBe(28)
|
||||
})
|
||||
|
||||
it('leaves the cashbox alone (bills leave, they do not arrive)', () => {
|
||||
const before = getCashbox()
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-manual-cashbox',
|
||||
type: 'manual_dispense',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 20, count: 1 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette2',
|
||||
position: 2,
|
||||
denomination: 20,
|
||||
provisioned: 1,
|
||||
dispensed: 1,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(getCashbox()).toEqual(before)
|
||||
expect(countsByPosition()[2]).toBe(49)
|
||||
})
|
||||
})
|
||||
|
||||
describe('state-store: getInventory represents a drained machine', () => {
|
||||
it('keeps configured bays at zero rather than dropping them', () => {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-drain-50s',
|
||||
type: 'cash_out',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 50, count: 30 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette3',
|
||||
position: 3,
|
||||
denomination: 50,
|
||||
provisioned: 30,
|
||||
dispensed: 30,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
// The $50 bay is empty but still configured. Dropping the key made this
|
||||
// look like "no inventory known", and callers then fell back to a stale
|
||||
// snapshot or to HAL.
|
||||
expect(getInventory()).toEqual({ 20: 100, 50: 0 })
|
||||
})
|
||||
|
||||
it('reports every bay at zero when the machine is fully drained', () => {
|
||||
for (const [txid, position, denomination, count] of [
|
||||
['d1', 1, 20, 50],
|
||||
['d2', 2, 20, 50],
|
||||
['d3', 3, 50, 30],
|
||||
] as const) {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid,
|
||||
type: 'cash_out',
|
||||
status: 'complete',
|
||||
bills: [{ denomination, count }],
|
||||
cassettes: [
|
||||
{
|
||||
name: `cassette${position}`,
|
||||
position,
|
||||
denomination,
|
||||
provisioned: count,
|
||||
dispensed: count,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
expect(getInventory()).toEqual({ 20: 0, 50: 0 })
|
||||
})
|
||||
|
||||
it('returns an empty map only when no cassettes are configured', () => {
|
||||
// A fresh DB with no bays at all — the one case that should read as
|
||||
// "nothing known", so callers may legitimately defer to the hardware.
|
||||
closeDatabase()
|
||||
initDatabase(':memory:')
|
||||
expect(getInventory()).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('state-store: unverified counts after a silent dispense', () => {
|
||||
it('starts clear, latches the first time, and keeps the earliest time', () => {
|
||||
expect(getCountsUncertainSince()).toBeNull()
|
||||
markCountsUncertain(1000)
|
||||
expect(getCountsUncertainSince()).toBe(1000)
|
||||
// A second failure does not move the clock forward — the question is how
|
||||
// long the numbers have been untrustworthy, not when we last noticed.
|
||||
markCountsUncertain(2000)
|
||||
expect(getCountsUncertainSince()).toBe(1000)
|
||||
})
|
||||
|
||||
it('clears on a recount, because that is what a recount is', () => {
|
||||
markCountsUncertain(1000)
|
||||
const result = applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'recount', position: 1, count: 40 },
|
||||
])
|
||||
expect(result.applied).toEqual(['op-1'])
|
||||
expect(getCountsUncertainSince()).toBeNull()
|
||||
})
|
||||
|
||||
it('does not clear on a refill', () => {
|
||||
// A refill adds to a number still known to be wrong. Only someone
|
||||
// opening the bay and counting it resolves that.
|
||||
markCountsUncertain(1000)
|
||||
const result = applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 10 },
|
||||
])
|
||||
expect(result.applied).toEqual(['op-1'])
|
||||
expect(getCountsUncertainSince()).toBe(1000)
|
||||
})
|
||||
|
||||
it('leaves the flag alone when the op is rejected', () => {
|
||||
markCountsUncertain(1000)
|
||||
// Bay 9 does not exist — the layout is hardware-determined.
|
||||
const result = applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'recount', position: 9, count: 40 },
|
||||
])
|
||||
expect(result.applied).toEqual([])
|
||||
expect(result.rejected).toHaveLength(1)
|
||||
expect(getCountsUncertainSince()).toBe(1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('state-store: operator cassette operations (ADR-004)', () => {
|
||||
beforeEach(() => {
|
||||
seedDuplicateDenomBays()
|
||||
})
|
||||
|
||||
it('applies a refill as a delta, not a total', () => {
|
||||
applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 30 },
|
||||
])
|
||||
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(80)
|
||||
})
|
||||
|
||||
it('is a no-op on a re-delivered operation', () => {
|
||||
// Addressable events are re-delivered on every relay reconnect and the
|
||||
// operator republishes a WINDOW, so the same op arrives many times. A
|
||||
// delta applied twice is simply wrong, which is why every op carries an
|
||||
// id and this table records the ones already applied.
|
||||
const op = {
|
||||
id: 'op-1',
|
||||
at: 1_700_000_000,
|
||||
type: 'refill' as const,
|
||||
position: 1,
|
||||
bills: 30,
|
||||
}
|
||||
expect(applyOperatorCassetteOps([op]).applied).toEqual(['op-1'])
|
||||
expect(applyOperatorCassetteOps([op]).applied).toEqual([])
|
||||
expect(applyOperatorCassetteOps([op, op]).applied).toEqual([])
|
||||
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(80)
|
||||
})
|
||||
|
||||
it('applies only the unseen ops from a window that mixes both', () => {
|
||||
applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 10 },
|
||||
])
|
||||
const result = applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 10 },
|
||||
{ id: 'op-2', at: 1_700_000_001, type: 'refill', position: 1, bills: 5 },
|
||||
])
|
||||
expect(result.applied).toEqual(['op-2'])
|
||||
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(65)
|
||||
})
|
||||
|
||||
it('applies a window oldest-first regardless of arrival order', () => {
|
||||
// A recount then a refill is not the same as the reverse, so ordering is
|
||||
// load-bearing and cannot be left to however the array arrived.
|
||||
applyOperatorCassetteOps([
|
||||
{ id: 'op-b', at: 1_700_000_002, type: 'refill', position: 1, bills: 7 },
|
||||
{ id: 'op-a', at: 1_700_000_001, type: 'recount', position: 1, count: 3 },
|
||||
])
|
||||
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(10)
|
||||
})
|
||||
|
||||
it('empties a bay and sets a denomination', () => {
|
||||
applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'empty', position: 2 },
|
||||
{ id: 'op-2', at: 1_700_000_001, type: 'set_denomination', position: 2, denomination: 10 },
|
||||
])
|
||||
const bay = loadCassettes().find((c) => c.position === 2)!
|
||||
expect(bay.count).toBe(0)
|
||||
expect(bay.denomination).toBe(10)
|
||||
})
|
||||
|
||||
it('rejects a malformed op without applying or recording it', () => {
|
||||
// Unrecorded on purpose: it stays pending on the operator's dashboard,
|
||||
// which is the honest outcome. Recording it as applied would silence the
|
||||
// noise by telling the operator their refill landed.
|
||||
const result = applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: -5 },
|
||||
])
|
||||
expect(result.applied).toEqual([])
|
||||
expect(result.rejected[0]!.id).toBe('op-1')
|
||||
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(50)
|
||||
expect(getAppliedOpIds()).not.toContain('op-1')
|
||||
})
|
||||
|
||||
it('echoes applied ids back, newest first', () => {
|
||||
applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 1 },
|
||||
{ id: 'op-2', at: 1_700_000_001, type: 'refill', position: 1, bills: 1 },
|
||||
])
|
||||
expect(getAppliedOpIds()).toContain('op-1')
|
||||
expect(getAppliedOpIds()).toContain('op-2')
|
||||
})
|
||||
|
||||
it('advances the sequence on an applied op but not on a duplicate', () => {
|
||||
const op = {
|
||||
id: 'op-1',
|
||||
at: 1_700_000_000,
|
||||
type: 'refill' as const,
|
||||
position: 1,
|
||||
bills: 1,
|
||||
}
|
||||
const before = getCassetteStateSeq()
|
||||
applyOperatorCassetteOps([op])
|
||||
const after = getCassetteStateSeq()
|
||||
expect(after).toBeGreaterThan(before)
|
||||
applyOperatorCassetteOps([op])
|
||||
expect(getCassetteStateSeq()).toBe(after)
|
||||
})
|
||||
|
||||
it('advances the sequence on a dispense', () => {
|
||||
const before = getCassetteStateSeq()
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-seq',
|
||||
type: 'cash_out',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 20, count: 1 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette1',
|
||||
position: 1,
|
||||
denomination: 20,
|
||||
provisioned: 1,
|
||||
dispensed: 1,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(getCassetteStateSeq()).toBeGreaterThan(before)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { openCardSession, scanUrlToSessionUrl } from './boltcard-session'
|
||||
|
||||
const LNURLW =
|
||||
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
|
||||
|
||||
/** Mock fetch returning the given JSON bodies per call, in order (status 200). */
|
||||
function mockFetch(bodies: unknown[], status = 200) {
|
||||
const calls: string[] = []
|
||||
const impl = vi.fn(async (url: string | URL) => {
|
||||
calls.push(url.toString())
|
||||
const body = bodies[calls.length - 1]
|
||||
return { status, json: async () => body } as Response
|
||||
})
|
||||
return { impl: impl as unknown as typeof fetch, calls }
|
||||
}
|
||||
|
||||
const SESSION = {
|
||||
authenticated: true,
|
||||
external_id: 'abc123',
|
||||
card_name: 'Alice',
|
||||
balance_msat: 123_456_789,
|
||||
currency: 'usd',
|
||||
fiat: 98.76,
|
||||
withdraw: {
|
||||
callback: 'https://lnbits.l484.com/boltcards/api/v1/lnurl/cb/hit1',
|
||||
k1: 'hit1',
|
||||
minWithdrawable: 1000,
|
||||
maxWithdrawable: 50_000_000,
|
||||
},
|
||||
withdraw_blocked_reason: null,
|
||||
pay: {
|
||||
callback: 'https://lnbits.l484.com/boltcards/api/v1/pay/cb/hit1',
|
||||
minSendable: 1000,
|
||||
maxSendable: 50_000_000,
|
||||
metadata: '[["text/plain","Bolt Card top-up"]]',
|
||||
},
|
||||
}
|
||||
|
||||
describe('scanUrlToSessionUrl', () => {
|
||||
it('rewrites /scan/ to /session/ and preserves p + c', () => {
|
||||
const u = scanUrlToSessionUrl(LNURLW)
|
||||
expect(u).toContain('https://lnbits.l484.com/boltcards/api/v1/session/abc123')
|
||||
expect(u).toContain('p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF')
|
||||
expect(u).toContain('c=1122334455667788')
|
||||
})
|
||||
it('returns null for a non-scan URL', () => {
|
||||
expect(scanUrlToSessionUrl('lnurlw://host/somethingelse?p=1&c=2')).toBeNull()
|
||||
expect(scanUrlToSessionUrl('http://host/boltcards/api/v1/scan/x')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('openCardSession', () => {
|
||||
it('opens a session: balance in sats, upper-cased currency, both steps', async () => {
|
||||
const f = mockFetch([SESSION])
|
||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
||||
expect(f.calls).toHaveLength(1)
|
||||
expect(f.calls[0]).toContain('/session/abc123')
|
||||
expect(out).toEqual({
|
||||
ok: true,
|
||||
session: {
|
||||
externalId: 'abc123',
|
||||
cardName: 'Alice',
|
||||
balanceSats: 123_456,
|
||||
currency: 'USD',
|
||||
fiat: 98.76,
|
||||
withdraw: SESSION.withdraw,
|
||||
withdrawBlockedReason: null,
|
||||
pay: SESSION.pay,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('carries a withheld withdraw step with its reason', async () => {
|
||||
const f = mockFetch([
|
||||
{ ...SESSION, withdraw: null, withdraw_blocked_reason: 'Max daily limit spent.' },
|
||||
])
|
||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
||||
expect(out.ok).toBe(true)
|
||||
if (!out.ok) return
|
||||
expect(out.session.withdraw).toBeNull()
|
||||
expect(out.session.withdrawBlockedReason).toBe('Max daily limit spent.')
|
||||
expect(out.session.pay.callback).toBe(SESSION.pay.callback)
|
||||
})
|
||||
|
||||
it('has no fiat when the server sent no currency', async () => {
|
||||
const f = mockFetch([{ ...SESSION, currency: null, fiat: null }])
|
||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
||||
expect(out.ok && out.session.currency).toBeNull()
|
||||
expect(out.ok && out.session.fiat).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces the server reason on a rejected tap', async () => {
|
||||
const f = mockFetch([{ authenticated: false, reason: 'This link is already used.' }])
|
||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
||||
expect(out).toEqual({ ok: false, reason: 'This link is already used.' })
|
||||
})
|
||||
|
||||
it('rejects an incomplete session (no pay step)', async () => {
|
||||
const f = mockFetch([{ ...SESSION, pay: undefined }])
|
||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
||||
expect(out).toEqual({ ok: false, reason: 'card server returned an incomplete session' })
|
||||
})
|
||||
|
||||
it('names an old card server that has no /session', async () => {
|
||||
const f = mockFetch([{ detail: 'Not Found' }], 404)
|
||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
||||
expect(out).toEqual({ ok: false, reason: 'card server does not support sessions' })
|
||||
})
|
||||
|
||||
it('rejects a non-card tag without a network call', async () => {
|
||||
const f = mockFetch([])
|
||||
const out = await openCardSession('https://host/not/a/card', { fetchImpl: f.impl })
|
||||
expect(out.ok).toBe(false)
|
||||
expect(f.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports an unreachable card server', async () => {
|
||||
const impl = vi.fn(async () => {
|
||||
throw new TypeError('fetch failed')
|
||||
}) as unknown as typeof fetch
|
||||
const out = await openCardSession(LNURLW, { fetchImpl: impl })
|
||||
expect(out).toEqual({ ok: false, reason: 'could not reach the card: fetch failed' })
|
||||
})
|
||||
})
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
/**
|
||||
* Bolt Card session (tap-to-enter) — one tap, one verified visit.
|
||||
*
|
||||
* A Bolt Card tap yields a single-use SUN `p`/`c`; anything that verifies it
|
||||
* spends it. The access gate (ADR-003) wants to verify the card at entry AND
|
||||
* let the holder finish a buy or sell later without tapping again, so the
|
||||
* aiolabs `boltcards` fork exposes `/session/<external_id>?p=&c=` — a sibling
|
||||
* of `/scan` and `/pay` that verifies once, records one hit, and returns:
|
||||
* - the card wallet's balance and fiat equivalent (display only),
|
||||
* - the LUD-03 second step (withdraw callback + k1 = hit) for cash-out,
|
||||
* - the LUD-06 second step (pay callback) for cash-in.
|
||||
* Both callbacks are keyed by the hit — the same single-use bearer `/scan`
|
||||
* and `/pay` hand out — so the ATM holds no p/c for the rest of the visit.
|
||||
* The withdraw step is withheld (with a reason) once the card's daily limit is
|
||||
* spent, exactly as `/scan` would refuse.
|
||||
*
|
||||
* Runs in the MAIN process (Node fetch) to avoid renderer CORS, like the other
|
||||
* LNURL modules. See docs/boltcard-session.md for the wire contract.
|
||||
*/
|
||||
|
||||
import { lnurlwToHttps, type WithdrawStep } from './lnurl-withdraw.js'
|
||||
import type { PayStep } from './lnurl-pay.js'
|
||||
|
||||
export interface CardSession {
|
||||
externalId: string
|
||||
cardName: string
|
||||
balanceSats: number
|
||||
/** ISO currency the card server priced the balance in; null → no fiat. */
|
||||
currency: string | null
|
||||
/** Balance in `currency` at the card server's rate; null when unknown. */
|
||||
fiat: number | null
|
||||
/** LUD-03 second step, or null when the card server withheld it. */
|
||||
withdraw: WithdrawStep | null
|
||||
/** Why `withdraw` is null (e.g. daily limit spent); safe to show on-screen. */
|
||||
withdrawBlockedReason: string | null
|
||||
/** LUD-06 second step for topping the card wallet up. */
|
||||
pay: PayStep
|
||||
}
|
||||
|
||||
export type OpenCardSessionResult =
|
||||
| { ok: true; session: CardSession }
|
||||
| { ok: false; reason: string }
|
||||
|
||||
type FetchLike = typeof fetch
|
||||
|
||||
export interface OpenCardSessionOptions {
|
||||
/** Injected for tests; defaults to global fetch. */
|
||||
fetchImpl?: FetchLike
|
||||
/** Per-request timeout (default 15s). */
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the session URL from a tapped card's `lnurlw`: the card presents
|
||||
* `…/boltcards/api/v1/scan/<id>?p=&c=`; the session endpoint is its sibling
|
||||
* `…/boltcards/api/v1/session/<id>?p=&c=` with the same SUN.
|
||||
*/
|
||||
export function scanUrlToSessionUrl(lnurlw: string): string | null {
|
||||
const https = lnurlwToHttps(lnurlw)
|
||||
if (!https) return null
|
||||
const u = new URL(https)
|
||||
if (!u.pathname.includes('/scan/')) return null
|
||||
u.pathname = u.pathname.replace('/scan/', '/session/')
|
||||
return u.toString()
|
||||
}
|
||||
|
||||
/** Wire shape of a `/session` reply (any of the fields may be missing/odd). */
|
||||
interface SessionWire {
|
||||
authenticated?: unknown
|
||||
reason?: unknown
|
||||
external_id?: unknown
|
||||
card_name?: unknown
|
||||
balance_msat?: unknown
|
||||
currency?: unknown
|
||||
fiat?: unknown
|
||||
withdraw?: unknown
|
||||
withdraw_blocked_reason?: unknown
|
||||
pay?: unknown
|
||||
}
|
||||
|
||||
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
|
||||
const optNum = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
|
||||
const optStr = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
||||
|
||||
function parseWithdraw(v: unknown): WithdrawStep | null {
|
||||
if (!isObj(v)) return null
|
||||
const callback = optStr(v.callback)
|
||||
const k1 = optStr(v.k1)
|
||||
if (!callback || !k1) return null
|
||||
return {
|
||||
callback,
|
||||
k1,
|
||||
minWithdrawable: optNum(v.minWithdrawable),
|
||||
maxWithdrawable: optNum(v.maxWithdrawable),
|
||||
}
|
||||
}
|
||||
|
||||
function parsePay(v: unknown): PayStep | null {
|
||||
if (!isObj(v)) return null
|
||||
const callback = optStr(v.callback)
|
||||
if (!callback) return null
|
||||
return {
|
||||
callback,
|
||||
minSendable: optNum(v.minSendable),
|
||||
maxSendable: optNum(v.maxSendable),
|
||||
metadata: optStr(v.metadata),
|
||||
}
|
||||
}
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
if (e instanceof Error)
|
||||
return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
|
||||
return String(e)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a session for a tapped card. Spends the tap's SUN. Never throws —
|
||||
* every failure returns `{ ok: false, reason }` (reasons come from the card
|
||||
* server verbatim and are safe to show).
|
||||
*/
|
||||
export async function openCardSession(
|
||||
lnurlw: string,
|
||||
opts: OpenCardSessionOptions = {}
|
||||
): Promise<OpenCardSessionResult> {
|
||||
const doFetch = opts.fetchImpl ?? fetch
|
||||
const timeoutMs = opts.timeoutMs ?? 15_000
|
||||
|
||||
const url = scanUrlToSessionUrl(lnurlw)
|
||||
if (!url) return { ok: false, reason: 'not a valid Bolt Card (lnurlw) tag' }
|
||||
|
||||
let wire: SessionWire
|
||||
try {
|
||||
const res = await doFetch(url, { signal: AbortSignal.timeout(timeoutMs) })
|
||||
if (res.status === 404) {
|
||||
// Older fork without /session — say so rather than "card rejected".
|
||||
return { ok: false, reason: 'card server does not support sessions' }
|
||||
}
|
||||
wire = (await res.json()) as SessionWire
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `could not reach the card: ${errMsg(e)}` }
|
||||
}
|
||||
|
||||
if (wire.authenticated !== true) {
|
||||
return { ok: false, reason: optStr(wire.reason) || 'card rejected the tap' }
|
||||
}
|
||||
const externalId = optStr(wire.external_id)
|
||||
const pay = parsePay(wire.pay)
|
||||
if (!externalId || !pay) {
|
||||
return { ok: false, reason: 'card server returned an incomplete session' }
|
||||
}
|
||||
const balanceMsat = optNum(wire.balance_msat) ?? 0
|
||||
const currency = optStr(wire.currency)?.toUpperCase() ?? null
|
||||
const fiat = optNum(wire.fiat)
|
||||
return {
|
||||
ok: true,
|
||||
session: {
|
||||
externalId,
|
||||
cardName: optStr(wire.card_name) ?? '',
|
||||
balanceSats: Math.floor(balanceMsat / 1000),
|
||||
currency,
|
||||
fiat: currency && fiat !== undefined ? fiat : null,
|
||||
withdraw: parseWithdraw(wire.withdraw),
|
||||
withdrawBlockedReason: optStr(wire.withdraw_blocked_reason) ?? null,
|
||||
pay,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -13,15 +13,8 @@
|
|||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import {
|
||||
NostrClient,
|
||||
LocalSigner,
|
||||
loadIdentityFromHex,
|
||||
resumeFromBinding,
|
||||
type Signer,
|
||||
} from '@bitSpire/nostr-client'
|
||||
import { NostrClient, loadIdentityFromHex } from '@bitSpire/nostr-client'
|
||||
import { LnbitsClient } from '@bitSpire/lnbits'
|
||||
import { initDatabase, getBunkerBinding } from './state-store.js'
|
||||
|
||||
// @ts-ignore — qrcode is a transitive dep (via qrcode.vue), no types needed
|
||||
import QRCode from 'qrcode'
|
||||
|
|
@ -63,38 +56,19 @@ async function main() {
|
|||
const lnbitsServerPubkey = env['VITE_LNBITS_SERVER_PUBKEY']
|
||||
const atmPrivateKey = env['VITE_ATM_PRIVATE_KEY']
|
||||
|
||||
if (!relayUrl || !lnbitsServerPubkey) {
|
||||
if (!relayUrl || !lnbitsServerPubkey || !atmPrivateKey) {
|
||||
console.error('Missing required config in', envPath)
|
||||
console.error('Need: VITE_RELAY_URL, VITE_LNBITS_SERVER_PUBKEY')
|
||||
console.error('Need: VITE_RELAY_URL, VITE_LNBITS_SERVER_PUBKEY, VITE_ATM_PRIVATE_KEY')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.error(`Generating invoice for ${amountSats} sats...`)
|
||||
|
||||
// Resolve the signer. Prod: resume the bunker binding from state.db (the
|
||||
// ATM's transport key — the connect token was already redeemed by the main
|
||||
// app, so we can't re-pair here). Dev: a local nsec via VITE_ATM_PRIVATE_KEY.
|
||||
let signer: Signer
|
||||
if (atmPrivateKey) {
|
||||
signer = new LocalSigner(loadIdentityFromHex(atmPrivateKey))
|
||||
} else {
|
||||
initDatabase()
|
||||
const binding = getBunkerBinding()
|
||||
if (!binding) {
|
||||
console.error('ATM is not paired (no bunker binding in state.db) and no')
|
||||
console.error('VITE_ATM_PRIVATE_KEY set. Pair the ATM via the main app first.')
|
||||
process.exit(1)
|
||||
}
|
||||
signer = await resumeFromBinding({
|
||||
clientSecretHex: binding.clientSecretHex,
|
||||
spirePubkey: binding.spirePubkey,
|
||||
bunkerUrl: binding.bunkerUrl,
|
||||
})
|
||||
}
|
||||
const identity = loadIdentityFromHex(atmPrivateKey)
|
||||
|
||||
const nostrClient = new NostrClient({
|
||||
relays: [{ url: relayUrl }],
|
||||
signer,
|
||||
identity,
|
||||
})
|
||||
await nostrClient.connect()
|
||||
|
||||
|
|
@ -102,7 +76,7 @@ async function main() {
|
|||
serverPubkey: lnbitsServerPubkey,
|
||||
relays: [relayUrl],
|
||||
})
|
||||
lnbits.initialize(nostrClient, signer)
|
||||
lnbits.initialize(nostrClient, identity)
|
||||
|
||||
const wallets = await lnbits.listWallets()
|
||||
const wallet = wallets[0]
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@
|
|||
* so it's available at runtime (unlike src/ which is only for Vite).
|
||||
*/
|
||||
|
||||
import type { BillValidator, BillDispenser, DispenseErrorClass } from '@bitSpire/hal'
|
||||
import { isDispenseError } from '@bitSpire/hal'
|
||||
import type { BillValidator, BillDispenser } from '@bitSpire/hal'
|
||||
|
||||
export interface CassetteConfig {
|
||||
/**
|
||||
|
|
@ -37,11 +36,6 @@ export interface HalConfig {
|
|||
export interface ValidatorCallbacks {
|
||||
shouldAcceptBill: (denomination: number) => boolean | 'hold'
|
||||
onBillRead?: (denomination: number) => void
|
||||
/**
|
||||
* Fires on the validator's stacked-confirmation (`billsValid`) — the
|
||||
* bill physically reached the stacker. This is the CREDIT event; it is
|
||||
* NOT emitted at stack-command time (a stack can still fail/return).
|
||||
*/
|
||||
onBillInserted: (denomination: number) => void
|
||||
onBillRejected: (reason: string) => void
|
||||
onError: (error: string) => void
|
||||
|
|
@ -49,20 +43,8 @@ export interface ValidatorCallbacks {
|
|||
|
||||
export interface DispenseResult {
|
||||
bills: { denomination: number; dispensed: number; rejected: number }[]
|
||||
/**
|
||||
* Σ(denomination × dispensed) === Σ(denomination × requested). Computed
|
||||
* here on VALUE (ADR-005 §3) — never a driver boolean. Only this routes
|
||||
* the state machine to `complete`.
|
||||
*/
|
||||
dispenseConfirmed: boolean
|
||||
/** Human message when not confirmed */
|
||||
dispensed: boolean
|
||||
error?: string
|
||||
/** The error's NAME — 'F56DispenseError', 'InsufficientInventory', … */
|
||||
errorCode?: string
|
||||
/** Driver-native code, e.g. '78 42' */
|
||||
rawCode?: string
|
||||
/** terminal | recoverable | inventory — see @bitSpire/hal error-codes */
|
||||
errorClass?: DispenseErrorClass
|
||||
cassettes?: {
|
||||
name: string
|
||||
position: number
|
||||
|
|
@ -160,14 +142,6 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
count: c.count ?? 0,
|
||||
}))
|
||||
|
||||
// Escrow / in-flight bookkeeping (legacy brain.js `billsRead` interlock):
|
||||
// `escrowDenomination` = bill held in escrow awaiting a stack/reject
|
||||
// decision; `inFlightDenomination` = stack commanded, awaiting the
|
||||
// validator's `billsValid` stacked-confirmation. onBillInserted (the
|
||||
// credit event) fires only on that confirmation.
|
||||
let escrowDenomination: number | null = null
|
||||
let inFlightDenomination: number | null = null
|
||||
|
||||
return {
|
||||
connectValidator: (callbacks: ValidatorCallbacks) => {
|
||||
if (!validator) {
|
||||
|
|
@ -179,11 +153,10 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
const decision = callbacks.shouldAcceptBill(data.denomination)
|
||||
if (decision === 'hold') {
|
||||
console.log('[HAL] Bill in escrow:', data.denomination)
|
||||
escrowDenomination = data.denomination
|
||||
callbacks.onBillRead?.(data.denomination)
|
||||
} else if (decision) {
|
||||
inFlightDenomination = data.denomination
|
||||
validator.stack()
|
||||
callbacks.onBillInserted(data.denomination)
|
||||
} else {
|
||||
console.log('[HAL] Bill rejected: insufficient balance for', data.denomination)
|
||||
validator.reject()
|
||||
|
|
@ -195,23 +168,7 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
}
|
||||
})
|
||||
|
||||
// Stacked-confirmation → the credit event.
|
||||
validator.on('billsValid', () => {
|
||||
if (inFlightDenomination === null) {
|
||||
console.warn('[HAL] billsValid with no bill in flight — ignoring')
|
||||
return
|
||||
}
|
||||
const denomination = inFlightDenomination
|
||||
inFlightDenomination = null
|
||||
console.log('[HAL] Bill stacked (confirmed):', denomination)
|
||||
callbacks.onBillInserted(denomination)
|
||||
})
|
||||
|
||||
validator.on('billsRejected', (data?: { reason: string; code: number | null }) => {
|
||||
// Covers both an escrow refusal and a failed/returned stack —
|
||||
// either way nothing was credited and nothing is in flight.
|
||||
escrowDenomination = null
|
||||
inFlightDenomination = null
|
||||
callbacks.onBillRejected(data?.reason ?? 'unknown')
|
||||
})
|
||||
|
||||
|
|
@ -234,32 +191,12 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
},
|
||||
|
||||
disableValidator: () => {
|
||||
// If a note is sitting in escrow when we disable (inactivity timeout,
|
||||
// cancel, or leaving the insert screen), return it to the customer.
|
||||
// Disabling alone does NOT release an escrowed note on EBDS — it would
|
||||
// be stranded in the transport until the next power cycle.
|
||||
if (escrowDenomination !== null) {
|
||||
console.log('[HAL] Returning escrowed bill on disable:', escrowDenomination)
|
||||
escrowDenomination = null
|
||||
validator?.reject()
|
||||
}
|
||||
validator?.disable()
|
||||
validator?.lightOff()
|
||||
},
|
||||
|
||||
stackBill: () => {
|
||||
if (escrowDenomination === null) {
|
||||
console.warn('[HAL] stackBill with no bill in escrow — ignoring')
|
||||
return
|
||||
}
|
||||
inFlightDenomination = escrowDenomination
|
||||
escrowDenomination = null
|
||||
validator?.stack()
|
||||
},
|
||||
rejectBill: () => {
|
||||
escrowDenomination = null
|
||||
validator?.reject()
|
||||
},
|
||||
stackBill: () => validator?.stack(),
|
||||
rejectBill: () => validator?.reject(),
|
||||
|
||||
dispenseCash: async (amounts): Promise<DispenseResult> => {
|
||||
console.log('[HAL] Dispensing:', amounts)
|
||||
|
|
@ -290,8 +227,6 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
notes[i] = (notes[i] ?? 0) + take
|
||||
remaining -= take
|
||||
}
|
||||
// Nothing has been asked of the hardware in either refusal below:
|
||||
// errorClass 'inventory' routes to outOfCash, not the fault screen.
|
||||
if (!matched) {
|
||||
return {
|
||||
bills: amounts.map((a) => ({
|
||||
|
|
@ -299,10 +234,8 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
dispensed: 0,
|
||||
rejected: 0,
|
||||
})),
|
||||
dispenseConfirmed: false,
|
||||
dispensed: false,
|
||||
error: `No cassette loaded with denomination: ${denomination}`,
|
||||
errorCode: 'NoCassetteForDenomination',
|
||||
errorClass: 'inventory',
|
||||
}
|
||||
}
|
||||
if (remaining > 0) {
|
||||
|
|
@ -312,10 +245,8 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
dispensed: 0,
|
||||
rejected: 0,
|
||||
})),
|
||||
dispenseConfirmed: false,
|
||||
dispensed: false,
|
||||
error: `Insufficient inventory for denomination ${denomination}: short ${remaining}`,
|
||||
errorCode: 'InsufficientInventory',
|
||||
errorClass: 'inventory',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -363,44 +294,11 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
}
|
||||
const bills = Array.from(billsByDenom.values())
|
||||
|
||||
// ADR-005 §3: confirmation is VALUE equality — what left the bays is
|
||||
// worth exactly what was asked — not a count, and not the driver's
|
||||
// opinion. lamassu computed the same thing (`tx.fiat.eq(Σ denomination
|
||||
// × dispensed)`); our previous count-based check was only equivalent
|
||||
// while every bay dispensed its own denomination.
|
||||
const requestedValue = amounts.reduce((s, a) => s + a.denomination * a.count, 0)
|
||||
const dispensedValue = cassetteResults.reduce((s, c) => s + c.denomination * c.dispensed, 0)
|
||||
const totalRequested = amounts.reduce((s, a) => s + a.count, 0)
|
||||
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
|
||||
const dispenseConfirmed = requestedValue === dispensedValue
|
||||
|
||||
if (result.error) {
|
||||
const e = result.error
|
||||
const info = isDispenseError(e)
|
||||
? { errorCode: e.errorCode, rawCode: e.rawCode, errorClass: e.errorClass, human: e.human }
|
||||
: {
|
||||
// Unreachable by type (drivers always tag), kept as a defensive
|
||||
// fallback for a driver that slips an untagged Error through.
|
||||
errorCode: (e as Error).name || 'DispenseError',
|
||||
rawCode: undefined,
|
||||
errorClass: 'terminal' as const,
|
||||
human: (e as Error).message,
|
||||
}
|
||||
console.error(
|
||||
`[HAL] Dispense error ${info.errorCode}${info.rawCode ? ` ${info.rawCode}` : ''} (${info.errorClass}): ${info.human} — requested ${requestedValue}, dispensed ${dispensedValue}`
|
||||
)
|
||||
// A dispensed value of zero WITH an error is not evidence that nothing
|
||||
// left the bay — a note stopped in the transport completes neither
|
||||
// counter (sintra, 2026-10-09). The store reads this combination and
|
||||
// flags counts unverified; we just report faithfully here.
|
||||
return {
|
||||
bills,
|
||||
cassettes: cassetteResults,
|
||||
dispenseConfirmed: false,
|
||||
error: info.human,
|
||||
errorCode: info.errorCode,
|
||||
rawCode: info.rawCode,
|
||||
errorClass: info.errorClass,
|
||||
}
|
||||
return { bills, cassettes: cassetteResults, dispensed: false, error: result.error.message }
|
||||
}
|
||||
|
||||
// Wait for customer to take bills
|
||||
|
|
@ -409,20 +307,7 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
console.log('[HAL] Bills removed by customer')
|
||||
}
|
||||
|
||||
if (!dispenseConfirmed) {
|
||||
// Short with no hardware error — the dispenser simply gave less.
|
||||
console.warn(`[HAL] Dispense short with no error: requested ${requestedValue}, dispensed ${dispensedValue}`)
|
||||
return {
|
||||
bills,
|
||||
cassettes: cassetteResults,
|
||||
dispenseConfirmed: false,
|
||||
error: `Dispensed ${dispensedValue} of ${requestedValue} with no dispenser error`,
|
||||
errorCode: 'DispenseShort',
|
||||
errorClass: 'inventory',
|
||||
}
|
||||
}
|
||||
|
||||
return { bills, cassettes: cassetteResults, dispenseConfirmed: true }
|
||||
return { bills, cassettes: cassetteResults, dispensed: totalRequested === totalDispensed }
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -441,10 +326,12 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
setCassettes: async (cassettes: CassetteConfig[]): Promise<void> => {
|
||||
console.log(
|
||||
'[HAL] Hot-reloading cassette layout:',
|
||||
cassettes.map((c) => `bay${c.position}:${c.denomination}×${c.count ?? 0}`).join(', ')
|
||||
cassettes
|
||||
.map((c) => `bay${c.position}:${c.denomination}×${c.count ?? 0}`)
|
||||
.join(', ')
|
||||
)
|
||||
const previousBays = bays
|
||||
const previousInitData = dispenserInitData
|
||||
// Rebuild bays first so subsequent dispense calls see the new layout
|
||||
// even if the dispenser re-init is slow / fails.
|
||||
bays = cassettes
|
||||
.slice()
|
||||
.sort((a, b) => a.position - b.position)
|
||||
|
|
@ -454,40 +341,31 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
count: c.count ?? 0,
|
||||
}))
|
||||
dispenserInitData = { fiatCode: valConfig.fiatCode, cassettes }
|
||||
// Close + re-init the dispenser so its internal per-bay state matches the
|
||||
// new layout. close() now resolves only once the port is really closed,
|
||||
// so the re-open below cannot race it (aiolabs/bitspire#118).
|
||||
//
|
||||
// On failure, roll the in-memory layout back. This reverses an earlier
|
||||
// deliberate choice to keep the new bays "even if the dispenser re-init
|
||||
// is slow / fails": with the re-init failing every time on douro, the app
|
||||
// kept a layout the device had never taken and the operator-config
|
||||
// consumer went on to publish a cassettes-state event advertising it. A
|
||||
// subsequent dispense would then pick bays by a layout the hardware does
|
||||
// not share. Better to surface the failure and stay truthful about what
|
||||
// the device is actually running.
|
||||
// Close + re-init the dispenser so its internal per-bay state matches
|
||||
// the new layout. Errors here surface to the caller (operator-config
|
||||
// consumer) — the renderer can decide whether to retry.
|
||||
try {
|
||||
await dispenser.close()
|
||||
await dispenser.init(dispenserInitData)
|
||||
dispenser.close()
|
||||
} catch (err) {
|
||||
bays = previousBays
|
||||
dispenserInitData = previousInitData
|
||||
console.error('[HAL] Dispenser re-init failed; keeping the previous cassette layout:', err)
|
||||
throw err
|
||||
console.warn('[HAL] Dispenser close during setCassettes raised:', err)
|
||||
}
|
||||
await dispenser.init(dispenserInitData)
|
||||
console.log('[HAL] Dispenser re-initialized with new cassettes')
|
||||
},
|
||||
|
||||
cleanup: async () => {
|
||||
return new Promise<void>((resolve) => {
|
||||
validator?.disable()
|
||||
validator?.lightOff()
|
||||
await dispenser.close()
|
||||
if (!validator) return
|
||||
await new Promise<void>((resolve) => {
|
||||
validator?.close((err?: Error) => {
|
||||
dispenser.close()
|
||||
if (validator) {
|
||||
validator.close((err?: Error) => {
|
||||
if (err) console.error('[HAL] Validator close error:', err)
|
||||
resolve()
|
||||
})
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,165 +0,0 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import {
|
||||
resolveCardInvoice,
|
||||
resolveInvoiceFromPayStep,
|
||||
scanUrlToResolver,
|
||||
lnAddressToLnurlp,
|
||||
} from './lnurl-pay'
|
||||
|
||||
const LNURLW =
|
||||
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
|
||||
const BOLT11 = 'lnbc10u1p3xyz...'
|
||||
|
||||
/** Mock fetch that returns the given JSON bodies per call, in order. */
|
||||
function mockFetch(bodies: unknown[]) {
|
||||
const calls: string[] = []
|
||||
const impl = vi.fn(async (url: string | URL) => {
|
||||
calls.push(url.toString())
|
||||
const body = bodies[calls.length - 1]
|
||||
return { json: async () => body } as Response
|
||||
})
|
||||
return { impl: impl as unknown as typeof fetch, calls }
|
||||
}
|
||||
|
||||
describe('scanUrlToResolver', () => {
|
||||
it('rewrites /scan/ to /pay/ and preserves p + c', () => {
|
||||
const r = scanUrlToResolver(LNURLW)
|
||||
expect(r).toContain('https://lnbits.l484.com/boltcards/api/v1/pay/abc123')
|
||||
expect(r).toContain('p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF')
|
||||
expect(r).toContain('c=1122334455667788')
|
||||
})
|
||||
it('returns null for a non-scan URL', () => {
|
||||
expect(scanUrlToResolver('lnurlw://host/somethingelse?p=1&c=2')).toBeNull()
|
||||
expect(scanUrlToResolver('http://host/boltcards/api/v1/scan/x')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('lnAddressToLnurlp', () => {
|
||||
it('maps name@host to the well-known lnurlp URL', () => {
|
||||
expect(lnAddressToLnurlp('cardname@l484.com')).toBe(
|
||||
'https://l484.com/.well-known/lnurlp/cardname'
|
||||
)
|
||||
})
|
||||
it('rejects non-addresses', () => {
|
||||
expect(lnAddressToLnurlp('not-an-address')).toBeNull()
|
||||
expect(lnAddressToLnurlp('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveCardInvoice', () => {
|
||||
const payReq = {
|
||||
tag: 'payRequest',
|
||||
callback: 'https://lnbits.l484.com/lnurlp/api/v1/lnurl/cb',
|
||||
minSendable: 1000,
|
||||
maxSendable: 100_000_000,
|
||||
metadata: '[["text/plain","bolt card top-up"]]',
|
||||
}
|
||||
|
||||
it('resolver returns a payRequest inline → fetches the invoice', async () => {
|
||||
const { impl, calls } = mockFetch([payReq, { pr: BOLT11 }])
|
||||
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
|
||||
expect(res).toEqual({ ok: true, bolt11: BOLT11 })
|
||||
// 1st call = the /pay resolver; 2nd = the callback with amount in msat.
|
||||
expect(calls[0]).toContain('/boltcards/api/v1/pay/abc123')
|
||||
expect(calls[1]).toContain('amount=21000')
|
||||
})
|
||||
|
||||
it('resolver returns a Lightning Address → LUD-16 → invoice', async () => {
|
||||
const { impl, calls } = mockFetch([
|
||||
{ lightningAddress: 'cardname@l484.com' },
|
||||
payReq,
|
||||
{ pr: BOLT11 },
|
||||
])
|
||||
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
|
||||
expect(res).toEqual({ ok: true, bolt11: BOLT11 })
|
||||
expect(calls[1]).toBe('https://l484.com/.well-known/lnurlp/cardname')
|
||||
expect(calls[2]).toContain('amount=21000')
|
||||
})
|
||||
|
||||
it('rejects a non-lnurlw tag', async () => {
|
||||
const { impl } = mockFetch([])
|
||||
const res = await resolveCardInvoice('http://nope', 21_000, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false })
|
||||
expect(res.reason).toMatch(/not a valid Bolt Card/i)
|
||||
})
|
||||
|
||||
it('rejects a zero amount', async () => {
|
||||
const { impl } = mockFetch([])
|
||||
const res = await resolveCardInvoice(LNURLW, 0, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false, reason: 'no amount to send' })
|
||||
})
|
||||
|
||||
it('surfaces an ERROR from the resolver (bad SUN)', async () => {
|
||||
const { impl } = mockFetch([{ status: 'ERROR', reason: 'invalid card' }])
|
||||
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false, reason: 'invalid card' })
|
||||
})
|
||||
|
||||
it('rejects (without calling the callback) when the amount exceeds maxSendable', async () => {
|
||||
const { impl, calls } = mockFetch([{ ...payReq, maxSendable: 5000 }])
|
||||
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false, reason: 'amount is above the card wallet maximum' })
|
||||
expect(calls).toHaveLength(1) // callback never hit
|
||||
})
|
||||
|
||||
it('surfaces an ERROR from the pay callback', async () => {
|
||||
const { impl } = mockFetch([payReq, { status: 'ERROR', reason: 'wallet frozen' }])
|
||||
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false, reason: 'wallet frozen' })
|
||||
})
|
||||
|
||||
it('rejects when the card wallet has no receive address', async () => {
|
||||
const { impl } = mockFetch([{ foo: 'bar' }])
|
||||
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false, reason: 'card wallet has no receive address' })
|
||||
})
|
||||
|
||||
it('handles a network failure gracefully', async () => {
|
||||
const impl = vi.fn(async () => {
|
||||
throw new Error('ECONNREFUSED')
|
||||
}) as unknown as typeof fetch
|
||||
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.reason).toMatch(/could not reach the card/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveInvoiceFromPayStep (session second step, no tap)', () => {
|
||||
const step = {
|
||||
callback: 'https://lnbits.l484.com/boltcards/api/v1/pay/cb/hit1',
|
||||
minSendable: 1000,
|
||||
maxSendable: 50_000_000,
|
||||
metadata: '[["text/plain","Bolt Card top-up"]]',
|
||||
}
|
||||
|
||||
it('fetches an invoice for the amount from the callback', async () => {
|
||||
const f = mockFetch([{ pr: BOLT11 }])
|
||||
const out = await resolveInvoiceFromPayStep(step, 25_000, { fetchImpl: f.impl })
|
||||
expect(out).toEqual({ ok: true, bolt11: BOLT11 })
|
||||
expect(f.calls).toHaveLength(1)
|
||||
expect(f.calls[0]).toContain('amount=25000')
|
||||
})
|
||||
|
||||
it('enforces the step bounds without calling out', async () => {
|
||||
const f = mockFetch([])
|
||||
expect(await resolveInvoiceFromPayStep(step, 500, { fetchImpl: f.impl })).toEqual({
|
||||
ok: false,
|
||||
reason: 'amount is below the card wallet minimum',
|
||||
})
|
||||
expect(await resolveInvoiceFromPayStep(step, 60_000_000, { fetchImpl: f.impl })).toEqual({
|
||||
ok: false,
|
||||
reason: 'amount is above the card wallet maximum',
|
||||
})
|
||||
expect(await resolveInvoiceFromPayStep(step, 0, { fetchImpl: f.impl })).toEqual({
|
||||
ok: false,
|
||||
reason: 'no amount to send',
|
||||
})
|
||||
expect(f.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('surfaces a callback decline', async () => {
|
||||
const f = mockFetch([{ status: 'ERROR', reason: 'Card is disabled.' }])
|
||||
const out = await resolveInvoiceFromPayStep(step, 25_000, { fetchImpl: f.impl })
|
||||
expect(out).toEqual({ ok: false, reason: 'Card is disabled.' })
|
||||
})
|
||||
})
|
||||
|
|
@ -1,252 +0,0 @@
|
|||
/**
|
||||
* LNURL-pay resolver (LUD-06 / LUD-16) — the ATM as the *paying* party.
|
||||
*
|
||||
* Bolt Card tap-to-RECEIVE for the cash-in (buy) flow. A Bolt Card only ever
|
||||
* emits its `lnurlw://…?p=…&c=…` voucher — a *withdraw* (spend) credential — so
|
||||
* we can't push sats into it directly. Instead the tap is used as an
|
||||
* authenticated identity (external_id + SUN p/c) to look up the card wallet's
|
||||
* *pay* target, then the ATM fetches an invoice for the payout amount:
|
||||
* 1. resolveCardPayTarget — GET the boltcards `/pay/<id>?p=&c=` resolver
|
||||
* (a sibling of `/scan`); it verifies the same SUN and returns the card
|
||||
* wallet's Lightning Address / lnurlp (or a LUD-06 payRequest directly).
|
||||
* 2. toPayRequest → LUD-16 (Lightning Address) or LUD-06 fetch → payRequest.
|
||||
* 3. requestInvoice — GET `callback?amount=<msat>` → a BOLT11 for the amount.
|
||||
* The returned BOLT11 is handed back to the renderer, which pays it over the
|
||||
* ATM's existing LNbits/nostr transport (stores/atm.ts `payInvoice`), so
|
||||
* settlement + PAYMENT_RECEIVED reuse the tested cash-in completion path.
|
||||
*
|
||||
* Runs in the MAIN process (Node fetch) to avoid renderer CORS, exactly like
|
||||
* lnurl-withdraw.ts.
|
||||
*
|
||||
* Transport seam: `resolveCardPayTarget()` is the single HTTPS-today /
|
||||
* Nostr-tomorrow swap point. The rest is standard LNURL-pay against whatever
|
||||
* pay target it returns and is transport-independent.
|
||||
*/
|
||||
|
||||
import { lnurlwToHttps } from './lnurl-withdraw.js'
|
||||
|
||||
export interface ResolveCardInvoiceResult {
|
||||
ok: boolean
|
||||
/** BOLT11 to pay when ok; the renderer settles it over the nostr transport. */
|
||||
bolt11?: string
|
||||
/** Human-readable reason when ok is false (safe to surface on-screen). */
|
||||
reason?: string
|
||||
}
|
||||
|
||||
type FetchLike = typeof fetch
|
||||
|
||||
export interface ResolveCardInvoiceOptions {
|
||||
/** Injected for tests; defaults to global fetch. */
|
||||
fetchImpl?: FetchLike
|
||||
/** Per-request timeout (default 15s). */
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
/** Resolver response — any of these shapes is accepted (see the spec doc). */
|
||||
interface CardPayTarget {
|
||||
status?: string
|
||||
reason?: string
|
||||
// (a) a LUD-06 payRequest, inline
|
||||
tag?: string
|
||||
callback?: string
|
||||
minSendable?: number
|
||||
maxSendable?: number
|
||||
metadata?: string
|
||||
// (b) a Lightning Address, e.g. "cardname@l484.com"
|
||||
lightningAddress?: string
|
||||
// (c) an lnurlp pointer (https or lnurl://)
|
||||
lnurlp?: string
|
||||
lnurl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The LUD-06 second step on its own: what a `payRequest` (or a Bolt Card
|
||||
* session, see boltcard-session.ts) hands us to fetch an invoice.
|
||||
*/
|
||||
export interface PayStep {
|
||||
callback: string
|
||||
minSendable?: number
|
||||
maxSendable?: number
|
||||
metadata?: string
|
||||
}
|
||||
|
||||
/** LUD-06 payRequest (subset) + error shape. */
|
||||
interface PayRequest {
|
||||
tag?: string
|
||||
callback?: string
|
||||
minSendable?: number
|
||||
maxSendable?: number
|
||||
metadata?: string
|
||||
status?: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/** LUD-06 second-response (the callback body). */
|
||||
interface PayValues {
|
||||
pr?: string
|
||||
status?: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
interface Ctx {
|
||||
doFetch: FetchLike
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
if (e instanceof Error)
|
||||
return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
|
||||
return String(e)
|
||||
}
|
||||
|
||||
function appendQuery(url: string, params: Record<string, string>): string {
|
||||
const u = new URL(url)
|
||||
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v)
|
||||
return u.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the boltcards *pay* resolver URL from a tapped card's `lnurlw`.
|
||||
* The card presents `…/boltcards/api/v1/scan/<id>?p=&c=` (a withdraw voucher);
|
||||
* the receive resolver is its sibling `…/boltcards/api/v1/pay/<id>?p=&c=`,
|
||||
* carrying the same SUN p/c. This is the HTTPS transport seam — a future
|
||||
* nostr-native card would resolve the same identity over nostr instead.
|
||||
*/
|
||||
export function scanUrlToResolver(lnurlw: string): string | null {
|
||||
const https = lnurlwToHttps(lnurlw)
|
||||
if (!https) return null
|
||||
const u = new URL(https)
|
||||
if (!u.pathname.includes('/scan/')) return null
|
||||
u.pathname = u.pathname.replace('/scan/', '/pay/')
|
||||
return u.toString()
|
||||
}
|
||||
|
||||
/** LUD-16: map a Lightning Address `name@host` to its lnurlp URL. */
|
||||
export function lnAddressToLnurlp(addr: string): string | null {
|
||||
const m = addr.trim().match(/^([a-z0-9._%+-]+)@([a-z0-9.-]+)$/i)
|
||||
if (!m) return null
|
||||
return `https://${m[2]}/.well-known/lnurlp/${m[1]}`
|
||||
}
|
||||
|
||||
async function fetchPayRequest(
|
||||
url: string,
|
||||
ctx: Ctx
|
||||
): Promise<{ ok: true; payRequest: PayRequest } | { ok: false; reason: string }> {
|
||||
let body: PayRequest
|
||||
try {
|
||||
const res = await ctx.doFetch(url, { signal: AbortSignal.timeout(ctx.timeoutMs) })
|
||||
body = (await res.json()) as PayRequest
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `could not reach the card wallet: ${errMsg(e)}` }
|
||||
}
|
||||
if (body.status === 'ERROR') {
|
||||
return { ok: false, reason: body.reason || 'card wallet rejected the request' }
|
||||
}
|
||||
if (body.tag !== 'payRequest' || !body.callback) {
|
||||
return { ok: false, reason: 'card wallet did not return a pay request' }
|
||||
}
|
||||
return { ok: true, payRequest: body }
|
||||
}
|
||||
|
||||
/** Turn a resolver response into a LUD-06 payRequest (fetching if needed). */
|
||||
async function toPayRequest(
|
||||
target: CardPayTarget,
|
||||
ctx: Ctx
|
||||
): Promise<{ ok: true; payRequest: PayRequest } | { ok: false; reason: string }> {
|
||||
// (a) resolver returned a LUD-06 payRequest inline.
|
||||
if (target.tag === 'payRequest' && target.callback) {
|
||||
return { ok: true, payRequest: target }
|
||||
}
|
||||
// (b) resolver returned a Lightning Address (the common case here).
|
||||
if (typeof target.lightningAddress === 'string') {
|
||||
const url = lnAddressToLnurlp(target.lightningAddress)
|
||||
if (!url) return { ok: false, reason: 'card wallet address is invalid' }
|
||||
return fetchPayRequest(url, ctx)
|
||||
}
|
||||
// (c) resolver returned an lnurlp pointer.
|
||||
const pointer = target.lnurlp ?? target.lnurl
|
||||
if (typeof pointer === 'string') {
|
||||
const url = lnurlwToHttps(pointer)
|
||||
if (!url) return { ok: false, reason: 'card wallet lnurlp is invalid' }
|
||||
return fetchPayRequest(url, ctx)
|
||||
}
|
||||
return { ok: false, reason: 'card wallet has no receive address' }
|
||||
}
|
||||
|
||||
async function requestInvoice(
|
||||
pr: PayStep,
|
||||
amountMsat: number,
|
||||
ctx: Ctx
|
||||
): Promise<ResolveCardInvoiceResult> {
|
||||
if (!(amountMsat > 0)) return { ok: false, reason: 'no amount to send' }
|
||||
if (typeof pr.minSendable === 'number' && amountMsat < pr.minSendable) {
|
||||
return { ok: false, reason: 'amount is below the card wallet minimum' }
|
||||
}
|
||||
if (typeof pr.maxSendable === 'number' && amountMsat > pr.maxSendable) {
|
||||
return { ok: false, reason: 'amount is above the card wallet maximum' }
|
||||
}
|
||||
const cbUrl = appendQuery(pr.callback, { amount: String(amountMsat) })
|
||||
let vals: PayValues
|
||||
try {
|
||||
const res = await ctx.doFetch(cbUrl, { signal: AbortSignal.timeout(ctx.timeoutMs) })
|
||||
vals = (await res.json()) as PayValues
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `could not fetch the invoice: ${errMsg(e)}` }
|
||||
}
|
||||
if (vals.status === 'ERROR') {
|
||||
return { ok: false, reason: vals.reason || 'card wallet declined' }
|
||||
}
|
||||
if (!vals.pr || !/^ln[a-z0-9]/i.test(vals.pr.trim())) {
|
||||
return { ok: false, reason: 'card wallet returned no invoice' }
|
||||
}
|
||||
return { ok: true, bolt11: vals.pr.trim() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tapped Bolt Card + a payout amount to a BOLT11 the ATM can pay.
|
||||
* Never throws — every failure returns `{ ok: false, reason }`.
|
||||
*/
|
||||
export async function resolveCardInvoice(
|
||||
lnurlw: string,
|
||||
amountMsat: number,
|
||||
opts: ResolveCardInvoiceOptions = {}
|
||||
): Promise<ResolveCardInvoiceResult> {
|
||||
const ctx: Ctx = { doFetch: opts.fetchImpl ?? fetch, timeoutMs: opts.timeoutMs ?? 15_000 }
|
||||
|
||||
const resolverUrl = scanUrlToResolver(lnurlw)
|
||||
if (!resolverUrl) return { ok: false, reason: 'not a valid Bolt Card (lnurlw) tag' }
|
||||
if (!(amountMsat > 0)) return { ok: false, reason: 'no amount to send' }
|
||||
|
||||
// 1) Resolve card → pay target (the transport seam: HTTPS today).
|
||||
let target: CardPayTarget
|
||||
try {
|
||||
const res = await ctx.doFetch(resolverUrl, { signal: AbortSignal.timeout(ctx.timeoutMs) })
|
||||
target = (await res.json()) as CardPayTarget
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `could not reach the card: ${errMsg(e)}` }
|
||||
}
|
||||
if (target.status === 'ERROR') {
|
||||
return { ok: false, reason: target.reason || 'card rejected the tap' }
|
||||
}
|
||||
|
||||
// 2) Normalize to a LUD-06 payRequest.
|
||||
const pr = await toPayRequest(target, ctx)
|
||||
if (!pr.ok) return pr
|
||||
|
||||
// 3) Ask for an invoice for the payout amount.
|
||||
return requestInvoice({ ...pr.payRequest, callback: pr.payRequest.callback! }, amountMsat, ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* The LUD-06 second step alone: fetch a BOLT11 for `amountMsat` from an
|
||||
* already-obtained pay step (from a Bolt Card session opened at tap-to-enter).
|
||||
* Never throws — every failure returns `{ ok: false, reason }`.
|
||||
*/
|
||||
export async function resolveInvoiceFromPayStep(
|
||||
step: PayStep,
|
||||
amountMsat: number,
|
||||
opts: ResolveCardInvoiceOptions = {}
|
||||
): Promise<ResolveCardInvoiceResult> {
|
||||
const ctx: Ctx = { doFetch: opts.fetchImpl ?? fetch, timeoutMs: opts.timeoutMs ?? 15_000 }
|
||||
return requestInvoice(step, amountMsat, ctx)
|
||||
}
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { executeLnurlWithdraw, executeWithdrawCallback, lnurlwToHttps } from './lnurl-withdraw'
|
||||
|
||||
const BOLT11 = 'lnbc10u1p3xyz...'
|
||||
const LNURLW =
|
||||
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
|
||||
|
||||
/** Build a mock fetch that returns the given JSON bodies per call, in order. */
|
||||
function mockFetch(bodies: unknown[]) {
|
||||
const calls: string[] = []
|
||||
const impl = vi.fn(async (url: string | URL) => {
|
||||
calls.push(url.toString())
|
||||
const body = bodies[calls.length - 1]
|
||||
return { json: async () => body } as Response
|
||||
})
|
||||
return { impl: impl as unknown as typeof fetch, calls }
|
||||
}
|
||||
|
||||
describe('lnurlwToHttps', () => {
|
||||
it('maps lnurlw:// and lnurl:// to https://', () => {
|
||||
expect(lnurlwToHttps('lnurlw://host/p?x=1')).toBe('https://host/p?x=1')
|
||||
expect(lnurlwToHttps('lnurl://host/p')).toBe('https://host/p')
|
||||
})
|
||||
it('strips a lightning: prefix', () => {
|
||||
expect(lnurlwToHttps('lightning:lnurlw://host/p')).toBe('https://host/p')
|
||||
})
|
||||
it('passes https:// through and trims', () => {
|
||||
expect(lnurlwToHttps(' https://host/p ')).toBe('https://host/p')
|
||||
})
|
||||
it('rejects http://, bech32 lnurl1…, and empty', () => {
|
||||
expect(lnurlwToHttps('http://host/p')).toBeNull()
|
||||
expect(lnurlwToHttps('LNURL1DP68GURN8GHJ7')).toBeNull()
|
||||
expect(lnurlwToHttps('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeLnurlWithdraw', () => {
|
||||
const withdrawReq = {
|
||||
tag: 'withdrawRequest',
|
||||
callback: 'https://lnbits.l484.com/boltcards/api/v1/scan/cb',
|
||||
k1: 'K1TOKEN',
|
||||
minWithdrawable: 1000,
|
||||
maxWithdrawable: 5_000_000,
|
||||
}
|
||||
|
||||
it('completes the two-step withdraw and passes k1 + pr to the callback', async () => {
|
||||
const { impl, calls } = mockFetch([withdrawReq, { status: 'OK' }])
|
||||
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl })
|
||||
expect(res).toEqual({ ok: true })
|
||||
// First call = the lnurlw as https; second = callback with k1 + pr.
|
||||
expect(calls[0]).toContain('https://lnbits.l484.com/boltcards/api/v1/scan/abc123')
|
||||
expect(calls[1]).toContain('k1=K1TOKEN')
|
||||
expect(calls[1]).toContain(`pr=${encodeURIComponent(BOLT11)}`)
|
||||
})
|
||||
|
||||
it('rejects a non-lnurlw tag', async () => {
|
||||
const { impl } = mockFetch([])
|
||||
const res = await executeLnurlWithdraw('http://nope', BOLT11, { fetchImpl: impl })
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.reason).toMatch(/not a valid Bolt Card/i)
|
||||
})
|
||||
|
||||
it('rejects when there is no invoice', async () => {
|
||||
const { impl } = mockFetch([])
|
||||
const res = await executeLnurlWithdraw(LNURLW, '', { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false, reason: 'no invoice to charge' })
|
||||
})
|
||||
|
||||
it('surfaces an ERROR from the withdraw request', async () => {
|
||||
const { impl } = mockFetch([{ status: 'ERROR', reason: 'spent today limit' }])
|
||||
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false, reason: 'spent today limit' })
|
||||
})
|
||||
|
||||
it('rejects a response that is not a withdrawRequest', async () => {
|
||||
const { impl } = mockFetch([{ tag: 'payRequest', callback: 'x' }])
|
||||
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false })
|
||||
expect(res.reason).toMatch(/withdraw voucher/i)
|
||||
})
|
||||
|
||||
it('rejects (without calling the callback) when the amount exceeds the card limit', async () => {
|
||||
const { impl, calls } = mockFetch([{ ...withdrawReq, maxWithdrawable: 2000 }])
|
||||
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl, amountMsat: 5000 })
|
||||
expect(res).toMatchObject({ ok: false, reason: 'card limit is below this amount' })
|
||||
expect(calls).toHaveLength(1) // callback never hit
|
||||
})
|
||||
|
||||
it('surfaces an ERROR from the callback (card declined)', async () => {
|
||||
const { impl } = mockFetch([withdrawReq, { status: 'ERROR', reason: 'insufficient funds' }])
|
||||
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false, reason: 'insufficient funds' })
|
||||
})
|
||||
|
||||
it('handles a network failure gracefully', async () => {
|
||||
const impl = vi.fn(async () => {
|
||||
throw new Error('ECONNREFUSED')
|
||||
}) as unknown as typeof fetch
|
||||
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl })
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.reason).toMatch(/could not reach the card/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeWithdrawCallback (session second step, no tap)', () => {
|
||||
const step = {
|
||||
callback: 'https://lnbits.l484.com/boltcards/api/v1/lnurl/cb/hit1',
|
||||
k1: 'hit1',
|
||||
maxWithdrawable: 5_000_000,
|
||||
}
|
||||
|
||||
it('hands the invoice straight to the callback with k1', async () => {
|
||||
const f = mockFetch([{ status: 'OK' }])
|
||||
const out = await executeWithdrawCallback(step, BOLT11, { fetchImpl: f.impl })
|
||||
expect(out).toEqual({ ok: true })
|
||||
expect(f.calls).toHaveLength(1)
|
||||
expect(f.calls[0]).toContain('k1=hit1')
|
||||
expect(f.calls[0]).toContain('pr=' + BOLT11)
|
||||
})
|
||||
|
||||
it('refuses an amount above the step limit without calling out', async () => {
|
||||
const f = mockFetch([])
|
||||
const out = await executeWithdrawCallback(step, BOLT11, {
|
||||
fetchImpl: f.impl,
|
||||
amountMsat: 6_000_000,
|
||||
})
|
||||
expect(out).toEqual({ ok: false, reason: 'card limit is below this amount' })
|
||||
expect(f.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('surfaces a callback decline', async () => {
|
||||
const f = mockFetch([{ status: 'ERROR', reason: 'Payment already claimed.' }])
|
||||
const out = await executeWithdrawCallback(step, BOLT11, { fetchImpl: f.impl })
|
||||
expect(out).toEqual({ ok: false, reason: 'Payment already claimed.' })
|
||||
})
|
||||
|
||||
it('rejects a missing invoice', async () => {
|
||||
const f = mockFetch([])
|
||||
expect(await executeWithdrawCallback(step, '', { fetchImpl: f.impl })).toEqual({
|
||||
ok: false,
|
||||
reason: 'no invoice to charge',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
/**
|
||||
* LNURL-withdraw executor (LUD-03) — the ATM as the *withdrawing* party.
|
||||
*
|
||||
* Bolt Card tap-to-pay for the cash-out flow: a Bolt Card presents an
|
||||
* `lnurlw://…?p=…&c=…` voucher (NTAG424 SUN — fresh p/c per tap). The ATM has
|
||||
* already generated its cash-out BOLT11; here it asks the card's wallet to pay
|
||||
* that invoice:
|
||||
* 1. GET the lnurlw URL → a `withdrawRequest` (callback, k1, max/min).
|
||||
* 2. GET `callback?k1=…&pr=<our bolt11>` → the card's wallet pays it.
|
||||
* Settlement itself is observed elsewhere (the existing invoice watcher over
|
||||
* nostr), so a returned `{ ok: true }` means "the card accepted the pull", not
|
||||
* "cash dispensed" — the state machine still waits for PAYMENT_RECEIVED.
|
||||
*
|
||||
* Runs in the MAIN process (Node fetch) to avoid renderer CORS: LNURL
|
||||
* endpoints don't send CORS headers, so a renderer fetch to the card's host
|
||||
* would be blocked.
|
||||
*/
|
||||
|
||||
export interface LnurlWithdrawResult {
|
||||
ok: boolean
|
||||
/** Human-readable reason when ok is false (safe to surface on-screen). */
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/** LUD-03 withdrawRequest (subset we consume) + LUD-06 error shape. */
|
||||
interface WithdrawRequest {
|
||||
tag?: string
|
||||
callback?: string
|
||||
k1?: string
|
||||
minWithdrawable?: number
|
||||
maxWithdrawable?: number
|
||||
defaultDescription?: string
|
||||
status?: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
type FetchLike = typeof fetch
|
||||
|
||||
/**
|
||||
* The LUD-03 second step on its own: what a `withdrawRequest` (or a Bolt Card
|
||||
* session, see boltcard-session.ts) hands us to actually pull a payment.
|
||||
*/
|
||||
export interface WithdrawStep {
|
||||
callback: string
|
||||
k1: string
|
||||
minWithdrawable?: number
|
||||
maxWithdrawable?: number
|
||||
}
|
||||
|
||||
export interface ExecuteLnurlWithdrawOptions {
|
||||
/** Injected for tests; defaults to global fetch. */
|
||||
fetchImpl?: FetchLike
|
||||
/**
|
||||
* Our invoice amount in millisats. When set, we reject early if it exceeds
|
||||
* the voucher's maxWithdrawable (defensive; the callback would reject anyway).
|
||||
*/
|
||||
amountMsat?: number
|
||||
/** Per-request timeout (default 15s). */
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a Bolt Card / LNURL-withdraw pointer to an https URL.
|
||||
* Bolt Cards emit `lnurlw://host/path?query`; we also accept `lnurl://` and a
|
||||
* bare `https://`. Bech32 `LNURL1…` is intentionally unsupported (Bolt Cards
|
||||
* never use it) and rejected with a clear reason.
|
||||
*/
|
||||
export function lnurlwToHttps(raw: string): string | null {
|
||||
let s = raw.trim()
|
||||
if (!s) return null
|
||||
if (s.toLowerCase().startsWith('lightning:')) s = s.slice('lightning:'.length)
|
||||
const lower = s.toLowerCase()
|
||||
if (lower.startsWith('lnurlw://')) return 'https://' + s.slice('lnurlw://'.length)
|
||||
if (lower.startsWith('lnurl://')) return 'https://' + s.slice('lnurl://'.length)
|
||||
if (lower.startsWith('https://')) return s
|
||||
// Reject http:// (must be TLS) and bech32 lnurl1… (not a Bolt Card).
|
||||
return null
|
||||
}
|
||||
|
||||
function appendQuery(url: string, params: Record<string, string>): string {
|
||||
const u = new URL(url)
|
||||
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v)
|
||||
return u.toString()
|
||||
}
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
if (e instanceof Error)
|
||||
return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
|
||||
return String(e)
|
||||
}
|
||||
|
||||
export async function executeLnurlWithdraw(
|
||||
lnurlw: string,
|
||||
bolt11: string,
|
||||
opts: ExecuteLnurlWithdrawOptions = {}
|
||||
): Promise<LnurlWithdrawResult> {
|
||||
const doFetch = opts.fetchImpl ?? fetch
|
||||
const timeoutMs = opts.timeoutMs ?? 15_000
|
||||
|
||||
const paramsUrl = lnurlwToHttps(lnurlw)
|
||||
if (!paramsUrl) return { ok: false, reason: 'not a valid Bolt Card (lnurlw) tag' }
|
||||
if (!bolt11 || !/^ln[a-z0-9]/i.test(bolt11.trim())) {
|
||||
return { ok: false, reason: 'no invoice to charge' }
|
||||
}
|
||||
|
||||
// 1) Fetch the withdraw request.
|
||||
let params: WithdrawRequest
|
||||
try {
|
||||
const res = await doFetch(paramsUrl, { signal: AbortSignal.timeout(timeoutMs) })
|
||||
params = (await res.json()) as WithdrawRequest
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `could not reach the card: ${errMsg(e)}` }
|
||||
}
|
||||
if (params.status === 'ERROR') {
|
||||
return { ok: false, reason: params.reason || 'card rejected the tap' }
|
||||
}
|
||||
if (params.tag !== 'withdrawRequest' || !params.callback || !params.k1) {
|
||||
return { ok: false, reason: 'card did not return a withdraw voucher' }
|
||||
}
|
||||
|
||||
// 2) Hand our invoice to the callback — the card's wallet pays it.
|
||||
return executeWithdrawCallback(
|
||||
{
|
||||
callback: params.callback,
|
||||
k1: params.k1,
|
||||
minWithdrawable: params.minWithdrawable,
|
||||
maxWithdrawable: params.maxWithdrawable,
|
||||
},
|
||||
bolt11,
|
||||
opts
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The LUD-03 second step alone: hand our invoice to an already-obtained
|
||||
* withdraw step (from a `/scan` withdrawRequest, or from a Bolt Card session
|
||||
* opened at tap-to-enter) — the card's wallet pays it. `{ ok: true }` means the
|
||||
* card accepted the pull; settlement is observed by the invoice watcher.
|
||||
*/
|
||||
export async function executeWithdrawCallback(
|
||||
step: WithdrawStep,
|
||||
bolt11: string,
|
||||
opts: ExecuteLnurlWithdrawOptions = {}
|
||||
): Promise<LnurlWithdrawResult> {
|
||||
const doFetch = opts.fetchImpl ?? fetch
|
||||
const timeoutMs = opts.timeoutMs ?? 15_000
|
||||
|
||||
if (!bolt11 || !/^ln[a-z0-9]/i.test(bolt11.trim())) {
|
||||
return { ok: false, reason: 'no invoice to charge' }
|
||||
}
|
||||
if (
|
||||
opts.amountMsat != null &&
|
||||
typeof step.maxWithdrawable === 'number' &&
|
||||
opts.amountMsat > step.maxWithdrawable
|
||||
) {
|
||||
return { ok: false, reason: 'card limit is below this amount' }
|
||||
}
|
||||
|
||||
const cbUrl = appendQuery(step.callback, { k1: step.k1, pr: bolt11.trim() })
|
||||
let cb: { status?: string; reason?: string }
|
||||
try {
|
||||
const res = await doFetch(cbUrl, { signal: AbortSignal.timeout(timeoutMs) })
|
||||
cb = (await res.json()) as { status?: string; reason?: string }
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `card payment failed: ${errMsg(e)}` }
|
||||
}
|
||||
if (cb.status === 'OK') return { ok: true }
|
||||
return { ok: false, reason: cb.reason || 'card declined the payment' }
|
||||
}
|
||||
|
|
@ -24,44 +24,18 @@ import {
|
|||
markCommandExecuting,
|
||||
completeCommand,
|
||||
getLastKnownConfigCreatedAt,
|
||||
getCountsUncertainSince,
|
||||
getLastStatePublishedAt,
|
||||
markCountsUncertain,
|
||||
getCashOutHold,
|
||||
setCashOutHold,
|
||||
clearCashOutHold,
|
||||
type CashOutHold,
|
||||
pendingDispenseReports,
|
||||
markDispenseReportAcked,
|
||||
noteDispenseReportAttempt,
|
||||
markStatePublished,
|
||||
resetStatePublishWatermark,
|
||||
resetForRepair,
|
||||
applyOperatorCassetteOps,
|
||||
getAppliedOpIds,
|
||||
getCassetteStateSeq,
|
||||
getBootstrapPublishedAt,
|
||||
markBootstrapPublished,
|
||||
applyOperatorCassettesConfig,
|
||||
getFeeConfig,
|
||||
getLastKnownFeeConfigCreatedAt,
|
||||
applyFeeConfig,
|
||||
getBunkerBinding,
|
||||
saveBunkerBinding,
|
||||
clearBunkerBinding,
|
||||
type CassetteOp,
|
||||
type ApplyOpsResult,
|
||||
type OperatorCassettesPayload,
|
||||
type FeeConfigPayload,
|
||||
type FeeConfigRow,
|
||||
type ApplyResult,
|
||||
type StoredBunkerBinding,
|
||||
} from './state-store.js'
|
||||
import { initializeHal, type HalInstance } from './hal-service.js'
|
||||
import {
|
||||
executeLnurlWithdraw,
|
||||
executeWithdrawCallback,
|
||||
type WithdrawStep,
|
||||
} from './lnurl-withdraw.js'
|
||||
import { resolveCardInvoice, resolveInvoiceFromPayStep, type PayStep } from './lnurl-pay.js'
|
||||
import { openCardSession, type OpenCardSessionResult } from './boltcard-session.js'
|
||||
import { startNfcReader, type NfcStatus } from './nfc-service.js'
|
||||
|
||||
// ESM equivalent of __dirname
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
|
|
@ -107,6 +81,16 @@ type BrandingConfig = {
|
|||
logoDarkDataUrl: string | null
|
||||
}
|
||||
|
||||
const VALID_THEMES = new Set([
|
||||
'gruvbox',
|
||||
'catppuccin',
|
||||
'cyberpunk',
|
||||
'dracula',
|
||||
'nord',
|
||||
'tokyo-night',
|
||||
'custom',
|
||||
])
|
||||
|
||||
function loadBranding(): BrandingConfig | null {
|
||||
const brandingDir = path.join(
|
||||
fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd(),
|
||||
|
|
@ -126,10 +110,7 @@ function loadBranding(): BrandingConfig | null {
|
|||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
|
||||
if (typeof raw.title === 'string') title = raw.title
|
||||
// No theme-name validation here: the renderer's `themes` list (plus its
|
||||
// 'custom' branch) is the single source of truth. Pass the string through
|
||||
// and let useTheme's applyBrandingTheme ignore anything it doesn't know.
|
||||
if (typeof raw.theme === 'string') theme = raw.theme
|
||||
if (typeof raw.theme === 'string' && VALID_THEMES.has(raw.theme)) theme = raw.theme
|
||||
if (raw.custom_colors && typeof raw.custom_colors === 'object') {
|
||||
const { dark, ...flat } = raw.custom_colors as Record<string, unknown>
|
||||
const colors = Object.fromEntries(
|
||||
|
|
@ -138,7 +119,9 @@ function loadBranding(): BrandingConfig | null {
|
|||
if (Object.keys(colors).length > 0) customColors = colors
|
||||
if (dark && typeof dark === 'object') {
|
||||
const darkColors = Object.fromEntries(
|
||||
Object.entries(dark as Record<string, unknown>).filter(([, v]) => typeof v === 'string')
|
||||
Object.entries(dark as Record<string, unknown>).filter(
|
||||
([, v]) => typeof v === 'string'
|
||||
)
|
||||
) as Record<string, string>
|
||||
if (Object.keys(darkColors).length > 0) customColorsDark = darkColors
|
||||
}
|
||||
|
|
@ -181,62 +164,6 @@ function loadBranding(): BrandingConfig | null {
|
|||
return { title, theme, customColors, customColorsDark, logoDataUrl, logoDarkDataUrl }
|
||||
}
|
||||
|
||||
// Access-control config loader (ADR-003). Env toggles the gate; an optional
|
||||
// /var/lib/bitspire/access.json carries the salt + allow-list. Defaults OFF —
|
||||
// a machine with neither env nor file behaves as if there is no access layer.
|
||||
// The allow-list shape mirrors the renderer's AllowListEntry (authorize.ts);
|
||||
// duplicated here to avoid a cross-project (electron↔renderer) import.
|
||||
interface AccessAllowListEntry {
|
||||
idHash: string
|
||||
role: 'user' | 'operator'
|
||||
pinHash?: string
|
||||
label?: string
|
||||
}
|
||||
function loadAccessControl() {
|
||||
// Env provides defaults; access.json (writable, operator-provisioned — same
|
||||
// spirit as branding/) overrides them, so the gate can be toggled on a
|
||||
// deployed machine by dropping a file + restarting the service, with no image
|
||||
// rebuild. Defaults OFF.
|
||||
let enabled = process.env.ACCESS_CONTROL_ENABLED === 'true'
|
||||
// Dev unlock is OFF unless explicitly enabled: a gated machine must not ship
|
||||
// a visible bypass button by default.
|
||||
let devUnlock = process.env.ACCESS_DEV_UNLOCK === 'true'
|
||||
let openEnrollment = process.env.ACCESS_OPEN_ENROLLMENT === 'true'
|
||||
let salt = process.env.ACCESS_SALT || ''
|
||||
let allowList: AccessAllowListEntry[] = []
|
||||
|
||||
const jsonPath = path.join(
|
||||
fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd(),
|
||||
'access.json'
|
||||
)
|
||||
if (fs.existsSync(jsonPath)) {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
|
||||
if (typeof raw.enabled === 'boolean') enabled = raw.enabled
|
||||
if (typeof raw.devUnlock === 'boolean') devUnlock = raw.devUnlock
|
||||
if (typeof raw.openEnrollment === 'boolean') openEnrollment = raw.openEnrollment
|
||||
if (typeof raw.salt === 'string' && raw.salt) salt = raw.salt
|
||||
if (Array.isArray(raw.allowList)) {
|
||||
allowList = (raw.allowList as unknown[]).filter(
|
||||
(e): e is AccessAllowListEntry =>
|
||||
!!e &&
|
||||
typeof (e as AccessAllowListEntry).idHash === 'string' &&
|
||||
((e as AccessAllowListEntry).role === 'user' ||
|
||||
(e as AccessAllowListEntry).role === 'operator')
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Electron] Failed to parse access.json:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// A gated machine needs a stable salt for deterministic hashing. Fall back to
|
||||
// a fixed default (prototype); production should provision a real salt.
|
||||
if (!salt) salt = 'bitspire-access-v1'
|
||||
|
||||
return { enabled, devUnlock, openEnrollment, salt, allowList }
|
||||
}
|
||||
|
||||
// Determine if we're in development
|
||||
const isDev =
|
||||
process.env.ELECTRON_FORCE_PROD !== '1' &&
|
||||
|
|
@ -353,20 +280,17 @@ ipcMain.handle('watchdog:pong', () => {
|
|||
// pragma: allowlist secret end
|
||||
ipcMain.handle('get-config', () => {
|
||||
return {
|
||||
// LNbits nostr-transport connection (public info only). Empty when
|
||||
// unprovisioned — the renderer then falls through to the pairing seed's
|
||||
// relay (aiolabs/bitspire#70). A non-empty default here would win via the
|
||||
// env-first precedence and override the seed.
|
||||
relayUrl: process.env.VITE_RELAY_URL || '',
|
||||
// LNbits nostr-transport connection (public info only)
|
||||
relayUrl: process.env.VITE_RELAY_URL || 'ws://localhost:7777',
|
||||
lnbitsServerPubkey: process.env.VITE_LNBITS_SERVER_PUBKEY || '',
|
||||
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',
|
||||
|
|
@ -384,9 +308,6 @@ ipcMain.handle('get-config', () => {
|
|||
|
||||
// Operator branding (logo/title/theme) — null when no override
|
||||
branding: loadBranding(),
|
||||
|
||||
// Access-control gate (ADR-003) — `enabled` defaults false (no gate).
|
||||
accessControl: loadAccessControl(),
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -409,148 +330,14 @@ let secretsConsumed = false
|
|||
ipcMain.handle('get-atm-secrets', () => {
|
||||
if (secretsConsumed) {
|
||||
console.warn('[Electron] SECURITY: get-atm-secrets called after secrets already consumed')
|
||||
return { spireSeed: '', bunkerBinding: null }
|
||||
return { atmPrivateKey: '' }
|
||||
}
|
||||
secretsConsumed = true
|
||||
// The spire pairing seed (one-shot connect token inside) + the persisted
|
||||
// bunker binding (transport key). The renderer resolves these into a
|
||||
// BunkerSigner; see services/signer-resolver.ts (aiolabs/bitspire#52).
|
||||
return {
|
||||
spireSeed: process.env.VITE_SPIRE_SEED || '',
|
||||
bunkerBinding: getBunkerBinding(),
|
||||
atmPrivateKey: process.env.VITE_ATM_PRIVATE_KEY || '',
|
||||
}
|
||||
})
|
||||
|
||||
// Bunker binding persistence — the renderer writes the binding after a
|
||||
// successful pairing (connectNewSeed), and resets the publish watermark so the
|
||||
// new operator receives the spire's hello-event (aiolabs/bitspire#52 / #56).
|
||||
ipcMain.handle('state:save-bunker-binding', (_event, binding: StoredBunkerBinding): void => {
|
||||
saveBunkerBinding(binding)
|
||||
})
|
||||
ipcMain.handle('state:clear-bunker-binding', (): void => {
|
||||
clearBunkerBinding()
|
||||
})
|
||||
ipcMain.handle('state:reset-state-publish-watermark', (): void => {
|
||||
resetStatePublishWatermark()
|
||||
})
|
||||
ipcMain.handle('state:reset-for-repair', (): void => {
|
||||
resetForRepair()
|
||||
})
|
||||
|
||||
// QR-pairing wizard (aiolabs/bitspire#52): an unpaired machine scans a
|
||||
// spire-seed off its camera, and we persist it as VITE_SPIRE_SEED in the
|
||||
// runtime .env so the next boot's signer-resolver redeems it (connectNewSeed)
|
||||
// exactly as if it had been provisioned. We deliberately do NOT pair here —
|
||||
// persisting + relaunching reuses the single, tested pairing path rather than
|
||||
// duplicating it in the renderer.
|
||||
function runtimeEnvPath(): string {
|
||||
const base = fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd()
|
||||
return path.join(base, '.env')
|
||||
}
|
||||
|
||||
ipcMain.handle('state:save-spire-seed', (_event, seed: string): void => {
|
||||
const trimmed = (seed || '').trim()
|
||||
if (!trimmed) throw new Error('save-spire-seed: empty seed')
|
||||
const envPath = runtimeEnvPath()
|
||||
const line = `VITE_SPIRE_SEED=${trimmed}`
|
||||
let lines: string[] = []
|
||||
if (fs.existsSync(envPath)) {
|
||||
lines = fs.readFileSync(envPath, 'utf8').split('\n')
|
||||
}
|
||||
const idx = lines.findIndex((l) => l.startsWith('VITE_SPIRE_SEED='))
|
||||
if (idx >= 0) {
|
||||
lines[idx] = line
|
||||
} else {
|
||||
// Drop a trailing empty element so we don't accumulate blank lines.
|
||||
if (lines.length && lines[lines.length - 1] === '') lines.pop()
|
||||
lines.push(line)
|
||||
}
|
||||
fs.writeFileSync(envPath, lines.join('\n') + '\n', { mode: 0o600 })
|
||||
// Keep this process's view in sync so get-atm-secrets reflects the new seed
|
||||
// even before relaunch (belt-and-suspenders; relaunch re-reads from disk).
|
||||
process.env.VITE_SPIRE_SEED = trimmed
|
||||
console.log('[Pairing] Spire seed persisted to', envPath)
|
||||
})
|
||||
|
||||
// Relaunch the kiosk so the new seed is picked up by a clean boot. Under
|
||||
// systemd (bitspire.service) the exit triggers an automatic restart; in dev
|
||||
// Electron's relaunch re-spawns the process.
|
||||
ipcMain.handle('app:relaunch', (): void => {
|
||||
console.log('[Pairing] Relaunching to apply new pairing')
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
})
|
||||
|
||||
// Connectivity recovery: reload the renderer to re-run init from a clean slate
|
||||
// (fresh JS context → no leaked actors/subscriptions), while preserving HAL in
|
||||
// this main process (reloadRenderer resets secretsConsumed so get-atm-secrets
|
||||
// works again, and hal:init is idempotent). The renderer calls this when it's
|
||||
// stuck on a connectivity-type "ATM Unavailable" and the network returns, or
|
||||
// when the operator taps the on-screen Retry (ADR-002 amendment 2026-08-04).
|
||||
ipcMain.handle('app:recover', (): void => {
|
||||
console.log('[Recovery] Reloading renderer to re-attempt initialization')
|
||||
reloadRenderer()
|
||||
})
|
||||
|
||||
// Bolt Card cash-out: pull payment for the current invoice from a tapped card
|
||||
// via LNURL-withdraw. Runs in the main process (Node fetch) to dodge renderer
|
||||
// CORS. Returns once the card accepts; settlement arrives via the invoice
|
||||
// watcher. See lnurl-withdraw.ts.
|
||||
ipcMain.handle(
|
||||
'lnurl:withdraw',
|
||||
async (
|
||||
_event,
|
||||
args: { lnurlw: string; bolt11: string; amountMsat?: number }
|
||||
): Promise<{ ok: boolean; reason?: string }> => {
|
||||
return executeLnurlWithdraw(args.lnurlw, args.bolt11, { amountMsat: args.amountMsat })
|
||||
}
|
||||
)
|
||||
|
||||
// Bolt Card cash-in (receive): resolve a tapped card + payout amount to a
|
||||
// BOLT11 on the card wallet, which the renderer then pays over the nostr
|
||||
// transport (stores/atm.ts payInvoice). HTTPS to the card host runs here in the
|
||||
// main process to dodge renderer CORS. See lnurl-pay.ts.
|
||||
ipcMain.handle(
|
||||
'lnurl:pay-card',
|
||||
async (
|
||||
_event,
|
||||
args: { lnurlw: string; amountMsat: number }
|
||||
): Promise<{ ok: boolean; bolt11?: string; reason?: string }> => {
|
||||
return resolveCardInvoice(args.lnurlw, args.amountMsat)
|
||||
}
|
||||
)
|
||||
|
||||
// Bolt Card tap-to-enter (ADR-003): open a verified session for a tapped card.
|
||||
// Spends the tap's SUN once and returns balance + fiat + the withdraw/pay
|
||||
// second steps the session reuses at Complete. See boltcard-session.ts.
|
||||
ipcMain.handle(
|
||||
'lnurl:open-card-session',
|
||||
async (_event, args: { lnurlw: string }): Promise<OpenCardSessionResult> => {
|
||||
return openCardSession(args.lnurlw)
|
||||
}
|
||||
)
|
||||
|
||||
// Session variants of the two Complete paths: no tap, no p/c — just the
|
||||
// hit-keyed second step the session already holds.
|
||||
ipcMain.handle(
|
||||
'lnurl:withdraw-session',
|
||||
async (
|
||||
_event,
|
||||
args: { withdraw: WithdrawStep; bolt11: string; amountMsat?: number }
|
||||
): Promise<{ ok: boolean; reason?: string }> => {
|
||||
return executeWithdrawCallback(args.withdraw, args.bolt11, { amountMsat: args.amountMsat })
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'lnurl:pay-session',
|
||||
async (
|
||||
_event,
|
||||
args: { pay: PayStep; amountMsat: number }
|
||||
): Promise<{ ok: boolean; bolt11?: string; reason?: string }> => {
|
||||
return resolveInvoiceFromPayStep(args.pay, args.amountMsat)
|
||||
}
|
||||
)
|
||||
|
||||
// State persistence IPC handlers
|
||||
ipcMain.handle('state:load-cassettes', () => loadCassettes())
|
||||
ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes))
|
||||
|
|
@ -567,47 +354,17 @@ ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedB
|
|||
ipcMain.handle('state:get-last-known-config-created-at', (): number =>
|
||||
getLastKnownConfigCreatedAt()
|
||||
)
|
||||
ipcMain.handle('state:get-last-state-published-at', (): number | null => getLastStatePublishedAt())
|
||||
ipcMain.handle('state:get-counts-uncertain-since', (): number | null => getCountsUncertainSince())
|
||||
ipcMain.handle('state:mark-counts-uncertain', (_event, unixTimestamp: number): void => {
|
||||
markCountsUncertain(unixTimestamp)
|
||||
})
|
||||
// Cash-out hold (ADR-005 §5)
|
||||
ipcMain.handle('state:get-cash-out-hold', (): CashOutHold | null => getCashOutHold())
|
||||
ipcMain.handle('state:set-cash-out-hold', (_event, hold: CashOutHold): CashOutHold => {
|
||||
if (!hold || typeof hold.reason !== 'string' || typeof hold.since !== 'number') {
|
||||
throw new Error('Invalid cash-out hold')
|
||||
}
|
||||
return setCashOutHold(hold)
|
||||
})
|
||||
ipcMain.handle('state:clear-cash-out-hold', (): boolean => clearCashOutHold())
|
||||
|
||||
// Dispense-report outbox (ADR-005 §2) — at-least-once to spirekeeper
|
||||
ipcMain.handle('state:pending-dispense-reports', (_event, limit?: number) =>
|
||||
pendingDispenseReports(typeof limit === 'number' ? limit : 20)
|
||||
ipcMain.handle('state:get-bootstrap-published-at', (): number | null =>
|
||||
getBootstrapPublishedAt()
|
||||
)
|
||||
ipcMain.handle('state:ack-dispense-report', (_event, txid: string): boolean => {
|
||||
if (typeof txid !== 'string' || !txid) throw new Error('Invalid txid')
|
||||
return markDispenseReportAcked(txid)
|
||||
ipcMain.handle('state:mark-bootstrap-published', (_event, unixTimestamp: number): void => {
|
||||
markBootstrapPublished(unixTimestamp)
|
||||
})
|
||||
ipcMain.handle(
|
||||
'state:note-dispense-report-attempt',
|
||||
(_event, txid: string, error: string | null): void => {
|
||||
if (typeof txid !== 'string' || !txid) throw new Error('Invalid txid')
|
||||
noteDispenseReportAttempt(txid, typeof error === 'string' ? error.slice(0, 512) : null)
|
||||
}
|
||||
'state:apply-operator-cassettes-config',
|
||||
(_event, payload: OperatorCassettesPayload, eventCreatedAt: number): ApplyResult =>
|
||||
applyOperatorCassettesConfig(payload, eventCreatedAt)
|
||||
)
|
||||
ipcMain.handle('state:mark-state-published', (_event, unixTimestamp: number): void => {
|
||||
markStatePublished(unixTimestamp)
|
||||
})
|
||||
ipcMain.handle(
|
||||
'state:apply-operator-cassette-ops',
|
||||
(_event, ops: CassetteOp[]): ApplyOpsResult => applyOperatorCassetteOps(ops)
|
||||
)
|
||||
ipcMain.handle('state:get-applied-op-ids', (_event, limit?: number): string[] =>
|
||||
getAppliedOpIds(limit)
|
||||
)
|
||||
ipcMain.handle('state:get-cassette-state-seq', (): number => getCassetteStateSeq())
|
||||
|
||||
// Operator-fees consumer (aiolabs/lamassu-next#57) — persisted singleton
|
||||
// fee config + per-d-tag replay watermark + atomic apply for kind-30078
|
||||
|
|
@ -657,17 +414,6 @@ let pendingBillDenomination: number | null = null
|
|||
|
||||
ipcMain.handle('hal:init', async (_event, config) => {
|
||||
try {
|
||||
// Idempotent: HAL lives in this (long-lived) main process, but the renderer
|
||||
// re-runs full init on every reload — the watchdog's crash-recovery reload
|
||||
// and the connectivity-recovery reload (app:recover) both re-invoke this.
|
||||
// initializeHal opens serial ports without closing prior handles, so
|
||||
// re-entering it would double-open the validator/dispenser. Reuse the
|
||||
// existing instance instead; its validator event wiring already targets the
|
||||
// (reloaded) mainWindow, so the reloaded renderer keeps receiving bill events.
|
||||
if (halInstance) {
|
||||
console.log('[Electron] HAL already initialized — reusing existing instance')
|
||||
return { success: true }
|
||||
}
|
||||
// Override cassette config with DB values (operator may have changed them via atm-tui
|
||||
// or via an operator-config publish from satmachineadmin). Pass per-position so the
|
||||
// HAL knows about every bay including duplicates of the same denomination — real
|
||||
|
|
@ -773,12 +519,10 @@ ipcMain.handle('hal:stack-bill', () => {
|
|||
console.warn('[Electron] hal:stack-bill called with no bill in escrow — ignoring')
|
||||
return
|
||||
}
|
||||
const denomination = pendingBillDenomination
|
||||
pendingBillDenomination = null
|
||||
// Credit is NOT sent here. hal-service fires onBillInserted (forwarded
|
||||
// as 'hal:bill-inserted') only on the validator's `billsValid`
|
||||
// stacked-confirmation — a stack command can still fail or return the
|
||||
// bill (aiolabs/bitspire#58).
|
||||
halInstance.stackBill()
|
||||
mainWindow?.webContents.send('hal:bill-inserted', denomination)
|
||||
})
|
||||
|
||||
ipcMain.handle('hal:reject-bill', () => {
|
||||
|
|
@ -868,12 +612,12 @@ 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,
|
||||
type: 'manual_dispense',
|
||||
status: result.dispenseConfirmed ? 'complete' : 'dispense_error',
|
||||
status: result.dispensed ? 'complete' : 'dispense_error',
|
||||
fiatCents: totalFiatCents,
|
||||
sats: 0,
|
||||
feeSats: 0,
|
||||
|
|
@ -885,14 +629,9 @@ function startCommandPoller(): void {
|
|||
error: result.error,
|
||||
})
|
||||
|
||||
// This dispense happened entirely in the main process, so the renderer
|
||||
// has no idea the bays moved — it would keep serving a stale inventory
|
||||
// and would never republish the operator's view. Tell it.
|
||||
mainWindow?.webContents.send('cassettes:changed')
|
||||
|
||||
// Only remediate the original tx if ALL requested bills were dispensed
|
||||
let refRemediated = false
|
||||
if (parsed.ref_txid && result.dispenseConfirmed) {
|
||||
if (parsed.ref_txid && result.dispensed) {
|
||||
refRemediated = remediateTransaction(parsed.ref_txid, txid)
|
||||
}
|
||||
|
||||
|
|
@ -900,13 +639,7 @@ function startCommandPoller(): void {
|
|||
cmd.id,
|
||||
JSON.stringify({
|
||||
txid,
|
||||
// Wire key kept as `dispensed` — spirekeeper's command poller
|
||||
// reads it. Value is the ADR-005 value-equality confirmation.
|
||||
dispensed: result.dispenseConfirmed,
|
||||
dispense_confirmed: result.dispenseConfirmed,
|
||||
error_code: result.errorCode,
|
||||
raw_code: result.rawCode,
|
||||
error_class: result.errorClass,
|
||||
dispensed: result.dispensed,
|
||||
ref_remediated: refRemediated,
|
||||
error: result.error,
|
||||
})
|
||||
|
|
@ -946,19 +679,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 },
|
||||
|
|
@ -990,30 +723,6 @@ app.whenReady().then(() => {
|
|||
startWatchdog()
|
||||
startCommandPoller()
|
||||
|
||||
// Bolt Card reader — forwards taps (lnurlw) + status to the renderer. Opt-in
|
||||
// per machine via services.bitspire.nfc.enable, which is off unless a CCID
|
||||
// reader is actually fitted: nfc-pcsc's pcsclite binding busy-spins this very
|
||||
// thread when pcscd is absent and wedges the whole app (see nfc-service.ts).
|
||||
// nfc-service also re-checks the pcscd socket, so this flag is the coarse
|
||||
// gate, not the only defence. Cash-out via QR never depends on any of it.
|
||||
if (process.env.BITSPIRE_NFC_ENABLED === 'true') {
|
||||
void startNfcReader(
|
||||
(lnurlw) => {
|
||||
// Don't log the value — it carries the card's single-use SUN p/c.
|
||||
console.log(`[NFC] card tapped — lnurlw (${lnurlw.length} chars) → renderer`)
|
||||
mainWindow?.webContents.send('nfc:card-tapped', lnurlw)
|
||||
},
|
||||
(status: NfcStatus) => {
|
||||
console.log(
|
||||
`[NFC] status=${status.state}${status.reader ? ` reader="${status.reader}"` : ''}${status.message ? ` — ${status.message}` : ''}`
|
||||
)
|
||||
mainWindow?.webContents.send('nfc:status', status)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
console.log('[NFC] no reader configured (BITSPIRE_NFC_ENABLED not "true") — skipping init')
|
||||
}
|
||||
|
||||
app.on('activate', () => {
|
||||
// macOS: re-create window when dock icon clicked
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { extractLnurlw, readNdefLnurlw } from './nfc-service'
|
||||
|
||||
const LNURLW =
|
||||
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
|
||||
|
||||
/** Build a Type-4 NDEF message with a single URI record carrying `uri`. */
|
||||
function ndefUriMessage(uri: string): Buffer {
|
||||
const uriBytes = Buffer.from(uri, 'ascii')
|
||||
const payload = Buffer.concat([Buffer.from([0x00]), uriBytes]) // 0x00 = no prefix
|
||||
// D1 = MB|ME|SR, TNF=well-known; type length 1; payload length; 'U'
|
||||
return Buffer.concat([Buffer.from([0xd1, 0x01, payload.length, 0x55]), payload])
|
||||
}
|
||||
|
||||
describe('extractLnurlw', () => {
|
||||
it('pulls an lnurlw:// URI out of an NDEF record', () => {
|
||||
expect(extractLnurlw(ndefUriMessage(LNURLW))).toBe(LNURLW)
|
||||
})
|
||||
it('pulls a boltcards https scan URL', () => {
|
||||
const https = 'https://lnbits.l484.com/boltcards/api/v1/scan/x?p=aa&c=bb'
|
||||
expect(extractLnurlw(ndefUriMessage(https))).toBe(https)
|
||||
})
|
||||
it('stops at the record boundary (no trailing binary)', () => {
|
||||
const msg = Buffer.concat([ndefUriMessage(LNURLW), Buffer.from([0x00, 0xfe, 0x01])])
|
||||
expect(extractLnurlw(msg)).toBe(LNURLW)
|
||||
})
|
||||
it('returns null when there is no lnurl', () => {
|
||||
expect(extractLnurlw(Buffer.from('just some text', 'ascii'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('readNdefLnurlw', () => {
|
||||
const SW_OK = Buffer.from([0x90, 0x00])
|
||||
const SW_NOTFOUND = Buffer.from([0x6a, 0x82])
|
||||
// Capability Container advertising the NDEF file id E104 (TLV 04 06 at [7,8]).
|
||||
const CC = Buffer.from([
|
||||
0x00, 0x0f, 0x20, 0x00, 0x3b, 0x00, 0x34, 0x04, 0x06, 0xe1, 0x04, 0x00, 0xff, 0x00, 0xff,
|
||||
])
|
||||
|
||||
/** Route APDUs by content so the CC-read + fallback loop is exercised. */
|
||||
function cardMock(opts: { noApp?: boolean; nlen0?: boolean; uri?: string } = {}) {
|
||||
const msg = ndefUriMessage(opts.uri ?? LNURLW)
|
||||
const nlen = msg.length
|
||||
return vi.fn(async (apdu: Buffer) => {
|
||||
const hex = apdu.toString('hex')
|
||||
if (hex.includes('d2760000850101')) return opts.noApp ? SW_NOTFOUND : SW_OK // select app
|
||||
if (hex.startsWith('00a4000c02e103')) return SW_OK // select CC
|
||||
if (hex.startsWith('00b000000f')) return Buffer.concat([CC, SW_OK]) // read CC
|
||||
if (hex.startsWith('00a4000c02e104')) return SW_OK // select NDEF file (E104)
|
||||
if (hex.startsWith('00a4000c020004')) return SW_NOTFOUND // fallback file id: absent
|
||||
if (hex.startsWith('00b0000002'))
|
||||
return opts.nlen0
|
||||
? Buffer.concat([Buffer.from([0x00, 0x00]), SW_OK])
|
||||
: Buffer.concat([Buffer.from([(nlen >> 8) & 0xff, nlen & 0xff]), SW_OK]) // NLEN
|
||||
if (hex.startsWith('00b0')) return Buffer.concat([msg, SW_OK]) // read message
|
||||
return SW_NOTFOUND
|
||||
})
|
||||
}
|
||||
|
||||
it('reads CC → NDEF file (E104) and returns the lnurlw', async () => {
|
||||
const transmit = cardMock()
|
||||
expect(await readNdefLnurlw(transmit)).toBe(LNURLW)
|
||||
// First APDU selects the NDEF application (AID D2760000850101).
|
||||
expect((transmit.mock.calls[0][0] as Buffer).toString('hex')).toContain('d2760000850101')
|
||||
})
|
||||
|
||||
it('returns null if selecting the NDEF app fails', async () => {
|
||||
expect(await readNdefLnurlw(cardMock({ noApp: true }))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null on an empty NDEF file', async () => {
|
||||
expect(await readNdefLnurlw(cardMock({ nlen0: true }))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,273 +0,0 @@
|
|||
/**
|
||||
* NFC reader driver (main process) for Bolt Card tap-to-pay.
|
||||
*
|
||||
* Wraps `nfc-pcsc` (PC/SC via the Feitian KP382 CCID reader). On each card
|
||||
* tap it reads the NTAG424 Type-4 NDEF file over ISO7816 APDUs and extracts
|
||||
* the `lnurlw://…?p=…&c=…` voucher (the card computes fresh SUN p/c per tap),
|
||||
* then hands it to the renderer over IPC. The renderer, when showing a
|
||||
* cash-out invoice, pays it via LNURL-withdraw (see lnurl-withdraw.ts).
|
||||
*
|
||||
* Everything here is best-effort and lazy: `nfc-pcsc` is a native addon, so it
|
||||
* is dynamically imported and every failure is swallowed into a status
|
||||
* callback. If the reader/library is absent, NFC is simply unavailable and the
|
||||
* QR path keeps working — cash-out never depends on this.
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
|
||||
export type NfcState = 'ready' | 'reading' | 'error' | 'card-removed' | 'unavailable'
|
||||
export interface NfcStatus {
|
||||
state: NfcState
|
||||
reader?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
type CardHandler = (lnurlw: string) => void
|
||||
type StatusHandler = (status: NfcStatus) => void
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
|
||||
/** Pull the lnurlw (or a boltcards https scan URL) out of a Type-4 NDEF blob. */
|
||||
export function extractLnurlw(ndef: Buffer): string | null {
|
||||
// Robust to record framing: the URI record embeds the literal string; grab
|
||||
// it directly, bounded to URL-safe characters so we stop at the record end.
|
||||
const text = ndef.toString('latin1')
|
||||
const urlChars = "[A-Za-z0-9._~:/?#\\[\\]@!$&'()*+,;=%-]+"
|
||||
const m =
|
||||
text.match(new RegExp('lnurlw://' + urlChars, 'i')) ||
|
||||
text.match(new RegExp('https://' + urlChars + '/boltcards/' + urlChars, 'i'))
|
||||
return m ? m[0] : null
|
||||
}
|
||||
|
||||
const swOk = (r: Buffer) => r.length >= 2 && r[r.length - 2] === 0x90 && r[r.length - 1] === 0x00
|
||||
|
||||
/** Select an EF by its 2-byte file id and read + parse its NDEF message. */
|
||||
async function readNdefFile(
|
||||
send: (bytes: number[]) => Promise<Buffer>,
|
||||
fid: [number, number]
|
||||
): Promise<string | null> {
|
||||
if (!swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, fid[0], fid[1]]))) return null
|
||||
// 2-byte NLEN header at offset 0.
|
||||
const lenResp = await send([0x00, 0xb0, 0x00, 0x00, 0x02])
|
||||
if (!swOk(lenResp)) return null
|
||||
const nlen = (lenResp[0] << 8) | lenResp[1]
|
||||
if (nlen <= 0 || nlen > 0x2000) return null
|
||||
// NDEF message starts at offset 2; read in <=250-byte chunks.
|
||||
const chunks: Buffer[] = []
|
||||
let offset = 2
|
||||
let remaining = nlen
|
||||
while (remaining > 0) {
|
||||
const toRead = Math.min(remaining, 0xfa)
|
||||
const resp = await send([0x00, 0xb0, (offset >> 8) & 0xff, offset & 0xff, toRead])
|
||||
if (!swOk(resp)) break
|
||||
const data = resp.subarray(0, resp.length - 2)
|
||||
if (data.length === 0) break
|
||||
chunks.push(data)
|
||||
offset += data.length
|
||||
remaining -= data.length
|
||||
}
|
||||
return extractLnurlw(Buffer.concat(chunks))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the NDEF of a Type-4 tag and return the extracted lnurlw, or null.
|
||||
* `transmit(apdu, maxLen) => Buffer` including the trailing SW1 SW2.
|
||||
*
|
||||
* Select the NDEF Tag Application, read the Capability Container to learn the
|
||||
* real NDEF FileID (NTAG424 Bolt Cards use E104, not the 0004 some tags use),
|
||||
* then read that file. Falls back to E104/0004 if the CC read is unavailable.
|
||||
*/
|
||||
export async function readNdefLnurlw(
|
||||
transmit: (apdu: Buffer, maxLen: number) => Promise<Buffer>
|
||||
): Promise<string | null> {
|
||||
const send = (bytes: number[]) => transmit(Buffer.from(bytes), 256)
|
||||
|
||||
// Select the NDEF Tag Application (AID D2760000850101).
|
||||
if (
|
||||
!swOk(
|
||||
await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00])
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
// NTAG424 Bolt Cards use NDEF FileID E104. Try it (and 0004) directly to
|
||||
// minimise APDU round-trips over a flaky RF link; only fall back to reading
|
||||
// the Capability Container to discover the id if both direct reads fail.
|
||||
for (const fid of [[0xe1, 0x04] as [number, number], [0x00, 0x04] as [number, number]]) {
|
||||
const found = await readNdefFile(send, fid)
|
||||
if (found) return found
|
||||
}
|
||||
if (swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, 0xe1, 0x03]))) {
|
||||
const cc = await send([0x00, 0xb0, 0x00, 0x00, 0x0f])
|
||||
// CC layout: …[07]=TLV tag 0x04, [08]=len, [09..10]=NDEF FileID.
|
||||
if (swOk(cc) && cc.length >= 13 && cc[7] === 0x04) {
|
||||
const found = await readNdefFile(send, [cc[9], cc[10]])
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
let stopFn: (() => void) | null = null
|
||||
|
||||
// ── Wedge auto-recovery ───────────────────────────────────────────────────
|
||||
// Cheap CCID readers (the Feitian R502-CL especially) occasionally wedge: they
|
||||
// keep detecting a card but every APDU returns "card absent or mute", and ONLY
|
||||
// a USB power-cycle clears it — pcscd/app restarts do NOT. When we see a run of
|
||||
// consecutive read failures we trigger nfc-reader-reset.service (a root oneshot
|
||||
// that re-binds the reader's USB device = a software replug); nfc-pcsc then
|
||||
// re-detects the reader on hotplug with no app restart. The trigger is gated by
|
||||
// a cooldown so a still-wedged reader can't reset-loop. A quality reader (e.g.
|
||||
// ACR1252U) wedges far less; this is belt-and-suspenders for any reader.
|
||||
const WEDGE_FAILURE_THRESHOLD = 3
|
||||
const RESET_COOLDOWN_MS = 30_000
|
||||
// Persist across reader re-enumerations (a reset spawns a fresh reader closure).
|
||||
let lastReaderResetAt = 0
|
||||
|
||||
/** Trigger the privileged USB power-cycle of the reader. Best-effort. */
|
||||
function resetWedgedReader(): void {
|
||||
// NixOS: the app runs unprivileged as `bitspire`; a polkit rule authorises it
|
||||
// to start this one unit. systemctl lives at a stable path on the device.
|
||||
execFile('/run/current-system/sw/bin/systemctl', ['start', 'nfc-reader-reset.service'], () => {
|
||||
/* best-effort — if it fails the reader stays wedged until a manual reset */
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Start listening for Bolt Card taps. Idempotent. Returns a stop function.
|
||||
* Never throws — failures surface via onStatus.
|
||||
*/
|
||||
/**
|
||||
* pcsc-lite's client socket, created by pcscd.
|
||||
*
|
||||
* When pcscd is NOT running, the pcsclite binding inside nfc-pcsc does not
|
||||
* fail — it retries SCardEstablishContext in a tight loop on the calling
|
||||
* thread (~12k stat()s per second on this path), and that thread is Electron's
|
||||
* main thread. The event loop then stops turning entirely: the window never
|
||||
* paints, the dead renderer is never reaped, and main.ts's watchdog can't fire
|
||||
* either, so nothing recovers it. A douro with no reader fitted sat wedged
|
||||
* like that for 11 hours, ignoring SIGTERM.
|
||||
*
|
||||
* Checking for the socket first is what makes the "best-effort" contract in
|
||||
* this module's header actually true. It also covers pcscd dying at runtime on
|
||||
* a machine that does have a reader.
|
||||
*/
|
||||
const PCSCD_SOCKET = '/run/pcscd/pcscd.comm'
|
||||
|
||||
export async function startNfcReader(
|
||||
onCard: CardHandler,
|
||||
onStatus: StatusHandler
|
||||
): Promise<() => void> {
|
||||
if (stopFn) return stopFn
|
||||
|
||||
if (!existsSync(PCSCD_SOCKET)) {
|
||||
onStatus({ state: 'unavailable', message: `pcscd not running (${PCSCD_SOCKET} absent)` })
|
||||
return () => {}
|
||||
}
|
||||
|
||||
let mod: unknown
|
||||
try {
|
||||
// Non-literal specifier: nfc-pcsc ships no types; keep it `any` to tsc
|
||||
// while resolving normally at runtime.
|
||||
const pkg = 'nfc-pcsc'
|
||||
mod = (await import(pkg)) as unknown
|
||||
} catch (e) {
|
||||
onStatus({ state: 'unavailable', message: `NFC library unavailable: ${errMsg(e)}` })
|
||||
return () => {}
|
||||
}
|
||||
const NFC =
|
||||
(mod as { NFC?: unknown }).NFC ?? (mod as { default?: { NFC?: unknown } }).default?.NFC
|
||||
if (typeof NFC !== 'function') {
|
||||
onStatus({ state: 'unavailable', message: 'NFC library has no NFC export' })
|
||||
return () => {}
|
||||
}
|
||||
|
||||
let nfc: { on: (e: string, cb: (...a: unknown[]) => void) => void; close?: () => void }
|
||||
try {
|
||||
nfc = new (NFC as new () => typeof nfc)()
|
||||
} catch (e) {
|
||||
onStatus({ state: 'unavailable', message: `NFC init failed: ${errMsg(e)}` })
|
||||
return () => {}
|
||||
}
|
||||
|
||||
nfc.on('reader', (reader: unknown) => {
|
||||
const r = reader as {
|
||||
name?: string
|
||||
reader?: { name?: string }
|
||||
autoProcessing?: boolean
|
||||
on: (e: string, cb: (...a: unknown[]) => void) => void
|
||||
transmit: (data: Buffer, maxLen: number) => Promise<Buffer>
|
||||
}
|
||||
const name = r.name ?? r.reader?.name ?? 'reader'
|
||||
// We do our own NDEF APDU read, not nfc-pcsc's UID auto-processing.
|
||||
r.autoProcessing = false
|
||||
onStatus({ state: 'ready', reader: name })
|
||||
|
||||
// Cooldown after a failed read: these cheap CCID readers can get wedged into
|
||||
// a present↔empty storm when hammered, so ignore re-detections for a beat
|
||||
// after a failure. Successful reads don't cool down.
|
||||
let cooldownUntil = 0
|
||||
// Consecutive failed reads → wedge detection (see resetWedgedReader above).
|
||||
// A completed read (Bolt Card or not) proves the reader is healthy and
|
||||
// clears the count; only a run of thrown transmits trips the reset.
|
||||
let consecutiveFailures = 0
|
||||
r.on('card', async () => {
|
||||
if (Date.now() < cooldownUntil) return
|
||||
onStatus({ state: 'reading', reader: name })
|
||||
// Single attempt: retrying hammers a flaky RF link. A read is a few APDU
|
||||
// round-trips; if the card shifts mid-read the transmit fails and the
|
||||
// user simply re-taps.
|
||||
try {
|
||||
const lnurlw = await readNdefLnurlw((apdu, maxLen) => r.transmit(apdu, maxLen))
|
||||
consecutiveFailures = 0
|
||||
if (lnurlw) {
|
||||
onCard(lnurlw)
|
||||
return
|
||||
}
|
||||
onStatus({ state: 'error', reader: name, message: 'not a Bolt Card' })
|
||||
} catch (e) {
|
||||
consecutiveFailures++
|
||||
if (
|
||||
consecutiveFailures >= WEDGE_FAILURE_THRESHOLD &&
|
||||
Date.now() - lastReaderResetAt > RESET_COOLDOWN_MS
|
||||
) {
|
||||
// Reader looks wedged — auto power-cycle it (only fix that works).
|
||||
lastReaderResetAt = Date.now()
|
||||
consecutiveFailures = 0
|
||||
onStatus({ state: 'error', reader: name, message: 'reader stuck — auto-resetting…' })
|
||||
resetWedgedReader()
|
||||
} else {
|
||||
onStatus({
|
||||
state: 'error',
|
||||
reader: name,
|
||||
message: 'card read failed — hold steady & retap',
|
||||
})
|
||||
}
|
||||
void e
|
||||
}
|
||||
cooldownUntil = Date.now() + 1500
|
||||
})
|
||||
r.on('card.off', () => onStatus({ state: 'card-removed', reader: name }))
|
||||
r.on('error', (err: unknown) =>
|
||||
onStatus({ state: 'error', reader: name, message: errMsg(err) })
|
||||
)
|
||||
r.on('end', () =>
|
||||
onStatus({ state: 'unavailable', reader: name, message: 'reader disconnected' })
|
||||
)
|
||||
})
|
||||
nfc.on('error', (err: unknown) => onStatus({ state: 'error', message: errMsg(err) }))
|
||||
|
||||
stopFn = () => {
|
||||
try {
|
||||
nfc.close?.()
|
||||
} catch {
|
||||
/* idempotent */
|
||||
}
|
||||
stopFn = null
|
||||
}
|
||||
return stopFn
|
||||
}
|
||||
|
|
@ -7,24 +7,6 @@
|
|||
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
/** Mirrors state-store.CashOutHold (ADR-005 §5) — preload can't import main-process modules. */
|
||||
interface CashOutHold {
|
||||
reason: string
|
||||
errorCode: string | null
|
||||
rawCode: string | null
|
||||
since: number
|
||||
}
|
||||
|
||||
/** Mirrors state-store.PendingDispenseReport (ADR-005 §2). */
|
||||
interface PendingDispenseReport {
|
||||
txid: string
|
||||
payload: unknown
|
||||
createdAt: number
|
||||
attempts: number
|
||||
lastAttemptAt: number | null
|
||||
lastError: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime configuration interface (public info only)
|
||||
* These values are read from environment variables at runtime (not build time)
|
||||
|
|
@ -35,6 +17,10 @@ export interface RuntimeConfig {
|
|||
relayUrl: string
|
||||
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
||||
lnbitsServerPubkey: string
|
||||
/** Legacy LP fields — retained until 3d removes the LP backend. Optional. */
|
||||
lightningPubPubkey?: string
|
||||
lightningPubApiUrl?: string
|
||||
extensionApiUrl?: string
|
||||
appId: string
|
||||
machineModel: string
|
||||
fiatCode: string
|
||||
|
|
@ -56,29 +42,13 @@ export interface BrandingConfig {
|
|||
logoDarkDataUrl: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted NIP-46 bunker binding (mirror of state-store's StoredBunkerBinding).
|
||||
*/
|
||||
export interface BunkerBindingRecord {
|
||||
clientSecretHex: string
|
||||
spirePubkey: string
|
||||
bunkerUrl: string
|
||||
seedFingerprint: string
|
||||
pairedAt: number
|
||||
/** LNbits transport relays from the seed (#70); absent on pre-#70 bindings. */
|
||||
relays?: string[]
|
||||
/** LNbits nostr-transport server pubkey (hex) from the seed (#70). */
|
||||
lnbitsServerPubkey?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* ATM secrets — returned once by getAtmSecrets(), then empty on subsequent calls.
|
||||
* The spire pairing seed (carries the one-shot connect token) plus the persisted
|
||||
* bunker binding; the renderer resolves these into a signer.
|
||||
*/
|
||||
export interface AtmSecrets {
|
||||
spireSeed: string
|
||||
bunkerBinding: BunkerBindingRecord | null
|
||||
atmPrivateKey: string
|
||||
/** Legacy LP admin token — retained until 3d removes the LP backend. */
|
||||
adminToken?: string
|
||||
}
|
||||
|
||||
// Expose protected methods to renderer
|
||||
|
|
@ -126,90 +96,17 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
// Operator-config consumer (aiolabs/lamassu-next#56)
|
||||
getLastKnownConfigCreatedAt: (): Promise<number> =>
|
||||
ipcRenderer.invoke('state:get-last-known-config-created-at'),
|
||||
getLastStatePublishedAt: (): Promise<number | null> =>
|
||||
ipcRenderer.invoke('state:get-last-state-published-at'),
|
||||
getCountsUncertainSince: (): Promise<number | null> =>
|
||||
ipcRenderer.invoke('state:get-counts-uncertain-since'),
|
||||
markCountsUncertain: (unixTimestamp: number): Promise<void> =>
|
||||
ipcRenderer.invoke('state:mark-counts-uncertain', unixTimestamp),
|
||||
// Cash-out hold (ADR-005 §5)
|
||||
getCashOutHold: (): Promise<CashOutHold | null> => ipcRenderer.invoke('state:get-cash-out-hold'),
|
||||
setCashOutHold: (hold: CashOutHold): Promise<CashOutHold> =>
|
||||
ipcRenderer.invoke('state:set-cash-out-hold', hold),
|
||||
clearCashOutHold: (): Promise<boolean> => ipcRenderer.invoke('state:clear-cash-out-hold'),
|
||||
// Dispense-report outbox (ADR-005 §2)
|
||||
pendingDispenseReports: (limit?: number): Promise<PendingDispenseReport[]> =>
|
||||
ipcRenderer.invoke('state:pending-dispense-reports', limit),
|
||||
ackDispenseReport: (txid: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('state:ack-dispense-report', txid),
|
||||
noteDispenseReportAttempt: (txid: string, error: string | null): Promise<void> =>
|
||||
ipcRenderer.invoke('state:note-dispense-report-attempt', txid, error),
|
||||
markStatePublished: (unixTimestamp: number): Promise<void> =>
|
||||
ipcRenderer.invoke('state:mark-state-published', unixTimestamp),
|
||||
|
||||
// Bunker binding persistence (aiolabs/bitspire#52)
|
||||
saveBunkerBinding: (binding: BunkerBindingRecord): Promise<void> =>
|
||||
ipcRenderer.invoke('state:save-bunker-binding', binding),
|
||||
clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'),
|
||||
resetStatePublishWatermark: (): Promise<void> =>
|
||||
ipcRenderer.invoke('state:reset-state-publish-watermark'),
|
||||
resetForRepair: (): Promise<void> => ipcRenderer.invoke('state:reset-for-repair'),
|
||||
|
||||
// QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed,
|
||||
// then relaunch so the normal boot flow pairs it.
|
||||
saveSpireSeed: (seed: string): Promise<void> => ipcRenderer.invoke('state:save-spire-seed', seed),
|
||||
relaunchApp: (): Promise<void> => ipcRenderer.invoke('app:relaunch'),
|
||||
// Reload the renderer to re-attempt initialization (connectivity recovery).
|
||||
recoverApp: (): Promise<void> => ipcRenderer.invoke('app:recover'),
|
||||
|
||||
// Bolt Card cash-out: pull payment for the current invoice from a tapped card.
|
||||
lnurlWithdraw: (args: {
|
||||
lnurlw: string
|
||||
bolt11: string
|
||||
amountMsat?: number
|
||||
}): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('lnurl:withdraw', args),
|
||||
|
||||
// Bolt Card cash-in: resolve a tapped card + amount to a BOLT11 to pay.
|
||||
resolveCardInvoice: (args: {
|
||||
lnurlw: string
|
||||
amountMsat: number
|
||||
}): Promise<{ ok: boolean; bolt11?: string; reason?: string }> =>
|
||||
ipcRenderer.invoke('lnurl:pay-card', args),
|
||||
|
||||
// Bolt Card tap-to-enter: one verified session per tap (balance + fiat +
|
||||
// the withdraw/pay second steps reused at Complete). Payload shapes are
|
||||
// declared in src/types/electron.d.ts (CardSession).
|
||||
openCardSession: (args: { lnurlw: string }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('lnurl:open-card-session', args),
|
||||
withdrawWithSession: (args: {
|
||||
withdraw: { callback: string; k1: string; minWithdrawable?: number; maxWithdrawable?: number }
|
||||
bolt11: string
|
||||
amountMsat?: number
|
||||
}): Promise<{ ok: boolean; reason?: string }> =>
|
||||
ipcRenderer.invoke('lnurl:withdraw-session', args),
|
||||
resolveSessionInvoice: (args: {
|
||||
pay: { callback: string; minSendable?: number; maxSendable?: number; metadata?: string }
|
||||
amountMsat: number
|
||||
}): Promise<{ ok: boolean; bolt11?: string; reason?: string }> =>
|
||||
ipcRenderer.invoke('lnurl:pay-session', args),
|
||||
|
||||
applyOperatorCassetteOps: (
|
||||
ops: {
|
||||
id: string
|
||||
at: number
|
||||
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
|
||||
position: number
|
||||
bills?: number
|
||||
count?: number
|
||||
denomination?: number
|
||||
}[]
|
||||
): Promise<{
|
||||
applied: string[]
|
||||
rejected: { id: string; reason: string }[]
|
||||
}> => ipcRenderer.invoke('state:apply-operator-cassette-ops', ops),
|
||||
getAppliedOpIds: (limit?: number): Promise<string[]> =>
|
||||
ipcRenderer.invoke('state:get-applied-op-ids', limit),
|
||||
getCassetteStateSeq: (): Promise<number> => ipcRenderer.invoke('state:get-cassette-state-seq'),
|
||||
getBootstrapPublishedAt: (): Promise<number | null> =>
|
||||
ipcRenderer.invoke('state:get-bootstrap-published-at'),
|
||||
markBootstrapPublished: (unixTimestamp: number): Promise<void> =>
|
||||
ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp),
|
||||
applyOperatorCassettesConfig: (
|
||||
payload: {
|
||||
positions: Record<string, { denomination: number; count: number }>
|
||||
},
|
||||
eventCreatedAt: number
|
||||
): Promise<{ applied: true } | { applied: false; reason: string }> =>
|
||||
ipcRenderer.invoke('state:apply-operator-cassettes-config', payload, eventCreatedAt),
|
||||
|
||||
// Operator-fees consumer (aiolabs/lamassu-next#57)
|
||||
getFeeConfig: (): Promise<{
|
||||
|
|
@ -264,27 +161,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
ipcRenderer.on('hal:error', (_event, error) => callback(error))
|
||||
},
|
||||
|
||||
// Bolt Card reader (main process → renderer). removeAllListeners first: a
|
||||
// renderer reload re-runs this, and a duplicated card-tap listener would
|
||||
// trigger the LNURL-withdraw twice.
|
||||
// The main process changed the cassettes table (an operator-command dispense,
|
||||
// boot seeding). The renderer reloads its inventory and republishes state.
|
||||
onCassettesChanged: (callback: () => void) => {
|
||||
ipcRenderer.removeAllListeners('cassettes:changed')
|
||||
ipcRenderer.on('cassettes:changed', () => callback())
|
||||
},
|
||||
|
||||
onNfcCardTapped: (callback: (lnurlw: string) => void) => {
|
||||
ipcRenderer.removeAllListeners('nfc:card-tapped')
|
||||
ipcRenderer.on('nfc:card-tapped', (_event, lnurlw) => callback(lnurlw))
|
||||
},
|
||||
onNfcStatus: (
|
||||
callback: (status: { state: string; reader?: string; message?: string }) => void
|
||||
) => {
|
||||
ipcRenderer.removeAllListeners('nfc:status')
|
||||
ipcRenderer.on('nfc:status', (_event, status) => callback(status))
|
||||
},
|
||||
|
||||
// Watchdog heartbeat (main process → renderer → main process)
|
||||
onWatchdogPing: (callback: () => void) => {
|
||||
ipcRenderer.on('watchdog:ping', () => callback())
|
||||
|
|
@ -334,48 +210,12 @@ declare global {
|
|||
emptyCashbox: () => Promise<void>
|
||||
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
|
||||
getLastKnownConfigCreatedAt: () => Promise<number>
|
||||
getLastStatePublishedAt: () => Promise<number | null>
|
||||
getCountsUncertainSince: () => Promise<number | null>
|
||||
markCountsUncertain: (unixTimestamp: number) => Promise<void>
|
||||
getCashOutHold: () => Promise<CashOutHold | null>
|
||||
setCashOutHold: (hold: CashOutHold) => Promise<CashOutHold>
|
||||
clearCashOutHold: () => Promise<boolean>
|
||||
pendingDispenseReports: (limit?: number) => Promise<PendingDispenseReport[]>
|
||||
ackDispenseReport: (txid: string) => Promise<boolean>
|
||||
noteDispenseReportAttempt: (txid: string, error: string | null) => Promise<void>
|
||||
markStatePublished: (unixTimestamp: number) => Promise<void>
|
||||
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
||||
clearBunkerBinding: () => Promise<void>
|
||||
resetStatePublishWatermark: () => Promise<void>
|
||||
resetForRepair: () => Promise<void>
|
||||
saveSpireSeed: (seed: string) => Promise<void>
|
||||
relaunchApp: () => Promise<void>
|
||||
recoverApp: () => Promise<void>
|
||||
lnurlWithdraw: (args: {
|
||||
lnurlw: string
|
||||
bolt11: string
|
||||
amountMsat?: number
|
||||
}) => Promise<{ ok: boolean; reason?: string }>
|
||||
resolveCardInvoice: (args: {
|
||||
lnurlw: string
|
||||
amountMsat: number
|
||||
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
|
||||
applyOperatorCassetteOps: (
|
||||
ops: {
|
||||
id: string
|
||||
at: number
|
||||
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
|
||||
position: number
|
||||
bills?: number
|
||||
count?: number
|
||||
denomination?: number
|
||||
}[]
|
||||
) => Promise<{
|
||||
applied: string[]
|
||||
rejected: { id: string; reason: string }[]
|
||||
}>
|
||||
getAppliedOpIds: (limit?: number) => Promise<string[]>
|
||||
getCassetteStateSeq: () => Promise<number>
|
||||
getBootstrapPublishedAt: () => Promise<number | null>
|
||||
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
|
||||
applyOperatorCassettesConfig: (
|
||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||
eventCreatedAt: number
|
||||
) => Promise<{ applied: true } | { applied: false; reason: string }>
|
||||
getFeeConfig: () => Promise<{
|
||||
cashInFeeFraction: number
|
||||
cashOutFeeFraction: number
|
||||
|
|
@ -409,10 +249,6 @@ declare global {
|
|||
onHalBillInserted: (callback: (denomination: number) => void) => void
|
||||
onHalBillRejected: (callback: (reason: string) => void) => void
|
||||
onHalError: (callback: (error: string) => void) => void
|
||||
onNfcCardTapped: (callback: (lnurlw: string) => void) => void
|
||||
onNfcStatus: (
|
||||
callback: (status: { state: string; reader?: string; message?: string }) => void
|
||||
) => void
|
||||
onWatchdogPing: (callback: () => void) => void
|
||||
watchdogPong: () => Promise<void>
|
||||
platform: NodeJS.Platform
|
||||
|
|
|
|||
|
|
@ -10,13 +10,12 @@
|
|||
*/
|
||||
|
||||
import Database from 'better-sqlite3'
|
||||
import type { DispenseReportBody } from '@bitSpire/lnbits'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
|
||||
let db: Database.Database | null = null
|
||||
|
||||
const SCHEMA_VERSION = '14'
|
||||
const SCHEMA_VERSION = '10'
|
||||
|
||||
function getDbPath(): string {
|
||||
const prodDir = '/var/lib/bitspire'
|
||||
|
|
@ -58,17 +57,6 @@ export function initDatabase(dbPath?: string): void {
|
|||
count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cassette_ops (
|
||||
id TEXT PRIMARY KEY,
|
||||
position INTEGER NOT NULL,
|
||||
op_type TEXT NOT NULL,
|
||||
bills INTEGER,
|
||||
count INTEGER,
|
||||
denomination INTEGER,
|
||||
op_at INTEGER NOT NULL,
|
||||
applied_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cashbox (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
total_bills INTEGER NOT NULL DEFAULT 0,
|
||||
|
|
@ -126,27 +114,6 @@ export function initDatabase(dbPath?: string): void {
|
|||
event_created_at INTEGER NOT NULL,
|
||||
applied_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bunker_binding (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
client_secret_hex TEXT NOT NULL,
|
||||
spire_pubkey TEXT NOT NULL,
|
||||
bunker_url TEXT NOT NULL,
|
||||
seed_fingerprint TEXT NOT NULL,
|
||||
paired_at INTEGER NOT NULL,
|
||||
relays TEXT,
|
||||
lnbits_server_pubkey TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dispense_reports (
|
||||
txid TEXT PRIMARY KEY REFERENCES transactions(txid),
|
||||
payload TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_attempt_at INTEGER,
|
||||
last_error TEXT,
|
||||
acked_at INTEGER
|
||||
);
|
||||
`)
|
||||
|
||||
// Seed meta + cashbox if first run, or run migrations
|
||||
|
|
@ -317,9 +284,7 @@ export function initDatabase(dbPath?: string): void {
|
|||
`)
|
||||
db.pragma('foreign_keys = ON')
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('9', 'schema_version')
|
||||
console.log(
|
||||
'[StateStore] Migrated schema v8 → v9 (cassettes PK position; allow duplicate denominations)'
|
||||
)
|
||||
console.log('[StateStore] Migrated schema v8 → v9 (cassettes PK position; allow duplicate denominations)')
|
||||
existing.value = '9'
|
||||
}
|
||||
|
||||
|
|
@ -355,104 +320,6 @@ export function initDatabase(dbPath?: string): void {
|
|||
)
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('10', 'schema_version')
|
||||
console.log('[StateStore] Migrated schema v9 → v10 (added fee_config + watermark)')
|
||||
existing.value = '10'
|
||||
}
|
||||
|
||||
if (existing && existing.value === '10') {
|
||||
// Migration v10 → v11: NIP-46 bunker binding (aiolabs/bitspire#52).
|
||||
// - bunker_binding singleton — the ATM's own NIP-46 transport key
|
||||
// (client_nsec) plus the spire signing identity, bunker URL, and a
|
||||
// fingerprint of the seed it was paired from. Persisted so a restart
|
||||
// resumes the bunker session without re-redeeming the one-shot connect
|
||||
// secret. A new/changed seed_fingerprint signals a re-pair (which also
|
||||
// resets bootstrapPublishedAt — see lightning.ts / bitspire#56).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS bunker_binding (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
client_secret_hex TEXT NOT NULL,
|
||||
spire_pubkey TEXT NOT NULL,
|
||||
bunker_url TEXT NOT NULL,
|
||||
seed_fingerprint TEXT NOT NULL,
|
||||
paired_at INTEGER NOT NULL
|
||||
);
|
||||
`)
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('11', 'schema_version')
|
||||
console.log('[StateStore] Migrated schema v10 → v11 (added bunker_binding)')
|
||||
existing.value = '11'
|
||||
}
|
||||
|
||||
if (existing && existing.value === '11') {
|
||||
// Migration v11 → v12: carry the LNbits transport config in the binding
|
||||
// (aiolabs/bitspire#70). relays (JSON array) + lnbits_server_pubkey let a
|
||||
// paired machine reach the backend from the pairing alone — no VITE_RELAY_URL
|
||||
// / VITE_LNBITS_SERVER_PUBKEY provisioning. Nullable: bindings written before
|
||||
// this (the seed didn't carry them) resume fine and fall back to env.
|
||||
db.exec(`
|
||||
ALTER TABLE bunker_binding ADD COLUMN relays TEXT;
|
||||
ALTER TABLE bunker_binding ADD COLUMN lnbits_server_pubkey TEXT;
|
||||
`)
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('12', 'schema_version')
|
||||
console.log('[StateStore] Migrated schema v11 → v12 (bunker_binding transport config)')
|
||||
}
|
||||
|
||||
if (existing && existing.value === '12') {
|
||||
// Migration v12 → v13: operator OPERATIONS replace operator counts
|
||||
// (aiolabs/bitspire ADR-004).
|
||||
//
|
||||
// The operator used to publish absolute counts and this machine applied
|
||||
// them outright. Both sides wrote the same value over a transport that
|
||||
// never tells a writer it lost, so a dashboard form loaded before a
|
||||
// dispense silently discarded that dispense — and nothing on either side
|
||||
// could detect it afterwards. The operator now publishes what it DID and
|
||||
// this machine, which holds the notes, owns the running total.
|
||||
//
|
||||
// `cassette_ops` is the dedup ledger. A delta applied twice is wrong, and
|
||||
// addressable events are re-delivered on every reconnect, so the operator
|
||||
// mints an id per operation and we record the ones we have applied. The
|
||||
// operator's window is a slice of recent operations rather than just the
|
||||
// newest, so one we missed arrives with the next publish; dedup is what
|
||||
// makes re-delivery free instead of dangerous.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS cassette_ops (
|
||||
id TEXT PRIMARY KEY,
|
||||
position INTEGER NOT NULL,
|
||||
op_type TEXT NOT NULL,
|
||||
bills INTEGER,
|
||||
count INTEGER,
|
||||
denomination INTEGER,
|
||||
op_at INTEGER NOT NULL,
|
||||
applied_at INTEGER NOT NULL
|
||||
);
|
||||
`)
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('13', 'schema_version')
|
||||
console.log('[StateStore] Migrated schema v12 → v13 (added cassette_ops)')
|
||||
existing.value = '13'
|
||||
}
|
||||
|
||||
if (existing && existing.value === '13') {
|
||||
// Migration v13 → v14: the dispense-report outbox (ADR-005 §2).
|
||||
//
|
||||
// Every cash-out's outcome — success or failure — is reported to
|
||||
// spirekeeper over a kind-21000 RPC, and that report is what lets the
|
||||
// server capture (distribute) the settlement or surface a customer who
|
||||
// is owed cash. A relay gives the publisher no delivery guarantee, so the
|
||||
// report is written here, in the SAME transaction as the transactions
|
||||
// row, and resent until the server acknowledges it. Idempotent on txid
|
||||
// server-side; `attempts` / `last_error` drive the resend backoff.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS dispense_reports (
|
||||
txid TEXT PRIMARY KEY REFERENCES transactions(txid),
|
||||
payload TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_attempt_at INTEGER,
|
||||
last_error TEXT,
|
||||
acked_at INTEGER
|
||||
);
|
||||
`)
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('14', 'schema_version')
|
||||
console.log('[StateStore] Migrated schema v13 → v14 (added dispense_reports outbox)')
|
||||
existing.value = '14'
|
||||
}
|
||||
|
||||
// Defensive: a fresh install at SCHEMA_VERSION skips all migrations.
|
||||
|
|
@ -461,7 +328,6 @@ export function initDatabase(dbPath?: string): void {
|
|||
seedMeta.run('lastKnownConfigCreatedAt', '0')
|
||||
seedMeta.run('bootstrapPublishedAt', '')
|
||||
seedMeta.run('lastKnownFeeConfigCreatedAt', '0')
|
||||
seedMeta.run('cassetteStateSeq', '0')
|
||||
|
||||
const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get()
|
||||
if (!cashboxRow) {
|
||||
|
|
@ -482,237 +348,32 @@ export function initDatabase(dbPath?: string): void {
|
|||
*/
|
||||
export function getLastKnownConfigCreatedAt(): number {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('lastKnownConfigCreatedAt') as
|
||||
| { value: string }
|
||||
| undefined
|
||||
const row = db
|
||||
.prepare('SELECT value FROM meta WHERE key = ?')
|
||||
.get('lastKnownConfigCreatedAt') as { value: string } | undefined
|
||||
return row ? Number(row.value) || 0 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The `created_at` of the last `bitspire-cassettes-state` event this machine
|
||||
* published, or null if it has never published one.
|
||||
*
|
||||
* This used to be a one-shot gate ("have we said hello yet"), which meant a
|
||||
* layout change after first boot was never announced (#94). It is now a
|
||||
* high-water mark: every publish records its stamp, and the next one is forced
|
||||
* strictly above it. Addressable events are ordered by `created_at` at second
|
||||
* granularity, and a relay silently keeps the higher one, so a clock that steps
|
||||
* backwards would otherwise make this machine's reports vanish with an `OK`.
|
||||
*
|
||||
* Stored under the original `bootstrapPublishedAt` meta key so no migration is
|
||||
* needed; the name is historical, the meaning is not.
|
||||
* Read the one-shot bootstrap-publish gate. Returns null if the ATM has
|
||||
* not yet published its `bitspire-cassettes-state:<machine_id>` hello-event.
|
||||
*/
|
||||
export function getLastStatePublishedAt(): number | null {
|
||||
export function getBootstrapPublishedAt(): number | null {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('bootstrapPublishedAt') as
|
||||
| { value: string }
|
||||
| undefined
|
||||
const row = db
|
||||
.prepare('SELECT value FROM meta WHERE key = ?')
|
||||
.get('bootstrapPublishedAt') as { value: string } | undefined
|
||||
if (!row || row.value === '') return null
|
||||
const n = Number(row.value)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the bay counts are known to be unverified, and since when.
|
||||
*
|
||||
* Set when a dispense ends without the dispenser reporting what it moved — a
|
||||
* driver throw, or the dispense timeout. Bills may well have reached the
|
||||
* customer, but nothing knows how many, so neither the rows here nor HAL's
|
||||
* bays were debited and both now read high. Reporting that number as fact is
|
||||
* the worst option available; saying the number is unverified is honest and
|
||||
* tells the operator to open the machine and recount.
|
||||
*
|
||||
* Cleared when an operator asserts authoritative counts (a config apply),
|
||||
* which is precisely what a recount is. Uses an upsert so no migration is
|
||||
* needed for machines whose meta table predates the key.
|
||||
* Mark the bootstrap hello-event as published. Idempotent — only takes
|
||||
* effect the first time it's set. Subsequent calls overwrite the
|
||||
* timestamp (harmless; the gate just needs to be non-null).
|
||||
*/
|
||||
export function getCountsUncertainSince(): number | null {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('countsUncertainSince') as
|
||||
| { value: string }
|
||||
| undefined
|
||||
if (!row || row.value === '') return null
|
||||
const n = Number(row.value)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
/** Flag the counts as unverified. Keeps the earliest time it went bad. */
|
||||
export function markCountsUncertain(unixTimestamp: number): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
if (getCountsUncertainSince() !== null) return
|
||||
db.prepare(
|
||||
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
).run('countsUncertainSince', String(unixTimestamp))
|
||||
console.warn('[StateStore] Cassette counts flagged unverified at', unixTimestamp)
|
||||
}
|
||||
|
||||
/** Clear the flag — an operator has asserted real counts. */
|
||||
export function clearCountsUncertain(): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
db.prepare(
|
||||
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
).run('countsUncertainSince', '')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cash-out hold (ADR-005 §5)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// A terminal dispenser fault latches cash-out off. The latch is machine
|
||||
// health, so it lives in `meta` (one JSON value) and survives restarts; the
|
||||
// renderer restores it into the state machine on boot and the operator
|
||||
// releases it with a `recount` or `resume_cash_out` op. Re-initialising the
|
||||
// dispenser never clears it — re-init does not move a stuck note.
|
||||
|
||||
export interface CashOutHold {
|
||||
reason: string
|
||||
errorCode: string | null
|
||||
rawCode: string | null
|
||||
/** unix seconds of the FIRST fault — kept across repeat faults */
|
||||
since: number
|
||||
}
|
||||
|
||||
export function getCashOutHold(): CashOutHold | null {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('cashOutHeld') as
|
||||
| { value: string }
|
||||
| undefined
|
||||
if (!row || row.value === '') return null
|
||||
try {
|
||||
const parsed = JSON.parse(row.value) as Partial<CashOutHold>
|
||||
if (typeof parsed.since !== 'number' || typeof parsed.reason !== 'string') return null
|
||||
return {
|
||||
reason: parsed.reason,
|
||||
errorCode: typeof parsed.errorCode === 'string' ? parsed.errorCode : null,
|
||||
rawCode: typeof parsed.rawCode === 'string' ? parsed.rawCode : null,
|
||||
since: parsed.since,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Latch cash-out off. Idempotent: an existing hold (and its `since`) is kept. */
|
||||
export function setCashOutHold(hold: CashOutHold): CashOutHold {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const existing = getCashOutHold()
|
||||
if (existing) return existing
|
||||
db.prepare(
|
||||
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
).run('cashOutHeld', JSON.stringify(hold))
|
||||
console.warn(
|
||||
`[StateStore] Cash-out HELD: ${hold.errorCode ?? 'fault'}${hold.rawCode ? ` ${hold.rawCode}` : ''} — ${hold.reason}`
|
||||
)
|
||||
return hold
|
||||
}
|
||||
|
||||
/** Release the latch — an operator has cleared the machine. */
|
||||
export function clearCashOutHold(): boolean {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const had = getCashOutHold() !== null
|
||||
db.prepare(
|
||||
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
).run('cashOutHeld', '')
|
||||
if (had) console.log('[StateStore] Cash-out hold released')
|
||||
return had
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispense-report outbox (ADR-005 §2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PendingDispenseReport {
|
||||
txid: string
|
||||
payload: DispenseReportBody
|
||||
createdAt: number
|
||||
attempts: number
|
||||
lastAttemptAt: number | null
|
||||
lastError: string | null
|
||||
}
|
||||
|
||||
/** Unacknowledged reports, oldest first. The renderer applies the backoff. */
|
||||
export function pendingDispenseReports(limit = 20): PendingDispenseReport[] {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const rows = db
|
||||
.prepare(
|
||||
'SELECT txid, payload, created_at, attempts, last_attempt_at, last_error FROM dispense_reports WHERE acked_at IS NULL ORDER BY created_at ASC LIMIT ?'
|
||||
)
|
||||
.all(limit) as Array<{
|
||||
txid: string
|
||||
payload: string
|
||||
created_at: number
|
||||
attempts: number
|
||||
last_attempt_at: number | null
|
||||
last_error: string | null
|
||||
}>
|
||||
const out: PendingDispenseReport[] = []
|
||||
for (const r of rows) {
|
||||
try {
|
||||
out.push({
|
||||
txid: r.txid,
|
||||
payload: JSON.parse(r.payload) as DispenseReportBody,
|
||||
createdAt: r.created_at,
|
||||
attempts: r.attempts,
|
||||
lastAttemptAt: r.last_attempt_at,
|
||||
lastError: r.last_error,
|
||||
})
|
||||
} catch {
|
||||
console.error('[StateStore] dispense_reports row has unparseable payload:', r.txid)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The server acknowledged this report. Returns whether a row changed. */
|
||||
export function markDispenseReportAcked(txid: string): boolean {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const res = db
|
||||
.prepare('UPDATE dispense_reports SET acked_at = ? WHERE txid = ? AND acked_at IS NULL')
|
||||
.run(Date.now(), txid)
|
||||
if (res.changes > 0) console.log('[StateStore] Dispense report acked:', txid)
|
||||
return res.changes > 0
|
||||
}
|
||||
|
||||
/** A send was attempted and did not get an OK. Drives the resend backoff. */
|
||||
export function noteDispenseReportAttempt(txid: string, error: string | null): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
db.prepare(
|
||||
'UPDATE dispense_reports SET attempts = attempts + 1, last_attempt_at = ?, last_error = ? WHERE txid = ?'
|
||||
).run(Date.now(), error, txid)
|
||||
}
|
||||
|
||||
/**
|
||||
* A counter bumped on every local change to a bay count, from any cause.
|
||||
*
|
||||
* It rides along in the state document so a reader can reject a regression
|
||||
* without trusting a clock. `created_at` cannot carry that: it has
|
||||
* second granularity, so two publishes in the same second are ordered by
|
||||
* whichever event id hashes lower — and a machine whose clock stepped
|
||||
* backwards would otherwise have every later report look older than the one
|
||||
* already on the relay.
|
||||
*/
|
||||
export function getCassetteStateSeq(): number {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('cassetteStateSeq') as
|
||||
| { value: string }
|
||||
| undefined
|
||||
return row ? Number(row.value) || 0 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Bump the counter. Safe to call inside an open transaction — every caller
|
||||
* that mutates a count does, so the bump commits or rolls back with it.
|
||||
*/
|
||||
export function bumpCassetteStateSeq(): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
db.prepare(
|
||||
'INSERT INTO meta (key, value) VALUES (?, ?) ' +
|
||||
'ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(meta.value AS INTEGER) + 1 AS TEXT)'
|
||||
).run('cassetteStateSeq', '1')
|
||||
}
|
||||
|
||||
/** Record the `created_at` just published, as the next publish's floor. */
|
||||
export function markStatePublished(unixTimestamp: number): void {
|
||||
export function markBootstrapPublished(unixTimestamp: number): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run(
|
||||
String(unixTimestamp),
|
||||
|
|
@ -720,332 +381,108 @@ export function markStatePublished(unixTimestamp: number): void {
|
|||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bunker binding — NIP-46 transport key + spire identity (aiolabs/bitspire#52)
|
||||
// ---------------------------------------------------------------------------
|
||||
export type OperatorCassettesPayload = {
|
||||
positions: Record<string, { denomination: number; count: number }>
|
||||
}
|
||||
|
||||
export type ApplyResult =
|
||||
| { applied: true }
|
||||
| { applied: false; reason: string }
|
||||
|
||||
export interface StoredBunkerBinding {
|
||||
/** The ATM's own NIP-46 transport secret key (`client_nsec`), hex. */
|
||||
clientSecretHex: string
|
||||
/** The spire's signing pubkey (hex) — the identity events are signed as. */
|
||||
spirePubkey: string
|
||||
/** `bunker://…` URL, re-parsed into a pointer on resume. */
|
||||
bunkerUrl: string
|
||||
/** Fingerprint of the seed this binding was paired from (re-pair detection). */
|
||||
seedFingerprint: string
|
||||
/** Unix seconds when the pairing was redeemed. */
|
||||
pairedAt: number
|
||||
/**
|
||||
* LNbits transport relays from the pairing seed (aiolabs/bitspire#70). Lets a
|
||||
* resumed (seedless) boot reach the backend without env provisioning.
|
||||
* Undefined for bindings written before the seed carried them.
|
||||
* Atomic apply of an operator-published cassette config (aiolabs/lamassu-next#56).
|
||||
*
|
||||
* Caller has already verified the event signature and decrypted the
|
||||
* content. This function:
|
||||
*
|
||||
* 1. Rechecks replay-protection against `meta.lastKnownConfigCreatedAt`
|
||||
* (defense-in-depth — caller should have done this too).
|
||||
* 2. Validates the payload's `positions` key set is *exactly* the set of
|
||||
* positions currently in the `cassettes` table. The bay count is
|
||||
* hardware-determined and can't be added to or removed from via this
|
||||
* path; only the per-bay denomination and count are operator-mutable.
|
||||
* 3. Validates per-entry `denomination` is a positive int, `count` is a
|
||||
* non-negative int. **Duplicate denominations across positions are
|
||||
* intentionally permitted** — real machines load multiple cassettes
|
||||
* with the same denomination for cash-out throughput.
|
||||
* 4. In a single SQLite transaction: updates `cassettes` rows by position
|
||||
* (denomination + count both mutable per row) AND advances
|
||||
* `meta.lastKnownConfigCreatedAt` to `eventCreatedAt`.
|
||||
*
|
||||
* Mid-write crashes roll back cleanly; on restart the same event is
|
||||
* re-delivered by the relay and the watermark check drops it as already
|
||||
* consumed (or the watermark is pre-event because the tx rolled back,
|
||||
* and the apply runs again from scratch).
|
||||
*/
|
||||
relays?: string[]
|
||||
/** LNbits nostr-transport server pubkey (hex) from the seed (#70). */
|
||||
lnbitsServerPubkey?: string
|
||||
}
|
||||
|
||||
/** Read the persisted bunker binding, or null if the ATM is unpaired. */
|
||||
export function getBunkerBinding(): StoredBunkerBinding | null {
|
||||
export function applyOperatorCassettesConfig(
|
||||
payload: OperatorCassettesPayload,
|
||||
eventCreatedAt: number
|
||||
): ApplyResult {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const row = db
|
||||
.prepare(
|
||||
'SELECT client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey FROM bunker_binding WHERE id = 1'
|
||||
)
|
||||
.get() as
|
||||
| {
|
||||
client_secret_hex: string
|
||||
spire_pubkey: string
|
||||
bunker_url: string
|
||||
seed_fingerprint: string
|
||||
paired_at: number
|
||||
relays: string | null
|
||||
lnbits_server_pubkey: string | null
|
||||
}
|
||||
| undefined
|
||||
if (!row) return null
|
||||
|
||||
const watermark = getLastKnownConfigCreatedAt()
|
||||
if (eventCreatedAt <= watermark) {
|
||||
return {
|
||||
clientSecretHex: row.client_secret_hex,
|
||||
spirePubkey: row.spire_pubkey,
|
||||
bunkerUrl: row.bunker_url,
|
||||
seedFingerprint: row.seed_fingerprint,
|
||||
pairedAt: row.paired_at,
|
||||
relays: parseRelaysColumn(row.relays),
|
||||
lnbitsServerPubkey: row.lnbits_server_pubkey ?? undefined,
|
||||
applied: false,
|
||||
reason: `event.created_at (${eventCreatedAt}) <= lastKnownConfigCreatedAt (${watermark})`,
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode the JSON-array `relays` column, tolerating null/legacy/garbage. */
|
||||
function parseRelaysColumn(value: string | null): string[] | undefined {
|
||||
if (!value) return undefined
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (Array.isArray(parsed) && parsed.every((r) => typeof r === 'string')) {
|
||||
return parsed as string[]
|
||||
const currentRows = db
|
||||
.prepare('SELECT position FROM cassettes')
|
||||
.all() as { position: number }[]
|
||||
const currentPositions = new Set(currentRows.map((r) => r.position))
|
||||
const payloadPositions = new Set(Object.keys(payload.positions).map((k) => Number(k)))
|
||||
|
||||
if (currentPositions.size !== payloadPositions.size) {
|
||||
return {
|
||||
applied: false,
|
||||
reason: `position count mismatch: state.db has ${currentPositions.size}, payload has ${payloadPositions.size}`,
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return undefined
|
||||
for (const p of currentPositions) {
|
||||
if (!payloadPositions.has(p)) {
|
||||
return { applied: false, reason: `payload missing position ${p}` }
|
||||
}
|
||||
}
|
||||
for (const p of payloadPositions) {
|
||||
if (!currentPositions.has(p)) {
|
||||
return { applied: false, reason: `payload includes unknown position ${p}` }
|
||||
}
|
||||
}
|
||||
|
||||
/** Upsert the bunker binding after a successful (re-)pairing. */
|
||||
export function saveBunkerBinding(binding: StoredBunkerBinding): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
db.prepare(
|
||||
`INSERT INTO bunker_binding (id, client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey)
|
||||
VALUES (1, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
client_secret_hex = excluded.client_secret_hex,
|
||||
spire_pubkey = excluded.spire_pubkey,
|
||||
bunker_url = excluded.bunker_url,
|
||||
seed_fingerprint = excluded.seed_fingerprint,
|
||||
paired_at = excluded.paired_at,
|
||||
relays = excluded.relays,
|
||||
lnbits_server_pubkey = excluded.lnbits_server_pubkey`
|
||||
).run(
|
||||
binding.clientSecretHex,
|
||||
binding.spirePubkey,
|
||||
binding.bunkerUrl,
|
||||
binding.seedFingerprint,
|
||||
binding.pairedAt,
|
||||
binding.relays ? JSON.stringify(binding.relays) : null,
|
||||
binding.lnbitsServerPubkey ?? null
|
||||
for (const [posKey, entry] of Object.entries(payload.positions)) {
|
||||
if (!Number.isInteger(entry.denomination) || entry.denomination <= 0) {
|
||||
return {
|
||||
applied: false,
|
||||
reason: `denomination must be positive int (position ${posKey}, got ${entry.denomination})`,
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(entry.count) || entry.count < 0) {
|
||||
return {
|
||||
applied: false,
|
||||
reason: `count must be non-negative int (position ${posKey}, got ${entry.count})`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateCassette = db.prepare(
|
||||
'UPDATE cassettes SET denomination = ?, count = ? WHERE position = ?'
|
||||
)
|
||||
const setWatermark = db.prepare('UPDATE meta SET value = ? WHERE key = ?')
|
||||
|
||||
const run = db.transaction(() => {
|
||||
for (const [posKey, entry] of Object.entries(payload.positions)) {
|
||||
updateCassette.run(entry.denomination, entry.count, Number(posKey))
|
||||
}
|
||||
setWatermark.run(String(eventCreatedAt), 'lastKnownConfigCreatedAt')
|
||||
})
|
||||
|
||||
/** Drop the bunker binding (e.g. after an operator revoke → force re-pair). */
|
||||
export function clearBunkerBinding(): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
db.prepare('DELETE FROM bunker_binding WHERE id = 1').run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget the publish high-water mark. Called on a re-pair (new seed): the
|
||||
* next publish is then free to use the wall clock, which is what a fresh
|
||||
* operator relationship wants. The state itself is republished on startup
|
||||
* regardless, so the new operator always receives current counts.
|
||||
*/
|
||||
export function resetStatePublishWatermark(): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt')
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe operator-scoped CONFIG/TRUST state on a re-pair to a new operator/backend,
|
||||
* so stale policy from the previous pairing can't linger or silently reject the
|
||||
* new operator's config.
|
||||
*
|
||||
* Clears the fee config and resets BOTH replay watermarks to 0. The watermark
|
||||
* reset is the load-bearing part: without it, a new backend whose first config
|
||||
* event has a lower `created_at` than the old operator's last event is silently
|
||||
* dropped as a replay — the exact remnant trap where re-pairing a long-lived
|
||||
* install to a fresh backend appears to "work" but never picks up new config.
|
||||
*
|
||||
* Deliberately does NOT touch cassettes / cashbox / transactions: those track
|
||||
* PHYSICAL cash, which survives an operator handover. A full wipe (decommission
|
||||
* or a truly-fresh test) is the factory-reset path, not this.
|
||||
*/
|
||||
export function resetForRepair(): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const database = db
|
||||
database.transaction(() => {
|
||||
database.prepare('DELETE FROM fee_config').run()
|
||||
const setWatermark = database.prepare('UPDATE meta SET value = ? WHERE key = ?')
|
||||
setWatermark.run('0', 'lastKnownFeeConfigCreatedAt')
|
||||
setWatermark.run('0', 'lastKnownConfigCreatedAt')
|
||||
})()
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of applying an operator-authored absolute config. Still the right
|
||||
* shape for fee config, where the operator is the only writer of the value
|
||||
* and a later event simply supersedes an earlier one. Cassette counts left
|
||||
* this model in ADR-004 precisely because they had two writers.
|
||||
*/
|
||||
export type ApplyResult = { applied: true } | { applied: false; reason: string }
|
||||
|
||||
/** One operator-authored operation, as it arrives on the wire. */
|
||||
export type CassetteOp = {
|
||||
id: string
|
||||
at: number
|
||||
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
|
||||
position: number
|
||||
bills?: number
|
||||
count?: number
|
||||
denomination?: number
|
||||
}
|
||||
|
||||
export type ApplyOpsResult = {
|
||||
/** Ids applied by this call. Empty when every op was already on file. */
|
||||
applied: string[]
|
||||
/** Ids rejected, with why. These stay unapplied and unrecorded. */
|
||||
rejected: { id: string; reason: string }[]
|
||||
}
|
||||
|
||||
const CASSETTE_OP_TYPES = new Set(['refill', 'empty', 'recount', 'set_denomination'])
|
||||
|
||||
/**
|
||||
* Validate one operation in isolation. Returns null when it is well-formed.
|
||||
*
|
||||
* Shape errors and unknown positions are treated the same way by the caller:
|
||||
* the op is neither applied nor recorded, so it stays pending on the
|
||||
* operator's dashboard. That is the honest outcome — it did not happen — and
|
||||
* it beats recording it as applied to stop the noise, which would tell the
|
||||
* operator their refill landed when the notes are unaccounted for.
|
||||
*/
|
||||
function validateCassetteOp(op: CassetteOp, knownPositions: Set<number>): string | null {
|
||||
if (typeof op.id !== 'string' || op.id.length === 0) return 'missing id'
|
||||
if (!CASSETTE_OP_TYPES.has(op.type)) return `unknown type ${String(op.type)}`
|
||||
if (!Number.isInteger(op.position)) return `position must be an integer (got ${op.position})`
|
||||
if (!knownPositions.has(op.position)) return `unknown position ${op.position}`
|
||||
if (!Number.isFinite(op.at)) return 'missing at'
|
||||
|
||||
if (op.type === 'refill') {
|
||||
if (!Number.isInteger(op.bills) || (op.bills as number) <= 0) {
|
||||
return `refill needs a positive integer bills (got ${op.bills})`
|
||||
}
|
||||
}
|
||||
if (op.type === 'recount') {
|
||||
if (!Number.isInteger(op.count) || (op.count as number) < 0) {
|
||||
return `recount needs a non-negative integer count (got ${op.count})`
|
||||
}
|
||||
}
|
||||
if (op.type === 'set_denomination') {
|
||||
if (!Number.isInteger(op.denomination) || (op.denomination as number) <= 0) {
|
||||
return `set_denomination needs a positive integer denomination (got ${op.denomination})`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an operator's cassette operations, skipping any already on file.
|
||||
*
|
||||
* This replaces applying absolute counts. The operator authors what it DID —
|
||||
* a refill in notes added, an empty, a recount, a denomination change — and
|
||||
* this machine, which holds the physical notes, keeps the running total.
|
||||
* Nobody but this process writes a count any more, so there is no second
|
||||
* writer to lose a race to.
|
||||
*
|
||||
* Deltas are not idempotent and addressable events ARE re-delivered on every
|
||||
* relay reconnect, so idempotency is carried explicitly: the operator mints an
|
||||
* id per operation, `cassette_ops` records the ones applied, and a repeat is a
|
||||
* no-op. That is also why there is no `created_at` watermark here any more.
|
||||
* Under absolute counts the watermark was the only replay defence; with
|
||||
* per-op ids it is strictly weaker than the dedup and would do active harm,
|
||||
* because an event that arrives out of order may still carry an operation this
|
||||
* machine has never seen.
|
||||
*
|
||||
* Applied oldest-first by `at`, ties broken by id so two operations stamped in
|
||||
* the same second still order the same way on every machine. Ordering matters
|
||||
* because a recount followed by a refill is not the same as the reverse.
|
||||
*
|
||||
* The whole batch runs in one SQLite transaction with the sequence bump, so a
|
||||
* crash mid-apply rolls back to a coherent count and the next publish re-offers
|
||||
* every op in the window.
|
||||
*/
|
||||
export function applyOperatorCassetteOps(ops: CassetteOp[]): ApplyOpsResult {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const database = db
|
||||
const result: ApplyOpsResult = { applied: [], rejected: [] }
|
||||
if (ops.length === 0) return result
|
||||
|
||||
const knownPositions = new Set(
|
||||
(database.prepare('SELECT position FROM cassettes').all() as { position: number }[]).map(
|
||||
(r) => r.position
|
||||
)
|
||||
)
|
||||
const seen = database.prepare('SELECT 1 FROM cassette_ops WHERE id = ?')
|
||||
|
||||
const pending: CassetteOp[] = []
|
||||
for (const op of ops) {
|
||||
if (op && typeof op.id === 'string' && seen.get(op.id)) continue
|
||||
const reason = validateCassetteOp(op, knownPositions)
|
||||
if (reason) {
|
||||
result.rejected.push({ id: op?.id ?? '<no id>', reason })
|
||||
continue
|
||||
}
|
||||
pending.push(op)
|
||||
}
|
||||
if (pending.length === 0) return result
|
||||
|
||||
pending.sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
|
||||
const addBills = database.prepare(
|
||||
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?'
|
||||
)
|
||||
const setCount = database.prepare('UPDATE cassettes SET count = ? WHERE position = ?')
|
||||
const setDenomination = database.prepare(
|
||||
'UPDATE cassettes SET denomination = ? WHERE position = ?'
|
||||
)
|
||||
const recordOp = database.prepare(
|
||||
'INSERT INTO cassette_ops (id, position, op_type, bills, count, denomination, op_at, applied_at) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
const upsertMeta = database.prepare(
|
||||
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
)
|
||||
|
||||
const appliedAt = Math.floor(Date.now() / 1000)
|
||||
let sawRecount = false
|
||||
|
||||
database.transaction(() => {
|
||||
for (const op of pending) {
|
||||
if (op.type === 'refill') addBills.run(op.bills, op.position)
|
||||
else if (op.type === 'empty') setCount.run(0, op.position)
|
||||
else if (op.type === 'recount') {
|
||||
setCount.run(op.count, op.position)
|
||||
sawRecount = true
|
||||
} else setDenomination.run(op.denomination, op.position)
|
||||
|
||||
recordOp.run(
|
||||
op.id,
|
||||
op.position,
|
||||
op.type,
|
||||
op.bills ?? null,
|
||||
op.count ?? null,
|
||||
op.denomination ?? null,
|
||||
Math.floor(op.at),
|
||||
appliedAt
|
||||
)
|
||||
result.applied.push(op.id)
|
||||
}
|
||||
bumpCassetteStateSeq()
|
||||
// A recount is an operator opening the bay and counting it, which is
|
||||
// exactly what resolves an unverified count. Nothing else does: a refill
|
||||
// adds to a number still known to be wrong.
|
||||
if (sawRecount) {
|
||||
upsertMeta.run('countsUncertainSince', '')
|
||||
// ADR-005 §5: a recount is an operator at the open machine — the one
|
||||
// gesture that also releases a cash-out hold.
|
||||
upsertMeta.run('cashOutHeld', '')
|
||||
}
|
||||
})()
|
||||
|
||||
run()
|
||||
console.log(
|
||||
`[StateStore] Applied ${result.applied.length} cassette op(s)` +
|
||||
(result.rejected.length ? `, rejected ${result.rejected.length}` : '')
|
||||
`[StateStore] Applied operator cassettes config @ created_at=${eventCreatedAt} (${Object.keys(payload.positions).length} positions)`
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* The ids most recently applied, newest first — the acknowledgement leg of
|
||||
* the protocol.
|
||||
*
|
||||
* An addressable event gives its publisher no failure signal at all: the relay
|
||||
* returns OK for an event it then discards, and a losing writer is never told.
|
||||
* Echoing the ids back in this machine's own state document is the only way
|
||||
* the operator can distinguish an operation that landed from one that was
|
||||
* merely sent.
|
||||
*/
|
||||
export function getAppliedOpIds(limit = 50): string[] {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const rows = db
|
||||
.prepare('SELECT id FROM cassette_ops ORDER BY applied_at DESC, rowid DESC LIMIT ?')
|
||||
.all(limit) as { id: string }[]
|
||||
return rows.map((r) => r.id)
|
||||
return { applied: true }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1130,7 +567,10 @@ export interface FeeConfigPayload {
|
|||
*/
|
||||
const FEE_CAP_PER_DIRECTION = 0.15
|
||||
|
||||
export function applyFeeConfig(payload: FeeConfigPayload, eventCreatedAt: number): ApplyResult {
|
||||
export function applyFeeConfig(
|
||||
payload: FeeConfigPayload,
|
||||
eventCreatedAt: number
|
||||
): ApplyResult {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
|
||||
const watermark = getLastKnownFeeConfigCreatedAt()
|
||||
|
|
@ -1223,7 +663,6 @@ export function setCassettes(
|
|||
const row = rows[i]!
|
||||
upsert.run(row.position ?? i + 1, row.denomination, row.count)
|
||||
}
|
||||
bumpCassetteStateSeq()
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -1238,14 +677,11 @@ export function setCassettes(
|
|||
*/
|
||||
export function updateCassetteCountByPosition(position: number, delta: number): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const database = db
|
||||
|
||||
database.transaction(() => {
|
||||
database
|
||||
.prepare('UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?')
|
||||
.run(delta, position)
|
||||
bumpCassetteStateSeq()
|
||||
})()
|
||||
db.prepare('UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?').run(
|
||||
delta,
|
||||
position
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1258,15 +694,10 @@ export function getInventory(): Record<number, number> {
|
|||
const rows = loadCassettes()
|
||||
const inv: Record<number, number> = {}
|
||||
for (const row of rows) {
|
||||
// Zero-count bays are KEPT. Dropping them made a drained machine
|
||||
// indistinguishable from an unconfigured one, and every caller reads an
|
||||
// empty map as "I don't know, ask the hardware" — so the last non-empty
|
||||
// snapshot stuck and the availability beacon went on advertising bills
|
||||
// that had already been dispensed. An empty map now means exactly one
|
||||
// thing: no cassettes are configured. Consumers already filter for
|
||||
// `> 0` before offering a denomination (CashOutView, machine.ts).
|
||||
if (row.count > 0) {
|
||||
inv[row.denomination] = (inv[row.denomination] ?? 0) + row.count
|
||||
}
|
||||
}
|
||||
return inv
|
||||
}
|
||||
|
||||
|
|
@ -1342,11 +773,6 @@ interface TransactionInput {
|
|||
rejected: number
|
||||
}[]
|
||||
error?: string | null
|
||||
/**
|
||||
* ADR-005 §2: dispense outcome to queue for spirekeeper. Inserted in the
|
||||
* same transaction as the row so a crash between them cannot lose it.
|
||||
*/
|
||||
report?: DispenseReportBody
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1368,20 +794,11 @@ export function recordTransaction(tx: TransactionInput): void {
|
|||
const insertBill = db.prepare(
|
||||
'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)'
|
||||
)
|
||||
// Outbox row (ADR-005 §2). REPLACE: a re-record of the same txid (should not
|
||||
// happen, but a crash-replay could) refreshes the payload and resets the
|
||||
// delivery state rather than failing the whole transaction.
|
||||
const insertReport = db.prepare(
|
||||
'INSERT OR REPLACE INTO dispense_reports (txid, payload, created_at, attempts, last_attempt_at, last_error, acked_at) VALUES (?, ?, ?, 0, NULL, NULL, NULL)'
|
||||
)
|
||||
const insertCassetteBill = db.prepare(
|
||||
'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
const updateCassetteByPosition = db.prepare(
|
||||
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?'
|
||||
)
|
||||
const selectBaysByDenom = db.prepare(
|
||||
'SELECT position, count FROM cassettes WHERE denomination = ? ORDER BY position'
|
||||
const updateCassette = db.prepare(
|
||||
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE denomination = ?'
|
||||
)
|
||||
const updateCashboxStmt = db.prepare(
|
||||
'UPDATE cashbox SET total_bills = total_bills + ?, total_fiat_cents = total_fiat_cents + ? WHERE id = 1'
|
||||
|
|
@ -1406,10 +823,6 @@ export function recordTransaction(tx: TransactionInput): void {
|
|||
insertBill.run(t.txid, bill.denomination, bill.count)
|
||||
}
|
||||
|
||||
if (t.report) {
|
||||
insertReport.run(t.txid, JSON.stringify(t.report), Date.now())
|
||||
}
|
||||
|
||||
// Insert per-cassette detail when available
|
||||
if (t.cassettes) {
|
||||
for (const c of t.cassettes) {
|
||||
|
|
@ -1425,49 +838,21 @@ export function recordTransaction(tx: TransactionInput): void {
|
|||
}
|
||||
}
|
||||
|
||||
// Any dispense empties bays, whoever asked for it. `manual_dispense`
|
||||
// (operator remediation, via the command poller or a kind-21003 command)
|
||||
// used to fall outside this branch: HAL decremented its in-memory bays but
|
||||
// the rows here did not move, and on the next boot HAL re-seeds from these
|
||||
// rows — so the machine came back believing it still held bills a customer
|
||||
// had already been handed (#76). A remediation against a partly-dispensed
|
||||
// original decrements again on purpose: the original only ever debited what
|
||||
// physically left, and this is a second lot of bills leaving.
|
||||
if (t.type === 'cash_out' || t.type === 'manual_dispense') {
|
||||
// Decrement cassettes by ACTUALLY dispensed count (not requested).
|
||||
// Position is the addressable unit (v9): duplicate denominations
|
||||
// across bays are legal, so a denomination-keyed UPDATE would
|
||||
// decrement every matching bay.
|
||||
if (t.type === 'cash_out') {
|
||||
// Decrement cassettes by ACTUALLY dispensed count (not requested)
|
||||
if (t.cassettes) {
|
||||
for (const c of t.cassettes) {
|
||||
if (c.dispensed > 0) {
|
||||
updateCassetteByPosition.run(-c.dispensed, c.position)
|
||||
updateCassette.run(-c.dispensed, c.denomination)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: per-denomination bill counts (mocks without per-bay
|
||||
// results). Drain matching bays greedily in position order —
|
||||
// the dispenser's own fill order.
|
||||
// Fallback: use bill counts (backward compat for mocks without cassette data)
|
||||
for (const bill of t.bills) {
|
||||
let remaining = bill.count
|
||||
const bays = selectBaysByDenom.all(bill.denomination) as {
|
||||
position: number
|
||||
count: number
|
||||
}[]
|
||||
for (const bay of bays) {
|
||||
if (remaining <= 0) break
|
||||
const take = Math.min(remaining, bay.count)
|
||||
if (take <= 0) continue
|
||||
updateCassetteByPosition.run(-take, bay.position)
|
||||
remaining -= take
|
||||
updateCassette.run(-bill.count, bill.denomination)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The counts moved, so the sequence must move with them, inside this
|
||||
// same transaction. It rides in the state document as the operator's
|
||||
// way to reject a regression without trusting either clock.
|
||||
bumpCassetteStateSeq()
|
||||
}
|
||||
|
||||
if (t.type === 'cash_in') {
|
||||
// Bills inserted by customer go into cashbox
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||
<!--
|
||||
Content Security Policy:
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss: http: https:; img-src 'self' data: blob:; font-src 'self'; frame-src 'none'; object-src 'none'"
|
||||
/>
|
||||
<title>bitSpire ATM</title>
|
||||
<title>Lamassu ATM</title>
|
||||
<style>
|
||||
/* Prevent text selection and context menu on kiosk */
|
||||
* {
|
||||
|
|
@ -33,17 +33,12 @@
|
|||
padding: 0;
|
||||
background: #000;
|
||||
}
|
||||
/* Kiosk-only: lock overflow. The cursor is NOT hidden here — that is
|
||||
src/style.css's `.kiosk` rule, which main.ts applies at runtime unless
|
||||
VITE_DEMO_TAG is set. This block can't make that distinction (static
|
||||
HTML, and the CSP forbids an inline script to read the env), so a
|
||||
`cursor: none` here would also blank the pointer on the public web
|
||||
demo, where people drive the kiosk with a mouse. One owner for cursor
|
||||
hiding, and it is the one that knows whether this is a real machine. */
|
||||
/* Kiosk-only: lock overflow and hide cursor */
|
||||
@media (min-width: 1024px) {
|
||||
html,
|
||||
body {
|
||||
overflow: hidden;
|
||||
cursor: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -14,8 +14,7 @@
|
|||
"dev": "concurrently -n vite,electron \"vite\" \"pnpm run electron:dev\"",
|
||||
"dev:vite": "vite",
|
||||
"electron:dev": "tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && electron dist-electron/main.js",
|
||||
"build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && npx esbuild electron/fund-atm.ts --bundle --platform=node --format=cjs --external:better-sqlite3 --outfile=dist-electron/fund-atm.bundle.cjs",
|
||||
"build:web": "vite build",
|
||||
"build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && npx esbuild electron/fund-atm.ts --bundle --platform=node --format=cjs --outfile=dist-electron/fund-atm.bundle.cjs",
|
||||
"build:electron": "pnpm build && electron-builder",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
|
|
@ -36,10 +35,8 @@
|
|||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.563.0",
|
||||
"marked": "^17.0.5",
|
||||
"nfc-pcsc": "^0.8.1",
|
||||
"nostr-tools": "^2.10.0",
|
||||
"pinia": "^2.2.0",
|
||||
"qr": "^0.6.0",
|
||||
"qrcode.vue": "^3.6.0",
|
||||
"reka-ui": "^2.7.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
|
@ -50,13 +47,11 @@
|
|||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/better-sqlite3": "^7.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"concurrently": "^9.0.0",
|
||||
"electron": "^33.0.0",
|
||||
"electron-builder": "^25.0.0",
|
||||
"esbuild": "^0.27.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.7.0",
|
||||
|
|
|
|||
|
|
@ -1,39 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { onMounted, onUnmounted, ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useSessionSecurity } from '@/composables/useSessionSecurity'
|
||||
import { setBranding } from '@/composables/useBranding'
|
||||
import { classifyInitError } from '@/services/init-error'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import PairingWizard from '@/components/PairingWizard.vue'
|
||||
import LockedView from '@/views/LockedView.vue'
|
||||
import ColorModeToggle from '@/components/ColorModeToggle.vue'
|
||||
import { Sun, Moon } from 'lucide-vue-next'
|
||||
|
||||
const atmStore = useAtmStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { current: currentTheme, themes, colorMode } = useTheme()
|
||||
|
||||
// ADR-003 session security: idle-inactivity re-lock (resets on touch) + an
|
||||
// absolute hard session cap. Enforced here at the always-mounted shell so it
|
||||
// spans the whole unlocked session, not just the idle view.
|
||||
useSessionSecurity()
|
||||
|
||||
// When the machine re-locks after a transaction (access gate enabled), the
|
||||
// router is still on /cash-in or /cash-out under the LockedView overlay. Reset
|
||||
// it to home so that when the gate reopens to `idle`, IdleView shows — not the
|
||||
// stale transaction view (e.g. a completed cash-out's collect screen). Runs
|
||||
// while locked, so the router-view is hidden; no flicker. The disabled-gate path
|
||||
// (never dwells in `locked`) still returns home via each view's isIdle watch.
|
||||
watch(
|
||||
() => atmStore.isLocked,
|
||||
(locked) => {
|
||||
if (locked && route.path !== '/') void router.push('/')
|
||||
}
|
||||
)
|
||||
const debugExpanded = ref(false)
|
||||
const isSupport = computed(() => route.path === '/support')
|
||||
// Network detected dynamically from Lightning invoice prefix
|
||||
|
|
@ -43,54 +20,6 @@ function formatSats(sats: number): string {
|
|||
return sats.toLocaleString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintenance-screen copy keyed by the `initError` sentinel. Falls back to a
|
||||
* generic out-of-service message (the raw error text shows under debug only).
|
||||
*/
|
||||
const MAINTENANCE_SCREENS: Record<string, { title: string; message: string }> = {
|
||||
maintenance: {
|
||||
title: 'Under Service',
|
||||
message: 'This machine is currently being serviced. We will be back shortly.',
|
||||
},
|
||||
'awaiting-fees': {
|
||||
title: 'Awaiting Configuration',
|
||||
message:
|
||||
'Awaiting fee configuration from operator. Contact operator to publish initial fee config.',
|
||||
},
|
||||
unpaired: {
|
||||
title: 'Pairing Required',
|
||||
message:
|
||||
'This machine needs to be re-paired by the operator before it can accept transactions.',
|
||||
},
|
||||
'signer-unreachable': {
|
||||
title: 'Signer Unreachable',
|
||||
message: 'Cannot reach the signing service right now. This usually resolves shortly.',
|
||||
},
|
||||
}
|
||||
|
||||
const GENERIC_SCREEN = {
|
||||
title: 'ATM Unavailable',
|
||||
message:
|
||||
'This machine is temporarily out of service. Please try again later or use another machine.',
|
||||
}
|
||||
|
||||
const maintenanceScreen = computed(() =>
|
||||
atmStore.initError ? (MAINTENANCE_SCREENS[atmStore.initError] ?? GENERIC_SCREEN) : GENERIC_SCREEN
|
||||
)
|
||||
|
||||
/** True when the screen is a known sentinel (hide the raw debug error line). */
|
||||
const isKnownMaintenanceScreen = computed(
|
||||
() => !!atmStore.initError && atmStore.initError in MAINTENANCE_SCREENS
|
||||
)
|
||||
|
||||
/**
|
||||
* `unpaired` is interactive, not a dead-end: render the QR-pairing wizard so
|
||||
* the operator can scan a spire-seed on-machine (aiolabs/bitspire#52). The
|
||||
* wizard only works under Electron (needs the seed-persist + relaunch bridge);
|
||||
* in browser dev it falls back to the static card.
|
||||
*/
|
||||
const showPairingWizard = computed(() => atmStore.initError === 'unpaired' && isElectron)
|
||||
|
||||
const formattedBtcPrice = computed(() => {
|
||||
if (atmStore.btcPrice === null) return null
|
||||
const local = `${atmStore.fiatCode}/BTC: ${atmStore.fiatSymbol}${Math.round(atmStore.btcPrice).toLocaleString()}`
|
||||
|
|
@ -122,21 +51,18 @@ onMounted(async () => {
|
|||
atmStore.initError = 'maintenance'
|
||||
// Publish maintenance beacon — minimal Nostr connection only (no Lightning.Pub)
|
||||
try {
|
||||
const { NostrClient, createSignedEvent } = await import('@bitSpire/nostr-client')
|
||||
const { resolveSigner } = await import('@/services/signer-resolver')
|
||||
// Best-effort: resolve a signer (bunker resume / pairing, or dev nsec).
|
||||
// If the ATM isn't paired yet, skip the beacon rather than fail the screen.
|
||||
const resolved = await resolveSigner({ allowEphemeral: true }).catch(() => null)
|
||||
const signer = resolved?.signer ?? null
|
||||
// Same env → pairing-seed precedence as lightning.ts: on a blank-.env
|
||||
// seed-driven machine the relay comes from the pairing transport, not env.
|
||||
const relayUrl =
|
||||
config?.relayUrl || import.meta.env.VITE_RELAY_URL || resolved?.transport?.relays?.[0]
|
||||
if (signer && relayUrl) {
|
||||
const client = new NostrClient({ relays: [{ url: relayUrl }], signer })
|
||||
const { NostrClient, loadIdentityFromHex, createSignedEvent } = await import(
|
||||
'@bitSpire/nostr-client'
|
||||
)
|
||||
const secrets = isElectron ? await window.electronAPI?.getAtmSecrets() : null
|
||||
const privKey = secrets?.atmPrivateKey || import.meta.env.VITE_ATM_PRIVATE_KEY
|
||||
const relayUrl = config?.relayUrl || import.meta.env.VITE_RELAY_URL
|
||||
if (privKey && relayUrl) {
|
||||
const identity = loadIdentityFromHex(privKey)
|
||||
const client = new NostrClient({ relays: [{ url: relayUrl }], identity })
|
||||
await client.connect()
|
||||
const publishBeacon = async () => {
|
||||
const event = await createSignedEvent(signer, {
|
||||
const publishBeacon = () => {
|
||||
const event = createSignedEvent(identity, {
|
||||
kind: 30078,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['d', 'atm-availability']],
|
||||
|
|
@ -151,8 +77,8 @@ onMounted(async () => {
|
|||
})
|
||||
client.publish(event).catch(() => {})
|
||||
}
|
||||
void publishBeacon()
|
||||
setInterval(() => void publishBeacon(), 5 * 60 * 1000)
|
||||
publishBeacon()
|
||||
setInterval(publishBeacon, 5 * 60 * 1000)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[App] Failed to start maintenance beacon:', e)
|
||||
|
|
@ -171,77 +97,14 @@ onMounted(async () => {
|
|||
atmStore.startPricePolling()
|
||||
} catch (error) {
|
||||
console.error('[App] Initialization failed:', error)
|
||||
atmStore.initError = classifyInitError(error)
|
||||
atmStore.initError = error instanceof Error ? error.message : 'Initialization failed'
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
atmStore.stopPricePolling()
|
||||
stopRecoveryWatch()
|
||||
})
|
||||
|
||||
// ── Connectivity recovery (ADR-002 amendment 2026-08-04) ──────────────────
|
||||
// A connectivity-type init failure lands on "ATM Unavailable" and, without
|
||||
// this, stays there forever (init is one-shot; the nostr reconnect only helps
|
||||
// AFTER a first successful connect). We recover by reloading the renderer —
|
||||
// which re-runs this whole init from a clean JS context while the main process
|
||||
// keeps HAL (see main.ts app:recover). Not for the operator/self-clearing
|
||||
// states: `unpaired` shows the pairing wizard, `awaiting-fees` clears itself on
|
||||
// the operator's fee-config event, `maintenance` is operator-set.
|
||||
const NON_RECOVERABLE = new Set(['maintenance', 'awaiting-fees', 'unpaired'])
|
||||
const isRecoverable = computed(
|
||||
() => !!atmStore.initError && !NON_RECOVERABLE.has(atmStore.initError)
|
||||
)
|
||||
const recovering = ref(false)
|
||||
const RECOVERY_RETRY_MS = 45_000
|
||||
let recoveryTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function triggerRecovery() {
|
||||
if (recovering.value) return
|
||||
recovering.value = true
|
||||
console.log('[App] Attempting connectivity recovery (renderer reload)')
|
||||
if (window.electronAPI?.recoverApp) {
|
||||
void window.electronAPI.recoverApp() // main reloads renderer → fresh init
|
||||
} else {
|
||||
location.reload() // browser-dev fallback
|
||||
}
|
||||
}
|
||||
|
||||
function onOnline() {
|
||||
// Network came back — recover immediately rather than waiting for the timer.
|
||||
triggerRecovery()
|
||||
}
|
||||
|
||||
function startRecoveryWatch() {
|
||||
stopRecoveryWatch()
|
||||
window.addEventListener('online', onOnline)
|
||||
// Safety net for the online-but-relay-unreachable case (navigator.onLine only
|
||||
// reflects a local route, not relay reachability).
|
||||
recoveryTimer = setInterval(triggerRecovery, RECOVERY_RETRY_MS)
|
||||
}
|
||||
|
||||
function stopRecoveryWatch() {
|
||||
window.removeEventListener('online', onOnline)
|
||||
if (recoveryTimer !== null) {
|
||||
clearInterval(recoveryTimer)
|
||||
recoveryTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Operator-facing "Retry" button on the maintenance screen. */
|
||||
function retryNow() {
|
||||
triggerRecovery()
|
||||
}
|
||||
|
||||
watch(
|
||||
isRecoverable,
|
||||
(recoverable) => {
|
||||
if (recoverable) startRecoveryWatch()
|
||||
else stopRecoveryWatch()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function toggleLiveServices() {
|
||||
if (atmStore.useLiveServices) {
|
||||
// Switch to mock
|
||||
|
|
@ -257,12 +120,9 @@ function toggleLiveServices() {
|
|||
<div
|
||||
class="flex min-h-dvh lg:h-dvh w-screen flex-col overflow-y-auto lg:overflow-hidden bg-background font-sans text-foreground"
|
||||
>
|
||||
<!-- Unpaired: interactive QR-pairing wizard (aiolabs/bitspire#52) -->
|
||||
<PairingWizard v-if="showPairingWizard" />
|
||||
|
||||
<!-- Maintenance screen: shown when initialization fails in production -->
|
||||
<div
|
||||
v-else-if="atmStore.initError"
|
||||
v-if="atmStore.initError"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-6 p-8"
|
||||
>
|
||||
<svg
|
||||
|
|
@ -282,37 +142,35 @@ function toggleLiveServices() {
|
|||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
<h1 class="text-2xl lg:text-[3.5rem] font-bold">
|
||||
{{ maintenanceScreen.title }}
|
||||
{{
|
||||
atmStore.initError === 'maintenance'
|
||||
? 'Under Service'
|
||||
: atmStore.initError === 'awaiting-fees'
|
||||
? 'Awaiting Configuration'
|
||||
: 'ATM Unavailable'
|
||||
}}
|
||||
</h1>
|
||||
<p class="max-w-md text-center text-base lg:text-2xl text-muted-foreground">
|
||||
{{ maintenanceScreen.message }}
|
||||
{{
|
||||
atmStore.initError === 'maintenance'
|
||||
? 'This machine is currently being serviced. We will be back shortly.'
|
||||
: atmStore.initError === 'awaiting-fees'
|
||||
? 'Awaiting fee configuration from operator. Contact operator to publish initial fee config.'
|
||||
: 'This machine is temporarily out of service. Please try again later or use another machine.'
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
v-if="atmStore.debugMode && !isKnownMaintenanceScreen"
|
||||
v-if="
|
||||
atmStore.debugMode &&
|
||||
atmStore.initError !== 'maintenance' &&
|
||||
atmStore.initError !== 'awaiting-fees'
|
||||
"
|
||||
class="max-w-lg text-center font-mono text-sm text-destructive"
|
||||
>
|
||||
{{ atmStore.initError }}
|
||||
</p>
|
||||
|
||||
<!-- Manual recovery for a connectivity failure; auto-recovery also runs
|
||||
in the background (online event + backoff). Not shown for operator/
|
||||
self-clearing states (maintenance / awaiting-fees / unpaired). -->
|
||||
<Button
|
||||
v-if="isRecoverable"
|
||||
size="kiosk"
|
||||
:disabled="recovering"
|
||||
class="mt-4"
|
||||
@click="retryNow"
|
||||
>
|
||||
{{ recovering ? 'Retrying…' : 'Retry' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Access gate (ADR-003): shown when the machine is healthy but locked,
|
||||
below the init/maintenance gates above. Never renders when access
|
||||
control is disabled (the machine never dwells in `locked`). -->
|
||||
<LockedView v-else-if="atmStore.isLocked" />
|
||||
|
||||
<template v-else>
|
||||
<router-view />
|
||||
|
||||
|
|
@ -364,10 +222,16 @@ function toggleLiveServices() {
|
|||
</div>
|
||||
|
||||
<!-- Light/dark toggle (production only — debug panel has this in dev) -->
|
||||
<ColorModeToggle
|
||||
<Button
|
||||
v-if="!atmStore.allowMockFallback"
|
||||
class="fixed bottom-3 right-3 z-50 lg:bottom-6 lg:right-6"
|
||||
/>
|
||||
variant="outline"
|
||||
class="fixed bottom-3 right-3 lg:bottom-6 lg:right-6 z-50 h-10 px-3 py-1 text-sm rounded-lg lg:h-[7vh] lg:min-h-[70px] lg:px-8 lg:py-3 lg:text-2xl lg:rounded-xl gap-2 lg:gap-3"
|
||||
@click="colorMode = colorMode === 'dark' ? 'light' : 'dark'"
|
||||
>
|
||||
<Sun v-if="colorMode === 'dark'" class="w-5 h-5 lg:w-7 lg:h-7" />
|
||||
<Moon v-else class="w-5 h-5 lg:w-7 lg:h-7" />
|
||||
{{ colorMode === 'dark' ? 'Light' : 'Dark' }}
|
||||
</Button>
|
||||
|
||||
<!-- Debug overlay (dev only) -->
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* The Bolt Card loaded for this session (ADR-003 tap-to-enter): card label,
|
||||
* and the card wallet's balance HIDDEN BY DEFAULT behind an eye toggle — a
|
||||
* kiosk in a public space must not show a stranger's balance unasked. The
|
||||
* revealed line mirrors the LNbits wallet page: sats, then the fiat
|
||||
* equivalent in the wallet's own currency (Intl currency formatting), falling
|
||||
* back to the ATM's fiat at its display rate when the card server priced
|
||||
* nothing. Reveal state lives in the store and resets on re-lock.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Nfc, Eye, EyeOff } from 'lucide-vue-next'
|
||||
|
||||
const atmStore = useAtmStore()
|
||||
const card = computed(() => atmStore.loadedBoltCard)
|
||||
|
||||
const label = computed(() => {
|
||||
const c = card.value
|
||||
if (!c) return ''
|
||||
return c.cardName
|
||||
? `${c.cardName} · ••${c.externalId.slice(-4)}`
|
||||
: `Card ••${c.externalId.slice(-4)}`
|
||||
})
|
||||
const sats = computed(() =>
|
||||
card.value ? new Intl.NumberFormat().format(card.value.balanceSats) : ''
|
||||
)
|
||||
const fiat = computed(() => {
|
||||
const f = atmStore.loadedCardFiat
|
||||
if (!f) return null
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, { style: 'currency', currency: f.currency }).format(
|
||||
f.amount
|
||||
)
|
||||
} catch {
|
||||
return `${f.amount.toFixed(2)} ${f.currency}`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="card"
|
||||
class="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-2 text-left lg:gap-4 lg:px-6 lg:py-3"
|
||||
>
|
||||
<Nfc class="size-6 shrink-0 text-primary lg:size-8" />
|
||||
<div class="flex min-w-0 flex-col leading-tight">
|
||||
<span class="truncate text-xs uppercase tracking-wide text-muted-foreground lg:text-sm">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="atmStore.cardBalanceRevealed"
|
||||
class="text-base font-semibold text-foreground lg:text-2xl"
|
||||
>
|
||||
{{ sats }} sats
|
||||
<span v-if="fiat" class="ml-2 font-normal text-muted-foreground">≈ {{ fiat }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-base font-semibold tracking-widest text-muted-foreground lg:text-2xl"
|
||||
aria-label="Balance hidden"
|
||||
>
|
||||
••••••
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="ml-auto h-10 w-10 shrink-0 rounded-full lg:h-14 lg:w-14"
|
||||
:aria-label="atmStore.cardBalanceRevealed ? 'Hide balance' : 'Show balance'"
|
||||
@click="atmStore.toggleCardBalance()"
|
||||
>
|
||||
<EyeOff v-if="atmStore.cardBalanceRevealed" class="size-5 lg:size-7" />
|
||||
<Eye v-else class="size-5 lg:size-7" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Light/dark toggle — the single reusable control for switching color mode.
|
||||
*
|
||||
* Kiosk-sized by default (large touch target for a public display). colorMode
|
||||
* is global + persisted (toggles `.dark` on <html>, and dark mode pulls
|
||||
* branding.json's dark palette + logo-dark.png), so this stays in sync
|
||||
* wherever it's used. Position it via a fallthrough `class` on the consumer,
|
||||
* e.g. `<ColorModeToggle class="fixed bottom-6 right-6" />`.
|
||||
*/
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Sun, Moon } from 'lucide-vue-next'
|
||||
|
||||
const { colorMode } = useTheme()
|
||||
|
||||
function toggle() {
|
||||
colorMode.value = colorMode.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-10 gap-2 rounded-lg px-3 py-1 text-sm lg:h-[7vh] lg:min-h-[70px] lg:gap-3 lg:rounded-xl lg:px-8 lg:py-3 lg:text-2xl"
|
||||
:aria-label="colorMode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||
@click="toggle"
|
||||
>
|
||||
<Sun v-if="colorMode === 'dark'" class="h-5 w-5 lg:h-7 lg:w-7" />
|
||||
<Moon v-else class="h-5 w-5 lg:h-7 lg:w-7" />
|
||||
{{ colorMode === 'dark' ? 'Light' : 'Dark' }}
|
||||
</Button>
|
||||
</template>
|
||||
|
|
@ -1,287 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* QR-pairing wizard (aiolabs/bitspire#52).
|
||||
*
|
||||
* Shown in place of the "Pairing Required" maintenance screen when the machine
|
||||
* is unpaired. The operator displays the spire-seed QR (minted by spirekeeper)
|
||||
* to the machine's camera; we decode it, persist it as VITE_SPIRE_SEED, and
|
||||
* relaunch so the normal boot path performs the bunker pairing.
|
||||
*
|
||||
* Capture is abstracted behind PairingSource, so NFC (or a HAL scanner) can be
|
||||
* offered later without changing this view.
|
||||
*/
|
||||
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'
|
||||
import {
|
||||
availablePairingSources,
|
||||
ingestScannedSeed,
|
||||
parseScannedSeed,
|
||||
testRelay,
|
||||
type PairingSource,
|
||||
type RelayTestResult,
|
||||
type StopCapture,
|
||||
} from '@/services/pairing'
|
||||
|
||||
type Phase = 'probing' | 'scanning' | 'review' | 'no-source' | 'pairing' | 'error'
|
||||
|
||||
const phase = ref<Phase>('probing')
|
||||
const errorMessage = ref('')
|
||||
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||
|
||||
const sources = shallowRef<PairingSource[]>([])
|
||||
const activeSource = shallowRef<PairingSource | null>(null)
|
||||
let stopCapture: StopCapture | null = null
|
||||
|
||||
// Review-step state: the scanned-but-not-yet-committed seed + relay tests.
|
||||
const scannedRaw = ref('')
|
||||
const previewSpire = ref('')
|
||||
const previewRelays = ref<string[]>([])
|
||||
type RelayState = { status: 'idle' | 'testing' | 'done'; result?: RelayTestResult }
|
||||
const relayTests = ref<Record<string, RelayState>>({})
|
||||
const testingRelays = ref(false)
|
||||
const committing = ref(false)
|
||||
|
||||
const anyRelayFailed = computed(() =>
|
||||
Object.values(relayTests.value).some((s) => s.status === 'done' && s.result != null && !s.result.ok),
|
||||
)
|
||||
|
||||
async function startWith(source: PairingSource) {
|
||||
await teardown()
|
||||
activeSource.value = source
|
||||
errorMessage.value = ''
|
||||
phase.value = 'scanning'
|
||||
try {
|
||||
stopCapture = await source.start({
|
||||
video: source.kind === 'qr' ? (videoEl.value ?? undefined) : undefined,
|
||||
onScan: handleScan,
|
||||
onError: (e) => console.warn('[Pairing] capture glitch:', e),
|
||||
})
|
||||
} catch (e) {
|
||||
phase.value = 'error'
|
||||
errorMessage.value =
|
||||
e instanceof Error ? e.message : 'Could not start the camera. Check permissions.'
|
||||
}
|
||||
}
|
||||
|
||||
let handling = false
|
||||
async function handleScan(raw: string) {
|
||||
if (handling) return
|
||||
handling = true
|
||||
// Validate only — don't commit yet. Show a review step with the decoded
|
||||
// relay + a "test relay" button so a well-formed but unreachable relay is
|
||||
// caught before we relaunch into a pairing crash-loop (aiolabs/bitspire#70).
|
||||
const preview = parseScannedSeed(raw)
|
||||
if (preview.ok) {
|
||||
await teardown() // camera off during review
|
||||
scannedRaw.value = raw.trim()
|
||||
previewSpire.value = preview.spirePubkey
|
||||
previewRelays.value = preview.relays
|
||||
relayTests.value = Object.fromEntries(preview.relays.map((r) => [r, { status: 'idle' }]))
|
||||
errorMessage.value = ''
|
||||
phase.value = 'review'
|
||||
return
|
||||
}
|
||||
// Reject non-seed / malformed scans (a stray QR, a corrupted relay) and resume.
|
||||
console.warn('[Pairing] rejected scan:', preview.reason, preview.message)
|
||||
errorMessage.value = 'That code is not a valid pairing code. Show the operator pairing QR.'
|
||||
handling = false
|
||||
if (activeSource.value) await startWith(activeSource.value)
|
||||
}
|
||||
|
||||
/** Probe every relay in the scanned seed and record reachability. */
|
||||
async function testRelays() {
|
||||
testingRelays.value = true
|
||||
await Promise.all(
|
||||
previewRelays.value.map(async (url) => {
|
||||
relayTests.value[url] = { status: 'testing' }
|
||||
const result = await testRelay(url)
|
||||
relayTests.value[url] = { status: 'done', result }
|
||||
}),
|
||||
)
|
||||
testingRelays.value = false
|
||||
}
|
||||
|
||||
/** Commit the reviewed seed: persist + relaunch into the real pairing path. */
|
||||
async function confirmPair() {
|
||||
committing.value = true
|
||||
const result = await ingestScannedSeed(scannedRaw.value)
|
||||
if (result.ok) {
|
||||
phase.value = 'pairing' // relaunch in flight
|
||||
return
|
||||
}
|
||||
committing.value = false
|
||||
errorMessage.value = result.message
|
||||
phase.value = 'error'
|
||||
}
|
||||
|
||||
/** Discard the scan and go back to scanning. */
|
||||
async function rescan() {
|
||||
scannedRaw.value = ''
|
||||
previewRelays.value = []
|
||||
relayTests.value = {}
|
||||
handling = false
|
||||
if (activeSource.value) await startWith(activeSource.value)
|
||||
}
|
||||
|
||||
async function teardown() {
|
||||
if (stopCapture) {
|
||||
try {
|
||||
stopCapture()
|
||||
} catch {
|
||||
/* idempotent */
|
||||
}
|
||||
stopCapture = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const available = await availablePairingSources()
|
||||
sources.value = available
|
||||
const first = available[0]
|
||||
if (!first) {
|
||||
phase.value = 'no-source'
|
||||
return
|
||||
}
|
||||
await startWith(first)
|
||||
})
|
||||
|
||||
onUnmounted(teardown)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-1 flex-col items-center justify-center gap-6 p-8">
|
||||
<h1 class="text-2xl lg:text-[3.5rem] font-bold">Pair This Machine</h1>
|
||||
|
||||
<!-- Camera viewfinder -->
|
||||
<div
|
||||
v-show="phase === 'scanning' && activeSource?.kind === 'qr'"
|
||||
class="relative overflow-hidden rounded-2xl border-4 border-primary/40 bg-black"
|
||||
style="width: min(80vw, 28rem); aspect-ratio: 1 / 1"
|
||||
>
|
||||
<!-- The Sintra's camera is mounted rotated, so rotate the preview 90° CCW
|
||||
for an upright image. Preview-only: qr-source decodes the raw (un-
|
||||
rotated) frame and QR decoding is rotation-invariant. The container is
|
||||
square + overflow-hidden, so the rotated square stays in the box. -->
|
||||
<video
|
||||
ref="videoEl"
|
||||
class="h-full w-full -rotate-90 object-cover"
|
||||
muted
|
||||
autoplay
|
||||
playsinline
|
||||
></video>
|
||||
<!-- Reticle -->
|
||||
<div class="pointer-events-none absolute inset-6 rounded-xl border-2 border-white/70"></div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="phase === 'scanning'"
|
||||
class="max-w-md text-center text-base lg:text-2xl text-muted-foreground"
|
||||
>
|
||||
Hold the operator's pairing QR up to the camera.
|
||||
</p>
|
||||
|
||||
<p v-if="phase === 'probing'" class="text-base lg:text-2xl text-muted-foreground">
|
||||
Starting camera…
|
||||
</p>
|
||||
|
||||
<div v-if="phase === 'pairing'" class="flex flex-col items-center gap-4">
|
||||
<p class="text-base lg:text-2xl text-muted-foreground">Pairing accepted — restarting…</p>
|
||||
</div>
|
||||
|
||||
<!-- Review: confirm the scanned relay is reachable before committing -->
|
||||
<div v-if="phase === 'review'" class="flex w-full max-w-md flex-col items-center gap-5">
|
||||
<p class="text-base lg:text-2xl text-muted-foreground">
|
||||
Pairing code scanned. Test the relay, then pair.
|
||||
</p>
|
||||
<div class="w-full rounded-xl border border-border p-4 text-left">
|
||||
<p class="text-xs uppercase text-muted-foreground">Spire</p>
|
||||
<p class="mb-3 break-all font-mono text-sm">{{ previewSpire.slice(0, 16) }}…</p>
|
||||
<p class="text-xs uppercase text-muted-foreground">Relay(s)</p>
|
||||
<ul class="flex flex-col gap-2">
|
||||
<li
|
||||
v-for="url in previewRelays"
|
||||
:key="url"
|
||||
class="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span class="break-all font-mono text-xs">{{ url }}</span>
|
||||
<span class="shrink-0 text-sm">
|
||||
<template v-if="relayTests[url]?.status === 'testing'">
|
||||
<span class="text-muted-foreground">testing…</span>
|
||||
</template>
|
||||
<template v-else-if="relayTests[url]?.status === 'done'">
|
||||
<span v-if="relayTests[url]?.result?.ok" class="text-green-500"
|
||||
>✓ {{ relayTests[url]?.result?.ms }}ms</span
|
||||
>
|
||||
<span v-else class="text-destructive">✗ unreachable</span>
|
||||
</template>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<button
|
||||
class="rounded-lg border border-border px-4 py-2 text-sm disabled:opacity-50"
|
||||
:disabled="testingRelays || committing"
|
||||
@click="testRelays"
|
||||
>
|
||||
{{ testingRelays ? 'Testing…' : 'Test relay' }}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg border border-border px-4 py-2 text-sm disabled:opacity-50"
|
||||
:disabled="committing"
|
||||
@click="rescan"
|
||||
>
|
||||
Rescan
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50"
|
||||
:disabled="committing"
|
||||
@click="confirmPair"
|
||||
>
|
||||
{{ committing ? 'Pairing…' : 'Pair this machine' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="anyRelayFailed" class="max-w-md text-center text-sm text-warning">
|
||||
A relay looks unreachable from this machine — pairing will fail unless it can reach the
|
||||
relay. Check the URL/network, or rescan a corrected code.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="phase === 'no-source'"
|
||||
class="max-w-md text-center text-base lg:text-2xl text-muted-foreground"
|
||||
>
|
||||
No camera or NFC reader is available on this machine. Pair by provisioning
|
||||
<span class="font-mono">VITE_SPIRE_SEED</span> instead.
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="phase === 'error'"
|
||||
class="max-w-md text-center text-base lg:text-xl text-destructive"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<!-- Transient rejected-scan hint while still scanning -->
|
||||
<p
|
||||
v-if="phase === 'scanning' && errorMessage"
|
||||
class="max-w-md text-center text-sm lg:text-base text-warning"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<!-- Alternate sources (e.g. NFC) when more than one is available -->
|
||||
<div v-if="sources.length > 1" class="flex gap-3">
|
||||
<button
|
||||
v-for="source in sources"
|
||||
:key="source.kind"
|
||||
class="rounded-lg border border-border px-4 py-2 text-sm"
|
||||
:class="activeSource?.kind === source.kind ? 'bg-primary text-primary-foreground' : ''"
|
||||
@click="startWith(source)"
|
||||
>
|
||||
{{ source.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
import { watch, type Ref } from 'vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import type { NostrClient, Signer } from '@bitSpire/nostr-client'
|
||||
import type { NostrClient, MachineIdentity } from '@bitSpire/nostr-client'
|
||||
import { createSignedEvent } from '@bitSpire/nostr-client'
|
||||
|
||||
type CashLevel = 'none' | 'low' | 'good' | 'full'
|
||||
|
|
@ -26,17 +26,11 @@ interface AvailabilitySnapshot {
|
|||
|
||||
interface UseAvailabilityBroadcastOptions {
|
||||
nostrClient: NostrClient
|
||||
signer: Signer
|
||||
identity: MachineIdentity
|
||||
/** Reactive inventory: denomination -> count */
|
||||
inventory: Ref<Record<number, number>>
|
||||
/** Reactive wallet balance in sats (null = unknown) */
|
||||
/** Reactive Lightning.Pub balance in sats (null = unknown) */
|
||||
balanceSats: Ref<number | null>
|
||||
/**
|
||||
* ADR-005 §5: cash-out is latched off after a terminal dispenser fault.
|
||||
* A machine with full bays and a jammed transport must not advertise
|
||||
* cash-out — that is exactly what sintra did for an hour on 2026-10-09.
|
||||
*/
|
||||
cashOutHeld?: Ref<boolean>
|
||||
/** Fiat currency code */
|
||||
fiatCode: string
|
||||
/** Machine model */
|
||||
|
|
@ -44,7 +38,7 @@ interface UseAvailabilityBroadcastOptions {
|
|||
}
|
||||
|
||||
export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOptions) {
|
||||
const { nostrClient, signer, inventory, balanceSats, cashOutHeld, fiatCode, model } = options
|
||||
const { nostrClient, identity, inventory, balanceSats, fiatCode, model } = options
|
||||
|
||||
let lastSnapshot: AvailabilitySnapshot | null = null
|
||||
|
||||
|
|
@ -59,7 +53,7 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption
|
|||
function computeSnapshot(): AvailabilitySnapshot {
|
||||
const totalBills = Object.values(inventory.value).reduce((s, c) => s + c, 0)
|
||||
return {
|
||||
cashOut: totalBills > 0 && !(cashOutHeld?.value ?? false),
|
||||
cashOut: totalBills > 0,
|
||||
cashIn: (balanceSats.value ?? 0) > 0,
|
||||
cashLevel: computeCashLevel(),
|
||||
}
|
||||
|
|
@ -79,22 +73,19 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption
|
|||
model,
|
||||
})
|
||||
|
||||
// Signing goes through the bunker, so it can throw BunkerTimeoutError /
|
||||
// BunkerRejectedError — keep it INSIDE the try so a transient signer blip
|
||||
// is swallowed (the beacon re-publishes every interval) rather than
|
||||
// surfacing as an uncaught rejection. `publish()` is fire-and-forget.
|
||||
try {
|
||||
const event = await createSignedEvent(signer, {
|
||||
const event = createSignedEvent(identity, {
|
||||
kind: 30078,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['d', 'atm-availability']],
|
||||
content,
|
||||
})
|
||||
|
||||
try {
|
||||
await nostrClient.publish(event)
|
||||
lastSnapshot = snap
|
||||
console.log('[Availability] Published:', content)
|
||||
} catch (e) {
|
||||
console.warn('[Availability] Publish failed (sign or relay):', e)
|
||||
console.warn('[Availability] Failed to publish:', e)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -107,7 +98,7 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption
|
|||
|
||||
// Watch reactive sources
|
||||
watch(
|
||||
cashOutHeld ? [inventory, balanceSats, cashOutHeld] : [inventory, balanceSats],
|
||||
[inventory, balanceSats],
|
||||
() => {
|
||||
debouncedPublish()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,103 +0,0 @@
|
|||
import { watch } from 'vue'
|
||||
import { useEventListener, useIntervalFn } from '@vueuse/core'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
|
||||
/**
|
||||
* Session security timeouts for the ADR-003 access gate.
|
||||
*
|
||||
* Enforced at the DOM layer on purpose: the state machine can't observe raw
|
||||
* pointer events, so an XState `after` delay can only count from state entry —
|
||||
* it never resets on a screen touch and therefore can't measure *inactivity*.
|
||||
* Two independent, fail-closed limits, both of which re-lock via the machine's
|
||||
* root-level END_SESSION transition:
|
||||
*
|
||||
* - SOFT idle (SOFT_IDLE_MS): re-lock after this long with no trusted user
|
||||
* input while on the idle menu. Resets on every genuine pointer/touch/key
|
||||
* event. Scoped to `idle` so it never interrupts an in-flight cash-in/out
|
||||
* (those carry their own, longer machine timeouts).
|
||||
* - HARD cap (HARD_CAP_MS): re-lock this long after the session began,
|
||||
* regardless of activity. Anchored to unlock time and never reset — an
|
||||
* absolute ceiling a forgotten or relayed card can't hold open. Like the
|
||||
* soft limit it only fires while on the idle menu: locking mid-transaction
|
||||
* would strand stacked bills or an in-flight dispense, and every
|
||||
* transaction already returns to `locked` on its own, so the cap simply
|
||||
* takes effect the moment the machine is back at idle.
|
||||
*
|
||||
* Security properties:
|
||||
* - Only `event.isTrusted` input resets the soft timer, so synthetic/scripted
|
||||
* events in the renderer can't keep a session alive.
|
||||
* - Limits are wall-clock deadline comparisons, not chained setTimeouts: a
|
||||
* suspended/resumed renderer re-locks on the very next tick instead of
|
||||
* silently extending the session past its deadline.
|
||||
* - Both limits only ever *lock*. The machine's accessGateActive guard makes
|
||||
* END_SESSION a no-op when the gate is off, so this is inert on a
|
||||
* gate-disabled machine.
|
||||
* - One-shot per session: after firing, it disarms until the next unlock, so
|
||||
* a lock that (under dev bypass) doesn't take can't spin.
|
||||
*
|
||||
* Call once from the always-mounted App shell.
|
||||
*/
|
||||
const SOFT_IDLE_MS = 60_000 // 60s of no interaction on the idle menu
|
||||
const HARD_CAP_MS = 600_000 // 10min absolute session ceiling
|
||||
|
||||
export function useSessionSecurity() {
|
||||
const atm = useAtmStore()
|
||||
|
||||
// Wall-clock anchors. `null` sessionStartedAt == disarmed (no live session).
|
||||
let sessionStartedAt: number | null = null
|
||||
let lastActivityAt = 0
|
||||
|
||||
function arm() {
|
||||
const now = Date.now()
|
||||
sessionStartedAt = now
|
||||
lastActivityAt = now
|
||||
}
|
||||
function disarm() {
|
||||
sessionStartedAt = null
|
||||
}
|
||||
|
||||
// Arm on each locked → unlocked edge; disarm on lock. Anchored to the
|
||||
// isLocked transition so the hard cap starts at unlock and does NOT restart
|
||||
// when moving idle → cashIn → idle within a single session.
|
||||
watch(
|
||||
() => atm.isLocked,
|
||||
(locked, wasLocked) => {
|
||||
if (wasLocked && !locked && atm.accessControl.enabled) arm()
|
||||
else if (locked) disarm()
|
||||
}
|
||||
)
|
||||
|
||||
// Only genuine hardware input counts as activity. Passive + capture so it
|
||||
// observes every touch without interfering with handling. useEventListener
|
||||
// auto-detaches on unmount.
|
||||
const onActivity = (e: Event) => {
|
||||
if (e.isTrusted) lastActivityAt = Date.now()
|
||||
}
|
||||
for (const type of ['pointerdown', 'touchstart', 'keydown', 'wheel'] as const) {
|
||||
useEventListener(document, type, onActivity, { passive: true, capture: true })
|
||||
}
|
||||
|
||||
// Single 1s evaluator — cheap, and coarse enough that timer drift/suspend
|
||||
// can only ever make it fire late-then-immediately, never early.
|
||||
useIntervalFn(() => {
|
||||
if (sessionStartedAt === null || atm.isLocked || !atm.accessControl.enabled) return
|
||||
// Never lock out from under a transaction (the machine only accepts
|
||||
// END_SESSION from idle anyway); the deadlines keep counting meanwhile, so
|
||||
// an expired session re-locks on the first tick back at the menu.
|
||||
if (atm.currentState !== 'idle') return
|
||||
const now = Date.now()
|
||||
|
||||
// Hard cap first — absolute, activity-independent.
|
||||
if (now - sessionStartedAt >= HARD_CAP_MS) {
|
||||
disarm() // one-shot; re-arms on next unlock
|
||||
atm.endSession('session-cap')
|
||||
return
|
||||
}
|
||||
|
||||
// Soft inactivity — resets on trusted input.
|
||||
if (now - lastActivityAt >= SOFT_IDLE_MS) {
|
||||
disarm()
|
||||
atm.endSession('inactivity')
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
||||
|
|
@ -139,21 +139,11 @@ export const MACHINE_PRESETS: Record<MachineModel, Omit<DeviceConfig, 'fiatCode'
|
|||
model: 'batm3',
|
||||
validator: {
|
||||
type: 'ebds',
|
||||
// MEI bill acceptor (EBDS) on a USB-serial bridge, exposed via the
|
||||
// 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.
|
||||
device: '/dev/ttyMEI',
|
||||
device: '/dev/ttyACM0',
|
||||
},
|
||||
dispenser: {
|
||||
type: 'f56',
|
||||
// Fujitsu F56 on a USB-serial bridge, via the stable udev symlink
|
||||
// /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.
|
||||
device: '/dev/ttyF56',
|
||||
device: '/dev/ttyUSB0',
|
||||
cassettes: [
|
||||
{ denomination: 20, count: 400 },
|
||||
{ denomination: 1, count: 400 },
|
||||
|
|
@ -214,31 +204,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 +238,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 +252,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,26 +5,3 @@ import { twMerge } from 'tailwind-merge'
|
|||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a denomination/count list for the journal.
|
||||
*
|
||||
* Electron's console bridge stringifies every console argument on its way to
|
||||
* the journal, so passing the array itself arrives as `[object Object]` and
|
||||
* the numbers are lost. Interpolate one of these instead. See CLAUDE.md,
|
||||
* "Useful invariants when debugging".
|
||||
*/
|
||||
export function formatBays(rows: { denomination: number; count?: number }[]): string {
|
||||
if (!rows?.length) return '(none)'
|
||||
// `count` is optional on a device-config cassette: a preset can declare the
|
||||
// denomination a bay holds without claiming how many notes are in it. Show
|
||||
// that as unknown rather than as zero, which would read as a drained bay.
|
||||
return rows.map((r) => `${r.denomination}x${r.count ?? '?'}`).join(' ')
|
||||
}
|
||||
|
||||
/** Same, for a denomination-keyed count map as `getInventory()` returns. */
|
||||
export function formatInventory(inv: Record<number, number>): string {
|
||||
const entries = Object.entries(inv ?? {})
|
||||
if (!entries.length) return '(none)'
|
||||
return entries.map(([denom, count]) => `${denom}x${count}`).join(' ')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,13 +24,6 @@ const router = createRouter({
|
|||
],
|
||||
})
|
||||
|
||||
// Kiosk chrome (hidden cursor) is the default — every real machine is a
|
||||
// touchscreen. The public web demo (VITE_DEMO_TAG) runs in a normal browser,
|
||||
// where an invisible pointer just reads as broken.
|
||||
if (!import.meta.env.VITE_DEMO_TAG) {
|
||||
document.documentElement.classList.add('kiosk')
|
||||
}
|
||||
|
||||
// Create Pinia store
|
||||
const pinia = createPinia()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { BunkerRejectedError, BunkerTimeoutError } from '@bitSpire/nostr-client'
|
||||
import { classifyInitError } from '../init-error.js'
|
||||
|
||||
describe('classifyInitError', () => {
|
||||
it('maps a bunker rejection (revoke / TTL / off-policy) to "unpaired"', () => {
|
||||
expect(classifyInitError(new BunkerRejectedError('revoked'))).toBe('unpaired')
|
||||
})
|
||||
|
||||
it('maps a bunker timeout to "signer-unreachable"', () => {
|
||||
expect(classifyInitError(new BunkerTimeoutError('no response'))).toBe('signer-unreachable')
|
||||
})
|
||||
|
||||
it('classifies by error name across bundle boundaries (no instanceof)', () => {
|
||||
// A structurally-equivalent error from a different module copy still maps.
|
||||
const lookalike = Object.assign(new Error('x'), { name: 'BunkerRejectedError' })
|
||||
expect(classifyInitError(lookalike)).toBe('unpaired')
|
||||
})
|
||||
|
||||
it('surfaces a generic error message unchanged', () => {
|
||||
expect(classifyInitError(new Error('relay down'))).toBe('relay down')
|
||||
})
|
||||
|
||||
it('uses the fallback for non-Error throws', () => {
|
||||
expect(classifyInitError('boom', 'Lightning initialization failed')).toBe(
|
||||
'Lightning initialization failed'
|
||||
)
|
||||
expect(classifyInitError(undefined)).toBe('Initialization failed')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { initialContext, type ATMContext } from '@bitSpire/state-machine'
|
||||
import type { LnbitsClient, LnbitsPayment } from '@bitSpire/lnbits'
|
||||
import { createATMServices } from '../lightning'
|
||||
|
||||
/**
|
||||
* The cash-out settlement watch (2026-09-22 regression).
|
||||
*
|
||||
* A one-tap Bolt Card Complete settles in about a second; subscribing over
|
||||
* nostr takes several. When the watch was armed at display time the push —
|
||||
* an ephemeral event with no replay — fired before anything listened, and the
|
||||
* machine sat on a paid invoice until it timed out, taking the sats without
|
||||
* dispensing. These pin the three defences: arm before the invoice is handed
|
||||
* out, latch a settlement that still beats the consumer, and poll so a push
|
||||
* that never arrives cannot strand a payment.
|
||||
*/
|
||||
|
||||
const BOLT11 = 'lnbc265u1p4t9gthpp5td44vd9a0s5er'
|
||||
const HASH = 'aa'.repeat(32)
|
||||
|
||||
const paid = (preimage = 'PREIMAGE'): LnbitsPayment =>
|
||||
({ payment_hash: HASH, status: 'success', preimage }) as LnbitsPayment
|
||||
|
||||
function makeLnbits(over: Partial<Record<string, unknown>> = {}) {
|
||||
let pushTo: ((p: LnbitsPayment) => void) | null = null
|
||||
const api = {
|
||||
createInvoice: vi.fn(async () => ({ payment_request: BOLT11, payment_hash: HASH })),
|
||||
subscribePayments: vi.fn(
|
||||
async (_w: unknown, _f: unknown, onPush: (p: LnbitsPayment) => void) => {
|
||||
pushTo = onPush
|
||||
return 'sub-1'
|
||||
}
|
||||
),
|
||||
getPayment: vi.fn(async (): Promise<LnbitsPayment | null> => null),
|
||||
unsubscribe: vi.fn(async () => true),
|
||||
decodePayment: vi.fn(async () => ({ payment_hash: HASH })),
|
||||
...over,
|
||||
}
|
||||
return { api, push: (p: LnbitsPayment) => pushTo?.(p) }
|
||||
}
|
||||
|
||||
const ctx = (): ATMContext => ({ ...initialContext, satsAmount: 26_500, exchangeRate: 1325 })
|
||||
|
||||
const services = (l: { api: Record<string, unknown> }) =>
|
||||
createATMServices(vi.fn(), l.api as unknown as LnbitsClient, 'wallet-1')
|
||||
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('cash-out settlement watch', () => {
|
||||
it('is armed before the invoice is handed out, without decoding it back', async () => {
|
||||
const l = makeLnbits()
|
||||
const invoice = await services(l).generateInvoice(ctx())
|
||||
|
||||
expect(invoice).toBe(BOLT11)
|
||||
// Armed during generateInvoice, not later at display time.
|
||||
expect(l.api.subscribePayments).toHaveBeenCalledTimes(1)
|
||||
expect(l.api.subscribePayments.mock.calls[0]![1]).toMatchObject({ payment_hash: HASH })
|
||||
// The hash came from the creation response, so no round trip to recover it.
|
||||
expect(l.api.decodePayment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replays a settlement that beat the consumer (the race that lost payments)', async () => {
|
||||
const l = makeLnbits()
|
||||
const svc = services(l)
|
||||
const invoice = await svc.generateInvoice(ctx())
|
||||
|
||||
// Card pays before the machine reaches displayingInvoice.
|
||||
l.push(paid())
|
||||
|
||||
const onPaid = vi.fn()
|
||||
svc.watchInvoice(invoice, onPaid)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(onPaid).toHaveBeenCalledWith('PREIMAGE')
|
||||
})
|
||||
|
||||
it('delivers a push that arrives while the consumer is attached', async () => {
|
||||
const l = makeLnbits()
|
||||
const svc = services(l)
|
||||
const invoice = await svc.generateInvoice(ctx())
|
||||
|
||||
const onPaid = vi.fn()
|
||||
svc.watchInvoice(invoice, onPaid)
|
||||
l.push(paid('LATER'))
|
||||
|
||||
expect(onPaid).toHaveBeenCalledWith('LATER')
|
||||
})
|
||||
|
||||
it('settles from the poll when no push ever arrives', async () => {
|
||||
const l = makeLnbits()
|
||||
l.api.getPayment = vi.fn(async () => paid('VIA-POLL'))
|
||||
const svc = services(l)
|
||||
const invoice = await svc.generateInvoice(ctx())
|
||||
|
||||
const onPaid = vi.fn()
|
||||
svc.watchInvoice(invoice, onPaid)
|
||||
expect(onPaid).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(7_000)
|
||||
expect(onPaid).toHaveBeenCalledWith('VIA-POLL')
|
||||
})
|
||||
|
||||
it('polls even when arming the subscription fails', async () => {
|
||||
const l = makeLnbits()
|
||||
l.api.subscribePayments = vi.fn(async () => {
|
||||
throw new Error('relay down')
|
||||
})
|
||||
l.api.getPayment = vi.fn(async () => paid('VIA-POLL'))
|
||||
const svc = services(l)
|
||||
const invoice = await svc.generateInvoice(ctx())
|
||||
|
||||
const onPaid = vi.fn()
|
||||
svc.watchInvoice(invoice, onPaid)
|
||||
await vi.advanceTimersByTimeAsync(7_000)
|
||||
|
||||
expect(onPaid).toHaveBeenCalledWith('VIA-POLL')
|
||||
})
|
||||
|
||||
it('reports each settlement once, whichever path saw it first', async () => {
|
||||
const l = makeLnbits()
|
||||
l.api.getPayment = vi.fn(async () => paid('VIA-POLL'))
|
||||
const svc = services(l)
|
||||
const invoice = await svc.generateInvoice(ctx())
|
||||
|
||||
const onPaid = vi.fn()
|
||||
svc.watchInvoice(invoice, onPaid)
|
||||
l.push(paid('VIA-PUSH'))
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
|
||||
expect(onPaid).toHaveBeenCalledTimes(1)
|
||||
expect(onPaid).toHaveBeenCalledWith('VIA-PUSH')
|
||||
})
|
||||
|
||||
it('stops polling and unsubscribes when the transaction ends', async () => {
|
||||
const l = makeLnbits()
|
||||
const svc = services(l)
|
||||
const invoice = await svc.generateInvoice(ctx())
|
||||
const stop = svc.watchInvoice(invoice, vi.fn())
|
||||
|
||||
stop()
|
||||
expect(l.api.unsubscribe).toHaveBeenCalledWith(undefined, 'sub-1')
|
||||
|
||||
const pollsAfterStop = (l.api.getPayment as ReturnType<typeof vi.fn>).mock.calls.length
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect((l.api.getPayment as ReturnType<typeof vi.fn>).mock.calls.length).toBe(pollsAfterStop)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { npubEncode, nprofileEncode } from 'nostr-tools/nip19'
|
||||
import { authorize, hashId, hashPin, type AllowListEntry } from '../authorize'
|
||||
import { parseBoltcardLnurlw } from '../boltcard'
|
||||
|
||||
const SALT = 'test-salt'
|
||||
const HEX_A = 'aa'.repeat(32)
|
||||
const HEX_B = 'bb'.repeat(32)
|
||||
const NPUB_A = npubEncode(HEX_A)
|
||||
const NPUB_B = npubEncode(HEX_B)
|
||||
|
||||
describe('access authorize (ADR-003)', () => {
|
||||
describe('open enrollment (prototype)', () => {
|
||||
it('grants any valid npub as user', async () => {
|
||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
expect(out.status).toBe('granted')
|
||||
expect(out).toMatchObject({ status: 'granted', role: 'user' })
|
||||
})
|
||||
|
||||
it('rejects a stray / non-npub QR', async () => {
|
||||
const out = await authorize({ kind: 'npub', npub: 'https://example.com/not-an-npub' }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
expect(out.status).toBe('denied')
|
||||
expect(out).toMatchObject({ reason: 'not a valid npub' })
|
||||
})
|
||||
|
||||
it('accepts a `nostr:` URI prefix (with surrounding whitespace)', async () => {
|
||||
const out = await authorize({ kind: 'npub', npub: ` nostr:${NPUB_A}\n` }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
expect(out.status).toBe('granted')
|
||||
})
|
||||
|
||||
it('accepts an nprofile and resolves to the same identity as its npub', async () => {
|
||||
const nprofile = nprofileEncode({ pubkey: HEX_A, relays: ['wss://relay.example'] })
|
||||
const viaNprofile = await authorize({ kind: 'npub', npub: nprofile }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
const viaNpub = await authorize({ kind: 'npub', npub: NPUB_A }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
expect(viaNprofile.status).toBe('granted')
|
||||
// Same underlying pubkey → same credential hash.
|
||||
expect(viaNprofile.credentialIdHash).toBe(viaNpub.credentialIdHash)
|
||||
})
|
||||
})
|
||||
|
||||
describe('allow-list (closed)', () => {
|
||||
it('denies an unlisted npub when not open-enrollment', async () => {
|
||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [], { salt: SALT })
|
||||
expect(out).toMatchObject({ status: 'denied', reason: 'not authorized' })
|
||||
})
|
||||
|
||||
it('grants a listed npub with its role, no PIN', async () => {
|
||||
const entry: AllowListEntry = { idHash: await hashId(HEX_A, SALT), role: 'operator' }
|
||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [entry], { salt: SALT })
|
||||
expect(out).toMatchObject({ status: 'granted', role: 'operator' })
|
||||
})
|
||||
|
||||
it('does not match npub B against npub A entry', async () => {
|
||||
const entry: AllowListEntry = { idHash: await hashId(HEX_A, SALT), role: 'user' }
|
||||
const out = await authorize({ kind: 'npub', npub: NPUB_B }, [entry], { salt: SALT })
|
||||
expect(out.status).toBe('denied')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PIN second factor', () => {
|
||||
const makeEntry = async (): Promise<AllowListEntry> => ({
|
||||
idHash: await hashId(HEX_A, SALT),
|
||||
role: 'user',
|
||||
pinHash: await hashPin('1234', SALT),
|
||||
})
|
||||
|
||||
it('asks for a PIN when one is configured and none supplied', async () => {
|
||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], {
|
||||
salt: SALT,
|
||||
})
|
||||
expect(out.status).toBe('pin-required')
|
||||
})
|
||||
|
||||
it('grants on correct PIN', async () => {
|
||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], {
|
||||
salt: SALT,
|
||||
pin: '1234',
|
||||
})
|
||||
expect(out).toMatchObject({ status: 'granted', role: 'user' })
|
||||
})
|
||||
|
||||
it('denies on wrong PIN', async () => {
|
||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], {
|
||||
salt: SALT,
|
||||
pin: '9999',
|
||||
})
|
||||
expect(out).toMatchObject({ status: 'denied', reason: 'incorrect PIN' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('challenge credential (v2 seam)', () => {
|
||||
it('is not yet authorized', async () => {
|
||||
const out = await authorize({ kind: 'challenge', pubkey: HEX_A, nonce: 'n', sig: 's' }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
expect(out.status).toBe('denied')
|
||||
})
|
||||
})
|
||||
|
||||
describe('boltcard credential (tap-to-enter)', () => {
|
||||
it('open-enrollment grants any card as user', async () => {
|
||||
const out = await authorize({ kind: 'boltcard', externalId: 'abc123' }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
expect(out).toMatchObject({ status: 'granted', role: 'user' })
|
||||
})
|
||||
|
||||
it('rejects a card with no external_id', async () => {
|
||||
const out = await authorize({ kind: 'boltcard', externalId: '' }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
expect(out).toMatchObject({ status: 'denied', reason: 'not a valid card' })
|
||||
})
|
||||
|
||||
it('allow-list matches by external_id hash', async () => {
|
||||
const idHash = await hashId('abc123', SALT)
|
||||
const list: AllowListEntry[] = [{ idHash, role: 'operator' }]
|
||||
const out = await authorize({ kind: 'boltcard', externalId: 'abc123' }, list, {
|
||||
salt: SALT,
|
||||
openEnrollment: false,
|
||||
})
|
||||
expect(out).toMatchObject({ status: 'granted', role: 'operator' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBoltcardLnurlw', () => {
|
||||
it('extracts external_id from a tapped lnurlw', () => {
|
||||
expect(
|
||||
parseBoltcardLnurlw('lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEAD&c=BEEF')
|
||||
).toEqual({ externalId: 'abc123' })
|
||||
})
|
||||
it('strips a lightning: prefix and accepts https', () => {
|
||||
expect(parseBoltcardLnurlw('lightning:lnurlw://h/boltcards/api/v1/scan/xyz?p=1')).toEqual({
|
||||
externalId: 'xyz',
|
||||
})
|
||||
expect(parseBoltcardLnurlw('https://h/boltcards/api/v1/scan/xyz?p=1')).toEqual({
|
||||
externalId: 'xyz',
|
||||
})
|
||||
})
|
||||
it('returns null for non-card / malformed input', () => {
|
||||
expect(parseBoltcardLnurlw('https://h/something/else')).toBeNull()
|
||||
expect(parseBoltcardLnurlw('not a url')).toBeNull()
|
||||
expect(parseBoltcardLnurlw('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
/**
|
||||
* Credential authorization (ADR-003).
|
||||
*
|
||||
* Decides whether a presented credential may unlock the terminal. Matching is
|
||||
* against a local allow-list of salted identity hashes, optionally behind a
|
||||
* PIN second factor; `openEnrollment` admits any well-formed credential when
|
||||
* the allow-list has no match (the current posture — see the ADR amendment:
|
||||
* with it on, the gate is a convenience, not a security boundary). Only
|
||||
* salted hashes are compared, stored or logged — never the raw id (KYC-free).
|
||||
*
|
||||
* Identity id per scan kind:
|
||||
* - boltcard → the card's boltcards `external_id` (parsed locally from the
|
||||
* lnurlw; the SUN p/c are NOT verified here — that happens at
|
||||
* payment time, where the voucher is actually spent)
|
||||
* - npub → hex pubkey (decoded, canonical)
|
||||
* - challenge → v2 seam, not yet authorized
|
||||
*/
|
||||
|
||||
import { decode as nip19Decode } from 'nostr-tools/nip19'
|
||||
import type { AccessRole } from '@bitSpire/state-machine'
|
||||
import type { AccessScan } from './types'
|
||||
|
||||
/** One authorized identity. `idHash` = hashId(<canonical id>, salt). */
|
||||
export interface AllowListEntry {
|
||||
idHash: string
|
||||
role: AccessRole
|
||||
/** When set, access requires this PIN (hashPin(pin, salt)) as a 2nd factor. */
|
||||
pinHash?: string
|
||||
/** Optional operator-facing label (never a person's real identity). */
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface AuthorizeOptions {
|
||||
/** Per-machine salt for all hashing. */
|
||||
salt: string
|
||||
/** Admit any valid credential when the allow-list has no match (prototype). */
|
||||
openEnrollment?: boolean
|
||||
/** PIN supplied on the follow-up call after a `pin-required` outcome. */
|
||||
pin?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Three outcomes, so the caller can drive a two-step flow:
|
||||
* - `granted` → send ACCESS_GRANTED
|
||||
* - `pin-required` → prompt for a PIN, then call authorize() again with `pin`
|
||||
* - `denied` → send ACCESS_DENIED(reason)
|
||||
*/
|
||||
export type AuthorizeOutcome =
|
||||
| { status: 'granted'; role: AccessRole; credentialIdHash: string }
|
||||
| { status: 'pin-required'; credentialIdHash: string }
|
||||
| { status: 'denied'; credentialIdHash: string; reason: string }
|
||||
|
||||
/** Salted SHA-256, hex-encoded. */
|
||||
async function sha256Hex(input: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(input)
|
||||
const digest = await crypto.subtle.digest('SHA-256', data)
|
||||
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
export const hashId = (id: string, salt: string): Promise<string> => sha256Hex(`id:${salt}:${id}`)
|
||||
export const hashPin = (pin: string, salt: string): Promise<string> =>
|
||||
sha256Hex(`pin:${salt}:${pin}`)
|
||||
|
||||
/**
|
||||
* Resolve a scan to a canonical identity string, or `null` if malformed.
|
||||
* npub is decoded to its hex pubkey so npub/hex forms compare equal and a
|
||||
* stray (non-npub) string is rejected.
|
||||
*/
|
||||
function canonicalId(scan: AccessScan): string | null {
|
||||
if (scan.kind === 'boltcard') return scan.externalId || null
|
||||
if (scan.kind === 'challenge') return null // v2 — handled separately
|
||||
// Tolerate real-world nostr QR shapes: a bare `npub1…`, a `nostr:` URI
|
||||
// prefix, and `nprofile1…` (npub + relay hints, what many clients export).
|
||||
const raw = scan.npub.trim().replace(/^nostr:/i, '')
|
||||
try {
|
||||
const decoded = nip19Decode(raw)
|
||||
if (decoded.type === 'npub' && typeof decoded.data === 'string') {
|
||||
return decoded.data
|
||||
}
|
||||
if (
|
||||
decoded.type === 'nprofile' &&
|
||||
decoded.data &&
|
||||
typeof (decoded.data as { pubkey?: unknown }).pubkey === 'string'
|
||||
) {
|
||||
return (decoded.data as { pubkey: string }).pubkey
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a scanned credential is authorized.
|
||||
* Always resolves (never throws) so the caller can uniformly react.
|
||||
*/
|
||||
export async function authorize(
|
||||
scan: AccessScan,
|
||||
allowList: AllowListEntry[],
|
||||
opts: AuthorizeOptions
|
||||
): Promise<AuthorizeOutcome> {
|
||||
if (scan.kind === 'challenge') {
|
||||
return {
|
||||
status: 'denied',
|
||||
credentialIdHash: '',
|
||||
reason: 'challenge-response credentials not yet supported',
|
||||
}
|
||||
}
|
||||
|
||||
const id = canonicalId(scan)
|
||||
if (!id) {
|
||||
return {
|
||||
status: 'denied',
|
||||
credentialIdHash: '',
|
||||
reason:
|
||||
scan.kind === 'npub'
|
||||
? 'not a valid npub'
|
||||
: scan.kind === 'boltcard'
|
||||
? 'not a valid card'
|
||||
: 'invalid credential',
|
||||
}
|
||||
}
|
||||
|
||||
const credentialIdHash = await hashId(id, opts.salt)
|
||||
const entry = allowList.find((e) => e.idHash === credentialIdHash)
|
||||
|
||||
if (!entry) {
|
||||
if (opts.openEnrollment) {
|
||||
return { status: 'granted', role: 'user', credentialIdHash }
|
||||
}
|
||||
return { status: 'denied', credentialIdHash, reason: 'not authorized' }
|
||||
}
|
||||
|
||||
// No PIN configured → single-factor grant.
|
||||
if (!entry.pinHash) {
|
||||
return { status: 'granted', role: entry.role, credentialIdHash }
|
||||
}
|
||||
|
||||
// PIN configured but not yet supplied → ask for it.
|
||||
if (opts.pin === undefined) {
|
||||
return { status: 'pin-required', credentialIdHash }
|
||||
}
|
||||
|
||||
// PIN supplied → verify.
|
||||
const pinHash = await hashPin(opts.pin, opts.salt)
|
||||
if (pinHash !== entry.pinHash) {
|
||||
return { status: 'denied', credentialIdHash, reason: 'incorrect PIN' }
|
||||
}
|
||||
return { status: 'granted', role: entry.role, credentialIdHash }
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
/**
|
||||
* Bolt Card lnurlw parsing for the access gate (ADR-003).
|
||||
*
|
||||
* The tap-to-enter flow reads a Bolt Card's `lnurlw://…/scan/<external_id>?p=&c=`
|
||||
* voucher and needs the `external_id` for the session identity — WITHOUT hitting
|
||||
* the server (that would burn the single-use SUN p/c we want to reuse at
|
||||
* Complete). So this is a purely local parse: extract the id from the URL path;
|
||||
* the p/c ride along in the stored lnurlw and are only spent at payment time.
|
||||
*/
|
||||
|
||||
/** Extract a Bolt Card's `external_id` from its tapped lnurlw. Null if not one. */
|
||||
export function parseBoltcardLnurlw(lnurlw: string): { externalId: string } | null {
|
||||
let s = lnurlw.trim()
|
||||
if (!s) return null
|
||||
if (s.toLowerCase().startsWith('lightning:')) s = s.slice('lightning:'.length)
|
||||
const https = s.replace(/^lnurlw:\/\//i, 'https://').replace(/^lnurl:\/\//i, 'https://')
|
||||
if (!/^https:\/\//i.test(https)) return null
|
||||
try {
|
||||
const u = new URL(https)
|
||||
// …/boltcards/api/v1/scan/<external_id>
|
||||
const m = u.pathname.match(/\/scan\/([^/?#]+)/)
|
||||
if (!m || !m[1]) return null
|
||||
return { externalId: decodeURIComponent(m[1]) }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
/**
|
||||
* Access-control module surface (ADR-003).
|
||||
*
|
||||
* Credential capture is NOT here: the reader is the main-process NFC service
|
||||
* (`electron/nfc-service.ts`, over the `nfc:card-tapped` IPC), and the store
|
||||
* turns a tapped lnurlw into a `boltcard` scan. This module only decides —
|
||||
* parse the card, hash the identity, match the allow-list.
|
||||
*/
|
||||
|
||||
export type { AccessScan, AccessRole } from './types'
|
||||
export { authorize, hashId, hashPin } from './authorize'
|
||||
export type { AllowListEntry, AuthorizeOptions, AuthorizeOutcome } from './authorize'
|
||||
export { parseBoltcardLnurlw } from './boltcard'
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
/**
|
||||
* Access-control credential types (ADR-003).
|
||||
*
|
||||
* A credential is captured elsewhere — for Bolt Cards by the main-process NFC
|
||||
* reader (`electron/nfc-service.ts`), which hands the tapped lnurlw to the
|
||||
* store over IPC — and arrives here as a RAW `AccessScan`. Hashing and
|
||||
* authorization (and the optional PIN second factor) happen in `authorize.ts`,
|
||||
* so raw ids never leave this layer (KYC-free).
|
||||
*/
|
||||
|
||||
import type { AccessRole } from '@bitSpire/state-machine'
|
||||
|
||||
export type { AccessRole }
|
||||
|
||||
/**
|
||||
* A raw credential. Discriminated union so new factors are additive:
|
||||
* - `boltcard` — what ships: a tapped Bolt Card, already verified by the
|
||||
* card server's `/session` (the tap's SUN was spent there).
|
||||
* `externalId` is the identity as the server returned it;
|
||||
* it is the only thing hashed/authorized. The session's
|
||||
* payment steps stay in the store, never in this layer.
|
||||
* - `npub` — a Nostr pubkey (bare npub, `nostr:` URI or nprofile).
|
||||
* No reader emits it today; kept, with the PIN second
|
||||
* factor, for a future non-card credential.
|
||||
* - `challenge` — card-signed nonce, challenge-response. v2 seam; not yet
|
||||
* authorized.
|
||||
*/
|
||||
export type AccessScan =
|
||||
| { kind: 'boltcard'; externalId: string }
|
||||
| { kind: 'npub'; npub: string }
|
||||
| { kind: 'challenge'; pubkey: string; nonce: string; sig: string }
|
||||
|
|
@ -15,7 +15,6 @@
|
|||
*/
|
||||
|
||||
import type { ATMServices } from '@bitSpire/state-machine'
|
||||
import { formatBays } from '@/lib/utils'
|
||||
|
||||
export interface CassetteConfig {
|
||||
denomination: number
|
||||
|
|
@ -110,12 +109,6 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
|||
})
|
||||
console.log('[HAL] Validator started')
|
||||
|
||||
// Escrow / in-flight bookkeeping: credit (onBillInserted) fires only on
|
||||
// the validator's `billsValid` stacked-confirmation, never at
|
||||
// stack-command time (mirrors electron/hal-service.ts).
|
||||
let escrowDenomination: number | null = null
|
||||
let inFlightDenomination: number | null = null
|
||||
|
||||
// Track inventory (decremented on dispense)
|
||||
const inventory: Record<number, number> = {}
|
||||
for (const cassette of dispConfig.cassettes) {
|
||||
|
|
@ -127,7 +120,7 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
|||
|
||||
const atmServices: Pick<ATMServices, 'dispenseCash' | 'getInventory'> = {
|
||||
dispenseCash: async (amounts) => {
|
||||
console.log(`[HAL] Dispensing: ${formatBays(amounts)}`)
|
||||
console.log('[HAL] Dispensing:', amounts)
|
||||
|
||||
// Re-initialize dispenser if it was closed after a previous error
|
||||
if (!dispenser.initialized) {
|
||||
|
|
@ -147,10 +140,8 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
|||
dispensed: 0,
|
||||
rejected: 0,
|
||||
})),
|
||||
dispenseConfirmed: false,
|
||||
dispensed: false,
|
||||
error: `No cassette loaded with denomination: ${denomination}`,
|
||||
errorCode: 'NoCassetteForDenomination',
|
||||
errorClass: 'inventory',
|
||||
}
|
||||
}
|
||||
notes[idx] = count
|
||||
|
|
@ -184,23 +175,11 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
|||
rejected: c.rejected,
|
||||
}))
|
||||
|
||||
// ADR-005 §3: confirmation on VALUE. Same contract as electron/hal-service.ts.
|
||||
const requestedValue = amounts.reduce((s, a) => s + a.denomination * a.count, 0)
|
||||
const dispensedValue = cassetteResults.reduce((s, c) => s + c.denomination * c.dispensed, 0)
|
||||
const totalRequested = amounts.reduce((s, a) => s + a.count, 0)
|
||||
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
|
||||
const dispenseConfirmed = requestedValue === dispensedValue
|
||||
|
||||
if (result.error) {
|
||||
const e = result.error
|
||||
return {
|
||||
bills,
|
||||
cassettes: cassetteResults,
|
||||
dispenseConfirmed: false,
|
||||
error: e.human ?? e.message,
|
||||
errorCode: e.errorCode ?? e.name,
|
||||
rawCode: e.rawCode,
|
||||
errorClass: e.errorClass ?? 'terminal',
|
||||
}
|
||||
return { bills, cassettes: cassetteResults, dispensed: false, error: result.error.message }
|
||||
}
|
||||
|
||||
// Wait for customer to take bills (only if bills were dispensed)
|
||||
|
|
@ -209,18 +188,7 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
|||
console.log('[HAL] Bills removed by customer')
|
||||
}
|
||||
|
||||
if (!dispenseConfirmed) {
|
||||
return {
|
||||
bills,
|
||||
cassettes: cassetteResults,
|
||||
dispenseConfirmed: false,
|
||||
error: `Dispensed ${dispensedValue} of ${requestedValue} with no dispenser error`,
|
||||
errorCode: 'DispenseShort',
|
||||
errorClass: 'inventory',
|
||||
}
|
||||
}
|
||||
|
||||
return { bills, cassettes: cassetteResults, dispenseConfirmed: true }
|
||||
return { bills, cassettes: cassetteResults, dispensed: totalRequested === totalDispensed }
|
||||
},
|
||||
|
||||
getInventory: async () => {
|
||||
|
|
@ -238,13 +206,10 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
|||
if (decision === 'hold') {
|
||||
// Hold in escrow — caller will call stackBill() or rejectBill()
|
||||
console.log('[HAL] Bill in escrow:', data.denomination)
|
||||
escrowDenomination = data.denomination
|
||||
callbacks.onBillRead?.(data.denomination)
|
||||
} else if (decision) {
|
||||
// Credit waits for the validator's stacked-confirmation
|
||||
// (`billsValid`) — see the handler below.
|
||||
inFlightDenomination = data.denomination
|
||||
validator.stack()
|
||||
callbacks.onBillInserted(data.denomination)
|
||||
} else {
|
||||
console.log('[HAL] Bill rejected: insufficient ATM balance for', data.denomination)
|
||||
validator.reject()
|
||||
|
|
@ -256,21 +221,7 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
|||
}
|
||||
})
|
||||
|
||||
// Stacked-confirmation → the credit event.
|
||||
validator.on('billsValid', () => {
|
||||
if (inFlightDenomination === null) {
|
||||
console.warn('[HAL] billsValid with no bill in flight — ignoring')
|
||||
return
|
||||
}
|
||||
const denomination = inFlightDenomination
|
||||
inFlightDenomination = null
|
||||
console.log('[HAL] Bill stacked (confirmed):', denomination)
|
||||
callbacks.onBillInserted(denomination)
|
||||
})
|
||||
|
||||
validator.on('billsRejected', (data?: { reason: string; code: number | null }) => {
|
||||
escrowDenomination = null
|
||||
inFlightDenomination = null
|
||||
callbacks.onBillRejected(data?.reason ?? 'unknown')
|
||||
})
|
||||
|
||||
|
|
@ -297,19 +248,8 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
|||
validator.lightOff()
|
||||
},
|
||||
|
||||
stackBill: () => {
|
||||
if (escrowDenomination === null) {
|
||||
console.warn('[HAL] stackBill with no bill in escrow — ignoring')
|
||||
return
|
||||
}
|
||||
inFlightDenomination = escrowDenomination
|
||||
escrowDenomination = null
|
||||
validator.stack()
|
||||
},
|
||||
rejectBill: () => {
|
||||
escrowDenomination = null
|
||||
validator.reject()
|
||||
},
|
||||
stackBill: () => validator.stack(),
|
||||
rejectBill: () => validator.reject(),
|
||||
|
||||
cleanup: async () => {
|
||||
return new Promise<void>((resolve) => {
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
/**
|
||||
* Classify an initialization failure into a maintenance-screen sentinel
|
||||
* (see App.vue's MAINTENANCE_SCREENS).
|
||||
*
|
||||
* Bunker failures (aiolabs/bitspire#52) get dedicated screens:
|
||||
* - `NoPairingError` (fresh machine, never paired) → `unpaired` — render the
|
||||
* interactive QR-pairing wizard so the operator can scan a spire-seed.
|
||||
* - `BunkerRejectedError` (revoked / TTL-expired / off-policy binding) →
|
||||
* `unpaired` too — re-pairing is the same scan-a-fresh-seed flow.
|
||||
* - `BunkerTimeoutError` (signer/relay unreachable) → `signer-unreachable`,
|
||||
* a transient condition.
|
||||
* Everything else surfaces its raw message (or the caller's fallback).
|
||||
*/
|
||||
export function classifyInitError(error: unknown, fallback = 'Initialization failed'): string {
|
||||
const name = (error as { name?: string } | null)?.name
|
||||
if (name === 'NoPairingError') return 'unpaired'
|
||||
if (name === 'BunkerRejectedError') return 'unpaired'
|
||||
if (name === 'BunkerTimeoutError') return 'signer-unreachable'
|
||||
return error instanceof Error ? error.message : fallback
|
||||
}
|
||||
|
|
@ -12,16 +12,19 @@
|
|||
* the customer's invoice.
|
||||
*/
|
||||
|
||||
import { NostrClient, type Signer } from '@bitSpire/nostr-client'
|
||||
import { resolveSigner } from './signer-resolver.js'
|
||||
import { LnbitsClient, type DispenseReportBody, type DispenseReportAck } from '@bitSpire/lnbits'
|
||||
import {
|
||||
NostrClient,
|
||||
generateIdentity,
|
||||
loadIdentityFromHex,
|
||||
type MachineIdentity,
|
||||
} from '@bitSpire/nostr-client'
|
||||
import { LnbitsClient } from '@bitSpire/lnbits'
|
||||
import { CLINKClient } from '@bitSpire/clink'
|
||||
import type { OfferRequest, ManagementRequest, ManagementResponse } from '@bitSpire/clink'
|
||||
import type { ATMServices, ATMContext } from '@bitSpire/state-machine'
|
||||
|
||||
// Import Electron types
|
||||
import type {} from '@/types/electron'
|
||||
import { formatBays, formatInventory } from '@/lib/utils'
|
||||
|
||||
// Check if we're running in Electron (electronAPI is exposed via preload)
|
||||
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
|
||||
|
|
@ -34,12 +37,14 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
|
|||
*
|
||||
* Environment variables:
|
||||
* - VITE_RELAY_URL: Nostr relay WebSocket URL
|
||||
* - VITE_LNBITS_SERVER_PUBKEY: LNbits nostr-transport server pubkey (hex)
|
||||
* - VITE_SPIRE_SEED: spire pairing seed (NIP-46 bunker); see signer-resolver.ts
|
||||
* - VITE_OPERATOR_PUBKEYS: comma-separated operator pubkeys (hex)
|
||||
* - VITE_LIGHTNING_PUB_PUBKEY: Lightning.Pub's Nostr pubkey (hex or npub)
|
||||
* - VITE_LIGHTNING_PUB_API_URL: Lightning.Pub HTTP API URL
|
||||
* - VITE_ATM_PRIVATE_KEY: ATM's Nostr private key (hex or nsec) // pragma: allowlist secret
|
||||
* - VITE_ADMIN_TOKEN: Lightning.Pub admin token (dev only)
|
||||
*/
|
||||
interface LightningConfig {
|
||||
relayUrl: string
|
||||
atmPrivateKey: string
|
||||
appId: string
|
||||
operatorPubkeys: string[]
|
||||
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
||||
|
|
@ -55,10 +60,8 @@ interface LightningConfig {
|
|||
*/
|
||||
async function loadLightningConfig(): Promise<LightningConfig> {
|
||||
const defaults: LightningConfig = {
|
||||
// Empty when unset (not the dev relay) so initializeLightningServices can
|
||||
// tell "operator gave us a relay" from "fall back to the pairing seed". See
|
||||
// aiolabs/bitspire#70 and DEV_DEFAULT_RELAY.
|
||||
relayUrl: '',
|
||||
relayUrl: 'ws://localhost:7777',
|
||||
atmPrivateKey: '',
|
||||
appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID
|
||||
operatorPubkeys: [],
|
||||
lnbitsServerPubkey: '',
|
||||
|
|
@ -67,8 +70,10 @@ async function loadLightningConfig(): Promise<LightningConfig> {
|
|||
if (isElectron && window.electronAPI) {
|
||||
try {
|
||||
const rc = await window.electronAPI.getConfig()
|
||||
const sec = await window.electronAPI.getAtmSecrets()
|
||||
return {
|
||||
relayUrl: rc.relayUrl || defaults.relayUrl,
|
||||
atmPrivateKey: sec.atmPrivateKey || defaults.atmPrivateKey,
|
||||
appId: rc.appId || defaults.appId,
|
||||
operatorPubkeys: rc.operatorPubkeys
|
||||
? rc.operatorPubkeys
|
||||
|
|
@ -85,6 +90,7 @@ async function loadLightningConfig(): Promise<LightningConfig> {
|
|||
|
||||
return {
|
||||
relayUrl: import.meta.env.VITE_RELAY_URL || defaults.relayUrl,
|
||||
atmPrivateKey: import.meta.env.VITE_ATM_PRIVATE_KEY || defaults.atmPrivateKey,
|
||||
appId: import.meta.env.VITE_APP_ID || defaults.appId,
|
||||
lnbitsServerPubkey:
|
||||
(import.meta.env.VITE_LNBITS_SERVER_PUBKEY as string | undefined) ||
|
||||
|
|
@ -101,10 +107,6 @@ async function loadLightningConfig(): Promise<LightningConfig> {
|
|||
// Config is loaded async now - will be set in initializeLightningServices
|
||||
let CONFIG: LightningConfig
|
||||
|
||||
/** Dev-only relay used when neither env nor the pairing supplies one. Matches
|
||||
* the dev stack — LNbits's bundled nostrrelay (no separate strfry container). */
|
||||
const DEV_DEFAULT_RELAY = 'ws://localhost:5001/nostrrelay/test'
|
||||
|
||||
/** Safety timeout in ms (15 minutes) — absolute maximum LNURL session lifetime.
|
||||
* Sessions are normally cleaned up by the state machine on idle transition.
|
||||
* This is a safety net in case the state machine doesn't clean up properly. */
|
||||
|
|
@ -117,27 +119,33 @@ const SESSION_SAFETY_TIMEOUT_MS = 15 * 60 * 1000
|
|||
/** Active LNURL-withdraw session */
|
||||
interface LnurlSession {
|
||||
sessionId: string
|
||||
/** Link ID — the management + settlement-watch key (delete/subscribe). */
|
||||
/** Link ID for management operations (delete/update) */
|
||||
linkId: string
|
||||
uniqueHash: string
|
||||
satsAmount: number
|
||||
status: 'active' | 'claimed' | 'expired'
|
||||
createdAt: number
|
||||
cleanup?: () => void
|
||||
}
|
||||
|
||||
/** Map of linkId -> LNURL session data. Keyed on link_id since the secure
|
||||
* `create_withdraw` response (spirekeeper#31) carries no `unique_hash`. */
|
||||
/** Map of uniqueHash -> LNURL session data */
|
||||
const lnurlSessions = new Map<string, LnurlSession>()
|
||||
|
||||
/**
|
||||
* Register a new LNURL-withdraw session, keyed by linkId.
|
||||
* Register a new LNURL-withdraw session
|
||||
*/
|
||||
function registerLnurlSession(sessionId: string, linkId: string, satsAmount: number): void {
|
||||
console.log('[LNURL Session] Registering:', linkId, 'for', satsAmount, 'sats')
|
||||
function registerLnurlSession(
|
||||
sessionId: string,
|
||||
linkId: string,
|
||||
uniqueHash: string,
|
||||
satsAmount: number,
|
||||
): void {
|
||||
console.log('[LNURL Session] Registering:', uniqueHash, 'for', satsAmount, 'sats')
|
||||
|
||||
lnurlSessions.set(linkId, {
|
||||
lnurlSessions.set(uniqueHash, {
|
||||
sessionId,
|
||||
linkId,
|
||||
uniqueHash,
|
||||
satsAmount,
|
||||
status: 'active',
|
||||
createdAt: Date.now(),
|
||||
|
|
@ -145,10 +153,10 @@ function registerLnurlSession(sessionId: string, linkId: string, satsAmount: num
|
|||
|
||||
// Safety timeout — normally cleaned up by state machine on idle transition.
|
||||
setTimeout(() => {
|
||||
const session = lnurlSessions.get(linkId)
|
||||
const session = lnurlSessions.get(uniqueHash)
|
||||
if (session && session.status === 'active') {
|
||||
console.warn('[LNURL Session] Safety timeout reached, expiring:', linkId)
|
||||
expireLnurlSession(linkId)
|
||||
console.warn('[LNURL Session] Safety timeout reached, expiring:', uniqueHash)
|
||||
expireLnurlSession(uniqueHash)
|
||||
}
|
||||
}, SESSION_SAFETY_TIMEOUT_MS)
|
||||
}
|
||||
|
|
@ -156,23 +164,23 @@ function registerLnurlSession(sessionId: string, linkId: string, satsAmount: num
|
|||
/** Invalidate an active LNURL session by cash-in sessionId. The session's
|
||||
* cleanup closure unsubscribes from LNbits and deletes the link. */
|
||||
function invalidateLnurlSessionBySessionId(sessionId: string): void {
|
||||
for (const [linkId, session] of lnurlSessions.entries()) {
|
||||
for (const [hash, session] of lnurlSessions.entries()) {
|
||||
if (session.sessionId === sessionId && session.status === 'active') {
|
||||
console.log('[LNURL Session] Invalidating previous session:', linkId)
|
||||
expireLnurlSession(linkId)
|
||||
console.log('[LNURL Session] Invalidating previous session:', hash)
|
||||
expireLnurlSession(hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Expire a single LNURL session via its cleanup closure. */
|
||||
function expireLnurlSession(linkId: string): void {
|
||||
const session = lnurlSessions.get(linkId)
|
||||
function expireLnurlSession(uniqueHash: string): void {
|
||||
const session = lnurlSessions.get(uniqueHash)
|
||||
if (!session || session.status !== 'active') return
|
||||
|
||||
console.log('[LNURL Session] Expiring:', linkId)
|
||||
console.log('[LNURL Session] Expiring:', uniqueHash)
|
||||
session.status = 'expired'
|
||||
if (session.cleanup) session.cleanup()
|
||||
setTimeout(() => lnurlSessions.delete(linkId), 60000)
|
||||
setTimeout(() => lnurlSessions.delete(uniqueHash), 60000)
|
||||
}
|
||||
|
||||
let _lnbitsRef: LnbitsClient | null = null
|
||||
|
|
@ -218,17 +226,15 @@ export interface LightningBackend {
|
|||
}): Promise<{ paymentRequest: string; paymentHash?: string }>
|
||||
payInvoice(
|
||||
bolt11: string,
|
||||
amountSats: number
|
||||
amountSats: number,
|
||||
): Promise<{ success: boolean; preimage?: string; error?: string }>
|
||||
}
|
||||
|
||||
interface LightningServices {
|
||||
/** ADR-005 §2: send one cash-out's dispense outcome to spirekeeper (outbox-driven). */
|
||||
reportDispense: (body: DispenseReportBody) => Promise<DispenseReportAck>
|
||||
nostrClient: NostrClient
|
||||
lightningPub: LightningBackend
|
||||
clink: CLINKClient
|
||||
signer: Signer
|
||||
identity: MachineIdentity
|
||||
/** Operator pubkeys (hex) authorized for kind-21003 management + operator-config events. */
|
||||
operatorPubkeys: string[]
|
||||
atmServices: ATMServices
|
||||
|
|
@ -405,85 +411,50 @@ export async function initializeLightningServices(options?: {
|
|||
// Load configuration (async for Electron runtime config)
|
||||
CONFIG = await loadLightningConfig()
|
||||
|
||||
// Resolve the signing identity BEFORE validating the LNbits transport
|
||||
// config. An unpaired machine must reach the QR-pairing wizard regardless
|
||||
// of relay/server-pubkey provisioning — pairing is what provides those — so
|
||||
// resolveSigner (which throws NoPairingError → 'unpaired' → wizard for a
|
||||
// machine with no seed and no binding) has to run ahead of the config
|
||||
// checks below. The relay/pubkey validation then only gates a *paired*
|
||||
// machine that's actually trying to talk to LNbits. See aiolabs/bitspire#70.
|
||||
//
|
||||
// In production this is a BunkerSigner over NIP-46 (the ATM holds only a
|
||||
// transport key; the operator's nsecbunkerd holds the signing key); in dev
|
||||
// it falls back to an in-process LocalSigner. The Phase-A Signer seam means
|
||||
// nothing downstream changes. See aiolabs/bitspire#52.
|
||||
const { signer, transport } = await resolveSigner({ allowEphemeral: !options?.strict })
|
||||
console.log('[Lightning] ATM pubkey:', signer.pubkey)
|
||||
console.log('[Lightning] Relay URL:', CONFIG.relayUrl)
|
||||
console.log('[Lightning] LNbits server pubkey:', CONFIG.lnbitsServerPubkey || '(not configured)')
|
||||
|
||||
// Resolve the effective LNbits transport. Precedence: explicit env wins (dev
|
||||
// + operator override), else the pairing (seed/binding) supplies it (#70) so
|
||||
// a blank-.env paired machine reaches the backend from the seed alone, else a
|
||||
// dev-only localhost fallback. CONFIG is mutated to the resolved values so
|
||||
// downstream (and the exported CONFIG) see a single source of truth.
|
||||
const envRelay = CONFIG.relayUrl
|
||||
const envPubkey = CONFIG.lnbitsServerPubkey
|
||||
const relays: string[] = envRelay
|
||||
? [envRelay]
|
||||
: transport && transport.relays.length > 0
|
||||
? transport.relays
|
||||
: [DEV_DEFAULT_RELAY]
|
||||
CONFIG.relayUrl = relays[0]!
|
||||
CONFIG.lnbitsServerPubkey = envPubkey || transport?.lnbitsServerPubkey || ''
|
||||
console.log(
|
||||
'[Lightning] Relay(s):',
|
||||
relays.join(', '),
|
||||
envRelay ? '(env)' : transport?.relays.length ? '(pairing)' : '(default)'
|
||||
)
|
||||
console.log(
|
||||
'[Lightning] LNbits server pubkey:',
|
||||
CONFIG.lnbitsServerPubkey || '(not configured)',
|
||||
envPubkey ? '(env)' : transport?.lnbitsServerPubkey ? '(pairing)' : ''
|
||||
)
|
||||
// Operator pubkey provenance. Today the ONLY source is VITE_OPERATOR_PUBKEYS
|
||||
// (env). An empty set disables the fees/operator-config services → the machine
|
||||
// sits at "awaiting configuration" — so log it loudly rather than fail silent.
|
||||
// (aiolabs/bitspire#70 P1 will source this from LNbits over the transport.)
|
||||
console.log(
|
||||
'[Lightning] Operator pubkey(s):',
|
||||
CONFIG.operatorPubkeys.length
|
||||
? CONFIG.operatorPubkeys.join(', ') + ' (env)'
|
||||
: '(none — fee/operator config gated until a server-delivered operator pubkey; #70 P1)'
|
||||
)
|
||||
|
||||
// Strict mode: validate the RESOLVED config is production-ready (no
|
||||
// localhost). Values may come from env or the pairing seed (#70).
|
||||
// Strict mode: validate config is production-ready (no localhost, no ephemeral identity)
|
||||
if (options?.strict) {
|
||||
const errors: string[] = []
|
||||
if (/localhost|127\.0\.0\.1/.test(CONFIG.relayUrl)) {
|
||||
errors.push('relay resolves to localhost (VITE_RELAY_URL / seed relays)')
|
||||
errors.push('VITE_RELAY_URL contains localhost')
|
||||
}
|
||||
if (!CONFIG.atmPrivateKey) {
|
||||
errors.push('VITE_ATM_PRIVATE_KEY is not set (ephemeral identity not allowed in production)')
|
||||
}
|
||||
if (!CONFIG.lnbitsServerPubkey) {
|
||||
errors.push('no LNbits server pubkey (VITE_LNBITS_SERVER_PUBKEY / seed lnbits_npub)')
|
||||
errors.push('VITE_LNBITS_SERVER_PUBKEY is not set')
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new Error('[Lightning] Production config validation failed:\n- ' + errors.join('\n- '))
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required configuration. Reached only for a paired machine (an
|
||||
// unpaired one threw NoPairingError above) — it needs the LNbits server
|
||||
// pubkey to talk to the transport, from either env or the pairing seed.
|
||||
// Validate required configuration
|
||||
if (!CONFIG.lnbitsServerPubkey) {
|
||||
throw new Error(
|
||||
'[Lightning] LNbits server pubkey is required — set VITE_LNBITS_SERVER_PUBKEY ' +
|
||||
'or pair with a seed that carries lnbits_npub (aiolabs/bitspire#70).'
|
||||
'[Lightning] VITE_LNBITS_SERVER_PUBKEY is required. ' +
|
||||
'Get it from: docker logs lnbits | grep nostr_transport pubkey',
|
||||
)
|
||||
}
|
||||
|
||||
// Load or generate ATM identity
|
||||
let identity: MachineIdentity
|
||||
if (CONFIG.atmPrivateKey) {
|
||||
identity = loadIdentityFromHex(CONFIG.atmPrivateKey)
|
||||
console.log('[Lightning] Loaded ATM identity from config')
|
||||
} else {
|
||||
identity = generateIdentity()
|
||||
console.warn('[Lightning] No VITE_ATM_PRIVATE_KEY configured - generated ephemeral identity')
|
||||
console.warn('[Lightning] Set VITE_ATM_PRIVATE_KEY for persistent identity across restarts')
|
||||
}
|
||||
console.log('[Lightning] ATM pubkey:', identity.publicKey)
|
||||
|
||||
// Create Nostr client
|
||||
const nostrClient = new NostrClient({
|
||||
relays: relays.map((url) => ({ url })),
|
||||
signer,
|
||||
relays: [{ url: CONFIG.relayUrl }],
|
||||
identity,
|
||||
})
|
||||
|
||||
await nostrClient.connect()
|
||||
|
|
@ -492,9 +463,9 @@ export async function initializeLightningServices(options?: {
|
|||
// LNbits nostr-transport client.
|
||||
const lnbits = new LnbitsClient({
|
||||
serverPubkey: CONFIG.lnbitsServerPubkey,
|
||||
relays,
|
||||
relays: [CONFIG.relayUrl],
|
||||
})
|
||||
lnbits.initialize(nostrClient, signer)
|
||||
lnbits.initialize(nostrClient, identity)
|
||||
_lnbitsRef = lnbits
|
||||
console.log('[Lightning] LNbits client initialized')
|
||||
|
||||
|
|
@ -508,83 +479,14 @@ export async function initializeLightningServices(options?: {
|
|||
}
|
||||
console.log('[Lightning] LNbits wallet:', lnbitsWalletId)
|
||||
|
||||
// ── Public web demo: stamp the throwaway account so it can be swept ──────
|
||||
// The browser demo (atm.demo.aiolabs.dev) runs with an EPHEMERAL identity —
|
||||
// a fresh keypair per page load — so LNbits mints a new account + a fresh
|
||||
// auto-credited wallet for every visitor. That isolation is the point (a
|
||||
// single baked-in key would be credited exactly once and then drain), but it
|
||||
// leaves throwaway accounts behind, and nothing in an auto-created row says
|
||||
// "demo": pubkey-set/prvkey-NULL also describes a real ATM.
|
||||
//
|
||||
// A nostr pubkey can't carry a marker (you'd have to grind a vanity prefix,
|
||||
// far too slow to do on page load), and the account/wallet the server
|
||||
// auto-creates isn't nameable by the client. So we mint one extra,
|
||||
// never-used wallet whose NAME is the tag: sweeping is then an exact string
|
||||
// match on wallet name rather than a heuristic about what looks disposable.
|
||||
//
|
||||
// Unset on every real machine, so this is inert outside the demo build. The
|
||||
// call is fire-and-forget: losing the marker degrades cleanup, not the demo.
|
||||
const demoTag = (import.meta.env.VITE_DEMO_TAG as string | undefined)?.trim()
|
||||
if (demoTag) {
|
||||
void lnbits
|
||||
.createWallet(demoTag)
|
||||
// Never log the reply — create_wallet returns adminkey/inkey.
|
||||
.then(() => console.log('[Lightning] Demo marker wallet created:', demoTag))
|
||||
.catch((e) => console.warn('[Lightning] Demo marker wallet failed:', e))
|
||||
}
|
||||
|
||||
// #70 P1: pull operator pubkey + fee config from LNbits over the authenticated
|
||||
// transport (spirekeeper#41 `get_machine_config`). A seed-only machine has no
|
||||
// VITE_OPERATOR_PUBKEYS, so without this it can't trust its fee config and sits
|
||||
// at "awaiting configuration". Only for the seed-only case — an explicit
|
||||
// VITE_OPERATOR_PUBKEYS override keeps the env/kind-30078 path untouched.
|
||||
// Soft-fail: an older spirekeeper (no RPC) or a transport error falls back to
|
||||
// whatever the operator services can pull from kind-30078.
|
||||
if (CONFIG.operatorPubkeys.length === 0) {
|
||||
try {
|
||||
const mc = await lnbits.getMachineConfig()
|
||||
if (mc.operator_pubkey) {
|
||||
CONFIG.operatorPubkeys = [mc.operator_pubkey]
|
||||
console.log(
|
||||
'[Lightning] Operator pubkey(s):',
|
||||
mc.operator_pubkey,
|
||||
'(server-delivered, #70 P1)'
|
||||
)
|
||||
}
|
||||
if (mc.fee_config && isElectron && window.electronAPI) {
|
||||
// Persist the server-delivered fee config so atm.ts's awaiting-fees gate
|
||||
// (getFeeConfig) clears immediately — robust to the replaceable kind-30078
|
||||
// event not being fetchable from the relay. The live kind-30078
|
||||
// subscription still handles mid-run fee updates.
|
||||
const applied = await window.electronAPI.applyFeeConfig(
|
||||
{
|
||||
cashInFeeFraction: mc.fee_config.cash_in_fee_fraction,
|
||||
cashOutFeeFraction: mc.fee_config.cash_out_fee_fraction,
|
||||
schemaVersion: mc.fee_config.schema_version,
|
||||
},
|
||||
mc.created_at
|
||||
)
|
||||
console.log(
|
||||
'[Lightning] Server-delivered fee config:',
|
||||
applied.applied ? 'applied' : `skipped (${applied.reason})`
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'[Lightning] get_machine_config unavailable; falling back to env/kind-30078 for operator config:',
|
||||
(e as Error).message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// CLINK client — kept in tree but not actively wired into LNbits flows.
|
||||
// operatorPubkey is the operator allowlist for kind-21003 management
|
||||
// commands; it has no Lightning.Pub dependency.
|
||||
const clink = new CLINKClient({
|
||||
nostrClient,
|
||||
signer,
|
||||
identity,
|
||||
operatorPubkey: CONFIG.operatorPubkeys,
|
||||
relays,
|
||||
relays: [CONFIG.relayUrl],
|
||||
})
|
||||
|
||||
// Callbacks for events
|
||||
|
|
@ -672,27 +574,23 @@ export async function initializeLightningServices(options?: {
|
|||
}
|
||||
|
||||
const atmServices = createATMServices(
|
||||
identity,
|
||||
(preimage) => {
|
||||
if (paymentReceivedCallback) {
|
||||
paymentReceivedCallback(preimage)
|
||||
}
|
||||
},
|
||||
lnbits,
|
||||
lnbitsWalletId
|
||||
lnbitsWalletId,
|
||||
)
|
||||
|
||||
return {
|
||||
nostrClient,
|
||||
lightningPub,
|
||||
clink,
|
||||
signer,
|
||||
identity,
|
||||
operatorPubkeys: CONFIG.operatorPubkeys,
|
||||
atmServices,
|
||||
/**
|
||||
* ADR-005 §2: send one cash-out's dispense outcome to spirekeeper. The
|
||||
* store keeps these in a durable outbox and calls this until it resolves.
|
||||
*/
|
||||
reportDispense: (body: DispenseReportBody) => lnbits.reportDispense(body),
|
||||
onOfferRequest: (callback: OfferRequestCallback) => {
|
||||
offerRequestCallback = callback
|
||||
},
|
||||
|
|
@ -718,142 +616,14 @@ export async function initializeLightningServices(options?: {
|
|||
/**
|
||||
* Create ATMServices implementation using the LNbits nostr-transport.
|
||||
*/
|
||||
export function createATMServices(
|
||||
function createATMServices(
|
||||
_identity: MachineIdentity,
|
||||
onPaymentSuccess: (preimage: string) => void,
|
||||
lnbits: LnbitsClient,
|
||||
lnbitsWalletId: string
|
||||
lnbitsWalletId: string,
|
||||
): ATMServices {
|
||||
const onPaymentCallback = onPaymentSuccess
|
||||
|
||||
// ── Cash-out settlement watch ───────────────────────────────────────────
|
||||
/**
|
||||
* A watch on one cash-out invoice, armed the moment the invoice exists and
|
||||
* consumed later by the state machine's `displayingInvoice` actor.
|
||||
*
|
||||
* Arming at creation rather than at display closes a race that swallowed
|
||||
* real payments on 2026-09-22 (sintra). Subscribing costs a nostr round
|
||||
* trip — about four seconds against a remote relay — while a one-tap Bolt
|
||||
* Card Complete settles in roughly one. The settlement push is an ephemeral
|
||||
* event with no replay, so it fired before anything was listening and the
|
||||
* machine sat on a paid invoice until it timed out, taking the sats without
|
||||
* dispensing. The old two-tap flow only worked because fumbling with the
|
||||
* card covered the gap.
|
||||
*
|
||||
* Three defences, in order: the invoice is not returned until its watch is
|
||||
* armed; a settlement that still beats the UI is latched and replayed when
|
||||
* the consumer attaches; and a poll runs alongside the subscription so a
|
||||
* lost or unsent push cannot strand a payment either way.
|
||||
*/
|
||||
interface InvoiceWatch {
|
||||
paymentHash: string
|
||||
subId: string | null
|
||||
/** Preimage seen before a consumer attached; replayed on attach. */
|
||||
settled: string | null
|
||||
consumer: ((preimage: string, paymentHash: string) => void) | null
|
||||
poll: ReturnType<typeof setInterval> | null
|
||||
released: boolean
|
||||
}
|
||||
const invoiceWatches = new Map<string, InvoiceWatch>()
|
||||
|
||||
// Backstop cadence. One cheap RPC; on the money path a little extra relay
|
||||
// traffic is worth far more than a payment that lands with no cash.
|
||||
const SETTLEMENT_POLL_MS = 6_000
|
||||
|
||||
function stopInvoiceWatchPoll(watch: InvoiceWatch): void {
|
||||
if (watch.poll) {
|
||||
clearInterval(watch.poll)
|
||||
watch.poll = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Deliver a settlement exactly once, to the consumer or into the latch. */
|
||||
function settleInvoiceWatch(watch: InvoiceWatch, preimage: string, via: string): void {
|
||||
if (watch.released || watch.settled) return
|
||||
watch.settled = preimage
|
||||
stopInvoiceWatchPoll(watch)
|
||||
console.log(`[ATM Service] Invoice paid (${via})!`)
|
||||
watch.consumer?.(preimage, watch.paymentHash)
|
||||
}
|
||||
|
||||
function startInvoiceWatchPoll(watch: InvoiceWatch): void {
|
||||
let inFlight = false
|
||||
watch.poll = setInterval(() => {
|
||||
if (inFlight || watch.settled || watch.released) return
|
||||
inFlight = true
|
||||
void lnbits
|
||||
.getPayment(watch.paymentHash)
|
||||
.then((payment) => {
|
||||
if (payment?.status === 'success') {
|
||||
settleInvoiceWatch(watch, payment.preimage ?? 'payment-confirmed', 'poll')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* transport blip — the next tick retries */
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = false
|
||||
})
|
||||
}, SETTLEMENT_POLL_MS)
|
||||
}
|
||||
|
||||
/** Arm the watch for a freshly created invoice. Resolves once it is live. */
|
||||
async function armInvoiceWatch(bolt11: string, paymentHash: string): Promise<void> {
|
||||
const startedAt = Date.now()
|
||||
const watch: InvoiceWatch = {
|
||||
paymentHash,
|
||||
subId: null,
|
||||
settled: null,
|
||||
consumer: null,
|
||||
poll: null,
|
||||
released: false,
|
||||
}
|
||||
invoiceWatches.set(bolt11, watch)
|
||||
try {
|
||||
// walletId omitted: payment_hash is the natural primary key for "wait
|
||||
// for THIS invoice to settle." Under path B
|
||||
// (NOSTR_TRANSPORT_ROSTER_REQUIRED=true) lnbits routes the payment to
|
||||
// the operator's wallet, so a subscription scoped to the ATM's
|
||||
// pre-override wallet_id would AND-filter the settlement out and never
|
||||
// fire. With wallet_id omitted, lnbits resolves the wallet from
|
||||
// get_standalone_payment(payment_hash) and ownership-checks against the
|
||||
// auth'd account — works on both pre/post-override wallets.
|
||||
// Coordination log 2026-05-31T18:50Z (lnbits) for the confirmation,
|
||||
// §18:35Z for the joint smoke that surfaced the bug.
|
||||
watch.subId = await lnbits.subscribePayments(
|
||||
undefined,
|
||||
{ payment_hash: paymentHash, max_seconds: 600 },
|
||||
(push) => {
|
||||
if (push.payment_hash !== paymentHash || push.status !== 'success') return
|
||||
settleInvoiceWatch(watch, push.preimage ?? 'payment-confirmed', 'LNbits push')
|
||||
},
|
||||
(reason) => console.log(`[ATM Service] Settlement subscription closed (${reason})`)
|
||||
)
|
||||
console.log(
|
||||
`[ATM Service] Settlement watch armed in ${Date.now() - startedAt} ms ` +
|
||||
`(hash ${paymentHash.slice(0, 12)}…)`
|
||||
)
|
||||
} catch (e) {
|
||||
// The poll below then carries settlement on its own, which is exactly
|
||||
// why it runs whether or not the subscription came up.
|
||||
console.error('[ATM Service] Settlement subscribe failed — polling only:', e)
|
||||
}
|
||||
startInvoiceWatchPoll(watch)
|
||||
}
|
||||
|
||||
/** Tear a watch down: the transaction ended, one way or another. */
|
||||
function releaseInvoiceWatch(bolt11: string): void {
|
||||
const watch = invoiceWatches.get(bolt11)
|
||||
if (!watch) return
|
||||
watch.released = true
|
||||
watch.consumer = null
|
||||
stopInvoiceWatchPoll(watch)
|
||||
invoiceWatches.delete(bolt11)
|
||||
if (watch.subId) {
|
||||
// wallet_id omitted to match the subscribePayments call above.
|
||||
void lnbits.unsubscribe(undefined, watch.subId).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* 3b.4: ndebit cash-in path removed. CashInView.vue ignores this
|
||||
|
|
@ -891,70 +661,57 @@ export function createATMServices(
|
|||
* over nostr, we trigger dispense.
|
||||
*/
|
||||
generateLnurlWithdraw: async (context: ATMContext): Promise<string> => {
|
||||
// GROSS principal (fiat × rate, BEFORE commission). The server derives
|
||||
// fee + NET from this, so we must NOT send the already-fee'd
|
||||
// context.satsAmount — doing so double-applies the commission (client
|
||||
// subtracts it in calculateSats, then the server subtracts it again,
|
||||
// e.g. 12% → 22.6% effective; the customer is short-changed while the
|
||||
// quote/receipt still read 12%). Mirror calculateSats's principal.
|
||||
const grossPrincipalSats = Math.floor((context.fiatCents / 100) * context.exchangeRate)
|
||||
console.log(
|
||||
`[ATM Service] Generating LNURL-withdraw: gross principal=${grossPrincipalSats} sats ` +
|
||||
`(net after ${(context.feeFraction * 100).toFixed(2)}% ≈ ${context.satsAmount})`
|
||||
)
|
||||
console.log('[ATM Service] Generating LNURL-withdraw for', context.satsAmount, 'sats')
|
||||
|
||||
try {
|
||||
if (context.cashInSessionId) {
|
||||
invalidateLnurlSessionBySessionId(context.cashInSessionId)
|
||||
}
|
||||
|
||||
// Secure cash-in: the ATM sends only the hardware-attested gross
|
||||
// principal; the operator side verifies the signer, derives fee + NET,
|
||||
// and stamps attribution (spirekeeper#31/#32). The ATM no longer sets
|
||||
// the amount or extra. We display the returned LNURL (for NET) and
|
||||
// watch link_id for settlement.
|
||||
const link = await lnbits.createWithdraw(lnbitsWalletId, {
|
||||
principal_sats: grossPrincipalSats,
|
||||
fiat_amount: context.fiatCents / 100,
|
||||
fiat_code: context.currency,
|
||||
const link = await lnbits.createWithdrawLink(lnbitsWalletId, {
|
||||
title: `bitSpire Cash-In ${context.cashInSessionId?.slice(0, 8) || 'session'}`,
|
||||
client_ref: context.txid ?? context.cashInSessionId ?? undefined,
|
||||
min_withdrawable: context.satsAmount,
|
||||
max_withdrawable: context.satsAmount,
|
||||
uses: 1,
|
||||
wait_time: 1,
|
||||
is_unique: false,
|
||||
})
|
||||
|
||||
if (!link.lnurl) {
|
||||
throw new Error(
|
||||
'[ATM Service] create_withdraw returned no lnurl — check withdraw#3 / LNBITS_BASEURL on the server'
|
||||
'[ATM Service] LNbits returned link.lnurl=null — check LNBITS_BASEURL on the server (aiolabs/withdraw#1)'
|
||||
)
|
||||
}
|
||||
const lnurl = link.lnurl.toUpperCase()
|
||||
console.log(
|
||||
`[ATM Service] create_withdraw: principal=${link.principal_sats} fee=${link.fee_sats} net=${link.net_sats} link=${link.link_id}`
|
||||
)
|
||||
|
||||
if (context.cashInSessionId) {
|
||||
// Track the NET (what the customer withdraws); keyed by link_id.
|
||||
registerLnurlSession(context.cashInSessionId, link.link_id, link.net_sats)
|
||||
registerLnurlSession(
|
||||
context.cashInSessionId,
|
||||
link.id,
|
||||
link.unique_hash,
|
||||
context.satsAmount,
|
||||
)
|
||||
const subId = await lnbits.subscribePayments(
|
||||
lnbitsWalletId,
|
||||
{ tag: 'withdraw', link_id: link.link_id, max_seconds: 600 },
|
||||
{ tag: 'withdraw', link_id: link.id, max_seconds: 600 },
|
||||
(push) => {
|
||||
console.log('[ATM Service] LNURL-withdraw claimed (LNbits push)!')
|
||||
const session = lnurlSessions.get(link.link_id)
|
||||
const session = lnurlSessions.get(link.unique_hash)
|
||||
if (session) {
|
||||
session.status = 'claimed'
|
||||
lnurlSessions.delete(link.link_id)
|
||||
lnurlSessions.delete(link.unique_hash)
|
||||
}
|
||||
if (onPaymentCallback) {
|
||||
onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.link_id}`)
|
||||
}
|
||||
onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.unique_hash}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
// Wire per-session cleanup so abort/expiry tears it down cleanly.
|
||||
const session = lnurlSessions.get(link.link_id)
|
||||
const session = lnurlSessions.get(link.unique_hash)
|
||||
if (session) {
|
||||
session.cleanup = () => {
|
||||
void lnbits.unsubscribe(lnbitsWalletId, subId).catch(() => {})
|
||||
void lnbits.deleteWithdrawLink(lnbitsWalletId, link.link_id).catch(() => {})
|
||||
void lnbits.deleteWithdrawLink(lnbitsWalletId, link.id).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -990,14 +747,15 @@ export function createATMServices(
|
|||
* matches machine fiat_code
|
||||
* - `type: "cash_out"` / `source: "bitspire"` — discriminators
|
||||
*/
|
||||
// (see armInvoiceWatch below — the watch is armed before this resolves)
|
||||
generateInvoice: async (context: ATMContext): Promise<string> => {
|
||||
const amountSats = context.satsAmount
|
||||
// Cash-out: satsAmount = principal + commission. principal is
|
||||
// derived from the raw market rate (no commission baked in) so a
|
||||
// consumer can independently audit the split.
|
||||
const principalSats =
|
||||
context.exchangeRate > 0 ? Math.floor((context.fiatCents / 100) * context.exchangeRate) : 0
|
||||
context.exchangeRate > 0
|
||||
? Math.floor((context.fiatCents / 100) * context.exchangeRate)
|
||||
: 0
|
||||
const feeSats = Math.max(0, amountSats - principalSats)
|
||||
console.log(
|
||||
'[ATM Service] Generating invoice — gross',
|
||||
|
|
@ -1037,17 +795,6 @@ export function createATMServices(
|
|||
if (!payment.payment_request) {
|
||||
throw new Error('LNbits createInvoice returned empty payment_request')
|
||||
}
|
||||
// Arm the settlement watch BEFORE the invoice reaches the screen, and
|
||||
// take the payment hash from the response we already have rather than
|
||||
// spending a round trip decoding it back off the bolt11. See
|
||||
// armInvoiceWatch for why the timing matters.
|
||||
if (payment.payment_hash) {
|
||||
await armInvoiceWatch(payment.payment_request, payment.payment_hash)
|
||||
} else {
|
||||
console.error(
|
||||
'[ATM Service] createInvoice returned no payment_hash — settlement watch will arm late'
|
||||
)
|
||||
}
|
||||
return payment.payment_request
|
||||
},
|
||||
|
||||
|
|
@ -1100,7 +847,7 @@ export function createATMServices(
|
|||
* Dispense cash (mock for development)
|
||||
*/
|
||||
dispenseCash: async (amounts) => {
|
||||
console.log(`[ATM Service] Dispensing cash: ${formatBays(amounts)}`)
|
||||
console.log('[ATM Service] Dispensing cash:', amounts)
|
||||
|
||||
// In production, this would interface with the Rust HAL
|
||||
// For now, simulate dispense delay
|
||||
|
|
@ -1113,7 +860,7 @@ export function createATMServices(
|
|||
dispensed: a.count,
|
||||
rejected: 0,
|
||||
})),
|
||||
dispenseConfirmed: true,
|
||||
dispensed: true,
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -1213,34 +960,16 @@ export function createATMServices(
|
|||
* Watch a BOLT11 invoice for payment via LNbits subscribe_payments
|
||||
* push, filtered by payment_hash. Returns a cleanup function.
|
||||
*/
|
||||
watchInvoice: (
|
||||
invoice: string,
|
||||
callback: (preimage: string, paymentHash?: string) => void
|
||||
): (() => void) => {
|
||||
watchInvoice: (invoice: string, callback: (preimage: string) => void): (() => void) => {
|
||||
console.log('[ATM Service] Watching invoice for payment:', invoice.slice(0, 32) + '...')
|
||||
|
||||
if (!invoice.toLowerCase().startsWith('ln')) {
|
||||
console.error('[ATM Service] Invalid invoice format - expected BOLT11')
|
||||
return () => {}
|
||||
}
|
||||
console.log('[ATM Service] Watching invoice for payment:', invoice.slice(0, 32) + '...')
|
||||
|
||||
const armed = invoiceWatches.get(invoice)
|
||||
if (armed) {
|
||||
armed.consumer = callback
|
||||
// Settled between arming and display (a one-tap card pull can beat the
|
||||
// state transition): replay the latched settlement instead of waiting
|
||||
// on a push that has already come and gone.
|
||||
if (armed.settled) {
|
||||
const preimage = armed.settled
|
||||
queueMicrotask(() => callback(preimage, armed.paymentHash))
|
||||
}
|
||||
return () => releaseInvoiceWatch(invoice)
|
||||
}
|
||||
|
||||
// No armed watch: an invoice this service didn't create. Recover the
|
||||
// hash over the wire and arm now. This is the pre-2026-09-22 behaviour
|
||||
// and carries the race that arming-at-creation fixes, so say so.
|
||||
console.warn('[ATM Service] No armed settlement watch for this invoice — arming late')
|
||||
let cancelled = false
|
||||
let subId: string | null = null
|
||||
;(async () => {
|
||||
try {
|
||||
const decoded = await lnbits.decodePayment(invoice)
|
||||
|
|
@ -1250,18 +979,36 @@ export function createATMServices(
|
|||
return
|
||||
}
|
||||
if (cancelled) return
|
||||
await armInvoiceWatch(invoice, paymentHash)
|
||||
const late = invoiceWatches.get(invoice)
|
||||
if (!late || cancelled) return
|
||||
late.consumer = callback
|
||||
if (late.settled) callback(late.settled, late.paymentHash)
|
||||
// walletId omitted: payment_hash is the natural primary key for
|
||||
// "wait for THIS invoice to settle." Under path B
|
||||
// (NOSTR_TRANSPORT_ROSTER_REQUIRED=true) lnbits routes the payment
|
||||
// to the operator's wallet, so a subscription scoped to the ATM's
|
||||
// pre-override wallet_id would AND-filter the settlement out and
|
||||
// never fire. With wallet_id omitted, lnbits resolves the wallet
|
||||
// from get_standalone_payment(payment_hash) and ownership-checks
|
||||
// against the auth'd account — works on both pre/post-override
|
||||
// wallets. Coordination log 2026-05-31T18:50Z (lnbits) for the
|
||||
// confirmation, §18:35Z for the joint smoke that surfaced the bug.
|
||||
subId = await lnbits.subscribePayments(
|
||||
undefined,
|
||||
{ payment_hash: paymentHash, max_seconds: 600 },
|
||||
(push) => {
|
||||
if (push.payment_hash !== paymentHash) return
|
||||
if (push.status !== 'success') return
|
||||
console.log('[ATM Service] Invoice paid (LNbits push)!')
|
||||
callback(push.preimage ?? 'payment-confirmed')
|
||||
},
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('[ATM Service] LNbits watchInvoice failed:', e)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
releaseInvoiceWatch(invoice)
|
||||
if (subId) {
|
||||
// wallet_id omitted to match the subscribePayments call above.
|
||||
void lnbits.unsubscribe(undefined, subId).catch(() => {})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -1280,7 +1027,7 @@ export function createATMServices(
|
|||
20: 50, // 50 x $20 bills = $1000 capacity
|
||||
}
|
||||
|
||||
console.log(`[ATM Service] Inventory: ${formatInventory(inventory)}`)
|
||||
console.log('[ATM Service] Inventory:', inventory)
|
||||
return inventory
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +1,34 @@
|
|||
/**
|
||||
* Operator-config consumer (aiolabs/lamassu-next#56, v2 per bitspire ADR-004).
|
||||
* Operator-config consumer (aiolabs/lamassu-next#56).
|
||||
*
|
||||
* Subscribes to operator-published kind-30078 events carrying cassette
|
||||
* OPERATIONS — a refill, an empty, a recount, a denomination change —
|
||||
* applies the ones it has not already seen, and hot-reloads the HAL
|
||||
* dispenser. It also publishes this machine's cassette state, which is
|
||||
* what populates the operator dashboard's bay rows.
|
||||
*
|
||||
* The operator used to publish absolute counts and this machine applied them
|
||||
* outright. Both sides wrote the same value over a transport that never tells
|
||||
* a writer it lost: a dashboard form loaded before a dispense silently
|
||||
* discarded that dispense, and neither side could detect it. This machine now
|
||||
* owns the count — it holds the notes — and the operator says what it did.
|
||||
* config updates, validates + applies them to state.db, and hot-reloads
|
||||
* the HAL dispenser. Also publishes a one-shot ATM-state hello-event on
|
||||
* first boot so the operator dashboard (satmachineadmin) can auto-populate
|
||||
* `cassette_configs` rows for this machine.
|
||||
*
|
||||
* Architecture (see ~/dev/coordination/log.md entries on 2026-05-30):
|
||||
*
|
||||
* - Operator → ATM: `kind=30078`, `["d", "bitspire-cassettes:<machine_id>"]`,
|
||||
* `["p", <atm_npub>]`, NIP-44 v2 encrypted content, author = operator pubkey
|
||||
* - ATM state: `kind=30078`, `["d", "bitspire-cassettes-state:<machine_id>"]`,
|
||||
* - ATM bootstrap: `kind=30078`, `["d", "bitspire-cassettes-state:<machine_id>"]`,
|
||||
* `["p", <operator_pubkey>]`, NIP-44 v2 encrypted content, author = ATM pubkey
|
||||
*
|
||||
* The ATM's hex pubkey serves as `<machine_id>` — globally unique, no
|
||||
* extra provisioning step required.
|
||||
*
|
||||
* The ATM publishes its state on startup, after every change to the bays, and
|
||||
* on a heartbeat. It was once a single hello-event gated on a one-shot flag,
|
||||
* which left the operator validating against a layout the machine no longer
|
||||
* had (#94), and left a dispense published during a relay outage lost for good.
|
||||
* v1 only publishes the one-shot bootstrap hello-event. The continuous
|
||||
* ATM-state reverse channel (publish on every count change + heartbeat)
|
||||
* is v2 territory.
|
||||
*/
|
||||
|
||||
import {
|
||||
type Signer,
|
||||
type MachineIdentity,
|
||||
type NostrClient,
|
||||
type Event,
|
||||
createSignedEvent,
|
||||
decryptContentV2,
|
||||
encryptContentV2,
|
||||
validateEvent,
|
||||
} from '@bitSpire/nostr-client'
|
||||
|
||||
|
|
@ -41,62 +36,9 @@ import type {} from '@/types/electron'
|
|||
|
||||
const KIND_NIP78 = 30078
|
||||
|
||||
/** The wire schema this machine speaks. Operations, not counts (ADR-004). */
|
||||
const CASSETTE_SCHEMA_VERSION = 2
|
||||
|
||||
/** One operator-authored cassette operation, as it arrives on the wire. */
|
||||
type CassetteOp = {
|
||||
id: string
|
||||
at: number
|
||||
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
|
||||
position: number
|
||||
bills?: number
|
||||
count?: number
|
||||
denomination?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* ADR-005 §5: the operator releases a cash-out hold without touching a bay
|
||||
* count. Rides the same operator event as the cassette ops (same id/at shape)
|
||||
* but is NOT a cassette op: it never reaches `applyOperatorCassetteOps`, which
|
||||
* would reject the type. Honoured only when stamped AFTER the hold began, so
|
||||
* a re-delivered resume from before a fresh fault cannot clear that fault.
|
||||
* A `recount` releases the hold too — it is the same "operator at the open
|
||||
* machine" gesture and already clears counts-uncertain.
|
||||
*/
|
||||
type ResumeCashOutOp = { id: string; at: number; type: 'resume_cash_out' }
|
||||
/**
|
||||
* ADR-005 §6: the operator paid the customer by hand, off-machine, for a
|
||||
* cash-out this machine recorded as dispense_error / partial. Flips that row
|
||||
* to `remediated` with the note as provenance so both ledgers close on one
|
||||
* act. Idempotent by nature — remediateTransaction only touches rows still
|
||||
* in an error state — so re-delivery is harmless.
|
||||
*/
|
||||
type SettleTransactionOp = {
|
||||
id: string
|
||||
at: number
|
||||
type: 'settle_transaction'
|
||||
txid: string
|
||||
note?: string
|
||||
}
|
||||
type OperatorOp = CassetteOp | ResumeCashOutOp | SettleTransactionOp
|
||||
|
||||
/** Accept operator events stamped up to this many seconds in the future. */
|
||||
const MAX_FUTURE_SKEW_S = 60
|
||||
|
||||
/**
|
||||
* Republish the cassette state on this interval even when nothing changed.
|
||||
*
|
||||
* A publish is a single fire-and-forget event with no retry: if the relay is
|
||||
* unreachable at the moment of a dispense, that update is simply gone and the
|
||||
* operator's view stays wrong until the next customer happens to buy cash. A
|
||||
* relay also acknowledges an event it then discards, so a publish that returns
|
||||
* cleanly is not proof of anything. The heartbeat is what makes the channel
|
||||
* self-healing, and it is also the only way an out-of-band edit to the table
|
||||
* (atm-tui, direct SQL) ever reaches the operator.
|
||||
*/
|
||||
const STATE_HEARTBEAT_MS = 5 * 60 * 1000
|
||||
|
||||
const operatorConfigDTag = (machineId: string) => `bitspire-cassettes:${machineId}`
|
||||
const atmStateDTag = (machineId: string) => `bitspire-cassettes-state:${machineId}`
|
||||
|
||||
|
|
@ -105,34 +47,17 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
|
|||
export interface OperatorConfigServiceConfig {
|
||||
/** Connected NostrClient — shared with the Lightning service. */
|
||||
nostrClient: NostrClient
|
||||
/** Signer for the ATM identity. Decrypts operator events + signs our state. */
|
||||
signer: Signer
|
||||
/** ATM's nostr identity. Used to decrypt operator events + sign the bootstrap. */
|
||||
identity: MachineIdentity
|
||||
/** Operator pubkeys (hex) authorized to publish cassette config. From VITE_OPERATOR_PUBKEYS. */
|
||||
operatorPubkeys: string[]
|
||||
/** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */
|
||||
/** Machine identifier for the d-tag. Defaults to identity.publicKey when omitted. */
|
||||
machineId?: string
|
||||
/**
|
||||
* ADR-005 §5: called when an operator op (recount, resume_cash_out) has
|
||||
* released a persisted cash-out hold, so the store can lift the state
|
||||
* machine's latch. The store wires this to `CASH_OUT_RELEASED`.
|
||||
*/
|
||||
onCashOutHoldReleased?: () => void
|
||||
}
|
||||
|
||||
export interface OperatorConfigService {
|
||||
/** Unsubscribe from operator events and free resources. */
|
||||
stop(): void
|
||||
/**
|
||||
* Republish the current cassette state (kind-30078, replaceable). Call after
|
||||
* a dispense and on a cassette reload so the operator's view tracks reality.
|
||||
* Best-effort — logs and swallows errors.
|
||||
*/
|
||||
publishCassettesState(): Promise<void>
|
||||
}
|
||||
|
||||
const NOOP_SERVICE: OperatorConfigService = {
|
||||
stop: () => {},
|
||||
publishCassettesState: async () => {},
|
||||
}
|
||||
|
||||
export async function startOperatorConfigService(
|
||||
|
|
@ -140,24 +65,21 @@ export async function startOperatorConfigService(
|
|||
): Promise<OperatorConfigService> {
|
||||
if (cfg.operatorPubkeys.length === 0) {
|
||||
console.log('[OperatorConfig] No operator pubkeys configured — service disabled')
|
||||
return NOOP_SERVICE
|
||||
return { stop: () => {} }
|
||||
}
|
||||
if (!isElectron || !window.electronAPI) {
|
||||
console.log('[OperatorConfig] Not in Electron — service disabled (browser dev mode)')
|
||||
return NOOP_SERVICE
|
||||
return { stop: () => {} }
|
||||
}
|
||||
const api = window.electronAPI
|
||||
const machineId = cfg.machineId ?? cfg.signer.pubkey
|
||||
const machineId = cfg.machineId ?? cfg.identity.publicKey
|
||||
|
||||
// Announce current state on every start. This used to be gated on a
|
||||
// one-shot "have we said hello" flag, so any later change to the layout —
|
||||
// a reseed, an atm-tui edit, direct SQL — was never published and the
|
||||
// operator's dashboard kept validating against a bay set that no longer
|
||||
// existed (#94). Best-effort; the heartbeat below is the safety net.
|
||||
// Bootstrap hello-event on first boot (best-effort — failure leaves the
|
||||
// gate null so the next boot retries).
|
||||
try {
|
||||
await publishCassettesState(cfg, api, machineId)
|
||||
await maybePublishBootstrap(cfg, api, machineId)
|
||||
} catch (err) {
|
||||
console.warn('[OperatorConfig] Startup cassettes-state publish failed:', err)
|
||||
console.warn('[OperatorConfig] Bootstrap publish failed (will retry next boot):', err)
|
||||
}
|
||||
|
||||
// Subscribe to operator-published cassette config events.
|
||||
|
|
@ -166,7 +88,7 @@ export async function startOperatorConfigService(
|
|||
[
|
||||
{
|
||||
kinds: [KIND_NIP78],
|
||||
'#p': [cfg.signer.pubkey],
|
||||
'#p': [cfg.identity.publicKey],
|
||||
'#d': [dTag],
|
||||
authors: cfg.operatorPubkeys,
|
||||
},
|
||||
|
|
@ -179,25 +101,10 @@ export async function startOperatorConfigService(
|
|||
},
|
||||
}
|
||||
)
|
||||
console.log(`[OperatorConfig] Subscribed: d=${dTag} sub=${subscriptionId}`)
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
publishCassettesState(cfg, api, machineId).catch((err) =>
|
||||
console.warn('[OperatorConfig] cassettes-state heartbeat failed:', err)
|
||||
)
|
||||
}, STATE_HEARTBEAT_MS)
|
||||
console.log('[OperatorConfig] Subscribed:', { dTag, subscriptionId })
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
clearInterval(heartbeat)
|
||||
cfg.nostrClient.unsubscribe(subscriptionId)
|
||||
},
|
||||
publishCassettesState: () =>
|
||||
publishCassettesState(cfg, api, machineId)
|
||||
.then(() => {})
|
||||
.catch((err) => {
|
||||
console.warn('[OperatorConfig] cassettes-state republish failed:', err)
|
||||
}),
|
||||
stop: () => cfg.nostrClient.unsubscribe(subscriptionId),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -217,13 +124,17 @@ async function handleOperatorConfigEvent(
|
|||
return
|
||||
}
|
||||
|
||||
// 2. There is deliberately no `created_at` watermark here any more.
|
||||
// Under absolute counts it was the only replay defence, and it cost us:
|
||||
// an event re-delivered out of order was dropped whole, operations
|
||||
// included. Idempotency now rides on the operations themselves — the
|
||||
// operator mints an id per op and this machine records the ones it
|
||||
// applied — which is strictly stronger, because it survives an event
|
||||
// that mixes operations we have seen with ones we have not.
|
||||
// 2. Replay protection — drop stale events. NIP-78 replaceable events
|
||||
// DO get re-delivered on reconnect/restart; without this check, the
|
||||
// ATM would re-apply the same payload on every boot and clobber any
|
||||
// cash-out decrements that landed between operator publishes.
|
||||
const watermark = await api.getLastKnownConfigCreatedAt()
|
||||
if (event.created_at <= watermark) {
|
||||
console.log(
|
||||
`[OperatorConfig] Stale event dropped (created_at=${event.created_at} <= watermark=${watermark})`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Clock-skew defense — reject events stamped too far in the future.
|
||||
// Limits damage from a leaked operator nsec future-stamping a fake
|
||||
|
|
@ -237,89 +148,30 @@ async function handleOperatorConfigEvent(
|
|||
}
|
||||
|
||||
// 4. Decrypt content (NIP-44 v2).
|
||||
let parsed: { schema_version?: number; ops?: unknown }
|
||||
let parsed: { positions: Record<string, { denomination: number; count: number }> }
|
||||
try {
|
||||
const plaintext = await cfg.signer.nip44Decrypt(event.pubkey, event.content)
|
||||
const plaintext = decryptContentV2(cfg.identity, event.pubkey, event.content)
|
||||
parsed = JSON.parse(plaintext) as typeof parsed
|
||||
} catch (err) {
|
||||
console.error('[OperatorConfig] Decrypt/parse failed:', err)
|
||||
return
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.ops)) {
|
||||
// A v1 operator publishing absolute counts lands here and is ignored.
|
||||
// That direction fails safe: the machine keeps its own counts, which it
|
||||
// is now the only writer of, and simply will not dispense notes it
|
||||
// believes it lacks. The opposite — applying a count from a form loaded
|
||||
// before a dispense — is what ADR-004 exists to stop.
|
||||
console.error('[OperatorConfig] Payload missing `ops` array — dropped')
|
||||
if (!parsed || typeof parsed !== 'object' || !parsed.positions) {
|
||||
console.error('[OperatorConfig] Payload missing `positions` field')
|
||||
return
|
||||
}
|
||||
const allOps = parsed.ops as OperatorOp[]
|
||||
|
||||
// 4b. ADR-005 §5 — split out resume_cash_out before the cassette apply.
|
||||
// Release only if a resume is stamped after the hold began; an idempotent
|
||||
// re-delivery of an older resume must not clear a newer fault.
|
||||
const holdBefore = await api.getCashOutHold()
|
||||
const resumeOps = allOps.filter(
|
||||
(o): o is ResumeCashOutOp => !!o && o.type === 'resume_cash_out'
|
||||
)
|
||||
const settleOps = allOps.filter(
|
||||
(o): o is SettleTransactionOp =>
|
||||
!!o && o.type === 'settle_transaction' && typeof (o as SettleTransactionOp).txid === 'string'
|
||||
)
|
||||
for (const op of settleOps) {
|
||||
const provenance = `settled-off-machine:${op.id}${op.note ? `:${op.note}` : ''}`
|
||||
const changed = await api.remediateTransaction(op.txid, provenance)
|
||||
console.log(
|
||||
`[OperatorConfig] settle_transaction ${op.id} for ${op.txid}: ` +
|
||||
(changed ? 'row marked remediated' : 'no row in an error state (already closed, or unknown)')
|
||||
)
|
||||
}
|
||||
const ops = allOps.filter(
|
||||
(o): o is CassetteOp =>
|
||||
!!o && o.type !== 'resume_cash_out' && o.type !== 'settle_transaction'
|
||||
)
|
||||
if (holdBefore && resumeOps.some((o) => typeof o.at === 'number' && o.at > holdBefore.since)) {
|
||||
await api.clearCashOutHold()
|
||||
console.log(
|
||||
`[OperatorConfig] Cash-out hold released by operator resume op ` +
|
||||
`(held since ${holdBefore.since}, ${resumeOps.length} resume op(s))`
|
||||
)
|
||||
} else if (resumeOps.length > 0) {
|
||||
console.log(
|
||||
`[OperatorConfig] ${resumeOps.length} resume_cash_out op(s) ignored — ` +
|
||||
(holdBefore ? 'all stamped before the current hold began' : 'no hold in place')
|
||||
)
|
||||
}
|
||||
|
||||
// 5. Apply the ones we have not seen, in one transaction with the sequence
|
||||
// bump. No `created_at` watermark: each op carries an operator-minted id
|
||||
// and the machine records what it applied, so a re-delivered event is a
|
||||
// no-op on its own merits. The watermark would be strictly weaker and
|
||||
// actively harmful — an event arriving out of order can still carry an
|
||||
// operation this machine has never seen.
|
||||
const result = ops.length
|
||||
? await api.applyOperatorCassetteOps(ops)
|
||||
: { applied: [] as string[], rejected: [] as { id: string; reason: string }[] }
|
||||
for (const bad of result.rejected) {
|
||||
console.warn(`[OperatorConfig] Op ${bad.id} rejected: ${bad.reason}`)
|
||||
}
|
||||
|
||||
// A recount (applied in the store, which also clears the hold) or the resume
|
||||
// above may have released the latch: tell the store so the state machine
|
||||
// lifts its guard. The republishes below carry the cleared state up.
|
||||
if (holdBefore && (await api.getCashOutHold()) === null) {
|
||||
cfg.onCashOutHoldReleased?.()
|
||||
}
|
||||
if (result.applied.length === 0) {
|
||||
console.log(`[OperatorConfig] No new ops in event ${event.id.slice(0, 12)}…`)
|
||||
// Still republish: the operator learns from our applied_ops echo that
|
||||
// earlier operations landed, and an event carrying nothing new can be the
|
||||
// first one we successfully answer after a relay outage.
|
||||
const machineIdNoop = cfg.machineId ?? cfg.signer.pubkey
|
||||
await publishCassettesState(cfg, api, machineIdNoop).catch((err) =>
|
||||
console.warn('[OperatorConfig] post-apply cassettes-state republish failed:', err)
|
||||
// 5. Atomic apply (cassettes + meta watermark) via IPC. The state-store
|
||||
// function re-validates watermark + position key-set equality +
|
||||
// per-entry types inside the SQLite transaction. Duplicate
|
||||
// denominations across positions are allowed — real machines load
|
||||
// N cassettes of the same denomination for cash-out throughput.
|
||||
const result = await api.applyOperatorCassettesConfig(
|
||||
{ positions: parsed.positions },
|
||||
event.created_at
|
||||
)
|
||||
if (!result.applied) {
|
||||
console.warn('[OperatorConfig] Apply rejected:', result.reason)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -327,8 +179,8 @@ async function handleOperatorConfigEvent(
|
|||
// picks up the new per-position mapping. state.db is already updated;
|
||||
// HAL re-init failure means the renderer's persistedInventory may be
|
||||
// ahead of the HAL until next service restart — log loudly but don't
|
||||
// unwind the state.db apply (the operation happened physically; HAL
|
||||
// can catch up).
|
||||
// unwind the state.db apply (the operator wants their config landed;
|
||||
// HAL can catch up).
|
||||
const cassettesAfter = await api.loadCassettes()
|
||||
const halResult = await api.halReloadCassettes(
|
||||
cassettesAfter.map((c) => ({
|
||||
|
|
@ -340,97 +192,51 @@ async function handleOperatorConfigEvent(
|
|||
if (!halResult.ok) {
|
||||
console.error('[OperatorConfig] HAL reload failed:', halResult.error)
|
||||
}
|
||||
console.log(`[OperatorConfig] Applied ops: ${result.applied.join(', ')}`)
|
||||
|
||||
// Republish our resulting cassette state so the operator's view reflects the
|
||||
// applied config (the "on cassette reload" case). Different d-tag from the
|
||||
// operator's config event, so no echo loop. Best-effort.
|
||||
const machineId = cfg.machineId ?? cfg.signer.pubkey
|
||||
await publishCassettesState(cfg, api, machineId).catch((err) =>
|
||||
console.warn('[OperatorConfig] post-apply cassettes-state republish failed:', err)
|
||||
console.log(
|
||||
`[OperatorConfig] Applied — created_at=${event.created_at}, positions=${Object.keys(parsed.positions).join(',')}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the ATM's current cassette state as a replaceable kind-30078 event
|
||||
* (`bitspire-cassettes-state:<machineId>`), NIP-44-encrypted to the operator.
|
||||
* Replaceable → latest wins; the operator consumes every update. Call after a
|
||||
* dispense, on a cassette reload, at startup and on a heartbeat, so the
|
||||
* operator view tracks reality (coord 2026-06-21 / lamassu-next#56).
|
||||
*
|
||||
* Returns whether an event was published (false when there are no cassettes /
|
||||
* no operator).
|
||||
*/
|
||||
async function publishCassettesState(
|
||||
async function maybePublishBootstrap(
|
||||
cfg: OperatorConfigServiceConfig,
|
||||
api: NonNullable<typeof window.electronAPI>,
|
||||
machineId: string
|
||||
): Promise<boolean> {
|
||||
): Promise<void> {
|
||||
const already = await api.getBootstrapPublishedAt()
|
||||
if (already !== null) {
|
||||
console.log('[OperatorConfig] Bootstrap already published at unix', already)
|
||||
return
|
||||
}
|
||||
const cassettes = await api.loadCassettes()
|
||||
if (cassettes.length === 0) return false
|
||||
if (cassettes.length === 0) {
|
||||
console.log('[OperatorConfig] state.db.cassettes empty — skipping bootstrap')
|
||||
return
|
||||
}
|
||||
|
||||
const operatorPubkey = cfg.operatorPubkeys[0]
|
||||
if (!operatorPubkey) return false
|
||||
if (!operatorPubkey) {
|
||||
console.log('[OperatorConfig] No operator pubkey — skipping bootstrap')
|
||||
return
|
||||
}
|
||||
|
||||
const positions: Record<string, { denomination: number; count: number }> = {}
|
||||
for (const c of cassettes) {
|
||||
positions[String(c.position)] = { denomination: c.denomination, count: c.count }
|
||||
}
|
||||
// Additive field: an operator on the old consumer reads `positions` and
|
||||
// ignores this, so it needs no coordinated release. When set, the counts
|
||||
// above are the machine's best guess, not a measurement.
|
||||
const countsUncertainSince = await api.getCountsUncertainSince()
|
||||
|
||||
// `applied_ops` is the acknowledgement leg. An addressable event gives its
|
||||
// publisher no failure signal — the relay returns OK for an event it then
|
||||
// discards — so echoing the ids back is the only way the operator can tell
|
||||
// an operation that landed from one that was merely sent. `seq` lets a
|
||||
// reader reject a regression without trusting a clock: `created_at` has
|
||||
// second granularity and ties break on event id, so it cannot order two
|
||||
// reports from the same second.
|
||||
const [appliedOps, seq] = await Promise.all([api.getAppliedOpIds(), api.getCassetteStateSeq()])
|
||||
const payload: Record<string, unknown> = {
|
||||
schema_version: CASSETTE_SCHEMA_VERSION,
|
||||
positions,
|
||||
seq,
|
||||
applied_ops: appliedOps,
|
||||
}
|
||||
if (countsUncertainSince) payload.counts_uncertain_since = countsUncertainSince
|
||||
// ADR-005 §5 — additive, same contract as counts_uncertain_since: an old
|
||||
// consumer ignores it. When present, this machine is refusing cash-out
|
||||
// until an operator recount or resume_cash_out op.
|
||||
const hold = await api.getCashOutHold()
|
||||
if (hold) {
|
||||
payload.cash_out_held_since = hold.since
|
||||
payload.cash_out_held_reason = hold.reason
|
||||
payload.cash_out_held_code = hold.rawCode ?? hold.errorCode ?? null
|
||||
}
|
||||
const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify(payload))
|
||||
|
||||
// Force the stamp strictly above our last one. Addressable events are ordered
|
||||
// by `created_at` at second granularity, ties broken by lowest event id, and
|
||||
// the relay keeps one and silently drops the other while acknowledging both.
|
||||
// So two publishes inside one second would leave the winner decided by a hash,
|
||||
// permanently — and a clock that stepped backwards would make every report
|
||||
// from this machine disappear. Neither failure is visible from here.
|
||||
const lastPublished = (await api.getLastStatePublishedAt()) ?? 0
|
||||
const createdAt = Math.max(Math.floor(Date.now() / 1000), lastPublished + 1)
|
||||
const ciphertext = encryptContentV2(cfg.identity, operatorPubkey, { positions })
|
||||
|
||||
const dTag = atmStateDTag(machineId)
|
||||
const event = await createSignedEvent(cfg.signer, {
|
||||
const event = createSignedEvent(cfg.identity, {
|
||||
kind: KIND_NIP78,
|
||||
content: ciphertext,
|
||||
tags: [
|
||||
['d', dTag],
|
||||
['p', operatorPubkey],
|
||||
],
|
||||
created_at: createdAt,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
|
||||
await cfg.nostrClient.publish(event)
|
||||
await api.markStatePublished(createdAt)
|
||||
console.log(
|
||||
`[OperatorConfig] cassettes-state published: id=${event.id.slice(0, 12)}… ` +
|
||||
`created_at=${createdAt} seq=${seq} applied_ops=${appliedOps.length} d=${dTag}`
|
||||
)
|
||||
return true
|
||||
await api.markBootstrapPublished(Math.floor(Date.now() / 1000))
|
||||
console.log('[OperatorConfig] Bootstrap hello-event published:', { dTag, eventId: event.id })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,9 +56,10 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
type Signer,
|
||||
type MachineIdentity,
|
||||
type NostrClient,
|
||||
type Event,
|
||||
decryptContentV2,
|
||||
validateEvent,
|
||||
} from '@bitSpire/nostr-client'
|
||||
|
||||
|
|
@ -79,11 +80,11 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
|
|||
export interface OperatorFeesServiceConfig {
|
||||
/** Connected NostrClient — shared with the Lightning service. */
|
||||
nostrClient: NostrClient
|
||||
/** Signer for the ATM identity. Decrypts operator events. */
|
||||
signer: Signer
|
||||
/** ATM's nostr identity. Used to decrypt operator events. */
|
||||
identity: MachineIdentity
|
||||
/** Operator pubkeys (hex) authorized to publish fee config. From VITE_OPERATOR_PUBKEYS. */
|
||||
operatorPubkeys: string[]
|
||||
/** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */
|
||||
/** Machine identifier for the d-tag. Defaults to identity.publicKey when omitted. */
|
||||
machineId?: string
|
||||
/**
|
||||
* Called when a valid fee-config event is applied. Renderer should
|
||||
|
|
@ -111,7 +112,7 @@ export async function startOperatorFeesService(
|
|||
return { stop: () => {} }
|
||||
}
|
||||
const api = window.electronAPI
|
||||
const machineId = cfg.machineId ?? cfg.signer.pubkey
|
||||
const machineId = cfg.machineId ?? cfg.identity.publicKey
|
||||
|
||||
// Subscribe to operator-published fee config events.
|
||||
const dTag = feeConfigDTag(machineId)
|
||||
|
|
@ -119,7 +120,7 @@ export async function startOperatorFeesService(
|
|||
[
|
||||
{
|
||||
kinds: [KIND_NIP78],
|
||||
'#p': [cfg.signer.pubkey],
|
||||
'#p': [cfg.identity.publicKey],
|
||||
'#d': [dTag],
|
||||
authors: cfg.operatorPubkeys,
|
||||
},
|
||||
|
|
@ -132,7 +133,7 @@ export async function startOperatorFeesService(
|
|||
},
|
||||
}
|
||||
)
|
||||
console.log(`[Fees] Subscribed: d=${dTag} sub=${subscriptionId}`)
|
||||
console.log('[Fees] Subscribed:', { dTag, subscriptionId })
|
||||
|
||||
return {
|
||||
stop: () => cfg.nostrClient.unsubscribe(subscriptionId),
|
||||
|
|
@ -188,7 +189,7 @@ async function handleFeeConfigEvent(
|
|||
// fields (v2 forward-compat — future promo payloads).
|
||||
let parsed: ParsedFeePayload
|
||||
try {
|
||||
const plaintext = await cfg.signer.nip44Decrypt(event.pubkey, event.content)
|
||||
const plaintext = decryptContentV2(cfg.identity, event.pubkey, event.content)
|
||||
const raw = JSON.parse(plaintext) as Record<string, unknown>
|
||||
parsed = parseV1Payload(raw)
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { ingestScannedSeed } from '../ingest'
|
||||
import { SPIRE_SEED_SCHEME } from '@bitSpire/nostr-client'
|
||||
import { npubEncode } from 'nostr-tools/nip19'
|
||||
|
||||
/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */
|
||||
function makeSeed(json: unknown): string {
|
||||
const b64 = Buffer.from(JSON.stringify(json), 'utf8')
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
return SPIRE_SEED_SCHEME + b64
|
||||
}
|
||||
|
||||
const SPIRE_PUBKEY = 'a'.repeat(64)
|
||||
const VALID_SEED = makeSeed({
|
||||
v: 1,
|
||||
spire_npub: npubEncode(SPIRE_PUBKEY),
|
||||
lnbits_npub: npubEncode('b'.repeat(64)),
|
||||
bunker_secret: 'deadbeef',
|
||||
relays: ['wss://events.relay/'],
|
||||
})
|
||||
|
||||
describe('ingestScannedSeed', () => {
|
||||
const originalWindow = globalThis.window
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.window = originalWindow
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('rejects a non-seed scan without touching the bridge', async () => {
|
||||
const saveSpireSeed = vi.fn()
|
||||
globalThis.window = { electronAPI: { saveSpireSeed } } as unknown as Window & typeof globalThis
|
||||
|
||||
const result = await ingestScannedSeed('https://example.com/not-a-seed')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toBe('invalid-seed')
|
||||
expect(saveSpireSeed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports no-bridge when Electron is absent', async () => {
|
||||
globalThis.window = {} as unknown as Window & typeof globalThis
|
||||
const result = await ingestScannedSeed(VALID_SEED)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toBe('no-bridge')
|
||||
})
|
||||
|
||||
it('persists the seed and relaunches on a valid scan', async () => {
|
||||
const saveSpireSeed = vi.fn().mockResolvedValue(undefined)
|
||||
const relaunchApp = vi.fn().mockResolvedValue(undefined)
|
||||
globalThis.window = {
|
||||
electronAPI: { saveSpireSeed, relaunchApp },
|
||||
} as unknown as Window & typeof globalThis
|
||||
|
||||
const result = await ingestScannedSeed(` ${VALID_SEED} `) // tolerate whitespace
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.spirePubkey).toBe(SPIRE_PUBKEY)
|
||||
expect(saveSpireSeed).toHaveBeenCalledWith(VALID_SEED)
|
||||
expect(relaunchApp).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('surfaces persist-failed when saveSpireSeed throws', async () => {
|
||||
const saveSpireSeed = vi.fn().mockRejectedValue(new Error('EACCES'))
|
||||
globalThis.window = { electronAPI: { saveSpireSeed } } as unknown as Window & typeof globalThis
|
||||
|
||||
const result = await ingestScannedSeed(VALID_SEED)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toBe('persist-failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ingest does not pair in-renderer', () => {
|
||||
it('never imports connect logic — persistence + relaunch only', () => {
|
||||
// Guard: the design intentionally reuses the boot-time pairing path.
|
||||
// If someone wires connectNewSeed here, this comment + the ingest source
|
||||
// should be revisited together.
|
||||
expect(ingestScannedSeed).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
/**
|
||||
* Pairing module surface (aiolabs/bitspire#52).
|
||||
*
|
||||
* `availablePairingSources()` probes each known source and returns those the
|
||||
* current device can actually run, in preference order (camera first, NFC if
|
||||
* present). The wizard renders the first available source and offers the rest
|
||||
* as alternates.
|
||||
*/
|
||||
|
||||
import { QrPairingSource } from './qr-source'
|
||||
import { NfcPairingSource } from './nfc-source'
|
||||
import type { PairingSource } from './types'
|
||||
|
||||
export type { PairingSource, PairingSourceKind, PairingSourceStartOptions, StopCapture } from './types'
|
||||
export { QrPairingSource } from './qr-source'
|
||||
export { NfcPairingSource } from './nfc-source'
|
||||
export { ingestScannedSeed, parseScannedSeed } from './ingest'
|
||||
export type { IngestResult, SeedPreview } from './ingest'
|
||||
export { testRelay } from './relay-test'
|
||||
export type { RelayTestResult } from './relay-test'
|
||||
|
||||
/** All sources in preference order, regardless of availability. */
|
||||
export function allPairingSources(): PairingSource[] {
|
||||
return [new QrPairingSource(), new NfcPairingSource()]
|
||||
}
|
||||
|
||||
/** Only the sources this device can run, in preference order. */
|
||||
export async function availablePairingSources(): Promise<PairingSource[]> {
|
||||
const sources = allPairingSources()
|
||||
const flags = await Promise.all(sources.map((s) => s.isAvailable()))
|
||||
return sources.filter((_, i) => flags[i])
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
/**
|
||||
* Seed ingest pipeline (aiolabs/bitspire#52).
|
||||
*
|
||||
* Turns a raw scanned payload into a paired machine. The wizard captures a
|
||||
* string off some PairingSource and hands it here; we:
|
||||
* 1. validate it parses as a spire-seed (reject anything else — a QR on the
|
||||
* counter, a URL, a different protocol),
|
||||
* 2. persist it as VITE_SPIRE_SEED via the Electron bridge,
|
||||
* 3. relaunch so the normal boot path (signer-resolver → connectNewSeed)
|
||||
* performs the actual bunker pairing.
|
||||
*
|
||||
* We do NOT pair in-renderer here: persisting + relaunching reuses the single,
|
||||
* hardware-tested pairing path rather than duplicating connect/redeem logic in
|
||||
* the wizard. The trade-off is a ~kiosk-restart of latency, which is fine for a
|
||||
* one-time provisioning step.
|
||||
*/
|
||||
|
||||
import { parseSpireSeed, seedFingerprint } from '@bitSpire/nostr-client'
|
||||
|
||||
export type IngestResult =
|
||||
| { ok: true; spirePubkey: string; fingerprint: string; relays: string[] }
|
||||
| { ok: false; reason: 'invalid-seed' | 'no-bridge' | 'persist-failed'; message: string }
|
||||
|
||||
export type SeedPreview =
|
||||
| { ok: true; spirePubkey: string; fingerprint: string; relays: string[] }
|
||||
| { ok: false; reason: 'invalid-seed'; message: string }
|
||||
|
||||
/**
|
||||
* Validate-only: parse a scanned payload as a spire-seed WITHOUT persisting or
|
||||
* relaunching. The wizard uses this to show a review step (decoded relay + a
|
||||
* "test relay" button) before committing, so a well-formed but unreachable
|
||||
* relay is caught before the machine relaunches into a pairing crash-loop.
|
||||
* `parseSpireSeed` already rejects a malformed relay (e.g. a QR misread of
|
||||
* `ws://` → `As://`); this surfaces that as an invalid-seed rejection.
|
||||
*/
|
||||
export function parseScannedSeed(raw: string): SeedPreview {
|
||||
const trimmed = (raw || '').trim()
|
||||
try {
|
||||
const seed = parseSpireSeed(trimmed)
|
||||
return {
|
||||
ok: true,
|
||||
spirePubkey: seed.spirePubkey,
|
||||
fingerprint: seedFingerprint(trimmed),
|
||||
relays: seed.relays,
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'invalid-seed',
|
||||
message: e instanceof Error ? e.message : 'Not a valid pairing code',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function ingestScannedSeed(raw: string): Promise<IngestResult> {
|
||||
const trimmed = (raw || '').trim()
|
||||
|
||||
let spirePubkey: string
|
||||
let relays: string[]
|
||||
try {
|
||||
const seed = parseSpireSeed(trimmed)
|
||||
spirePubkey = seed.spirePubkey
|
||||
relays = seed.relays
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'invalid-seed',
|
||||
message: e instanceof Error ? e.message : 'Not a valid pairing code',
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined' || !window.electronAPI) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'no-bridge',
|
||||
message: 'Pairing must run on the machine (no kiosk bridge available).',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await window.electronAPI.saveSpireSeed(trimmed)
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'persist-failed',
|
||||
message: e instanceof Error ? e.message : 'Could not save the pairing.',
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget: the relaunch tears this process down.
|
||||
void window.electronAPI.relaunchApp()
|
||||
|
||||
return { ok: true, spirePubkey, fingerprint: seedFingerprint(trimmed), relays }
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
/**
|
||||
* NFC pairing source — SCAFFOLD (aiolabs/bitspire#52).
|
||||
*
|
||||
* The user flagged NFC as a plausible future pairing method (tap a tag/phone
|
||||
* carrying the spire-seed). This wires the seam against the Web NFC API
|
||||
* (`NDEFReader`) so a future build can light it up without reworking the
|
||||
* wizard. It is NOT active on current hardware: Web NFC ships only on Chrome
|
||||
* for Android, so `isAvailable()` returns false on the Sintra's Linux Electron
|
||||
* and the wizard simply won't offer it.
|
||||
*
|
||||
* When real NFC hardware lands (likely a HAL peripheral rather than Web NFC),
|
||||
* replace the body of `start()` with that driver — the PairingSource contract
|
||||
* stays the same.
|
||||
*/
|
||||
|
||||
import type { PairingSource, PairingSourceStartOptions, StopCapture } from './types'
|
||||
|
||||
// Minimal structural type for the Web NFC API (not in lib.dom for Electron).
|
||||
interface NDEFReaderLike {
|
||||
scan(): Promise<void>
|
||||
addEventListener(
|
||||
type: 'reading',
|
||||
listener: (event: { message: { records: Array<{ recordType: string; data?: BufferSource }> } }) => void
|
||||
): void
|
||||
addEventListener(type: 'readingerror', listener: (event: unknown) => void): void
|
||||
}
|
||||
|
||||
function getNDEFReaderCtor(): (new () => NDEFReaderLike) | null {
|
||||
const ctor = (globalThis as { NDEFReader?: new () => NDEFReaderLike }).NDEFReader
|
||||
return ctor ?? null
|
||||
}
|
||||
|
||||
export class NfcPairingSource implements PairingSource {
|
||||
readonly kind = 'nfc' as const
|
||||
readonly label = 'NFC tap'
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return getNDEFReaderCtor() !== null
|
||||
}
|
||||
|
||||
async start(opts: PairingSourceStartOptions): Promise<StopCapture> {
|
||||
const Ctor = getNDEFReaderCtor()
|
||||
if (!Ctor) throw new Error('Web NFC unavailable on this device')
|
||||
|
||||
const reader = new Ctor()
|
||||
const decoder = new TextDecoder()
|
||||
let stopped = false
|
||||
|
||||
reader.addEventListener('reading', (event) => {
|
||||
if (stopped) return
|
||||
for (const record of event.message.records) {
|
||||
if (record.recordType === 'text' && record.data) {
|
||||
const raw = decoder.decode(record.data).trim()
|
||||
if (raw) opts.onScan(raw)
|
||||
}
|
||||
}
|
||||
})
|
||||
reader.addEventListener('readingerror', (e) => opts.onError?.(e))
|
||||
|
||||
await reader.scan()
|
||||
// Web NFC has no explicit stop; the AbortController form would, but the
|
||||
// scaffold just flips a guard so late events are ignored after teardown.
|
||||
return () => {
|
||||
stopped = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
/**
|
||||
* Camera-based QR pairing source (aiolabs/bitspire#52).
|
||||
*
|
||||
* Decodes with `qr` (paulmillr) — a zero-dependency, auditable, dual
|
||||
* MIT/Apache library from the same author as the `@noble`/`@scure` crypto our
|
||||
* nostr stack already trusts (chosen over the dormant `jsqr` for that ethos +
|
||||
* active maintenance). Its `qr/dom.js` browser helper wraps getUserMedia and
|
||||
* the per-frame decode loop, so this source is a thin adapter onto the
|
||||
* PairingSource contract.
|
||||
*
|
||||
* The first successful decode wins; the loop then stops itself so a single
|
||||
* seed isn't ingested repeatedly.
|
||||
*/
|
||||
|
||||
import { QRCanvas, frontalCamera, frameLoop } from 'qr/dom.js'
|
||||
import type { PairingSource, PairingSourceStartOptions, StopCapture } from './types'
|
||||
|
||||
export class QrPairingSource implements PairingSource {
|
||||
readonly kind = 'qr' as const
|
||||
readonly label = 'Camera'
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return (
|
||||
typeof navigator !== 'undefined' &&
|
||||
!!navigator.mediaDevices &&
|
||||
typeof navigator.mediaDevices.getUserMedia === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
async start(opts: PairingSourceStartOptions): Promise<StopCapture> {
|
||||
const { onScan, onError, video } = opts
|
||||
if (!video) throw new Error('QrPairingSource requires a <video> element')
|
||||
|
||||
const camera = await frontalCamera(video)
|
||||
|
||||
// `frontalCamera` requests `ideal: screen.{width,height}`, so on the kiosk's
|
||||
// 1280x800 panel it otherwise streams at ~720p — and `decodeQR` then
|
||||
// center-crops to a square, leaving too few pixels-per-module for a dense
|
||||
// spire-seed QR on this fixed-focus lens. Pin a deliberate 1280x960 capture
|
||||
// instead: lamassu-machine caps QR scanning at 640x480 for decode speed
|
||||
// (megapixels just slow the per-frame decode), but our seed QR is denser
|
||||
// than a lightning invoice, so 1280x960 is the balance — ~14-18px/module at
|
||||
// frame-fill, still fast, and it meters exposure better than maxing the
|
||||
// sensor (a frame-filling QR keeps auto-exposure from blowing out on a
|
||||
// bright phone screen). The stream lives on the <video>'s srcObject
|
||||
// (QRCamera.stream is private); soft `ideal` so a camera that can't honor
|
||||
// it degrades to its closest mode instead of throwing.
|
||||
try {
|
||||
const stream = video.srcObject
|
||||
if (stream instanceof MediaStream) {
|
||||
await stream.getVideoTracks()[0]?.applyConstraints({
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 960 },
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
onError?.(e)
|
||||
}
|
||||
|
||||
const canvas = new QRCanvas() // decode-only; no overlay canvases needed
|
||||
|
||||
let stopped = false
|
||||
let cancel: (() => void) | null = null
|
||||
const stop: StopCapture = () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
cancel?.()
|
||||
camera.stop()
|
||||
}
|
||||
|
||||
cancel = frameLoop(() => {
|
||||
if (stopped) return
|
||||
try {
|
||||
// `fullSize: true` decodes the camera's intrinsic frame (videoWidth ×
|
||||
// videoHeight) rather than the <video> element's CSS box — the default
|
||||
// (`false`) was decoding the few-hundred-px on-screen preview, which
|
||||
// (compounded by `object-cover` cropping) starved the decoder.
|
||||
const result = camera.readFrame(canvas, true)
|
||||
if (result) {
|
||||
stop()
|
||||
onScan(result)
|
||||
}
|
||||
} catch (e) {
|
||||
onError?.(e)
|
||||
}
|
||||
})
|
||||
|
||||
return stop
|
||||
}
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
/**
|
||||
* Relay reachability probe for the pairing wizard (aiolabs/bitspire#70).
|
||||
*
|
||||
* `parseSpireSeed` catches a MALFORMED relay (e.g. a QR misread of `ws://` into
|
||||
* `As://`), but a well-formed-yet-unreachable relay — `ws://localhost:…` baked
|
||||
* into a seed for a remote machine, a wrong LAN IP, or a relay that's simply
|
||||
* down — still parses fine and would only fail later as a NIP-46 connect
|
||||
* crash-loop. This opens a WebSocket to the relay (and sends a NIP-01 REQ so a
|
||||
* real relay answers) so the operator can confirm reachability on-machine,
|
||||
* before committing the pairing.
|
||||
*/
|
||||
|
||||
export interface RelayTestResult {
|
||||
url: string
|
||||
ok: boolean
|
||||
/** Round-trip time to open (ms), when reachable. */
|
||||
ms?: number
|
||||
/** True when the relay answered our REQ — i.e. it's actually a nostr relay. */
|
||||
answered?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Open a WebSocket to `url` and report whether it connects within `timeoutMs`. */
|
||||
export function testRelay(url: string, timeoutMs = 6000): Promise<RelayTestResult> {
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now()
|
||||
let ws: WebSocket | null = null
|
||||
let settled = false
|
||||
|
||||
const finish = (r: Omit<RelayTestResult, 'url'>): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
try {
|
||||
ws?.close()
|
||||
} catch {
|
||||
/* already closing */
|
||||
}
|
||||
resolve({ url, ...r })
|
||||
}
|
||||
|
||||
const timer = setTimeout(
|
||||
() => finish({ ok: false, error: `timed out after ${timeoutMs}ms` }),
|
||||
timeoutMs,
|
||||
)
|
||||
|
||||
try {
|
||||
ws = new WebSocket(url)
|
||||
} catch (e) {
|
||||
finish({ ok: false, error: e instanceof Error ? e.message : 'invalid relay URL' })
|
||||
return
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
// Connected. Probe it as a nostr relay; a genuine relay replies (EOSE /
|
||||
// notice). If it stays silent we still count the open as reachable.
|
||||
try {
|
||||
ws?.send(JSON.stringify(['REQ', 'bitspire-relay-test', { limit: 0 }]))
|
||||
} catch {
|
||||
/* send failed, but the socket opened → still reachable */
|
||||
}
|
||||
const graceMs = Math.min(600, timeoutMs)
|
||||
setTimeout(() => finish({ ok: true, ms: Date.now() - start, answered: false }), graceMs)
|
||||
}
|
||||
ws.onmessage = () => finish({ ok: true, ms: Date.now() - start, answered: true })
|
||||
ws.onerror = () =>
|
||||
finish({ ok: false, error: 'connection failed (unreachable or not a relay)' })
|
||||
})
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
/**
|
||||
* Pairing-source abstraction (aiolabs/bitspire#52).
|
||||
*
|
||||
* A fresh ATM is paired by getting a `spire-seed:v1:…` onto the device. The
|
||||
* operator's spirekeeper mints that seed and renders it as a QR (and, later,
|
||||
* possibly an NFC tag). The machine ingests it via whatever capture hardware
|
||||
* it has — today a camera, tomorrow maybe an NFC reader or a HAL barcode
|
||||
* scanner. `PairingSource` is the seam that keeps the wizard UI and the
|
||||
* ingest pipeline agnostic to *how* the seed arrived.
|
||||
*
|
||||
* Implementations live next to this file: `qr-source.ts` (camera + jsQR),
|
||||
* `nfc-source.ts` (Web NFC scaffold). A HAL-scanner source can be added the
|
||||
* same way without touching the wizard.
|
||||
*/
|
||||
|
||||
export type PairingSourceKind = 'qr' | 'nfc'
|
||||
|
||||
export interface PairingSourceStartOptions {
|
||||
/** Invoked with each decoded payload (the raw seed string). */
|
||||
onScan: (raw: string) => void
|
||||
/** Invoked on a non-fatal capture error (e.g. a frame decode glitch). */
|
||||
onError?: (error: unknown) => void
|
||||
/**
|
||||
* The <video> element the camera preview renders into. Required by
|
||||
* camera-based sources; ignored by sources that don't show a viewfinder
|
||||
* (e.g. NFC).
|
||||
*/
|
||||
video?: HTMLVideoElement
|
||||
}
|
||||
|
||||
/** Releases capture hardware (camera stream, NFC reader). Idempotent. */
|
||||
export type StopCapture = () => void
|
||||
|
||||
export interface PairingSource {
|
||||
readonly kind: PairingSourceKind
|
||||
/** Short label for the wizard's source picker (e.g. "Camera", "NFC tap"). */
|
||||
readonly label: string
|
||||
/** Whether this source can run in the current environment. */
|
||||
isAvailable(): Promise<boolean>
|
||||
/** Begin capturing; resolves once hardware is live. */
|
||||
start(opts: PairingSourceStartOptions): Promise<StopCapture>
|
||||
}
|
||||
|
|
@ -1,193 +0,0 @@
|
|||
/**
|
||||
* Signer resolution — turns the ATM's pairing state into a live `Signer`.
|
||||
*
|
||||
* Three outcomes, in priority order (aiolabs/bitspire#52, model A1):
|
||||
* 1. A seed is present whose fingerprint differs from the stored binding
|
||||
* (first pair or re-pair) → generate a fresh NIP-46 transport key, redeem
|
||||
* the one-shot connect secret, persist the binding, and reset the
|
||||
* publish watermark so the (possibly new) operator gets current state (#56).
|
||||
* 2. A seed is present matching the stored binding, OR no seed but a stored
|
||||
* binding exists → resume the bunker session with the persisted transport
|
||||
* key (no re-redeem — the binding is server-persistent).
|
||||
* 3. Neither → ephemeral LocalSigner, dev only. In strict (production) mode
|
||||
* this throws instead: no pairing means no signing identity.
|
||||
*
|
||||
* Runs in the renderer (where the relay I/O lives); state.db reads/writes go
|
||||
* through the one-shot get-atm-secrets channel + the binding IPC handlers.
|
||||
*/
|
||||
|
||||
import {
|
||||
LocalSigner,
|
||||
connectNewSeed,
|
||||
resumeFromBinding,
|
||||
generateClientTransportKey,
|
||||
generateIdentity,
|
||||
loadIdentityFromHex,
|
||||
parseSpireSeed,
|
||||
seedFingerprint,
|
||||
type Signer,
|
||||
type SpireSeed,
|
||||
} from '@bitSpire/nostr-client'
|
||||
import type { BunkerBindingRecord } from '@/types/electron'
|
||||
|
||||
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
|
||||
|
||||
/**
|
||||
* Thrown in strict mode when the machine has no seed and no binding — it is
|
||||
* genuinely unpaired, not misconfigured. The renderer catches this to show the
|
||||
* QR-pairing wizard (camera scan of a spire-seed) rather than a fault screen.
|
||||
* Distinct `.name` so it survives the bundle boundary (instanceof is fragile
|
||||
* across the electron/renderer split). See services/init-error.ts.
|
||||
*/
|
||||
export class NoPairingError extends Error {
|
||||
override readonly name = 'NoPairingError'
|
||||
constructor() {
|
||||
super('[Signer] Machine is unpaired — no spire seed and no bunker binding.')
|
||||
}
|
||||
}
|
||||
|
||||
export interface ResolveSignerOptions {
|
||||
/** Allow an ephemeral LocalSigner when no seed/binding exists (dev only). */
|
||||
allowEphemeral: boolean
|
||||
}
|
||||
|
||||
/** LNbits transport config carried by the pairing (aiolabs/bitspire#70). */
|
||||
export interface TransportConfig {
|
||||
/** LNbits transport relays (kind-21000 / 30078). */
|
||||
relays: string[]
|
||||
/** LNbits nostr-transport server pubkey (hex). */
|
||||
lnbitsServerPubkey: string
|
||||
}
|
||||
|
||||
export interface ResolvedSigner {
|
||||
signer: Signer
|
||||
/**
|
||||
* Transport config sourced from the pairing — the seed on a fresh pair /
|
||||
* seeded resume, the binding on a seedless resume. Null when unavailable (an
|
||||
* ephemeral dev signer, or a pre-#70 binding that never stored it); the
|
||||
* caller then falls back to env provisioning.
|
||||
*/
|
||||
transport: TransportConfig | null
|
||||
}
|
||||
|
||||
interface PairingState {
|
||||
spireSeed: string
|
||||
binding: BunkerBindingRecord | null
|
||||
}
|
||||
|
||||
/** Gather the seed + persisted binding from Electron, or env in browser dev. */
|
||||
async function loadPairingState(): Promise<PairingState> {
|
||||
if (isElectron && window.electronAPI) {
|
||||
const secrets = await window.electronAPI.getAtmSecrets()
|
||||
return { spireSeed: secrets.spireSeed || '', binding: secrets.bunkerBinding ?? null }
|
||||
}
|
||||
return { spireSeed: (import.meta.env.VITE_SPIRE_SEED as string | undefined) || '', binding: null }
|
||||
}
|
||||
|
||||
export async function resolveSigner(opts: ResolveSignerOptions): Promise<ResolvedSigner> {
|
||||
const { spireSeed, binding } = await loadPairingState()
|
||||
|
||||
const resume = (b: BunkerBindingRecord): Promise<Signer> =>
|
||||
resumeFromBinding({
|
||||
clientSecretHex: b.clientSecretHex,
|
||||
spirePubkey: b.spirePubkey,
|
||||
bunkerUrl: b.bunkerUrl,
|
||||
})
|
||||
|
||||
// Transport config from a binding — present only when the pairing seed
|
||||
// carried it (post-#70) and it was persisted. Null on pre-#70 bindings.
|
||||
const transportFromBinding = (b: BunkerBindingRecord): TransportConfig | null =>
|
||||
b.relays && b.relays.length > 0 && b.lnbitsServerPubkey
|
||||
? { relays: b.relays, lnbitsServerPubkey: b.lnbitsServerPubkey }
|
||||
: null
|
||||
|
||||
const transportFromSeed = (s: SpireSeed): TransportConfig => ({
|
||||
relays: s.relays,
|
||||
lnbitsServerPubkey: s.lnbitsServerPubkey,
|
||||
})
|
||||
|
||||
if (spireSeed) {
|
||||
let seed: SpireSeed
|
||||
let fingerprint: string
|
||||
try {
|
||||
seed = parseSpireSeed(spireSeed)
|
||||
fingerprint = seedFingerprint(spireSeed)
|
||||
} catch (err) {
|
||||
// A stored seed we can't parse — e.g. a legacy-shape seed left in .env
|
||||
// after the seed format changed (bitspire-#70). If we already hold a
|
||||
// binding it's authoritative (server-persistent), so resume from it
|
||||
// rather than bricking a paired machine on the next boot. With no
|
||||
// binding the seed is our only pairing input, so fail closed.
|
||||
if (binding) {
|
||||
console.warn(
|
||||
'[Signer] Stored spire seed is unparseable; resuming from existing binding:',
|
||||
(err as Error).message
|
||||
)
|
||||
return { signer: await resume(binding), transport: transportFromBinding(binding) }
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
if (binding && binding.seedFingerprint === fingerprint) {
|
||||
console.log('[Signer] Resuming bunker session for spire', seed.spirePubkey)
|
||||
// Seed present + parsed → prefer its (fresh) transport config over the
|
||||
// binding's, which may predate the seed carrying transport (pre-#70).
|
||||
return { signer: await resume(binding), transport: transportFromSeed(seed) }
|
||||
}
|
||||
|
||||
// First pair or re-pair: redeem the one-shot connect secret.
|
||||
console.log('[Signer] Pairing to bunker for spire', seed.spirePubkey)
|
||||
const transport = generateClientTransportKey()
|
||||
const signer = await connectNewSeed({
|
||||
spirePubkey: seed.spirePubkey,
|
||||
bunkerUrl: seed.bunkerUrl,
|
||||
clientSecretHex: transport.secretHex,
|
||||
})
|
||||
if (isElectron && window.electronAPI) {
|
||||
// Re-pair (a NEW seed replacing a prior binding) → wipe the previous
|
||||
// operator's config/trust state (fee config + replay watermarks) so it
|
||||
// can't linger or silently replay-block the new operator's config. A
|
||||
// first pair (no prior binding) has nothing to reset. Cash accounting is
|
||||
// preserved — see resetForRepair; a full wipe is the factory-reset path.
|
||||
if (binding) {
|
||||
console.log(
|
||||
'[Signer] Re-pair (new seed fingerprint) — clearing prior operator config state'
|
||||
)
|
||||
await window.electronAPI.resetForRepair()
|
||||
}
|
||||
// Persist the seed's transport config alongside the binding so a later
|
||||
// seedless resume still reaches the backend without env provisioning.
|
||||
await window.electronAPI.saveBunkerBinding({
|
||||
clientSecretHex: transport.secretHex,
|
||||
spirePubkey: seed.spirePubkey,
|
||||
bunkerUrl: seed.bunkerUrl,
|
||||
seedFingerprint: fingerprint,
|
||||
pairedAt: Math.floor(Date.now() / 1000),
|
||||
relays: seed.relays,
|
||||
lnbitsServerPubkey: seed.lnbitsServerPubkey,
|
||||
})
|
||||
// Re-pair → re-publish the cassette-state hello to the new operator (#56).
|
||||
await window.electronAPI.resetStatePublishWatermark()
|
||||
}
|
||||
return { signer, transport: transportFromSeed(seed) }
|
||||
}
|
||||
|
||||
// No seed in this boot but a binding survives → resume.
|
||||
if (binding) {
|
||||
console.log('[Signer] Resuming bunker session from stored binding (no seed this boot)')
|
||||
return { signer: await resume(binding), transport: transportFromBinding(binding) }
|
||||
}
|
||||
|
||||
if (opts.allowEphemeral) {
|
||||
// Dev-only: a hex key gives a stable dev identity; otherwise ephemeral.
|
||||
const devKey = !isElectron ? (import.meta.env.VITE_ATM_PRIVATE_KEY as string | undefined) : ''
|
||||
if (devKey) {
|
||||
console.warn('[Signer] No bunker pairing — using LocalSigner from VITE_ATM_PRIVATE_KEY (dev)')
|
||||
return { signer: new LocalSigner(loadIdentityFromHex(devKey)), transport: null }
|
||||
}
|
||||
console.warn('[Signer] No bunker pairing — generated ephemeral LocalSigner (dev only)')
|
||||
return { signer: new LocalSigner(generateIdentity()), transport: null }
|
||||
}
|
||||
|
||||
throw new NoPairingError()
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,10 @@
|
|||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
/* Hide cursor completely on touchscreen kiosk.
|
||||
Scoped to .kiosk (set on <html> by main.ts) so the public web demo, which
|
||||
runs in an ordinary browser with a mouse, keeps a visible pointer. */
|
||||
.kiosk,
|
||||
.kiosk *,
|
||||
.kiosk *::before,
|
||||
.kiosk *::after {
|
||||
/* Hide cursor completely on touchscreen kiosk */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
cursor: none !important;
|
||||
}
|
||||
|
||||
|
|
|
|||
171
apps/machine/src/types/electron.d.ts
vendored
171
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -2,61 +2,14 @@
|
|||
* Type declarations for Electron API exposed via preload
|
||||
*/
|
||||
|
||||
import type { AllowListEntry } from '../services/access/authorize'
|
||||
|
||||
/**
|
||||
* Access-control config (ADR-003). Loaded by the main process from env +
|
||||
* an optional /var/lib/bitspire/access.json. `enabled` defaults false, so a
|
||||
* machine with no access config behaves exactly as before.
|
||||
*/
|
||||
export interface AccessControlConfig {
|
||||
/** Master switch for the badge-to-enter gate. */
|
||||
enabled: boolean
|
||||
/** Allow the runtime dev/operator unlock gesture on the locked screen. */
|
||||
devUnlock: boolean
|
||||
/** Prototype: admit any valid npub when the allow-list has no match. */
|
||||
openEnrollment: boolean
|
||||
/** Per-machine salt for hashing credentials/PINs. */
|
||||
salt: string
|
||||
/** Authorized identities (hashed). Empty in open-enrollment prototype mode. */
|
||||
allowList: AllowListEntry[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A Bolt Card session opened at tap-to-enter (ADR-003). Wire payload from the
|
||||
* main process (electron/boltcard-session.ts) — mirrored here rather than
|
||||
* imported to avoid a cross-project electron↔renderer import. The withdraw and
|
||||
* pay steps are keyed by a single-use server-side hit; no card secret is held.
|
||||
*/
|
||||
export interface CardSession {
|
||||
externalId: string
|
||||
cardName: string
|
||||
balanceSats: number
|
||||
/** ISO currency the card server priced the balance in; null → no fiat. */
|
||||
currency: string | null
|
||||
/** Balance in `currency` at the card server's rate; null when unknown. */
|
||||
fiat: number | null
|
||||
/** LUD-03 second step, or null when the card server withheld it. */
|
||||
withdraw: {
|
||||
callback: string
|
||||
k1: string
|
||||
minWithdrawable?: number
|
||||
maxWithdrawable?: number
|
||||
} | null
|
||||
/** Why `withdraw` is null (e.g. daily limit spent); safe to show on-screen. */
|
||||
withdrawBlockedReason: string | null
|
||||
/** LUD-06 second step for topping the card wallet up. */
|
||||
pay: { callback: string; minSendable?: number; maxSendable?: number; metadata?: string }
|
||||
}
|
||||
|
||||
export type OpenCardSessionResult =
|
||||
| { ok: true; session: CardSession }
|
||||
| { ok: false; reason: string }
|
||||
|
||||
export interface RuntimeConfig {
|
||||
relayUrl: string
|
||||
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
||||
lnbitsServerPubkey: string
|
||||
/** Legacy LP fields — retained until 3d removes the LP backend. Optional. */
|
||||
lightningPubPubkey?: string
|
||||
lightningPubApiUrl?: string
|
||||
extensionApiUrl?: string
|
||||
appId: string
|
||||
machineModel: string
|
||||
fiatCode: string
|
||||
|
|
@ -68,8 +21,6 @@ export interface RuntimeConfig {
|
|||
maintenanceMode: boolean
|
||||
/** Operator branding override loaded from /var/lib/bitspire/branding/. Null when no override. */
|
||||
branding: BrandingConfig | null
|
||||
/** Access-control gate config (ADR-003). Always present; `enabled` defaults false. */
|
||||
accessControl: AccessControlConfig
|
||||
}
|
||||
|
||||
/** Operator branding config. Wire payload from Electron IPC; renderer applies via useBranding(). */
|
||||
|
|
@ -88,24 +39,10 @@ export interface BrandingConfig {
|
|||
logoDarkDataUrl: string | null
|
||||
}
|
||||
|
||||
/** Persisted NIP-46 bunker binding (mirror of state-store's StoredBunkerBinding). */
|
||||
export interface BunkerBindingRecord {
|
||||
clientSecretHex: string
|
||||
spirePubkey: string
|
||||
bunkerUrl: string
|
||||
seedFingerprint: string
|
||||
pairedAt: number
|
||||
/** LNbits transport relays from the seed (#70); absent on pre-#70 bindings. */
|
||||
relays?: string[]
|
||||
/** LNbits nostr-transport server pubkey (hex) from the seed (#70). */
|
||||
lnbitsServerPubkey?: string
|
||||
}
|
||||
|
||||
export interface AtmSecrets {
|
||||
/** Spire pairing seed URL (`spire-seed:v1:…`); carries the one-shot connect token. */
|
||||
spireSeed: string
|
||||
/** Persisted bunker binding, or null when the ATM is unpaired. */
|
||||
bunkerBinding: BunkerBindingRecord | null
|
||||
atmPrivateKey: string
|
||||
/** Legacy LP admin token — retained until 3d removes the LP backend. */
|
||||
adminToken?: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
|
@ -146,86 +83,12 @@ declare global {
|
|||
emptyCashbox: () => Promise<void>
|
||||
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
|
||||
getLastKnownConfigCreatedAt: () => Promise<number>
|
||||
getLastStatePublishedAt: () => Promise<number | null>
|
||||
/** When the bay counts became unverified (a dispense that reported nothing), or null. */
|
||||
getCountsUncertainSince: () => Promise<number | null>
|
||||
markCountsUncertain: (unixTimestamp: number) => Promise<void>
|
||||
// Cash-out hold (ADR-005 §5)
|
||||
getCashOutHold: () => Promise<{
|
||||
reason: string
|
||||
errorCode: string | null
|
||||
rawCode: string | null
|
||||
since: number
|
||||
} | null>
|
||||
setCashOutHold: (hold: {
|
||||
reason: string
|
||||
errorCode: string | null
|
||||
rawCode: string | null
|
||||
since: number
|
||||
}) => Promise<{ reason: string; errorCode: string | null; rawCode: string | null; since: number }>
|
||||
clearCashOutHold: () => Promise<boolean>
|
||||
// Dispense-report outbox (ADR-005 §2)
|
||||
pendingDispenseReports: (limit?: number) => Promise<
|
||||
Array<{
|
||||
txid: string
|
||||
payload: unknown
|
||||
createdAt: number
|
||||
attempts: number
|
||||
lastAttemptAt: number | null
|
||||
lastError: string | null
|
||||
}>
|
||||
>
|
||||
ackDispenseReport: (txid: string) => Promise<boolean>
|
||||
noteDispenseReportAttempt: (txid: string, error: string | null) => Promise<void>
|
||||
markStatePublished: (unixTimestamp: number) => Promise<void>
|
||||
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
||||
clearBunkerBinding: () => Promise<void>
|
||||
resetStatePublishWatermark: () => Promise<void>
|
||||
resetForRepair: () => Promise<void>
|
||||
saveSpireSeed: (seed: string) => Promise<void>
|
||||
relaunchApp: () => Promise<void>
|
||||
/** Reload the renderer to re-attempt initialization (connectivity recovery). */
|
||||
recoverApp: () => Promise<void>
|
||||
/** Bolt Card cash-out: pull payment for the current invoice from a tapped card. */
|
||||
lnurlWithdraw: (args: {
|
||||
lnurlw: string
|
||||
bolt11: string
|
||||
amountMsat?: number
|
||||
}) => Promise<{ ok: boolean; reason?: string }>
|
||||
/** Bolt Card cash-in: resolve a tapped card + amount to a BOLT11 to pay. */
|
||||
resolveCardInvoice: (args: {
|
||||
lnurlw: string
|
||||
amountMsat: number
|
||||
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
|
||||
/** Bolt Card tap-to-enter: open one verified session for a tapped card (spends the SUN). */
|
||||
openCardSession: (args: { lnurlw: string }) => Promise<OpenCardSessionResult>
|
||||
/** Cash-out via a session's withdraw step (no tap). */
|
||||
withdrawWithSession: (args: {
|
||||
withdraw: NonNullable<CardSession['withdraw']>
|
||||
bolt11: string
|
||||
amountMsat?: number
|
||||
}) => Promise<{ ok: boolean; reason?: string }>
|
||||
/** Cash-in via a session's pay step (no tap): a BOLT11 to pay. */
|
||||
resolveSessionInvoice: (args: {
|
||||
pay: CardSession['pay']
|
||||
amountMsat: number
|
||||
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
|
||||
applyOperatorCassetteOps: (
|
||||
ops: {
|
||||
id: string
|
||||
at: number
|
||||
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
|
||||
position: number
|
||||
bills?: number
|
||||
count?: number
|
||||
denomination?: number
|
||||
}[]
|
||||
) => Promise<{
|
||||
applied: string[]
|
||||
rejected: { id: string; reason: string }[]
|
||||
}>
|
||||
getAppliedOpIds: (limit?: number) => Promise<string[]>
|
||||
getCassetteStateSeq: () => Promise<number>
|
||||
getBootstrapPublishedAt: () => Promise<number | null>
|
||||
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
|
||||
applyOperatorCassettesConfig: (
|
||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||
eventCreatedAt: number
|
||||
) => Promise<{ applied: true } | { applied: false; reason: string }>
|
||||
getFeeConfig: () => Promise<{
|
||||
cashInFeeFraction: number
|
||||
cashOutFeeFraction: number
|
||||
|
|
@ -259,14 +122,6 @@ declare global {
|
|||
onHalBillInserted: (callback: (denomination: number) => void) => void
|
||||
onHalBillRejected: (callback: (reason: string) => void) => void
|
||||
onHalError: (callback: (error: string) => void) => void
|
||||
/** The main process mutated the cassettes table; reload + republish. */
|
||||
onCassettesChanged: (callback: () => void) => void
|
||||
/** Bolt Card reader: a tapped card's lnurlw voucher. */
|
||||
onNfcCardTapped: (callback: (lnurlw: string) => void) => void
|
||||
/** Bolt Card reader status (ready / reading / error / unavailable). */
|
||||
onNfcStatus: (
|
||||
callback: (status: { state: string; reader?: string; message?: string }) => void
|
||||
) => void
|
||||
onWatchdogPing: (callback: () => void) => void
|
||||
watchdogPong: () => Promise<void>
|
||||
platform: NodeJS.Platform
|
||||
|
|
|
|||
|
|
@ -34,12 +34,6 @@ export interface TransactionRecord {
|
|||
}[]
|
||||
error?: string | null
|
||||
remediatedBy?: string | null
|
||||
/**
|
||||
* ADR-005 §2: the dispense outcome to queue for spirekeeper, written in
|
||||
* the same SQLite transaction as the row so a crash between the two cannot
|
||||
* lose it. Cash-out only. Shape is @bitSpire/lnbits DispenseReportBody.
|
||||
*/
|
||||
report?: import('@bitSpire/lnbits').DispenseReportBody
|
||||
}
|
||||
|
||||
export interface ATMAvailability {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { watch, computed, ref } from 'vue'
|
|||
import { useRouter } from 'vue-router'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import CardChip from '@/components/CardChip.vue'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import QRCode from '@/components/QRCode.vue'
|
||||
|
|
@ -49,9 +48,6 @@ const showCancelButton = computed(() => {
|
|||
// Invoice input for manual payment
|
||||
const invoiceInput = ref('')
|
||||
|
||||
// Dev: paste an lnurlw to simulate a Bolt Card tap-to-receive
|
||||
const mockLnurlw = ref('')
|
||||
|
||||
// Copy state for ndebit URI
|
||||
const copied = ref(false)
|
||||
|
||||
|
|
@ -246,10 +242,7 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
|||
</p>
|
||||
|
||||
<!-- Status -->
|
||||
<p v-if="context?.billPending" class="text-sm lg:text-xl text-muted-foreground">
|
||||
⏳ Processing bill…
|
||||
</p>
|
||||
<p v-else-if="balanceLimitReached" class="text-sm lg:text-xl text-muted-foreground">
|
||||
<p v-if="balanceLimitReached" class="text-sm lg:text-xl text-muted-foreground">
|
||||
Maximum amount reached — press Done to continue
|
||||
</p>
|
||||
<p v-else class="text-sm lg:text-xl text-muted-foreground">
|
||||
|
|
@ -276,16 +269,11 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
|||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- Done button — also blocked while a bill is between the
|
||||
stack command and the validator's stacked-confirmation
|
||||
(the machine guard drops FINISH_INSERTING regardless;
|
||||
this keeps the UI honest about it) -->
|
||||
<!-- Done button -->
|
||||
<Button
|
||||
class="w-full bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"
|
||||
size="kiosk-lg"
|
||||
:disabled="
|
||||
!context || context.billsInserted.length === 0 || context.billPending !== null
|
||||
"
|
||||
:disabled="!context || context.billsInserted.length === 0"
|
||||
@click="finishInserting"
|
||||
>
|
||||
Done Inserting
|
||||
|
|
@ -337,47 +325,10 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
|||
{{ atmStore.fiatSymbol }}{{ ((context?.fiatCents || 0) / 100).toFixed(2) }}
|
||||
</p>
|
||||
|
||||
<!-- Waiting indicator + Bolt Card tap-to-receive status -->
|
||||
<div class="flex flex-col items-center gap-2 pt-2 lg:pt-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Waiting indicator -->
|
||||
<div class="flex items-center gap-3 pt-2 lg:pt-4">
|
||||
<PickaxeIcon :size="32" />
|
||||
<p class="text-sm lg:text-xl text-muted-foreground">
|
||||
{{
|
||||
atmStore.boltCardProcessing
|
||||
? 'Processing card…'
|
||||
: isElectron
|
||||
? 'Tap your card or scan to receive'
|
||||
: 'Waiting for wallet scan...'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
v-if="atmStore.nfcStatus?.message"
|
||||
class="text-sm lg:text-lg"
|
||||
:class="
|
||||
atmStore.nfcStatus.state === 'declined' || atmStore.nfcStatus.state === 'error'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
"
|
||||
>
|
||||
{{ atmStore.nfcStatus.message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tap-to-enter: card already loaded → one-press Complete (no re-tap) -->
|
||||
<div
|
||||
v-if="atmStore.loadedBoltCard"
|
||||
class="flex w-full max-w-md flex-col items-center gap-2 pt-2"
|
||||
>
|
||||
<CardChip class="w-full" />
|
||||
<Button
|
||||
class="w-full bg-success text-success-foreground"
|
||||
size="kiosk-lg"
|
||||
:disabled="atmStore.boltCardProcessing"
|
||||
@click="atmStore.completeWithCard()"
|
||||
>
|
||||
{{ atmStore.boltCardProcessing ? 'Completing…' : 'Complete Purchase' }}
|
||||
</Button>
|
||||
<p class="text-sm lg:text-xl text-muted-foreground">Waiting for wallet scan...</p>
|
||||
</div>
|
||||
|
||||
<!-- LNURL URI (web-ui only) -->
|
||||
|
|
@ -396,8 +347,8 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debug: simulate payment / Bolt Card tap-to-receive -->
|
||||
<div v-if="atmStore.debugMode" class="pt-2 flex flex-col items-center gap-2">
|
||||
<!-- Debug: Simulate payment button -->
|
||||
<div v-if="atmStore.debugMode" class="pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -406,21 +357,6 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
|||
>
|
||||
Dev: Skip to Success
|
||||
</Button>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="mockLnurlw"
|
||||
placeholder="lnurlw://… (paste to simulate a card tap)"
|
||||
class="w-56 rounded border border-input bg-background px-2 py-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!mockLnurlw"
|
||||
@click="atmStore.simulateBoltCardReceive(mockLnurlw)"
|
||||
>
|
||||
Tap
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { watch, computed, ref } from 'vue'
|
|||
import { useRouter } from 'vue-router'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import CardChip from '@/components/CardChip.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import QRCode from '@/components/QRCode.vue'
|
||||
|
|
@ -16,10 +15,6 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
|
|||
|
||||
const cashOutSteps = ['Select', 'Pay', 'Collect']
|
||||
|
||||
// Dev-only: paste a real card's lnurlw to exercise the Bolt Card pull without
|
||||
// the reader (single-use, so a live tap each time).
|
||||
const mockLnurlw = ref('')
|
||||
|
||||
const currentStepIndex = computed(() => {
|
||||
switch (nestedState.value) {
|
||||
case 'fetchingRate':
|
||||
|
|
@ -63,19 +58,13 @@ watch(
|
|||
const nestedState = computed(() => atmStore.nestedState)
|
||||
const context = computed(() => atmStore.context)
|
||||
|
||||
// Terminal-screen countdowns (ADR-005 §4). The machine owns the real timers
|
||||
// (DISPENSE_FAULT_TIMEOUT 120 s, DISPENSE_ERROR_TIMEOUT 30 s); this mirrors
|
||||
// them for display only.
|
||||
const TERMINAL_SECONDS: Record<string, number> = { dispenseFault: 120, outOfCash: 30 }
|
||||
// Dispense error 30s countdown
|
||||
const dispenseErrorCountdown = ref(30)
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
watch(nestedState, (newState, oldState) => {
|
||||
const entering = typeof newState === 'string' && newState in TERMINAL_SECONDS
|
||||
const leaving = typeof oldState === 'string' && oldState in TERMINAL_SECONDS
|
||||
if (entering && newState !== oldState) {
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
dispenseErrorCountdown.value = TERMINAL_SECONDS[newState as string] ?? 30
|
||||
if (newState === 'dispenseError' && oldState !== 'dispenseError') {
|
||||
dispenseErrorCountdown.value = 30
|
||||
countdownTimer = setInterval(() => {
|
||||
dispenseErrorCountdown.value--
|
||||
if (dispenseErrorCountdown.value <= 0 && countdownTimer) {
|
||||
|
|
@ -83,21 +72,12 @@ watch(nestedState, (newState, oldState) => {
|
|||
countdownTimer = null
|
||||
}
|
||||
}, 1000)
|
||||
} else if (leaving && !entering && countdownTimer) {
|
||||
} else if (oldState === 'dispenseError' && countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
function acknowledgeFault() {
|
||||
atmStore.acknowledgeFault()
|
||||
}
|
||||
|
||||
const faultTime = computed(() => {
|
||||
const t = context.value?.startedAt
|
||||
return t ? new Date(t).toLocaleString() : ''
|
||||
})
|
||||
|
||||
// Available denominations from inventory
|
||||
const availableDenominations = computed(() => {
|
||||
if (!context.value?.inventory) return []
|
||||
|
|
@ -193,24 +173,6 @@ function formatFiat(cents: number): string {
|
|||
key="selectingAmount"
|
||||
class="flex flex-1 flex-col justify-center px-4 lg:px-[8vw] py-4 lg:py-6 gap-4 lg:gap-6"
|
||||
>
|
||||
<!-- A payment was taken and no cash came out. Say so plainly, with
|
||||
the reference an operator needs; do not let the customer wander
|
||||
back into the amount grid believing nothing happened. -->
|
||||
<div
|
||||
v-if="atmStore.settlementError"
|
||||
class="rounded-lg border-2 border-destructive bg-destructive/10 px-4 py-3 text-center lg:px-6 lg:py-4"
|
||||
>
|
||||
<p class="text-base font-bold text-destructive lg:text-2xl">
|
||||
{{ atmStore.settlementError.message }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground lg:text-lg">
|
||||
Please contact the operator<template v-if="atmStore.settlementError.txid">
|
||||
and quote
|
||||
<span class="font-mono-code">{{ atmStore.settlementError.txid }}</span> </template
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Denomination grid -->
|
||||
<div class="grid grid-cols-2 gap-3 lg:gap-6">
|
||||
<div
|
||||
|
|
@ -322,9 +284,7 @@ function formatFiat(cents: number): string {
|
|||
<div
|
||||
class="flex w-full lg:w-[52%] flex-col items-center justify-center gap-3 lg:gap-5 px-4 lg:px-[4vw] py-4 lg:py-0"
|
||||
>
|
||||
<p class="text-lg lg:text-[2rem] font-semibold text-warning">
|
||||
{{ isElectron ? 'Tap Card or Scan to Pay' : 'Scan to Pay' }}
|
||||
</p>
|
||||
<p class="text-lg lg:text-[2rem] font-semibold text-warning">Scan to Pay</p>
|
||||
<p class="text-3xl lg:text-[7vh] font-bold text-bitcoin leading-tight">
|
||||
{{ context ? formatSats(context.satsAmount) : 0 }} sats
|
||||
</p>
|
||||
|
|
@ -335,60 +295,10 @@ function formatFiat(cents: number): string {
|
|||
</Badge>
|
||||
</p>
|
||||
|
||||
<!-- Waiting indicator + Bolt Card status -->
|
||||
<div class="flex flex-col items-center gap-2 pt-2 lg:pt-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Waiting indicator -->
|
||||
<div class="flex items-center gap-3 pt-2 lg:pt-4">
|
||||
<PickaxeIcon :size="32" />
|
||||
<p class="text-sm lg:text-xl text-muted-foreground">
|
||||
{{
|
||||
atmStore.boltCardProcessing
|
||||
? 'Processing card…'
|
||||
: isElectron
|
||||
? 'Tap your card or scan the QR'
|
||||
: 'Waiting for payment...'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
v-if="atmStore.nfcStatus?.message"
|
||||
class="text-sm lg:text-lg"
|
||||
:class="
|
||||
atmStore.nfcStatus.state === 'declined' || atmStore.nfcStatus.state === 'error'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
"
|
||||
>
|
||||
{{ atmStore.nfcStatus.message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tap-to-enter: card already loaded → one-press Complete (no re-tap) -->
|
||||
<div
|
||||
v-if="atmStore.loadedBoltCard"
|
||||
class="flex w-full max-w-md flex-col items-center gap-2 pt-2"
|
||||
>
|
||||
<CardChip class="w-full" />
|
||||
<!-- The card server withheld this session's withdraw step (e.g.
|
||||
the card's daily limit is spent). Nothing the customer does
|
||||
here can lift it, so say so rather than offering a button
|
||||
that always fails. The QR remains as the way to get paid. -->
|
||||
<p
|
||||
v-if="!atmStore.loadedBoltCard.withdraw"
|
||||
class="w-full text-center text-sm font-medium text-destructive lg:text-lg"
|
||||
>
|
||||
{{
|
||||
atmStore.loadedBoltCard.withdrawBlockedReason ?? 'This card cannot sell right now'
|
||||
}}
|
||||
</p>
|
||||
<Button
|
||||
v-else
|
||||
class="w-full bg-success text-success-foreground"
|
||||
size="kiosk-lg"
|
||||
:disabled="atmStore.boltCardProcessing"
|
||||
@click="atmStore.completeWithCard()"
|
||||
>
|
||||
{{ atmStore.boltCardProcessing ? 'Completing…' : 'Complete Sale' }}
|
||||
</Button>
|
||||
<p class="text-sm lg:text-xl text-muted-foreground">Waiting for payment...</p>
|
||||
</div>
|
||||
|
||||
<!-- Invoice info with copy button (web-ui only) -->
|
||||
|
|
@ -412,21 +322,6 @@ function formatFiat(cents: number): string {
|
|||
>
|
||||
Simulate Payment
|
||||
</Button>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
v-model="mockLnurlw"
|
||||
placeholder="lnurlw://… (paste to simulate a card tap)"
|
||||
class="w-56 rounded border border-input bg-background px-2 py-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!mockLnurlw"
|
||||
@click="atmStore.simulateBoltCardTap(mockLnurlw)"
|
||||
>
|
||||
Tap
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
|
@ -550,92 +445,63 @@ function formatFiat(cents: number): string {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dispense fault / could-not-dispense (ADR-005 §4).
|
||||
Both reach here AFTER payment: the customer has paid and received
|
||||
less than they paid for. dispenseFault = the dispenser reported an
|
||||
error (and may have latched cash-out off); outOfCash = it reported
|
||||
none. Either way: evidence on screen, operator notified. The raw
|
||||
dispenser code is deliberately NOT shown — it travels in the report. -->
|
||||
<!-- Dispense Error (timed, 30s → idle) -->
|
||||
<div
|
||||
v-else-if="nestedState === 'dispenseFault' || nestedState === 'outOfCash'"
|
||||
key="dispenseTerminal"
|
||||
v-else-if="nestedState === 'dispenseError'"
|
||||
key="dispenseError"
|
||||
class="flex flex-1 flex-col lg:flex-row items-center justify-center gap-6 lg:gap-10 p-4 lg:p-8"
|
||||
style="background: color-mix(in srgb, var(--destructive) 8%, var(--background))"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-3 lg:gap-5 max-w-xl">
|
||||
<!-- Left side — error details -->
|
||||
<div class="flex flex-col items-center gap-3 lg:gap-5">
|
||||
<div class="text-5xl lg:text-[8vh]">⚠️</div>
|
||||
<h3 class="text-2xl lg:text-[3rem] font-bold text-destructive">
|
||||
{{ nestedState === 'dispenseFault' ? 'Dispenser fault' : 'Could not dispense' }}
|
||||
</h3>
|
||||
<p class="text-base lg:text-2xl text-foreground text-center font-semibold">
|
||||
Your payment went through. The cash below could not be dispensed.
|
||||
<h3 class="text-2xl lg:text-[3rem] font-bold text-destructive">Dispense Error</h3>
|
||||
<p class="text-base lg:text-2xl text-muted-foreground">
|
||||
{{ context?.error || 'Cash could not be dispensed' }}
|
||||
</p>
|
||||
|
||||
<div class="w-full rounded-xl bg-background/60 px-4 py-4 lg:px-8 lg:py-6 space-y-2">
|
||||
<div class="flex justify-between text-base lg:text-2xl">
|
||||
<span class="text-muted-foreground">You paid</span>
|
||||
<span class="font-semibold"
|
||||
>{{ atmStore.fiatSymbol }}{{ ((context?.fiatCents ?? 0) / 100).toFixed(2) }}
|
||||
<span class="text-muted-foreground text-sm lg:text-lg"
|
||||
>({{ (context?.satsAmount ?? 0).toLocaleString() }} sats)</span
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
<!-- Partial dispense info -->
|
||||
<div
|
||||
v-for="bill in context?.dispenseResult?.bills ?? []"
|
||||
v-if="context?.dispenseResult?.bills?.length"
|
||||
class="w-full max-w-md rounded-xl bg-background/60 px-4 py-4 lg:px-8 lg:py-6 space-y-2"
|
||||
>
|
||||
<div
|
||||
v-for="bill in context.dispenseResult.bills"
|
||||
:key="bill.denomination"
|
||||
class="flex justify-between text-base lg:text-2xl"
|
||||
>
|
||||
<span class="text-muted-foreground"
|
||||
>{{ atmStore.fiatSymbol }}{{ bill.denomination }} notes</span
|
||||
>{{ atmStore.fiatSymbol }}{{ bill.denomination }}</span
|
||||
>
|
||||
<span :class="bill.dispensed > 0 ? 'text-success' : 'text-destructive'">
|
||||
{{ bill.dispensed }} dispensed
|
||||
<span v-if="bill.rejected > 0" class="text-destructive">
|
||||
({{ bill.rejected }} rejected)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-base lg:text-xl text-foreground text-center">
|
||||
The operator has been notified and holds a record of this transaction.
|
||||
<strong>Keep this reference</strong> — photograph it or write it down.
|
||||
</p>
|
||||
<p v-if="context?.error" class="text-xs lg:text-sm text-muted-foreground text-center">
|
||||
Technical detail: {{ context.error }}
|
||||
<p class="text-sm lg:text-lg text-muted-foreground">
|
||||
Please contact support with the transaction ID below.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-3">
|
||||
<Button
|
||||
v-if="nestedState === 'dispenseFault'"
|
||||
class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black"
|
||||
size="kiosk"
|
||||
@click="acknowledgeFault"
|
||||
>
|
||||
I've saved this
|
||||
</Button>
|
||||
<Button variant="outline" size="kiosk" @click="cancel"> Return to Start </Button>
|
||||
</div>
|
||||
<!-- Countdown -->
|
||||
<p class="text-sm lg:text-base text-muted-foreground">
|
||||
Returning to start in {{ dispenseErrorCountdown }}s
|
||||
</p>
|
||||
|
||||
<Button variant="outline" size="kiosk" @click="cancel"> Return to Start </Button>
|
||||
</div>
|
||||
|
||||
<div v-if="context?.txid" class="flex flex-col items-center gap-3">
|
||||
<QRCode :value="context.txid" :size="260" />
|
||||
<p class="text-xs lg:text-sm text-muted-foreground">Transaction</p>
|
||||
<!-- Right side — txid QR -->
|
||||
<div v-if="context?.txid" class="flex flex-col items-center gap-4">
|
||||
<QRCode :value="context.txid" :size="280" />
|
||||
<p
|
||||
class="font-mono-code text-sm lg:text-base text-foreground max-w-[320px] text-center break-all"
|
||||
class="font-mono-code text-sm text-muted-foreground max-w-[300px] text-center break-all"
|
||||
>
|
||||
{{ context.txid }}
|
||||
</p>
|
||||
<template v-if="context?.paymentHash">
|
||||
<p class="text-xs lg:text-sm text-muted-foreground">Payment hash</p>
|
||||
<p
|
||||
class="font-mono-code text-xs lg:text-sm text-foreground max-w-[320px] text-center break-all"
|
||||
>
|
||||
{{ context.paymentHash }}
|
||||
</p>
|
||||
</template>
|
||||
<p v-if="faultTime" class="text-xs lg:text-sm text-muted-foreground">{{ faultTime }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { useBranding } from '@/composables/useBranding'
|
||||
import { initialContext } from '@bitSpire/state-machine'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import CardChip from '@/components/CardChip.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import BitcoinIcon from '@/components/BitcoinIcon.vue'
|
||||
import QRCode from '@/components/QRCode.vue'
|
||||
|
|
@ -15,8 +15,18 @@ const atmStore = useAtmStore()
|
|||
const { logoUrl, title: brandTitle } = useBranding()
|
||||
const lndconnectUrl = import.meta.env.VITE_LNDCONNECT_URL || ''
|
||||
const showZeusQR = ref(false)
|
||||
const showLpQR = ref(false)
|
||||
const copied = ref(false)
|
||||
|
||||
// Build nprofile for Lightning.Pub (pubkey + relay hint)
|
||||
const lpNprofile = computed(() => {
|
||||
const pubkey = import.meta.env.VITE_LIGHTNING_PUB_PUBKEY
|
||||
if (!pubkey) return ''
|
||||
const relayUrl = import.meta.env.VITE_RELAY_URL
|
||||
const relays = relayUrl ? [relayUrl.replace('ws://', 'wss://')] : []
|
||||
return nip19.nprofileEncode({ pubkey, relays })
|
||||
})
|
||||
|
||||
async function copyToClipboard(value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
|
|
@ -74,12 +84,16 @@ function handleCashOut() {
|
|||
>Just Bitcoin</Badge
|
||||
>
|
||||
</div>
|
||||
<!-- Commission rates. The machine's own balance is NOT repeated here: App.vue
|
||||
already shows it in the top-right status chip, and next to the holder's
|
||||
card chip an "Available: N sats" line reads as *their* balance. -->
|
||||
<!-- Balance + Commission rates -->
|
||||
<div
|
||||
class="mt-1 flex flex-wrap items-center justify-center gap-2 lg:gap-4 text-[11px] lg:text-[2vh] text-muted-foreground"
|
||||
>
|
||||
<span v-if="atmStore.balanceSats !== null">
|
||||
Available:
|
||||
<span class="font-bold text-primary"
|
||||
>{{ atmStore.balanceSats.toLocaleString() }} sats</span
|
||||
>
|
||||
</span>
|
||||
<span>
|
||||
Buy:
|
||||
<span class="font-bold text-bitcoin"
|
||||
|
|
@ -101,8 +115,6 @@ function handleCashOut() {
|
|||
>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Tap-to-enter: the holder's card session, balance hidden until revealed -->
|
||||
<CardChip v-if="atmStore.loadedBoltCard" class="mt-1 w-full max-w-md" />
|
||||
</div>
|
||||
|
||||
<!-- Touch Zones -->
|
||||
|
|
@ -121,32 +133,16 @@ function handleCashOut() {
|
|||
>
|
||||
</button>
|
||||
|
||||
<!-- Sell Bitcoin — disabled while cash-out is held after a terminal
|
||||
dispenser fault (ADR-005 §5). The state machine refuses
|
||||
SELECT_CASH_OUT regardless; this just tells the customer why. -->
|
||||
<!-- Sell Bitcoin -->
|
||||
<button
|
||||
class="flex aspect-square w-40 lg:w-[36vh] flex-col items-center justify-center gap-1.5 lg:gap-4 rounded-full border-2 transition-all"
|
||||
:class="
|
||||
atmStore.context?.cashOutHeld
|
||||
? 'border-muted-foreground/30 bg-muted/20 opacity-60 cursor-not-allowed'
|
||||
: 'border-success/50 bg-success/10 active:scale-[0.97]'
|
||||
"
|
||||
:disabled="!!atmStore.context?.cashOutHeld"
|
||||
class="flex aspect-square w-40 lg:w-[36vh] flex-col items-center justify-center gap-1.5 lg:gap-4 rounded-full border-2 border-success/50 bg-success/10 transition-all active:scale-[0.97]"
|
||||
@click="handleCashOut"
|
||||
>
|
||||
<span class="text-4xl lg:text-[8vh] leading-none">{{
|
||||
atmStore.context?.cashOutHeld ? '🔧' : '💵'
|
||||
}}</span>
|
||||
<span
|
||||
class="text-base lg:text-[3.5vh] font-bold"
|
||||
:class="atmStore.context?.cashOutHeld ? 'text-muted-foreground' : 'text-success'"
|
||||
>Sell Bitcoin</span
|
||||
<span class="text-4xl lg:text-[8vh] leading-none">💵</span>
|
||||
<span class="text-base lg:text-[3.5vh] font-bold text-success">Sell Bitcoin</span>
|
||||
<span class="text-[10px] lg:text-[1.8vh] text-foreground/70"
|
||||
>Pay invoice, receive cash</span
|
||||
>
|
||||
<span class="text-[10px] lg:text-[1.8vh] text-foreground/70 text-center px-3">{{
|
||||
atmStore.context?.cashOutHeld
|
||||
? 'Temporarily unavailable — operator notified'
|
||||
: 'Pay invoice, receive cash'
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -161,6 +157,15 @@ function handleCashOut() {
|
|||
>
|
||||
Zeus QR (lnd-alice)
|
||||
</Button>
|
||||
<Button
|
||||
v-if="lpNprofile"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-xs text-muted-foreground"
|
||||
@click="showLpQR = true"
|
||||
>
|
||||
Lightning.Pub nprofile
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Zeus QR fullscreen overlay -->
|
||||
|
|
@ -188,37 +193,36 @@ function handleCashOut() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top-left utility buttons: Help, plus an End-session "✕" while the access
|
||||
gate is engaged. Kept in the left corner (not top-right) so they never
|
||||
collide with the centered balance/commission chips, which wrap into the
|
||||
top-right on narrower screens (e.g. sintra). A tap-in loads the holder's
|
||||
Bolt Card for the whole session, so the ✕ gives them an explicit way to
|
||||
re-lock the moment they're done rather than waiting out the idle timeout
|
||||
(which would leave the card usable by the next person meanwhile). -->
|
||||
<div class="absolute top-4 left-4 lg:top-8 lg:left-8 flex items-center gap-2 lg:gap-3">
|
||||
<!-- End session first (leftmost): a solid `destructive` swatch so the exit
|
||||
reads as red in every theme (--destructive is theme-scoped). Shown
|
||||
only while the access gate is engaged. -->
|
||||
<Button
|
||||
v-if="atmStore.accessControl.enabled"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
class="h-14 w-14 lg:h-20 lg:w-20 rounded-full text-2xl lg:text-4xl font-bold"
|
||||
aria-label="End session"
|
||||
@click="atmStore.endSession()"
|
||||
<!-- Lightning.Pub nprofile QR fullscreen overlay -->
|
||||
<div
|
||||
v-if="showLpQR"
|
||||
class="fixed inset-0 z-[100] flex flex-col items-center justify-center gap-4 bg-black/90 p-4"
|
||||
@click.self="showLpQR = false"
|
||||
>
|
||||
✕
|
||||
<p class="text-sm text-white/70">Lightning.Pub nprofile</p>
|
||||
<div class="rounded-2xl">
|
||||
<QRCode :value="lpNprofile" :size="400" />
|
||||
</div>
|
||||
<code class="max-w-[90vw] truncate text-xs text-white/50">{{ lpNprofile }}</code>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" class="text-white" @click="copyToClipboard(lpNprofile)">
|
||||
{{ copied ? 'Copied!' : 'Copy' }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="text-white" @click="showLpQR = false">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help button (top-left) -->
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-14 w-14 lg:h-20 lg:w-20 rounded-full border-2 border-muted-foreground/30 text-2xl lg:text-4xl text-muted-foreground"
|
||||
aria-label="Help"
|
||||
class="absolute top-4 left-4 lg:top-8 lg:left-8 h-14 w-14 lg:h-20 lg:w-20 rounded-full border-2 border-muted-foreground/30 text-2xl lg:text-4xl text-muted-foreground"
|
||||
@click="$router.push('/support')"
|
||||
>
|
||||
?
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Debug toggle (only visible when debug bar is hidden, never in production) -->
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,109 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Access gate — "tap your Bolt Card" screen (ADR-003, tap-to-enter).
|
||||
*
|
||||
* Shown when the machine is healthy but locked (App.vue's `isLocked` branch).
|
||||
* Entry is a single Bolt Card tap: the card is read by the main-process NFC
|
||||
* reader and routed to the store (`handleBoltCardEntry`) which authorizes it
|
||||
* (open-enrollment) and, on grant, loads the card into the session so buy/sell
|
||||
* only need "Complete". This view is presentation-only — it shows the prompt
|
||||
* and live reader status; the store owns the tap handling and machine events.
|
||||
*
|
||||
* Card-only by design: no camera/npub-QR, no PIN. A dev paste-box (debug builds)
|
||||
* and a dev-unlock button remain for testing without hardware.
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { useBranding } from '@/composables/useBranding'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import ColorModeToggle from '@/components/ColorModeToggle.vue'
|
||||
import { Nfc } from 'lucide-vue-next'
|
||||
|
||||
const atmStore = useAtmStore()
|
||||
const { logoUrl, title } = useBranding()
|
||||
|
||||
const denyReason = computed(() => atmStore.snapshot?.context.accessDenyReason ?? null)
|
||||
const showDevUnlock = computed(() => atmStore.accessControl.devUnlock)
|
||||
const nfc = computed(() => atmStore.nfcStatus)
|
||||
const reading = computed(() => atmStore.boltCardProcessing)
|
||||
|
||||
// Dev: paste an lnurlw to simulate a tap-to-enter without a card.
|
||||
const mockLnurlw = ref('')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex flex-1 flex-col items-center justify-center gap-10 bg-background p-8 text-foreground"
|
||||
>
|
||||
<!-- Light/dark toggle — shared kiosk-sized component -->
|
||||
<ColorModeToggle class="absolute right-4 top-4 z-10" />
|
||||
|
||||
<!-- Brand: logo + title only, colours from the active theme (branding.json) -->
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<img v-if="logoUrl" :src="logoUrl" alt="" class="h-[16vh] max-h-44 w-auto object-contain" />
|
||||
<h1 class="text-3xl font-bold tracking-tight lg:text-5xl">{{ title }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Tap target -->
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
<div
|
||||
class="flex items-center justify-center rounded-full border-4 border-primary bg-card shadow-xl"
|
||||
:class="reading ? 'animate-pulse' : ''"
|
||||
style="width: min(48vw, 15rem); aspect-ratio: 1 / 1"
|
||||
>
|
||||
<Nfc class="size-24 text-primary lg:size-28" />
|
||||
</div>
|
||||
|
||||
<p class="text-2xl font-semibold text-foreground lg:text-4xl">
|
||||
{{ reading ? 'Reading card…' : 'Tap your Bolt Card to begin' }}
|
||||
</p>
|
||||
|
||||
<!-- Reader status / denial reason -->
|
||||
<p v-if="denyReason" class="text-lg font-medium text-destructive lg:text-2xl">
|
||||
{{ denyReason }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="nfc?.message"
|
||||
class="text-base lg:text-xl"
|
||||
:class="
|
||||
nfc.state === 'declined' || nfc.state === 'error'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
"
|
||||
>
|
||||
{{ nfc.message }}
|
||||
</p>
|
||||
<p v-else class="max-w-md text-center text-base text-muted-foreground lg:text-xl">
|
||||
Hold your card flat against the reader
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Dev affordances -->
|
||||
<div class="mt-2 flex flex-col items-center gap-2">
|
||||
<Button
|
||||
v-if="showDevUnlock"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-muted-foreground opacity-40 transition-opacity hover:opacity-100"
|
||||
@click="atmStore.devUnlock()"
|
||||
>
|
||||
Dev unlock
|
||||
</Button>
|
||||
<div v-if="atmStore.debugMode" class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="mockLnurlw"
|
||||
placeholder="lnurlw://… (paste to simulate a tap)"
|
||||
class="w-56 rounded border border-input bg-background px-2 py-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!mockLnurlw"
|
||||
@click="atmStore.simulateBoltCardEntry(mockLnurlw)"
|
||||
>
|
||||
Tap
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { marked } from 'marked'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
|
|
@ -22,6 +23,35 @@ import { QrCode, ExternalLink } from 'lucide-vue-next'
|
|||
const router = useRouter()
|
||||
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
|
||||
|
||||
// Lightning.Pub config loaded at runtime from Electron main process
|
||||
const lpPubkey = ref('')
|
||||
const relayUrl = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
if (isElectron && window.electronAPI) {
|
||||
const config = await window.electronAPI.getConfig()
|
||||
lpPubkey.value = config.lightningPubPubkey || ''
|
||||
relayUrl.value = config.relayUrl || ''
|
||||
} else {
|
||||
// Dev fallback: use Vite env vars
|
||||
lpPubkey.value = import.meta.env.VITE_LIGHTNING_PUB_PUBKEY || ''
|
||||
relayUrl.value = import.meta.env.VITE_RELAY_URL || ''
|
||||
}
|
||||
})
|
||||
|
||||
// Build nprofile for Lightning.Pub (pubkey + relay hint)
|
||||
const lpNprofile = computed(() => {
|
||||
if (!lpPubkey.value) return ''
|
||||
const relays = relayUrl.value ? [relayUrl.value.replace('ws://', 'wss://')] : []
|
||||
return nip19.nprofileEncode({ pubkey: lpPubkey.value, relays })
|
||||
})
|
||||
|
||||
// Deep link URL: opens ShockWallet with this ATM's Lightning.Pub pre-filled
|
||||
const shockwalletDeepLink = computed(() => {
|
||||
if (!lpNprofile.value) return ''
|
||||
return `https://wallet.aiolabs.dev/sources/add?nprofile=${encodeURIComponent(lpNprofile.value)}`
|
||||
})
|
||||
|
||||
interface SupportPage {
|
||||
id: string
|
||||
title: string
|
||||
|
|
@ -44,11 +74,17 @@ const defaultPages: SupportPage[] = [
|
|||
| Blink | No | Partial | Yes | Yes | https://www.blink.sv |
|
||||
| Zeus | Yes | Yes | Yes | Yes | https://zeusln.com |
|
||||
| Breez | Yes | Yes | Yes | Yes | https://breez.technology |
|
||||
| ShockWallet | No | Yes | Yes | Yes | https://shockwallet.app |
|
||||
| ShockWallet | No | Yes | Yes | Yes | [shockwallet-deep-link] |
|
||||
|
||||
Tap a QR icon to scan and download a wallet.
|
||||
|
||||
**Non-custodial** means you hold your own keys and have full control of your Bitcoin. **KYC-free** means no identity verification is required. Partial (~) means limits apply without verification.`,
|
||||
**Non-custodial** means you hold your own keys and have full control of your Bitcoin. **KYC-free** means no identity verification is required. Partial (~) means limits apply without verification.
|
||||
|
||||
## Using ShockWallet with this ATM
|
||||
|
||||
Scan the QR code below to add this ATM's Lightning node to your ShockWallet. This lets you send and receive sats directly through the ATM's payment system.
|
||||
|
||||
[lp-nprofile]`,
|
||||
},
|
||||
{
|
||||
id: 'faq',
|
||||
|
|
@ -117,6 +153,7 @@ type Segment =
|
|||
| { type: 'qr'; content: string }
|
||||
| { type: 'table'; table: ParsedTable }
|
||||
| { type: 'qr-placeholder' }
|
||||
| { type: 'lp-nprofile' }
|
||||
|
||||
/** Parse markdown table into structured data */
|
||||
function parseMarkdownTable(tableLines: string[]): ParsedTable | null {
|
||||
|
|
@ -139,8 +176,17 @@ function parseMarkdownTable(tableLines: string[]): ParsedTable | null {
|
|||
return { headers, rows }
|
||||
}
|
||||
|
||||
/** Resolve dynamic placeholders in markdown content */
|
||||
function resolvePlaceholders(md: string): string {
|
||||
return md.replace(
|
||||
'[shockwallet-deep-link]',
|
||||
shockwalletDeepLink.value || 'https://wallet.aiolabs.dev'
|
||||
)
|
||||
}
|
||||
|
||||
/** Parse content into segments: html, qr, or table */
|
||||
function parseContent(md: string): Segment[] {
|
||||
md = resolvePlaceholders(md)
|
||||
const segments: Segment[] = []
|
||||
const lines = md.split('\n')
|
||||
let htmlBlock = ''
|
||||
|
|
@ -189,6 +235,9 @@ function parseContent(md: string): Segment[] {
|
|||
} else if (trimmed === '[operator-qr-placeholder]') {
|
||||
flushHtml()
|
||||
segments.push({ type: 'qr-placeholder' })
|
||||
} else if (trimmed === '[lp-nprofile]') {
|
||||
flushHtml()
|
||||
segments.push({ type: 'lp-nprofile' })
|
||||
} else {
|
||||
htmlBlock += line + '\n'
|
||||
}
|
||||
|
|
@ -326,6 +375,33 @@ onUnmounted(() => {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Lightning.Pub nprofile QR (scannable by ShockWallet) -->
|
||||
<Card v-else-if="seg.type === 'lp-nprofile'" class="my-6 mx-auto max-w-xs">
|
||||
<CardContent class="flex flex-col items-center gap-3 p-6">
|
||||
<template v-if="lpNprofile">
|
||||
<div class="rounded-xl bg-white p-3">
|
||||
<QrcodeVue
|
||||
:value="lpNprofile"
|
||||
:size="180"
|
||||
level="L"
|
||||
render-as="svg"
|
||||
background="#ffffff"
|
||||
foreground="#000000"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground text-center px-2">
|
||||
Scan with ShockWallet to connect
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<QrCode class="h-16 w-16 text-muted-foreground/30" />
|
||||
<span class="text-sm text-muted-foreground/50 text-center">
|
||||
Lightning.Pub not configured
|
||||
</span>
|
||||
</template>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Table with inline QR codes -->
|
||||
<div v-else-if="seg.type === 'table'" class="mb-8">
|
||||
<Table class="text-sm sm:text-base lg:text-xl w-full">
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ deploy/nixos/
|
|||
│ └── 99-bitspire-hardware.rules # additional udev rules (loaded via configuration.nix)
|
||||
├── provision-atm.sh # Push LNbits credentials to a deployed ATM via SSH
|
||||
├── atm-transactions.sh # Operator query tool — reads /var/lib/bitspire/state.db
|
||||
├── flash-douro-usb.sh # Helper for flashing a douro LIVE ISO (not the -usb disk image)
|
||||
├── flash-douro-usb.sh # Helper for flashing a douro live USB
|
||||
└── build-iso.sh # Convenience wrapper for `nix build .#iso-<model>`
|
||||
```
|
||||
|
||||
|
|
@ -33,36 +33,8 @@ Each ATM model has two flake outputs:
|
|||
| `nixosConfigurations.<model>-installed` | installed | full GPT + systemd-boot install, ext4 root, supports `nixos-rebuild switch` |
|
||||
| `packages.x86_64-linux.iso-<model>` | ISO | ISO image of the live variant |
|
||||
| `packages.x86_64-linux.disk-image-<model>` | raw image | dd-able full disk image of the installed variant |
|
||||
| `nixosConfigurations.<model>-usb` | installed, on a stick | `<model>-installed` hardened to run from a USB stick: `nixos-usb`/`ESP-USB` labels, `nofail` `/boot`, no partition growing, auto-upgrade off, `uas` blacklisted |
|
||||
| `packages.x86_64-linux.disk-image-<model>-usb` | raw image | dd-able image of the `-usb` variant — flash, plug in, boot; no installer step |
|
||||
|
||||
Models: `douro`, `tejo`, `sintra`, `batm3`. All four have `-usb` outputs.
|
||||
|
||||
**Run-from-USB deployments** skip the Alpine + dd-to-internal-disk procedure
|
||||
below entirely: the stick *is* the system. That is how `batm3` runs today, how
|
||||
`douro` runs since the LNbits cutover, and how `tejo` is being brought up — its
|
||||
internal storage still holds the factory Debian (`ubilinux4`) and is never
|
||||
touched. Flash `disk-image-<model>-usb` with balenaEtcher (it verifies the
|
||||
write — a truncated or bad copy fails stage-1 fsck on first boot) onto a stick
|
||||
of 16 GB or more, plug it in, power on. Updates go in-place against the
|
||||
`<model>-usb` config (`nix copy` the toplevel + `switch-to-configuration`),
|
||||
which keeps pairing and `/var/lib/bitspire`.
|
||||
|
||||
### Two bootloader shapes, picked by firmware
|
||||
|
||||
The `-usb` images are not interchangeable between models, because the fleet's
|
||||
firmware is not:
|
||||
|
||||
| Models | Partition table | Bootloader | Why |
|
||||
|---|---|---|---|
|
||||
| `batm3`, `douro` | `efi` (GPT + ESP) | systemd-boot | Their firmware UEFI-USB-boots fine via the ESP's removable `/EFI/BOOT/BOOTX64.EFI` fallback |
|
||||
| `tejo`, `sintra` | `hybrid` (GPT + `bios_grub` + ESP) | GRUB, BIOS **and** UEFI | Aaeon UP Board firmware USB-boots in Legacy/BIOS mode — it boots the live ISO via isolinux, not the ESP. systemd-boot is UEFI-only, so a dd'd systemd-boot stick isn't recognised as bootable at all. The hybrid image boots either way |
|
||||
|
||||
Both shapes come out of `mkUsbDiskImage` in `flake.nix`; the GRUB override is
|
||||
`usbGrubHybridModule`. A UP Board `-usb` config installs GRUB with
|
||||
`devices = [ "nodev" ]` so an in-place `switch-to-configuration` on a live
|
||||
stick only regenerates `grub.cfg` — the image build is the one place the BIOS
|
||||
stage gets written to an MBR.
|
||||
Models: `douro`, `tejo`, `sintra`, `batm3`.
|
||||
|
||||
```bash
|
||||
# Build a Sintra disk image
|
||||
|
|
@ -87,7 +59,7 @@ scp bitspire@<sintra-ip>:/var/lib/bitspire/.env ~/sintra-backup-$(date +%Y
|
|||
scp bitspire@<sintra-ip>:/var/lib/bitspire/state.db ~/sintra-backup-$(date +%Y%m%d)/
|
||||
```
|
||||
|
||||
The `.env` is the load-bearing one — it contains `VITE_SPIRE_SEED` (the NIP-46 bunker pairing seed; or the dev-only `VITE_ATM_PRIVATE_KEY` fallback) plus the LNbits / relay URLs. Note the persisted bunker binding (the ATM's transport key) lives in `state.db` once paired — so on a bunker-backed unit, keep `state.db` too or you'll need to re-pair. `state.db` also holds transaction history. Reuse these in step 7 instead of regenerating.
|
||||
The `.env` is the load-bearing one — it contains `VITE_ATM_PRIVATE_KEY` plus the LNbits / relay URLs. `state.db` is transaction history (cheap to keep, fine to drop on dev units). Reuse these in step 7 instead of regenerating.
|
||||
|
||||
Also before powering off the Sintra: make sure any unpushed commits on `dev` have been pushed AND `./deploy/push-cache.sh sintra` has run. Otherwise the next 04:00 auto-upgrade on the freshly-flashed unit will fail to substitute the new closure (or silently downgrade to whatever `origin/dev` HEAD points at).
|
||||
|
||||
|
|
@ -215,7 +187,7 @@ The `dev`-branch `flake.nix` pins the auto-upgrade source to `?ref=dev` so any A
|
|||
```nix
|
||||
system.autoUpgrade = {
|
||||
enable = true;
|
||||
flake = "git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=dev#${machineModel}-installed";
|
||||
flake = "git+ssh://forgejo@git.atitlan.io/aiolabs/lamassu-next.git?ref=dev#${machineModel}-installed";
|
||||
dates = "04:00";
|
||||
allowReboot = false;
|
||||
};
|
||||
|
|
@ -230,7 +202,7 @@ Production ATMs on `main` continue to read `main`'s flake (no `?ref=` pin → re
|
|||
| Path | Owner | Purpose |
|
||||
|------|-------|---------|
|
||||
| `/var/lib/bitspire/` | bitspire:bitspire, 0750 | Service data directory |
|
||||
| `/var/lib/bitspire/.env` | bitspire:bitspire, 0600 | Runtime config — `VITE_RELAY_URL`, `VITE_LNBITS_SERVER_PUBKEY`, `VITE_SPIRE_SEED` (or dev `VITE_ATM_PRIVATE_KEY`), … |
|
||||
| `/var/lib/bitspire/.env` | bitspire:bitspire, 0600 | Runtime config — `VITE_RELAY_URL`, `VITE_LNBITS_SERVER_PUBKEY`, `VITE_ATM_PRIVATE_KEY`, … |
|
||||
| `/var/lib/bitspire/state.db` | bitspire:bitspire | SQLite — cassette inventory, cashbox state, transaction history |
|
||||
| `/var/lib/bitspire/logs/` | bitspire:bitspire, 0750 | Service logs (if app writes them) |
|
||||
| `/var/lib/bitspire/branding/` | bitspire:bitspire, 0755 | Operator branding override (logo.png + branding.json) — see issue #47 |
|
||||
|
|
@ -290,8 +262,8 @@ ls -la /dev/serial/by-id/
|
|||
{
|
||||
services.bitspire = {
|
||||
enable = true;
|
||||
relayUrl = ""; # seed-provided (#70); set to PIN a relay
|
||||
lnbitsServerPubkey = ""; # seed-provided (#70); set to PIN a pubkey
|
||||
relayUrl = "wss://relay.aiolabs.dev"; # ATM ↔ LNbits relay
|
||||
lnbitsServerPubkey = "<64-hex>"; # LNbits transport pubkey
|
||||
appDir = "/opt/bitspire"; # rarely overridden — defaults via flake
|
||||
dataDir = "/var/lib/bitspire"; # rarely overridden
|
||||
logLevel = "info"; # error | warn | info | debug
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"enabled": true,
|
||||
"openEnrollment": true,
|
||||
"devUnlock": false
|
||||
}
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# atm-reconcile — Check each cassette's ledger count against recorded history
|
||||
#
|
||||
# The cassettes table is a running total maintained by the machine: operator
|
||||
# ops add to it (refill) or set it (recount / empty), and dispenses subtract
|
||||
# from it. That means the count can be re-derived, and a derived value that
|
||||
# disagrees with the stored one is evidence of something the ledger never saw.
|
||||
#
|
||||
# Reconciliation runs forward from each bay's last ABSOLUTE truth point — a
|
||||
# `recount` (someone opened the bay and counted it) or an `empty` (set to
|
||||
# zero). Refills are deltas and cannot serve as a baseline: a refill applied
|
||||
# on top of a wrong number just carries the error forward, which is exactly
|
||||
# how a bad count survives for weeks.
|
||||
#
|
||||
# expected = base + refills_since_base - dispensed_since_base
|
||||
# gap = ledger - expected
|
||||
#
|
||||
# A non-zero gap means either notes moved without an op recording it, or a
|
||||
# dispense moved notes the dispenser's counters did not report. The second is
|
||||
# real: a note that leaves the bay and jams in the transport completes neither
|
||||
# the `dispensed` nor the `rejected` counter, so the bay silently reads one
|
||||
# high while the transaction row says nothing was dispensed (aiolabs/bitspire#122).
|
||||
#
|
||||
# A bay with NO baseline cannot be reconciled at all — its starting number
|
||||
# came from somewhere unrecorded. Publish a recount for it; that is the only
|
||||
# op that establishes ground truth (and the only one that clears the
|
||||
# counts-uncertain flag).
|
||||
#
|
||||
# Usage:
|
||||
# atm-reconcile # reconcile every bay
|
||||
# atm-reconcile --csv # machine-readable
|
||||
# atm-reconcile --quiet # exit status only, no output
|
||||
#
|
||||
# Exit status:
|
||||
# 0 every bay reconciles
|
||||
# 1 at least one bay has a non-zero gap or no baseline
|
||||
# 2 database missing / unreadable
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DB="${ATM_STATE_DB:-/var/lib/bitspire/state.db}"
|
||||
|
||||
FORMAT="-column -header"
|
||||
QUIET=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--csv) FORMAT="-csv -header"; shift ;;
|
||||
--quiet) QUIET=true; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,36p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ ! -r "$DB" ]; then
|
||||
echo "ERROR: state database not readable at $DB" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Per-bay baseline, then the deltas since it. Written as scalar subqueries
|
||||
# rather than joins on purpose: joining transaction_bills to cassettes fans
|
||||
# out across bays, and a LEFT JOIN whose rows are all filtered out by the
|
||||
# baseline cutoff collapses to NULL and poisons the arithmetic downstream.
|
||||
RECONCILE_SQL="
|
||||
WITH bay AS (
|
||||
SELECT
|
||||
c.position AS position,
|
||||
c.denomination AS denomination,
|
||||
c.count AS ledger,
|
||||
(SELECT o.op_at FROM cassette_ops o
|
||||
WHERE o.position = c.position AND o.op_type IN ('recount','empty')
|
||||
ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_at,
|
||||
(SELECT CASE o.op_type WHEN 'empty' THEN 0 ELSE o.count END FROM cassette_ops o
|
||||
WHERE o.position = c.position AND o.op_type IN ('recount','empty')
|
||||
ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_count
|
||||
FROM cassettes c
|
||||
),
|
||||
delta AS (
|
||||
SELECT
|
||||
bay.*,
|
||||
(SELECT COALESCE(SUM(o.bills), 0) FROM cassette_ops o
|
||||
WHERE o.position = bay.position AND o.op_type = 'refill'
|
||||
AND o.op_at > COALESCE(bay.base_at, -1)) AS added,
|
||||
(SELECT COALESCE(SUM(tb.count), 0)
|
||||
FROM transaction_bills tb
|
||||
JOIN transactions t ON t.txid = tb.txid
|
||||
WHERE tb.denomination = bay.denomination
|
||||
AND t.type IN ('cash_out','manual_dispense')
|
||||
AND t.created_at / 1000 > COALESCE(bay.base_at, -1)) AS dispensed
|
||||
FROM bay
|
||||
)
|
||||
SELECT
|
||||
position AS 'Bay',
|
||||
denomination AS 'Denom',
|
||||
CASE WHEN base_at IS NULL THEN '(none)' ELSE datetime(base_at,'unixepoch') END AS 'Baseline',
|
||||
CASE WHEN base_at IS NULL THEN NULL ELSE base_count END AS 'Base',
|
||||
added AS 'Refilled',
|
||||
dispensed AS 'Dispensed',
|
||||
ledger AS 'Ledger',
|
||||
CASE WHEN base_at IS NULL THEN NULL
|
||||
ELSE base_count + added - dispensed END AS 'Expected',
|
||||
CASE WHEN base_at IS NULL THEN 'NO BASELINE'
|
||||
ELSE printf('%+d', ledger - (base_count + added - dispensed)) END AS 'Gap'
|
||||
FROM delta
|
||||
ORDER BY position;
|
||||
"
|
||||
|
||||
# Bays sharing a denomination break per-bay attribution: transaction_bills
|
||||
# records what denomination went out, never which bay it came from, so the
|
||||
# dispensed figure lands on every matching bay.
|
||||
AMBIGUOUS=$(sqlite3 "$DB" \
|
||||
"SELECT group_concat(denomination) FROM (
|
||||
SELECT denomination FROM cassettes GROUP BY denomination HAVING COUNT(*) > 1);")
|
||||
|
||||
PROBLEMS=$(sqlite3 "$DB" "
|
||||
WITH bay AS (
|
||||
SELECT c.position AS position, c.denomination AS denomination, c.count AS ledger,
|
||||
(SELECT o.op_at FROM cassette_ops o
|
||||
WHERE o.position = c.position AND o.op_type IN ('recount','empty')
|
||||
ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_at,
|
||||
(SELECT CASE o.op_type WHEN 'empty' THEN 0 ELSE o.count END FROM cassette_ops o
|
||||
WHERE o.position = c.position AND o.op_type IN ('recount','empty')
|
||||
ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_count
|
||||
FROM cassettes c
|
||||
)
|
||||
SELECT COUNT(*) FROM bay WHERE base_at IS NULL OR ledger <> (
|
||||
base_count
|
||||
+ (SELECT COALESCE(SUM(o.bills),0) FROM cassette_ops o
|
||||
WHERE o.position = bay.position AND o.op_type = 'refill' AND o.op_at > bay.base_at)
|
||||
- (SELECT COALESCE(SUM(tb.count),0) FROM transaction_bills tb
|
||||
JOIN transactions t ON t.txid = tb.txid
|
||||
WHERE tb.denomination = bay.denomination
|
||||
AND t.type IN ('cash_out','manual_dispense')
|
||||
AND t.created_at / 1000 > bay.base_at));")
|
||||
|
||||
if ! $QUIET; then
|
||||
# shellcheck disable=SC2086 # $FORMAT must word-split into two sqlite3 flags
|
||||
sqlite3 $FORMAT "$DB" "$RECONCILE_SQL"
|
||||
echo
|
||||
|
||||
UNCERTAIN=$(sqlite3 "$DB" \
|
||||
"SELECT COALESCE(NULLIF(value,''),'') FROM meta WHERE key = 'countsUncertainSince';")
|
||||
if [ -n "$UNCERTAIN" ]; then
|
||||
echo "Counts flagged UNVERIFIED since $(date -u -d "@$UNCERTAIN" '+%F %T UTC' 2>/dev/null || echo "$UNCERTAIN")"
|
||||
echo " A dispense ended without a trustworthy report. Only a recount clears this."
|
||||
else
|
||||
echo "Counts not flagged unverified."
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== Unresolved dispense failures ==="
|
||||
# shellcheck disable=SC2086 # $FORMAT must word-split into two sqlite3 flags
|
||||
sqlite3 $FORMAT "$DB" "
|
||||
SELECT txid AS 'TX ID', status AS 'Status',
|
||||
printf('%.2f', fiat_cents / 100.0) AS 'Fiat', currency AS 'Cur',
|
||||
COALESCE(error,'') AS 'Error',
|
||||
datetime(created_at / 1000,'unixepoch') AS 'Time (UTC)'
|
||||
FROM transactions
|
||||
WHERE status IN ('dispense_error','partial')
|
||||
ORDER BY created_at DESC;"
|
||||
|
||||
if [ -n "$AMBIGUOUS" ]; then
|
||||
echo
|
||||
echo "WARNING: denomination(s) $AMBIGUOUS are loaded in more than one bay."
|
||||
echo " Dispenses are recorded by denomination, not by bay, so the Dispensed"
|
||||
echo " column double-counts across those bays. Reconcile them as a group."
|
||||
fi
|
||||
fi
|
||||
|
||||
[ "${PROBLEMS:-0}" -eq 0 ] || exit 1
|
||||
|
|
@ -141,7 +141,7 @@ sql "SELECT
|
|||
t.fiat_cents / 100 AS 'Fiat',
|
||||
t.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'
|
||||
|
|
|
|||
|
|
@ -20,17 +20,18 @@ in
|
|||
|
||||
relayUrl = mkOption {
|
||||
type = types.str;
|
||||
default = "";
|
||||
default = "wss://relay.aiolabs.dev";
|
||||
description = ''
|
||||
Optional override for the Nostr relay the ATM uses. Empty by
|
||||
default (aiolabs/bitspire#70): the relay comes from the pairing
|
||||
SEED, not from provisioning — a fresh machine boots blank, scans a
|
||||
spire-seed, and the seed's relay drives the connection. A non-empty
|
||||
value here is seeded into `/var/lib/bitspire/.env` as
|
||||
`VITE_RELAY_URL=…` and WINS over the seed (env-first precedence), so
|
||||
only set it to pin a machine to a specific relay. The renderer's
|
||||
resolution order is: `VITE_RELAY_URL` (this / .env) → the pairing
|
||||
seed's relay → a dev-only `ws://localhost:7777` fallback.
|
||||
Nostr relay URL the ATM and LNbits both subscribe to.
|
||||
|
||||
On a fresh-boot disk image this value is seeded into
|
||||
`/var/lib/bitspire/.env` as `VITE_RELAY_URL=…` (see flake.nix
|
||||
`bitspire-env` activation script). The operator can override
|
||||
the seeded value at runtime by editing `.env` directly or by
|
||||
re-running `deploy/nixos/provision-atm.sh` with a different
|
||||
`RELAY_URL`. The renderer's resolution order is:
|
||||
`/var/lib/bitspire/.env` → this NixOS default → renderer
|
||||
hardcoded fallback (`ws://localhost:7777`).
|
||||
'';
|
||||
};
|
||||
|
||||
|
|
@ -38,13 +39,10 @@ in
|
|||
type = types.str;
|
||||
default = "";
|
||||
description = ''
|
||||
Optional override for the LNbits nostr-transport server pubkey
|
||||
(hex, 64 chars). Empty by default (aiolabs/bitspire#70): the
|
||||
pubkey comes from the pairing SEED (the seed's `lnbits_npub`), so
|
||||
a seed-paired machine needs nothing here. A non-empty value is
|
||||
seeded into `.env` as `VITE_LNBITS_SERVER_PUBKEY=…` and WINS over
|
||||
the seed (env-first precedence) — set it only to pin a machine to
|
||||
a specific server. Mirrors `relayUrl`.
|
||||
LNbits nostr-transport server pubkey (hex, 64 chars). Published
|
||||
by the LNbits server on startup. Required for the ATM to talk
|
||||
to its wallet. Provisioned by provision-atm.sh; can be left
|
||||
empty on disk-image builds.
|
||||
'';
|
||||
};
|
||||
|
||||
|
|
@ -132,28 +130,6 @@ in
|
|||
description = "Camera device";
|
||||
};
|
||||
};
|
||||
|
||||
# Contactless (CCID) card reader for Bolt Card taps — ADR-003.
|
||||
#
|
||||
# Opt-in, and deliberately defaulted off: only some machines have a reader
|
||||
# fitted, and on a machine without one the app must not so much as
|
||||
# initialise nfc-pcsc, because pcsclite busy-spins Electron's main thread
|
||||
# when pcscd is absent (the full story is in nfc-service.ts). Per-model
|
||||
# truth lives in `nfcReaderForModel` in flake.nix, since sintra and tejo
|
||||
# share hardware/upboard.nix but only one of them has a reader.
|
||||
nfc = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Enable the Bolt Card reader. Starts pcscd, authorises the `bitspire`
|
||||
user to talk to it and to the card via polkit, installs the
|
||||
wedge-recovery unit, and tells the app to initialise NFC at all.
|
||||
Leave false on machines with no reader fitted; cash-out over QR is
|
||||
unaffected either way.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
|
|
@ -165,14 +141,11 @@ in
|
|||
"d ${cfg.dataDir}/branding 0755 bitspire bitspire -"
|
||||
];
|
||||
|
||||
# Descriptive-only ATM info at /etc/bitspire/config.env. NOTE: this is NOT
|
||||
# the runtime environment — the systemd service's EnvironmentFile is
|
||||
# mkForce'd to /var/lib/bitspire/.env, and the renderer reads only VITE_*
|
||||
# vars. Relay + server pubkey are deliberately omitted here: they come from
|
||||
# the pairing seed (aiolabs/bitspire#70), and duplicating them as non-VITE
|
||||
# RELAY_URL/LNBITS_SERVER_PUBKEY only invited "looks authoritative" confusion.
|
||||
# Environment file for ATM configuration
|
||||
environment.etc."bitspire/config.env".text = ''
|
||||
# bitSpire ATM Configuration (descriptive; not the runtime env)
|
||||
# bitSpire ATM Configuration
|
||||
RELAY_URL=${cfg.relayUrl}
|
||||
LNBITS_SERVER_PUBKEY=${cfg.lnbitsServerPubkey}
|
||||
LOG_LEVEL=${cfg.logLevel}
|
||||
DATA_DIR=${cfg.dataDir}
|
||||
|
||||
|
|
@ -193,70 +166,6 @@ in
|
|||
ELECTRON_DISABLE_GPU=false
|
||||
'';
|
||||
|
||||
# ── Bolt Card reader (services.bitspire.nfc.enable) ─────────────────
|
||||
# Lifted out of hardware/batm3.nix and hardware/upboard.nix so that "is a
|
||||
# reader fitted" is one per-machine flag rather than a block copied into
|
||||
# each hardware file — upboard.nix is shared by sintra (OMNIKEY 5022) and
|
||||
# tejo (no reader), so a hardware file cannot answer the question.
|
||||
|
||||
# pcscd binds the CCID driver to the reader; the app talks to pcscd's
|
||||
# socket via nfc-pcsc rather than the USB device directly. Reader-agnostic
|
||||
# (Feitian KP382 on batm3, HID Global OMNIKEY 5022 on sintra).
|
||||
services.pcscd.enable = mkIf cfg.nfc.enable true;
|
||||
|
||||
# pcscd gates client access via polkit; without a rule the sandboxed
|
||||
# `bitspire` service user is "Rejected unauthorized PC/SC client".
|
||||
# Authorise it to talk to the daemon and the card, and to trigger the
|
||||
# wedge-recovery unit below.
|
||||
security.polkit.extraConfig = mkIf cfg.nfc.enable ''
|
||||
polkit.addRule(function(action, subject) {
|
||||
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
|
||||
action.id == "org.debian.pcsc-lite.access_card") &&
|
||||
subject.user == "bitspire") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (action.id == "org.freedesktop.systemd1.manage-units" &&
|
||||
action.lookup("unit") == "nfc-reader-reset.service" &&
|
||||
subject.user == "bitspire") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
'';
|
||||
|
||||
# NFC reader wedge-recovery. A CCID reader (the Feitian R502-CL especially)
|
||||
# can wedge: it keeps detecting a card but every APDU returns "card absent
|
||||
# or mute", and ONLY a USB power-cycle clears it — restarting pcscd or the
|
||||
# app does not. This oneshot re-binds the reader's USB device (a software
|
||||
# replug); pcscd + nfc-pcsc then re-detect it on hotplug with no app
|
||||
# restart (verified on-device). The app (unprivileged `bitspire`) starts it
|
||||
# via the polkit rule above when it sees repeated read failures. Matches
|
||||
# the USB CCID interface class (0x0B), so a future reader swap needs no
|
||||
# config change.
|
||||
systemd.services.nfc-reader-reset = mkIf cfg.nfc.enable {
|
||||
description = "Power-cycle a wedged CCID NFC reader (USB re-bind)";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = pkgs.writeShellScript "reset-nfc-reader" ''
|
||||
set -u
|
||||
found=0
|
||||
for iface in /sys/bus/usb/devices/*:*/bInterfaceClass; do
|
||||
[ -f "$iface" ] || continue
|
||||
[ "$(${pkgs.coreutils}/bin/cat "$iface" 2>/dev/null)" = "0b" ] || continue
|
||||
ifname=$(${pkgs.coreutils}/bin/basename "$(${pkgs.coreutils}/bin/dirname "$iface")")
|
||||
dev=''${ifname%%:*}
|
||||
echo "reset-nfc-reader: power-cycling CCID reader USB device $dev" >&2
|
||||
echo -n "$dev" > /sys/bus/usb/drivers/usb/unbind 2>/dev/null || true
|
||||
${pkgs.coreutils}/bin/sleep 2
|
||||
echo -n "$dev" > /sys/bus/usb/drivers/usb/bind 2>/dev/null || true
|
||||
found=1
|
||||
done
|
||||
[ "$found" = 1 ] || { echo "reset-nfc-reader: no CCID reader found" >&2; exit 1; }
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
# Main ATM service
|
||||
systemd.services.bitspire = {
|
||||
description = "bitSpire ATM Application";
|
||||
|
|
@ -267,11 +176,6 @@ in
|
|||
];
|
||||
wants = [ "network-online.target" ];
|
||||
|
||||
# Read by electron/main.ts. Lives in the unit rather than the .env
|
||||
# EnvironmentFile because .env is only written when absent, so a machine
|
||||
# provisioned months ago would never pick a new value up.
|
||||
environment.BITSPIRE_NFC_ENABLED = boolToString cfg.nfc.enable;
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = "bitspire";
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ esac
|
|||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
echo "=== Building bitSpire ATM Live USB ISO (model: $MODEL) ==="
|
||||
echo "=== Building Lamassu ATM Live USB ISO (model: $MODEL) ==="
|
||||
echo ""
|
||||
echo "This is a pure Nix build — no local pnpm required."
|
||||
echo ""
|
||||
|
|
|
|||
|
|
@ -3,256 +3,10 @@
|
|||
|
||||
{ config, lib, pkgs, pkgs-unstable, ... }:
|
||||
|
||||
let
|
||||
# ── Firmware pruning (bitspire#70 sizing) ────────────────────────────
|
||||
# hardware.enableRedistributableFirmware installs the entire linux-firmware
|
||||
# tree: 752MB compressed, 16% of the image and its single largest item. The
|
||||
# fleet is four fixed Intel boards. The other ~640MB is firmware for
|
||||
# Qualcomm, Mellanox, NVIDIA, Marvell, AMD and MediaTek parts that will
|
||||
# never appear in one of these machines.
|
||||
#
|
||||
# Keep only what a bitSpire board can plausibly load. Entries are paths
|
||||
# inside lib/firmware; nothing outside this list is copied.
|
||||
firmwareKeep = [
|
||||
# Intel GPU. Gen9 (Apollo Lake) loads DMC from here. Bay Trail and
|
||||
# Haswell load nothing, but 9.6MB is cheap insurance against a board swap.
|
||||
"i915"
|
||||
# Intel WiFi, 89MB and the bulk of what survives, covering every Intel
|
||||
# card since 2008. This is the conservative half of the trade: losing the
|
||||
# network on a deployed ATM is not remotely recoverable. Narrow it to the
|
||||
# specific generation once each machine's card is known, via
|
||||
# `lspci -k | grep -A3 Network` on the box.
|
||||
"intel/iwlwifi"
|
||||
"rtl_nic" # Realtek GbE (r8169) — the UP Board's onboard NIC
|
||||
"rtw88" # Realtek WiFi, the usual M.2 or USB retrofit
|
||||
"rtw89"
|
||||
"brcm" # Broadcom WiFi, the other usual retrofit
|
||||
# Intel Smart Sound Technology DSP, 420KB. Cherry Trail boards (sintra,
|
||||
# tejo) probe intel_sst_acpi at boot whether or not anything will use the
|
||||
# audio, and without the blob every boot logs
|
||||
# Direct firmware load for intel/fw_sst_22a8.bin failed with error -2
|
||||
# Found by pruning, rebooting sintra and reading dmesg. The audio stack is
|
||||
# gone so this changes no behaviour, but a recurring error in a payment
|
||||
# terminal's boot log is worth 420KB to remove: an error people learn to
|
||||
# ignore is one they will ignore when it matters.
|
||||
"intel/fw_sst_0f28.bin"
|
||||
"intel/fw_sst_0f28_ssp0.bin"
|
||||
"intel/fw_sst_22a8.bin"
|
||||
];
|
||||
|
||||
# Prune the tree rather than hand-pick files, so a firmware bump can't
|
||||
# silently drop a blob we depend on. Left UNCOMPRESSED on purpose: NixOS
|
||||
# compresses each hardware.firmware entry itself, zstd or xz depending on
|
||||
# what the machine's kernel understands, and douro's 5.15 predates zstd
|
||||
# firmware support. Pre-compressing here would hand douro a tree it cannot
|
||||
# read.
|
||||
bitspireFirmware = pkgs.runCommand "linux-firmware-bitspire"
|
||||
{
|
||||
inherit (pkgs.linux-firmware) version;
|
||||
meta = pkgs.linux-firmware.meta // {
|
||||
description = "linux-firmware pruned to the hardware bitSpire ships on";
|
||||
};
|
||||
}
|
||||
''
|
||||
src=${pkgs.linux-firmware}/lib/firmware
|
||||
dst=$out/lib/firmware
|
||||
mkdir -p "$dst"
|
||||
|
||||
for p in ${lib.escapeShellArgs firmwareKeep}; do
|
||||
if [ ! -e "$src/$p" ]; then
|
||||
echo "ERROR: firmwareKeep entry '$p' is not in linux-firmware" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$dst/$(dirname "$p")"
|
||||
cp -a "$src/$p" "$dst/$p"
|
||||
done
|
||||
|
||||
# A kept directory can contain symlinks pointing at blobs OUTSIDE it:
|
||||
# brcm/brcmfmac*.bin are links into cypress/, for instance. Left dangling
|
||||
# they fail nixpkgs' firmware compression step, and silently deleting
|
||||
# them would quietly drop firmware a device needs. So pull the targets in
|
||||
# instead. Looped because a resolved target can itself be a link.
|
||||
for _pass in 1 2 3; do
|
||||
_pulled=0
|
||||
while IFS= read -r link; do
|
||||
tgt=$(readlink -m "$link")
|
||||
case "$tgt" in
|
||||
"$dst"/*) rel=''${tgt#"$dst"/} ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
if [ ! -e "$dst/$rel" ] && [ -e "$src/$rel" ]; then
|
||||
mkdir -p "$dst/$(dirname "$rel")"
|
||||
cp -a "$src/$rel" "$dst/$rel"
|
||||
_pulled=1
|
||||
fi
|
||||
done < <(find "$dst" -xtype l)
|
||||
[ "$_pulled" -eq 0 ] && break
|
||||
done
|
||||
|
||||
# Anything still dangling is not in linux-firmware at all. Fail loudly
|
||||
# rather than ship a tree with holes in it.
|
||||
if find "$dst" -xtype l | grep -q .; then
|
||||
echo "ERROR: dangling firmware symlinks after resolution:" >&2
|
||||
find "$dst" -xtype l >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# linux-firmware stores many blobs under a vendor directory and leaves a
|
||||
# flat top-level symlink pointing at them, e.g.
|
||||
# iwlwifi-cc-a0-77.ucode -> intel/iwlwifi/iwlwifi-cc-a0-77.ucode. The
|
||||
# kernel requests the flat name, so a kept blob is useless without its
|
||||
# link. Recreate every top-level link whose target survived the prune.
|
||||
( cd "$src"
|
||||
find . -maxdepth 1 -type l -printf '%f\t%l\n' \
|
||||
| while IFS="$(printf '\t')" read -r link target; do
|
||||
# if/then, not `[ ... ] && ln`: the latter makes the loop's exit
|
||||
# status depend on whether the LAST candidate matched, and a
|
||||
# non-match returns 1, which set -e turns into a build failure.
|
||||
# Whether it fails is then a function of readdir order.
|
||||
if [ -e "$dst/$target" ]; then
|
||||
ln -s "$target" "$dst/$link"
|
||||
fi
|
||||
done
|
||||
)
|
||||
|
||||
echo "firmware kept: $(find "$dst" -type f | wc -l) files, \
|
||||
$(find "$dst" -type l | wc -l) links, $(du -sh "$dst" | cut -f1) uncompressed"
|
||||
'';
|
||||
in
|
||||
{
|
||||
# System basics
|
||||
system.stateVersion = "24.05";
|
||||
|
||||
# ── Image slimming (bitspire#70 sizing) ──────────────────────────────
|
||||
# This is a single-purpose Electron kiosk; strip the desktop/multimedia
|
||||
# baggage NixOS pulls in by default so the disk image stays lean.
|
||||
# - speechd: text-to-speech (speech-dispatcher → espeak-ng → mbrola, ~1GB).
|
||||
# An ATM does not talk.
|
||||
# - documentation: man/info/NixOS manual — no one reads them on a kiosk.
|
||||
# - pipewire: the audio stack (+ WirePlumber, ALSA, the PulseAudio shim),
|
||||
# ~353MB. The app has never played a sound — nothing under apps/machine or
|
||||
# packages/ constructs an Audio element or ships an audio file.
|
||||
#
|
||||
# All three need mkForce, not just an absent/false assignment: enabling
|
||||
# services.xserver pulls in NixOS's `graphical-desktop` module, which
|
||||
# mkDefault-enables speechd AND pipewire (services/misc/graphical-desktop.nix).
|
||||
# Dropping our own `enable = true` simply falls back to that default — the
|
||||
# 353MB stayed until this was forced off. Re-enable pipewire (with alsa +
|
||||
# pulse) and security.rtkit if transaction sounds are ever added.
|
||||
services.speechd.enable = lib.mkForce false;
|
||||
services.pipewire.enable = lib.mkForce false;
|
||||
documentation.enable = false;
|
||||
documentation.nixos.enable = false;
|
||||
|
||||
# Ship the pruned firmware tree instead of all of linux-firmware. mkForce
|
||||
# because every hardware/*.nix sets enableRedistributableFirmware = true;
|
||||
# overriding once here keeps the four machines in step. Turning that option
|
||||
# off also drops the extras it bundles (sof-firmware, libreelec-dvb,
|
||||
# alsa-firmware, intel2200BG, zd1211fw and friends), none of which applies to
|
||||
# a soundless kiosk on a wired Intel board. The regulatory database is
|
||||
# normally implied by the same option, so ask for it explicitly: without it
|
||||
# WiFi is pinned to the most restrictive channel set.
|
||||
hardware.enableRedistributableFirmware = lib.mkForce false;
|
||||
hardware.wirelessRegulatoryDatabase = true;
|
||||
hardware.firmware = [ bitspireFirmware ];
|
||||
|
||||
# Make the prune stick. Without this, a nixpkgs bump or a stray module
|
||||
# setting enableRedistributableFirmware back to true silently re-adds 750MB
|
||||
# and nobody notices until an eMMC runs out of room at 04:00. The regex
|
||||
# matches the upstream package's versioned name (linux-firmware-20260519)
|
||||
# and deliberately not ours (linux-firmware-bitspire), so the pruned tree
|
||||
# passes and the full one fails the build with a readable error.
|
||||
system.forbiddenDependenciesRegexes = [ "linux-firmware-[0-9]" ];
|
||||
|
||||
# Mesa without an LLVM-backed rasterizer.
|
||||
#
|
||||
# nixpkgs builds Mesa with 21 gallium drivers. Two of them, llvmpipe and
|
||||
# radeonsi, link LLVM, and that RPATH pulls llvm-21-lib into the system
|
||||
# closure: 540MB, a ninth of the image, on a kiosk with a soldered Intel GPU.
|
||||
#
|
||||
# The driver list has to span three Intel generations:
|
||||
# crocus EVERY machine in the fleet. Surveyed, not assumed: sintra
|
||||
# and tejo are Braswell [8086:22b0], batm3 is Haswell GT2
|
||||
# [8086:0412], douro is Bay Trail. sintra and batm3 were read
|
||||
# straight off their running X logs; both say crocus.
|
||||
# i915 pre-Gen4, insurance against an older board turning up.
|
||||
# softpipe the software rasterizer that does NOT use LLVM. Kept so a
|
||||
# board whose KMS driver fails still brings up X, slowly,
|
||||
# rather than dying headless in the field.
|
||||
#
|
||||
# ── IRIS IS DELIBERATELY ABSENT AND RE-ADDING IT COSTS 540MB ────────
|
||||
# iris covers Gen8+ big-core Intel, which nothing here has. Its absence is
|
||||
# what lets -Dllvm=disabled below work: mesa's meson puts
|
||||
# with_gallium_iris in with_driver_using_cl and then
|
||||
# with_llvm.enable_if(with_clc, 'CLC requires LLVM')
|
||||
# so asking for iris drags in the OpenCL frontend and the whole of
|
||||
# llvm-lib. Mesa's closure is 88MB without iris, 633MB with.
|
||||
#
|
||||
# A newer x86 board — a modern NUC, the "build it from these parts" kiosk
|
||||
# — WILL need iris. Until one exists, such a board falls back to softpipe
|
||||
# and renders in software: it boots, it displays, it looks fine, and it is
|
||||
# very slow. Check `DRI driver:` in /var/log/X.0.log on any new hardware
|
||||
# rather than assuming this list still covers it.
|
||||
# i915 pre-Gen4, insurance against an older board turning up
|
||||
# softpipe the software rasterizer that does NOT use LLVM. Kept so a
|
||||
# board whose KMS driver fails still brings up X, slowly,
|
||||
# rather than dying headless in the field. This is the role
|
||||
# llvmpipe was playing, for 540MB.
|
||||
#
|
||||
# Vulkan is emptied because nothing here uses it, and its software ICD
|
||||
# (lavapipe) is the other LLVM consumer. The VDPAU and VA state trackers
|
||||
# have to go with it: meson refuses to build them unless one of the AMD or
|
||||
# NVIDIA gallium drivers is present. Intel VA-API is unaffected, it comes
|
||||
# from intel-media-driver in hardware/*.nix.
|
||||
hardware.graphics.package =
|
||||
(pkgs.mesa.override {
|
||||
galliumDrivers = [ "crocus" "i915" "softpipe" ];
|
||||
vulkanDrivers = [ ];
|
||||
vulkanLayers = [ ];
|
||||
}).overrideAttrs
|
||||
(old: {
|
||||
mesonFlags = old.mesonFlags ++ [
|
||||
# Severs LLVM outright. Only possible because iris is out of the
|
||||
# driver list above; with iris present meson refuses this flag.
|
||||
# Verified with patchelf: libgallium.so ends up with no libLLVM in
|
||||
# its DT_NEEDED, not merely absent from the closure listing.
|
||||
# Dropping llvmpipe alone never achieved this.
|
||||
(lib.mesonEnable "llvm" false)
|
||||
(lib.mesonBool "gallium-rusticl" false)
|
||||
# nixpkgs builds the asahi/panfrost cross tools and installs
|
||||
# mesa-clc on native builds. Both reference prog_mesa_clc, which
|
||||
# exists only when CLC is on, so they go with LLVM. An x86 kiosk
|
||||
# has no use for either.
|
||||
(lib.mesonOption "tools" "")
|
||||
(lib.mesonBool "install-mesa-clc" false)
|
||||
(lib.mesonBool "install-precomp-compiler" false)
|
||||
(lib.mesonEnable "gallium-vdpau" false)
|
||||
(lib.mesonEnable "gallium-va" false)
|
||||
(lib.mesonEnable "intel-rt" false)
|
||||
];
|
||||
# Mesa declares spirv2dxil and cross_tools as outputs unconditionally,
|
||||
# but they only receive files when the d3d12, asahi or panfrost gallium
|
||||
# drivers are built, and none of those are in the list above. Nix fails
|
||||
# a build that leaves a declared output unproduced, so create them
|
||||
# empty. (Mesa sets __structuredAttrs, so $outputs is a bash array and
|
||||
# a plain `for o in $outputs` loop silently does nothing here.)
|
||||
postInstall = (old.postInstall or "") + ''
|
||||
mkdir -p "$spirv2dxil" "$cross_tools" "$opencl"
|
||||
'';
|
||||
|
||||
# With rusticl off there is no libRusticlOpenCL.so, and Mesa's
|
||||
# postFixup patchelfs it unconditionally. Drop just that argument.
|
||||
# The assert makes a nixpkgs bump that reshapes this line fail loudly
|
||||
# here rather than silently stop removing LLVM.
|
||||
postFixup =
|
||||
let
|
||||
marker = " $opencl/lib/libRusticlOpenCL.so";
|
||||
in
|
||||
assert lib.assertMsg (lib.hasInfix marker old.postFixup)
|
||||
"mesa postFixup no longer patchelfs libRusticlOpenCL.so; revisit this override";
|
||||
lib.replaceStrings [ marker ] [ "" ] old.postFixup;
|
||||
});
|
||||
|
||||
# Networking
|
||||
networking = {
|
||||
hostName = "bitspire";
|
||||
|
|
@ -335,48 +89,45 @@ in
|
|||
user = "bitspire";
|
||||
};
|
||||
|
||||
# Audio (for transaction sounds)
|
||||
security.rtkit.enable = true;
|
||||
services.pipewire = {
|
||||
enable = true;
|
||||
alsa.enable = true;
|
||||
pulse.enable = true;
|
||||
};
|
||||
|
||||
# System packages
|
||||
#
|
||||
# Kept deliberately thin — this is a kiosk, and every entry here is closure
|
||||
# that ships to each ATM and eats eMMC headroom the nightly rebuild needs.
|
||||
# Deliberately absent (see #70 sizing):
|
||||
# git 70MB. nixos-rebuild fetches the flake with its OWN git-minimal,
|
||||
# which stays in the closure via unit-nixos-upgrade.service, so
|
||||
# auto-upgrade is unaffected.
|
||||
# vim 43MB. Replaced by nano — an on-box editor is worth a few MB for
|
||||
# field edits to /var/lib/bitspire/.env, vim's bulk is not.
|
||||
# nodejs_22 94MB. Nothing runs it: the app is Electron (which embeds its
|
||||
# own node) and fund-atm already pins pkgs-unstable.nodejs itself.
|
||||
# wget curl covers it.
|
||||
environment.systemPackages = with pkgs; [
|
||||
# System utilities
|
||||
htop
|
||||
nano
|
||||
vim
|
||||
git
|
||||
curl
|
||||
wget
|
||||
|
||||
# Hardware debugging
|
||||
usbutils
|
||||
pciutils
|
||||
lsof
|
||||
|
||||
# Serial port tools (validator/dispenser live on ttyJ5/ttyJ7 — these are
|
||||
# how a field fault gets diagnosed, and they cost ~2MB between them)
|
||||
# Serial port tools
|
||||
minicom
|
||||
screen
|
||||
|
||||
# For the Electron app
|
||||
pkgs-unstable.electron
|
||||
|
||||
# Camera support. v4l-utils' default build drags in the whole Qt6 stack
|
||||
# for its qv4l2 GUI (~0.5GB) — we only ever use the v4l2-ctl CLI, so drop
|
||||
# the GUI.
|
||||
(v4l-utils.override { withGUI = false; })
|
||||
# Node.js for the application
|
||||
pkgs-unstable.nodejs_22
|
||||
|
||||
# Camera support
|
||||
v4l-utils
|
||||
fswebcam
|
||||
|
||||
# ATM operations
|
||||
sqlite
|
||||
(writeShellScriptBin "atm-transactions" (builtins.readFile ./atm-transactions.sh))
|
||||
(writeShellScriptBin "atm-reconcile" (builtins.readFile ./atm-reconcile.sh))
|
||||
];
|
||||
|
||||
# Enable SSH for remote administration
|
||||
|
|
@ -396,18 +147,6 @@ in
|
|||
# Auto-updates (optional - disabled by default for stability)
|
||||
# system.autoUpgrade.enable = false;
|
||||
|
||||
# Trust the Forgejo host key up front. system.autoUpgrade fetches the flake
|
||||
# over ssh AS ROOT, and a machine whose root has never connected by hand has
|
||||
# no known_hosts entry, so every nightly run dies at
|
||||
# "Host key verification failed" before it reaches authentication. batm3 did
|
||||
# exactly that, silently, from its 2026-08-06 install until 09-22 (#98): it
|
||||
# sat on its install generation for six weeks while reporting a failed unit
|
||||
# nobody was watching. sintra only ever worked because a human had ssh'd as
|
||||
# root once and accepted the key. Declaring it means a freshly flashed ATM
|
||||
# can update from first boot with no manual step.
|
||||
programs.ssh.knownHosts."git.atitlan.io".publicKey =
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMlo3f05o4+bk0+8x2VG91o9GubshOb46HmBPvND9pJx";
|
||||
|
||||
# pragma: allowlist secret
|
||||
# Ensure WireGuard private key directory exists with correct permissions
|
||||
system.activationScripts.wireguard-key = ''
|
||||
|
|
@ -418,40 +157,6 @@ in
|
|||
fi
|
||||
'';
|
||||
|
||||
# The tunnel is operator-provisioned: wg0.key is written per machine after
|
||||
# flashing, and until it is, `wg set … private-key` exits 1 with
|
||||
# "fopen: No such file or directory". One failed unit makes
|
||||
# switch-to-configuration exit 4, which marks the entire nightly
|
||||
# system.autoUpgrade run as failed — so an ATM that simply never had its
|
||||
# tunnel provisioned reports a broken updater for the life of the machine
|
||||
# (sintra, #98). Skip the unit when there is no key instead of failing
|
||||
# activation over an interface that was never set up; a provisioned machine
|
||||
# is unaffected. Guarded on wg0 still being declared so the live image,
|
||||
# which mkForce's the interfaces away, doesn't get a unit with no ExecStart.
|
||||
systemd.services = lib.mkIf (config.networking.wireguard.interfaces ? wg0) (
|
||||
let
|
||||
iface = config.networking.wireguard.interfaces.wg0;
|
||||
guard = { unitConfig.ConditionPathExists = "/var/lib/wireguard/wg0.key"; };
|
||||
# The module emits one unit per peer alongside the interface unit, and a
|
||||
# skipped interface is NOT a failed dependency, so the peer units still
|
||||
# run and die on "Unable to modify interface: No such device" — same
|
||||
# exit 4, different unit. Guard them too. Names come from the module's
|
||||
# own `peers.*.name` option (whose default is the escaped public key)
|
||||
# rather than re-deriving the escaping here; the `-refresh` suffix
|
||||
# follows nixpkgs' peerUnitServiceName, where a peer's null refresh
|
||||
# interval falls back to the interface's.
|
||||
refreshes = peer:
|
||||
(if peer.dynamicEndpointRefreshSeconds != null then
|
||||
peer.dynamicEndpointRefreshSeconds
|
||||
else
|
||||
iface.dynamicEndpointRefreshSeconds) != 0;
|
||||
peerUnit = peer:
|
||||
"wireguard-wg0-peer-${peer.name}" + lib.optionalString (refreshes peer) "-refresh";
|
||||
in
|
||||
{ wireguard-wg0 = guard; }
|
||||
// lib.listToAttrs (map (peer: lib.nameValuePair (peerUnit peer) guard) iface.peers)
|
||||
);
|
||||
|
||||
# In-place rename migration: lamassu user → bitspire user.
|
||||
# Runs after `users` activation so the bitspire user exists with its UID.
|
||||
# Idempotent: re-running on an already-migrated system is a chown no-op.
|
||||
|
|
@ -479,26 +184,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 = ''
|
||||
# Activation scripts run with a minimal PATH (coreutils, not gnugrep /
|
||||
# gnused) — the first run of this snippet died with "sed: command not
|
||||
# found" after printing success, so reference both by store path.
|
||||
if [ -f /var/lib/bitspire/.env ] \
|
||||
&& ${pkgs.gnugrep}/bin/grep -q '^VITE_LAMASSU_' /var/lib/bitspire/.env; then
|
||||
${pkgs.gnused}/bin/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 = ''
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Factory-reset a bitSpire ATM to a truly-fresh state — the deterministic way to
|
||||
# reproduce a brand-new machine so tests aren't masked by leftover env/db values
|
||||
# (aiolabs/bitspire#70 remnant hygiene).
|
||||
#
|
||||
# WIPES:
|
||||
# - /var/lib/bitspire/state.db (bunker binding, fee config, cassettes, cashbox,
|
||||
# transactions, operator commands, replay watermarks — recreated on next boot)
|
||||
# - /var/lib/bitspire/.env (truncated to the minimal image-baked template:
|
||||
# machine model + fiat + display; drops relay, server pubkey, operator pubkey
|
||||
# and any stored spire seed)
|
||||
#
|
||||
# After this the ATM boots UNPAIRED into the pairing wizard, exactly like a fresh
|
||||
# disk image — so a scanned seed is the sole source of truth.
|
||||
#
|
||||
# Usage:
|
||||
# bash factory-reset-atm.sh # SSH to localhost:2222 (QEMU)
|
||||
# bash factory-reset-atm.sh 192.168.1.50 # a real ATM on the LAN
|
||||
# bash factory-reset-atm.sh 192.168.1.50 22 # custom SSH port
|
||||
# FORCE=1 bash factory-reset-atm.sh … # skip the confirmation prompt
|
||||
# ATM_USER=root bash factory-reset-atm.sh … # override SSH user (default: bitspire)
|
||||
set -euo pipefail
|
||||
|
||||
ATM_HOST="${1:-localhost}"
|
||||
ATM_SSH_PORT="${2:-2222}"
|
||||
ATM_USER="${ATM_USER:-bitspire}"
|
||||
|
||||
echo "=== Factory-reset bitSpire ATM at $ATM_USER@$ATM_HOST:$ATM_SSH_PORT ==="
|
||||
echo "This WIPES state.db and truncates .env to the minimal template (keeps only"
|
||||
echo "machine model + fiat). ALL pairing, cash accounting, and transaction history"
|
||||
echo "on the ATM will be lost."
|
||||
if [ "${FORCE:-}" != "1" ]; then
|
||||
read -r -p "Type 'yes' to proceed: " confirm
|
||||
[ "$confirm" = "yes" ] || { echo "Aborted."; exit 1; }
|
||||
fi
|
||||
|
||||
ssh -o StrictHostKeyChecking=no -p "$ATM_SSH_PORT" "$ATM_USER@$ATM_HOST" 'sudo bash -s' <<'REMOTE'
|
||||
set -euo pipefail
|
||||
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=${model:-sintra}
|
||||
fiat=${fiat:-EUR}
|
||||
|
||||
systemctl stop bitspire 2>/dev/null || true
|
||||
|
||||
# Wipe persisted state (db + WAL/SHM sidecars).
|
||||
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_SPIRE_SEED=
|
||||
ELECTRON_FORCE_PROD=1
|
||||
DISPLAY=:0
|
||||
EOF
|
||||
chmod 600 "$ENV"
|
||||
chown bitspire:bitspire "$ENV" 2>/dev/null || true
|
||||
|
||||
systemctl start bitspire 2>/dev/null || true
|
||||
|
||||
echo "--- .env is now (values blanked) ---"
|
||||
sed -E 's/=.*/=/' "$ENV"
|
||||
echo "--- state.db removed (recreated fresh on next boot) ---"
|
||||
REMOTE
|
||||
|
||||
echo ""
|
||||
echo "=== ATM factory-reset. It boots UNPAIRED → the pairing wizard. ==="
|
||||
echo "Watch: ssh -p $ATM_SSH_PORT $ATM_USER@$ATM_HOST 'sudo journalctl -u bitspire -f'"
|
||||
|
|
@ -12,36 +12,13 @@
|
|||
timeout = 3;
|
||||
};
|
||||
|
||||
# Pin the 6.6 LTS kernel. The Dell 9030 AIO's eGalax SAW touch panel
|
||||
# (0eef:0001) works with the usbtouchscreen driver on 6.6 (the known-good
|
||||
# internal-SATA install runs 6.6.68). On 25.11's default 6.12 kernel this
|
||||
# old controller regressed: hid-multitouch grabs it and mis-parses the HID
|
||||
# report ("failed to fetch feature 7", axes read stuck), usbtouchscreen
|
||||
# refuses it, and touch is unusable regardless of udev/X config. Matching
|
||||
# douro.nix's per-hardware kernel pin. Re-test touch before bumping this.
|
||||
kernelPackages = pkgs.linuxPackages_6_6;
|
||||
|
||||
initrd.availableKernelModules = [
|
||||
"xhci_pci"
|
||||
"ahci"
|
||||
"usbhid"
|
||||
"sd_mod"
|
||||
# USB mass-storage: required to boot the dd'd image from a USB stick
|
||||
# (stage-1 must bind the flash drive as a SCSI disk so
|
||||
# /dev/disk/by-label/nixos appears). Harmless on the internal-SATA
|
||||
# install, where ahci+sd_mod already cover the root device.
|
||||
#
|
||||
# NOTE: deliberately NO "uas" here. Many USB sticks/bridges advertise
|
||||
# UAS but drop off the bus ("device offline error, dev sdb") under the
|
||||
# sustained write load of first-boot growPartition/journal/swapfile.
|
||||
# Blacklisting uas below forces the slower-but-reliable usb-storage
|
||||
# (Bulk-Only Transport) path. SATA/eMMC installs don't use uas anyway.
|
||||
"usb_storage"
|
||||
];
|
||||
|
||||
# Keep the USB flash drive off the flaky UAS driver (see note above).
|
||||
blacklistedKernelModules = [ "uas" ];
|
||||
|
||||
kernelModules = [
|
||||
"kvm-intel"
|
||||
"usbtouchscreen"
|
||||
|
|
@ -50,9 +27,6 @@
|
|||
kernelParams = [
|
||||
"quiet"
|
||||
"splash"
|
||||
# Disable USB autosuspend so the boot medium (and kiosk peripherals)
|
||||
# aren't power-suspended mid-I/O — another cause of "device offline".
|
||||
"usbcore.autosuspend=-1"
|
||||
];
|
||||
};
|
||||
|
||||
|
|
@ -85,11 +59,6 @@
|
|||
cpuFreqGovernor = "performance";
|
||||
};
|
||||
|
||||
# The Feitian KP382 contactless reader (096e:0608) is declared as a machine
|
||||
# capability, not here: `nfcReaderForModel` in flake.nix drives
|
||||
# services.bitspire.nfc.enable, which owns pcscd, the polkit rules and the
|
||||
# wedge-recovery unit (deploy/nixos/bitspire-atm.nix).
|
||||
|
||||
# Disable suspend/hibernate for kiosk
|
||||
systemd.targets = {
|
||||
sleep.enable = false;
|
||||
|
|
@ -136,20 +105,10 @@
|
|||
'';
|
||||
|
||||
# eGalax touchscreen (Dell 9030 AIO built-in panel)
|
||||
# By default usbhid/hid-multitouch claim the eGalax and mis-parse its
|
||||
# HID report descriptor (X axis reads as stuck), so touch is unusable.
|
||||
# Fix: hand the device to the usbtouchscreen kernel driver, which parses
|
||||
# the raw eGalax protocol into a clean single-touch ABS device that the
|
||||
# X evdev driver + calibration matrix (below) map correctly. This mirrors
|
||||
# the known-good internal-SATA install.
|
||||
#
|
||||
# The RUN command modprobes usbtouchscreen ITSELF before unbinding usbhid
|
||||
# and handing over via new_id. usbtouchscreen is also in boot.kernelModules
|
||||
# (systemd-modules-load), but on a USB boot systemd-udev-trigger fires this
|
||||
# rule (~2s) BEFORE modules-load gets usbtouchscreen in (~12s) — so the
|
||||
# new_id write hit a not-yet-loaded driver and the panel was left bound to
|
||||
# nothing. Loading it inline here makes the handoff independent of that
|
||||
# boot-ordering race (on internal-SATA boot the order happened to work).
|
||||
# The eGalax HID descriptor confuses libinput (treats it as touchpad).
|
||||
# Fix: unbind from usbhid at boot, bind to usbtouchscreen kernel module,
|
||||
# then apply calibration matrix after X11 starts.
|
||||
# Unbind eGalax from usbhid, bind to usbtouchscreen
|
||||
services.udev.extraRules = lib.mkAfter ''
|
||||
KERNEL=="ttyS[0-9]*", MODE="0666"
|
||||
KERNEL=="ttyUSB[0-9]*", MODE="0666"
|
||||
|
|
@ -157,32 +116,7 @@
|
|||
SUBSYSTEM=="tty", ATTRS{serial}=="DDDLb103Y23", SYMLINK+="ttyF56", MODE="0666"
|
||||
SUBSYSTEM=="tty", ATTRS{serial}=="A9YW78OC", SYMLINK+="ttyMEI", MODE="0666"
|
||||
SUBSYSTEM=="tty", ATTRS{serial}=="A9ZF8ELY", SYMLINK+="ttyNFC", MODE="0666"
|
||||
ACTION=="add", SUBSYSTEM=="usb", ATTRS{idVendor}=="0eef", ATTRS{idProduct}=="0001", RUN+="${pkgs.bash}/bin/bash -c '${pkgs.kmod}/bin/modprobe usbtouchscreen 2>/dev/null; echo ''$kernel:1.0 > /sys/bus/usb/drivers/usbhid/unbind 2>/dev/null; echo 0eef 0001 > /sys/bus/usb/drivers/usbtouchscreen/new_id 2>/dev/null'"
|
||||
# Belt-and-suspenders for touch calibration: on a slow USB boot the
|
||||
# usbtouchscreen panel can bind AFTER egalax-calibrate's poll window, which
|
||||
# leaves the panel uncalibrated and unresponsive ("dead"). (Re)start the
|
||||
# calibration the instant the eGalax input node actually appears — this is
|
||||
# device-driven, so it cannot lose a boot-timing race no matter how late the
|
||||
# driver hands over. Pairs with egalax-calibrate's own (widened) poll loop.
|
||||
ACTION=="add", SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="eGalax Inc. USB TouchController", TAG+="systemd", ENV{SYSTEMD_WANTS}+="egalax-calibrate.service"
|
||||
'';
|
||||
|
||||
# Force the X evdev driver on the eGalax (not libinput). The usbtouchscreen
|
||||
# node is a plain single-touch absolute device; evdev + the transformation
|
||||
# matrix in egalax-calibrate below give correct orientation. Mirrors the
|
||||
# working internal-SATA install's /etc/X11/xorg.conf.d/99-egalax.conf.
|
||||
environment.etc."X11/xorg.conf.d/99-egalax.conf".text = ''
|
||||
Section "InputClass"
|
||||
Identifier "eGalax Touchscreen"
|
||||
MatchVendor "0eef"
|
||||
MatchProduct "0001"
|
||||
MatchDevicePath "/dev/input/event*"
|
||||
Driver "evdev"
|
||||
Option "InvertY" "false"
|
||||
Option "InvertX" "false"
|
||||
Option "SwapAxes" "false"
|
||||
Option "Calibration" ""
|
||||
EndSection
|
||||
ACTION=="add", SUBSYSTEM=="usb", ATTRS{idVendor}=="0eef", ATTRS{idProduct}=="0001", RUN+="${pkgs.bash}/bin/bash -c 'echo ''$kernel:1.0 > /sys/bus/usb/drivers/usbhid/unbind 2>/dev/null; echo 0eef 0001 > /sys/bus/usb/drivers/usbtouchscreen/new_id 2>/dev/null'"
|
||||
'';
|
||||
|
||||
# Apply touchscreen calibration after X11 starts
|
||||
|
|
@ -196,32 +130,12 @@
|
|||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
User = "bitspire";
|
||||
# DISPLAY *and* XAUTHORITY — without the auth cookie xinput dies with
|
||||
# "Invalid MIT-MAGIC-COOKIE-1 key / Unable to connect to X server" and
|
||||
# the matrix is never applied, so touches register but land in the wrong
|
||||
# place (the panel then feels dead). This was the actual boot-time bug.
|
||||
Environment = [ "DISPLAY=:0" "XAUTHORITY=/home/bitspire/.Xauthority" ];
|
||||
# Wait for the eGalax X device to appear (usbtouchscreen binds a little
|
||||
# after display-manager on a USB boot) and retry, instead of a fixed
|
||||
# sleep — more robust to boot timing. 120s window: on a slow USB boot the
|
||||
# panel has bound as late as ~30-60s after display-manager, so a 30s cap
|
||||
# gave up before the device appeared and left touch dead (this service is
|
||||
# ALSO re-triggered by a udev rule when the input node shows up, so this
|
||||
# loop is the fallback, not the only path). Matrix: swap X/Y + invert +
|
||||
# scale to the active panel area (matches the known-good internal install).
|
||||
ExecStart = pkgs.writeShellScript "egalax-calibrate" ''
|
||||
for i in $(${pkgs.coreutils}/bin/seq 1 120); do
|
||||
if ${pkgs.xorg.xinput}/bin/xinput list --name-only 2>/dev/null | ${pkgs.gnugrep}/bin/grep -qx 'eGalax Inc. USB TouchController'; then
|
||||
exec ${pkgs.xorg.xinput}/bin/xinput set-prop 'eGalax Inc. USB TouchController' \
|
||||
'Coordinate Transformation Matrix' 0 -1.268 1.147 -1.224 0 1.118 0 0 1
|
||||
fi
|
||||
${pkgs.coreutils}/bin/sleep 1
|
||||
done
|
||||
echo "egalax-calibrate: eGalax device not found after 120s" >&2
|
||||
exit 1
|
||||
'';
|
||||
Environment = "DISPLAY=:0";
|
||||
ExecStartPre = "${pkgs.coreutils}/bin/sleep 3";
|
||||
ExecStart = "${pkgs.xorg.xinput}/bin/xinput set-prop 'eGalax Inc. USB TouchController' 'Coordinate Transformation Matrix' 0 -1.268 1.147 -1.224 0 1.118 0 0 1";
|
||||
};
|
||||
};
|
||||
|
||||
# WireGuard VPN address → wireguardIpForModel in flake.nix.
|
||||
# WireGuard VPN address
|
||||
networking.wireguard.interfaces.wg0.ips = [ "10.0.0.5/24" ];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,24 +20,12 @@
|
|||
initrd.availableKernelModules = [
|
||||
"xhci_pci"
|
||||
"ahci"
|
||||
# USB mass-storage: required to boot the dd'd image from a USB stick
|
||||
# (stage-1 must bind the flash drive as a SCSI disk so
|
||||
# /dev/disk/by-label/* appears). Harmless on the internal install.
|
||||
#
|
||||
# NOTE: deliberately NO "uas" here. Many USB sticks/bridges advertise
|
||||
# UAS but drop off the bus ("device offline error, dev sdb") under
|
||||
# sustained write load. Blacklisting uas below forces the slower-but-
|
||||
# reliable usb-storage (Bulk-Only Transport) path. SATA/mSATA installs
|
||||
# don't use uas anyway. (Same hardening as batm3.nix.)
|
||||
"usb_storage"
|
||||
"sd_mod"
|
||||
"sdhci_pci"
|
||||
"i915"
|
||||
];
|
||||
|
||||
# Keep the USB flash drive off the flaky UAS driver (see note above).
|
||||
blacklistedKernelModules = [ "uas" ];
|
||||
|
||||
kernelModules = [
|
||||
"kvm-intel"
|
||||
"i2c-dev"
|
||||
|
|
@ -49,9 +37,6 @@
|
|||
"vt.handoff=7" # Bay Trail: preserve BIOS display init
|
||||
"quiet"
|
||||
"splash"
|
||||
# Disable USB autosuspend so the boot medium (and kiosk peripherals)
|
||||
# aren't power-suspended mid-I/O — another cause of "device offline".
|
||||
"usbcore.autosuspend=-1"
|
||||
];
|
||||
};
|
||||
|
||||
|
|
@ -93,7 +78,8 @@
|
|||
hybrid-sleep.enable = false;
|
||||
};
|
||||
|
||||
# WireGuard VPN address → wireguardIpForModel in flake.nix.
|
||||
# WireGuard VPN address
|
||||
networking.wireguard.interfaces.wg0.ips = [ "10.0.0.4/24" ];
|
||||
|
||||
# Serial port access for bill validator/dispenser
|
||||
services.udev.extraRules = lib.mkAfter ''
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
# UP Board serial peripherals — the validator / dispenser / printer wiring
|
||||
# shared by the INSTALLED configs (hardware/upboard.nix, used by both
|
||||
# tejo-installed and sintra-installed) AND the sintra live ISO (live.nix).
|
||||
# Single source of truth so the two artifacts can't drift — the earlier bug
|
||||
# was exactly this drift (the sintra live ISO lacked ftdi_sio + the ttyJ7
|
||||
# symlink, so the F56 dispenser failed while the installed image worked).
|
||||
#
|
||||
# Sintra IS a UP Board, so these are the UP Board rules; ttyS1/ttyS5 cover the
|
||||
# older UP Board / UP4000 (Tejo) dispenser nodes and ttyS4 covers the Sintra
|
||||
# (Apollo Lake) where the F56 is on the SoC MMIO UART. Only the device that
|
||||
# actually exists at runtime gets the symlink, so all three coexist safely.
|
||||
#
|
||||
# Serial port mapping:
|
||||
# ttyJ4 = Printer (Nippon NP-2511D-2)
|
||||
# ttyJ5 = Validator (iVIZION, ID003)
|
||||
# ttyJ7 = Dispenser (Fujitsu F53/F56)
|
||||
{ lib, ... }:
|
||||
|
||||
{
|
||||
boot.kernelModules = [
|
||||
"usbserial" # USB-to-serial adapters
|
||||
"ftdi_sio" # FTDI USB serial (the iVIZION validator bridge)
|
||||
"cp210x" # CP210x USB serial (alternative adapter)
|
||||
];
|
||||
|
||||
boot.kernelParams = [
|
||||
# Do NOT route the kernel console through ttyS4 on Sintra. ttyS4 is the
|
||||
# SoC's MMIO 16550A (the only real UART besides the legacy ttyS0 at I/O
|
||||
# 0x3f8) and is wired to the Fujitsu F56 dispenser's RS-232 header. Holding
|
||||
# it as console prevents userspace opening it at 9600 baud and HAL fails
|
||||
# with "Input/output error setting custom baud rate of 9600". For serial
|
||||
# debug, point console at ttyS0 instead.
|
||||
"console=tty0"
|
||||
];
|
||||
|
||||
services.udev.extraRules = lib.mkAfter ''
|
||||
# Generic serial port permissions (so the non-root HAL user can open them)
|
||||
KERNEL=="ttyS[0-9]*", MODE="0666"
|
||||
KERNEL=="ttyUSB[0-9]*", MODE="0666"
|
||||
KERNEL=="ttyACM[0-9]*", MODE="0666"
|
||||
|
||||
# Printer (ttyJ4)
|
||||
KERNELS=="1-7.2:1.0", SYMLINK+="ttyJ4"
|
||||
KERNEL=="ttyUSB0", SYMLINK+="ttyJ4"
|
||||
|
||||
# Validator (ttyJ5)
|
||||
KERNELS=="1-7.3:1.0", SYMLINK+="ttyJ5"
|
||||
KERNEL=="ttyUSB1", SYMLINK+="ttyJ5"
|
||||
|
||||
# Dispenser (ttyJ7). ttyS1/ttyS5 = older UP Board / UP4000; ttyS4 = Sintra.
|
||||
KERNEL=="ttyS1", SYMLINK+="ttyJ7"
|
||||
KERNEL=="ttyS4", SYMLINK+="ttyJ7"
|
||||
KERNEL=="ttyS5", SYMLINK+="ttyJ7"
|
||||
|
||||
# Legacy ttyAMA0 alias
|
||||
SUBSYSTEM=="tty", KERNEL=="ttyS1", SYMLINK+="ttyAMA0", GROUP="dialout"
|
||||
|
||||
# Disable USB autosuspend (prevents serial adapters from sleeping)
|
||||
ACTION=="add", SUBSYSTEM=="usb", TEST=="power/control", ATTR{power/control}="on"
|
||||
'';
|
||||
}
|
||||
|
|
@ -11,10 +11,6 @@
|
|||
{ config, lib, pkgs, ... }:
|
||||
|
||||
{
|
||||
# Serial peripherals (validator/dispenser/printer modules + udev symlinks +
|
||||
# console=tty0) are shared with the live ISO via ./upboard-serial.nix.
|
||||
imports = [ ./upboard-serial.nix ];
|
||||
|
||||
boot = {
|
||||
loader = {
|
||||
systemd-boot.enable = true;
|
||||
|
|
@ -46,12 +42,21 @@
|
|||
"kvm-intel"
|
||||
"i2c-dev"
|
||||
"spi-dev"
|
||||
# Serial modules (usbserial/ftdi_sio/cp210x) → ./upboard-serial.nix.
|
||||
"usbserial" # USB-to-serial adapters
|
||||
"ftdi_sio" # FTDI USB serial
|
||||
"cp210x" # CP210x USB serial
|
||||
];
|
||||
|
||||
kernelParams = [
|
||||
"i915.enable_psr=0"
|
||||
# console=tty0 (keeps ttyS4 free for the F56) → ./upboard-serial.nix.
|
||||
# NOTE: do NOT route the kernel console through ttyS4 on Sintra.
|
||||
# ttyS4 is the SoC's MMIO 16550A (the only real UART besides the
|
||||
# legacy ttyS0 at I/O 0x3f8) and is wired to the Fujitsu F56
|
||||
# dispenser's RS-232 header on Sintra. Holding it as console
|
||||
# prevents userspace from opening it at 9600 baud and HAL fails
|
||||
# with "Input/output error setting custom baud rate of 9600".
|
||||
# If you want serial debug, point console at ttyS0 instead.
|
||||
"console=tty0"
|
||||
"quiet"
|
||||
"splash"
|
||||
];
|
||||
|
|
@ -91,10 +96,6 @@
|
|||
cpuFreqGovernor = "performance";
|
||||
};
|
||||
|
||||
# No pcscd here. This file is shared by sintra (HID Global OMNIKEY 5022
|
||||
# fitted) and tejo (no reader), so the reader is declared per model via
|
||||
# `nfcReaderForModel` in flake.nix → services.bitspire.nfc.enable.
|
||||
|
||||
# Disable suspend/hibernate for kiosk
|
||||
systemd.targets = {
|
||||
sleep.enable = false;
|
||||
|
|
@ -103,9 +104,37 @@
|
|||
hybrid-sleep.enable = false;
|
||||
};
|
||||
|
||||
# Camera + LED/SPI peripherals. The serial rules (validator/dispenser/printer
|
||||
# symlinks + permissions) are shared with the live ISO in ./upboard-serial.nix.
|
||||
# Serial port permissions + tejo-specific symlinks
|
||||
services.udev.extraRules = lib.mkAfter ''
|
||||
# Generic serial port permissions
|
||||
KERNEL=="ttyS[0-9]*", MODE="0666"
|
||||
KERNEL=="ttyUSB[0-9]*", MODE="0666"
|
||||
KERNEL=="ttyACM[0-9]*", MODE="0666"
|
||||
|
||||
# ── Tejo serial port symlinks ──────────────────────────────────────
|
||||
# Both UP Board and UP4000 rules included (match different kernel paths)
|
||||
|
||||
# Printer (ttyJ4)
|
||||
KERNELS=="1-7.2:1.0", SYMLINK+="ttyJ4"
|
||||
KERNEL=="ttyUSB0", SYMLINK+="ttyJ4"
|
||||
|
||||
# Validator (ttyJ5)
|
||||
KERNELS=="1-7.3:1.0", SYMLINK+="ttyJ5"
|
||||
KERNEL=="ttyUSB1", SYMLINK+="ttyJ5"
|
||||
|
||||
# Dispenser (ttyJ7).
|
||||
# ttyS1 / ttyS5 cover earlier UP Board variants where the dispenser
|
||||
# lands on those kernel-enumerated serial nodes; ttyS4 covers the
|
||||
# Sintra (UP Board Atom/Apollo Lake) where the dispenser is wired
|
||||
# to the SoC's MMIO UART. Whichever device actually exists at
|
||||
# runtime gets the ttyJ7 symlink.
|
||||
KERNEL=="ttyS1", SYMLINK+="ttyJ7"
|
||||
KERNEL=="ttyS4", SYMLINK+="ttyJ7"
|
||||
KERNEL=="ttyS5", SYMLINK+="ttyJ7"
|
||||
|
||||
# Legacy ttyAMA0 alias
|
||||
SUBSYSTEM=="tty", KERNEL=="ttyS1", SYMLINK+="ttyAMA0", GROUP="dialout"
|
||||
|
||||
# ── Camera devices ─────────────────────────────────────────────────
|
||||
SUBSYSTEM=="video4linux", ATTR{index}=="0", KERNELS=="1-5", ATTRS{idVendor}=="0ac8", ATTRS{idProduct}=="0345", SYMLINK+="video-scan"
|
||||
SUBSYSTEM=="video4linux", ATTR{index}=="0", KERNELS=="1-2", ATTRS{idVendor}=="0ac8", ATTRS{idProduct}=="0345", SYMLINK+="video-scan"
|
||||
|
|
@ -118,5 +147,8 @@
|
|||
SUBSYSTEM=="spidev", GROUP="spi", MODE="0660"
|
||||
SUBSYSTEM=="i2c-dev", GROUP="i2c", MODE="0660"
|
||||
SUBSYSTEM=="leds", KERNEL=="upboard:*", ACTION=="add|change", RUN+="${pkgs.findutils}/bin/find /sys$devpath -type f -exec ${pkgs.coreutils}/bin/chmod g+u {} + -exec ${pkgs.coreutils}/bin/chown :leds {} +"
|
||||
|
||||
# Disable USB autosuspend (prevents serial adapters from sleeping)
|
||||
ACTION=="add", SUBSYSTEM=="usb", TEST=="power/control", ATTR{power/control}="on"
|
||||
'';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# bitSpire ATM Live USB Configuration
|
||||
# Lamassu ATM Live USB Configuration
|
||||
# Bootable ISO for testing on physical hardware without installing to disk.
|
||||
#
|
||||
# Parameterized by machineModel (passed via specialArgs from flake.nix):
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
# Does NOT import hardware/upboard.nix (its fileSystems conflict with live boot).
|
||||
# Instead, duplicates only the hardware-relevant kernel modules and GPU config.
|
||||
|
||||
{ config, lib, pkgs, pkgs-unstable, nixpkgs, machineModel ? "douro", atm-app, kioskLauncher, ... }:
|
||||
{ config, lib, pkgs, pkgs-unstable, nixpkgs, machineModel ? "douro", atm-app, ... }:
|
||||
|
||||
let
|
||||
# Fiat code per machine model (for envTemplate display only)
|
||||
|
|
@ -21,17 +21,19 @@ let
|
|||
batm3 = "USD";
|
||||
}.${machineModel} or "USD";
|
||||
|
||||
# Minimal .env template (aiolabs/bitspire#70 remnant hygiene). Seed ONLY
|
||||
# image-baked, non-maskable values. Relay + server pubkey come from the pairing
|
||||
# SEED, operator pubkey + fee config come from LNbits over the transport — so we
|
||||
# deliberately do NOT pre-seed those keys (a present-but-empty VITE_RELAY_URL /
|
||||
# VITE_LNBITS_SERVER_PUBKEY / VITE_OPERATOR_PUBKEYS would win over the seed and
|
||||
# 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.
|
||||
# .env template — runtime secrets are provisioned later via provision-atm.sh.
|
||||
# Only non-secret defaults and display vars go here.
|
||||
envTemplate = pkgs.writeText "bitspire-env" ''
|
||||
VITE_BITSPIRE_MACHINE_MODEL=${machineModel}
|
||||
VITE_BITSPIRE_FIAT_CODE=${fiatCodeForModel}
|
||||
VITE_SPIRE_SEED=
|
||||
VITE_RELAY_URL=
|
||||
VITE_LIGHTNING_PUB_PUBKEY=
|
||||
VITE_LIGHTNING_PUB_API_URL=
|
||||
VITE_ADMIN_TOKEN=
|
||||
VITE_ATM_PRIVATE_KEY=
|
||||
VITE_EXTENSION_API_URL=
|
||||
VITE_APP_ID=
|
||||
VITE_LNDCONNECT_URL=
|
||||
VITE_LAMASSU_MACHINE_MODEL=${machineModel}
|
||||
VITE_LAMASSU_FIAT_CODE=${fiatCodeForModel}
|
||||
ELECTRON_FORCE_PROD=1
|
||||
DISPLAY=:0
|
||||
'';
|
||||
|
|
@ -47,24 +49,13 @@ in
|
|||
|
||||
# Reuse ATM systemd service module
|
||||
./bitspire-atm.nix
|
||||
]
|
||||
# Sintra: share the UP Board serial hardware (validator/dispenser/printer
|
||||
# modules + udev symlinks + console=tty0) with the installed image so the
|
||||
# live ISO drives the same hardware. Safe to import here — unlike upboard.nix
|
||||
# it declares no fileSystems, so there's no live-boot mount conflict.
|
||||
++ lib.optionals (machineModel == "sintra") [ ./hardware/upboard-serial.nix ];
|
||||
];
|
||||
|
||||
# ISO image settings
|
||||
image.fileName = "bitspire-${machineModel}-live.iso";
|
||||
isoImage = {
|
||||
makeEfiBootable = true;
|
||||
makeBiosBootable = true;
|
||||
# Apply the isohybrid MBR + GPT/ESP so the image boots when dd'd to a USB
|
||||
# stick — not just from optical media via El Torito. Without this the ISO
|
||||
# has BIOS+UEFI El Torito boot catalogs but no partition table, and picky
|
||||
# firmware (e.g. the Sintra's Aaeon UP Board) won't recognise the USB as
|
||||
# bootable. Requires makeBiosBootable (isohdpfx.bin), set above.
|
||||
makeUsbBootable = true;
|
||||
squashfsCompression = "zstd -Xcompression-level 6";
|
||||
};
|
||||
|
||||
|
|
@ -162,7 +153,7 @@ in
|
|||
Environment = "LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib";
|
||||
# Electron needs --no-sandbox in the live/testing environment
|
||||
# --enable-logging makes renderer console.log visible in journalctl
|
||||
ExecStart = lib.mkForce "${kioskLauncher}";
|
||||
ExecStart = lib.mkForce "${pkgs-unstable.electron}/bin/electron --no-sandbox --disable-gpu-sandbox --disable-gpu --disable-software-rasterizer --enable-logging ${atm-app}";
|
||||
# Prevent Electron from consuming all RAM on memory-constrained ATMs
|
||||
MemoryMax = lib.mkForce "1G";
|
||||
# Disable all security hardening that conflicts with Electron
|
||||
|
|
@ -176,15 +167,8 @@ in
|
|||
};
|
||||
};
|
||||
|
||||
# Install the .env on first boot. Attrset form with deps=["users"] so the
|
||||
# chown runs AFTER the bitspire user is created. Otherwise on a fresh live
|
||||
# boot (where /var/lib/bitspire/.env doesn't exist yet) the chown runs in the
|
||||
# default activation order — before `users` — and fails with
|
||||
# "chown: invalid user: 'bitspire:bitspire'". The installed system skips this
|
||||
# block because its .env already exists, which is why only live boots tripped.
|
||||
system.activationScripts.bitspire-env = {
|
||||
deps = [ "users" ];
|
||||
text = ''
|
||||
# Install the .env on first boot
|
||||
system.activationScripts.bitspire-env = ''
|
||||
mkdir -p /var/lib/bitspire
|
||||
if [ ! -f /var/lib/bitspire/.env ]; then
|
||||
cp ${envTemplate} /var/lib/bitspire/.env
|
||||
|
|
@ -192,13 +176,10 @@ in
|
|||
chown bitspire:bitspire /var/lib/bitspire/.env
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
# Reset display output after X starts (required for kexec boots where
|
||||
# the GPU wasn't reinitialized by BIOS firmware). Only the eDP-panel models
|
||||
# (Douro/Tejo) have an eDP-1 output; the Sintra drives HDMI-1, so the
|
||||
# `xrandr --output eDP-1` here just errors out — skip it there.
|
||||
systemd.services.display-reset = lib.mkIf (machineModel != "sintra") {
|
||||
# the GPU wasn't reinitialized by BIOS firmware)
|
||||
systemd.services.display-reset = {
|
||||
description = "Reset eDP display output";
|
||||
after = [ "display-manager.service" ];
|
||||
requires = [ "display-manager.service" ];
|
||||
|
|
@ -212,17 +193,12 @@ in
|
|||
};
|
||||
};
|
||||
|
||||
# Low-RAM models (Douro/Tejo, 2GB) need a swap cushion or they hard-freeze
|
||||
# under memory pressure. The live system is RAM-rooted, so a /var/swapfile
|
||||
# lives in tmpfs — pointless, and its init fails on a fresh boot. Use
|
||||
# compressed RAM swap (zram) instead; no on-disk file required.
|
||||
zramSwap.enable = true;
|
||||
|
||||
# The wg0 VPN tunnel (declared in configuration.nix) needs a provisioned key
|
||||
# at /var/lib/wireguard/wg0.key, which a fresh live boot doesn't have — it
|
||||
# fails and drags network-setup down with it. A live test image doesn't need
|
||||
# the VPN, so drop the interface entirely.
|
||||
networking.wireguard.interfaces = lib.mkForce { };
|
||||
# Swap file — Douro/Tejo have only 2GB RAM; without swap the system
|
||||
# hard-freezes under memory pressure instead of gracefully OOM-killing.
|
||||
swapDevices = [{
|
||||
device = "/var/swapfile";
|
||||
size = 1024; # MB
|
||||
}];
|
||||
|
||||
# Clean /tmp on boot to prevent stale Nix build artifacts from filling disk
|
||||
boot.tmp.cleanOnBoot = true;
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Provision the access-control gate (ADR-003) to a deployed bitSpire ATM.
|
||||
# Pushes an access.json to /var/lib/bitspire/ and restarts the service, so the
|
||||
# gate can be toggled on a machine without an image rebuild (mirrors
|
||||
# provision-branding.sh). Env defaults are overridden by whatever this file sets.
|
||||
#
|
||||
# Usage:
|
||||
# bash provision-access.sh <access.json> # SSH to localhost:2222 (QEMU)
|
||||
# bash provision-access.sh <access.json> 192.168.1.50 # a real ATM on the LAN
|
||||
# bash provision-access.sh <access.json> 192.168.1.50 22 # custom SSH port
|
||||
#
|
||||
# access.json schema (all keys optional; omitted keys fall back to env/defaults):
|
||||
# {
|
||||
# "enabled": true, // master switch for the gate
|
||||
# "openEnrollment": true, // admit any Bolt Card (gate is not a security boundary)
|
||||
# "devUnlock": false, // on-screen dev/operator unlock (bypasses the gate; default off)
|
||||
# "salt": "per-machine", // hashing salt (provision a real one for prod)
|
||||
# "allowList": [ // authorized identities (hashed); empty in open mode
|
||||
# { "idHash": "<hashId(external_id,salt)>", "role": "user", "pinHash": "<hashPin(pin,salt)>" }
|
||||
# ]
|
||||
# }
|
||||
#
|
||||
# To DISABLE the gate again: push a file with {"enabled": false} (or delete
|
||||
# /var/lib/bitspire/access.json on the machine) and restart.
|
||||
set -euo pipefail
|
||||
|
||||
ACCESS_FILE="${1:-}"
|
||||
ATM_HOST="${2:-localhost}"
|
||||
ATM_SSH_PORT="${3:-2222}"
|
||||
ATM_USER="bitspire"
|
||||
REMOTE_FILE="/var/lib/bitspire/access.json"
|
||||
|
||||
if [ -z "$ACCESS_FILE" ]; then
|
||||
echo "Usage: $0 <access.json> [host] [port]" >&2
|
||||
echo " $0 ./access.json (QEMU on localhost:2222)" >&2
|
||||
echo " $0 ./access.json 192.168.1.50 (real ATM)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$ACCESS_FILE" ]; then
|
||||
echo "ERROR: access file not found: $ACCESS_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fail fast on malformed JSON before touching the machine.
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq empty "$ACCESS_FILE" || { echo "ERROR: $ACCESS_FILE is not valid JSON" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
echo "=== Provisioning access gate to $ATM_HOST:$ATM_SSH_PORT ==="
|
||||
echo "Local file : $ACCESS_FILE"
|
||||
echo "Remote file: $REMOTE_FILE"
|
||||
cat "$ACCESS_FILE"
|
||||
echo ""
|
||||
|
||||
# Copy over SSH. --rsync-path=sudo because /var/lib/bitspire is owned by the
|
||||
# bitspire service user, not the SSH user.
|
||||
rsync -avz \
|
||||
--rsync-path="sudo rsync" \
|
||||
-e "ssh -o StrictHostKeyChecking=no -p $ATM_SSH_PORT" \
|
||||
"$ACCESS_FILE" \
|
||||
"$ATM_USER@$ATM_HOST:$REMOTE_FILE"
|
||||
|
||||
# Restart so loadAccessControl() re-reads the file.
|
||||
ssh -o StrictHostKeyChecking=no -p "$ATM_SSH_PORT" "$ATM_USER@$ATM_HOST" \
|
||||
"sudo systemctl restart bitspire"
|
||||
|
||||
echo ""
|
||||
echo "=== Access gate provisioned. Service restarted. ==="
|
||||
|
|
@ -4,25 +4,16 @@
|
|||
# kind-21000 NIP-44 v2 events on a relay — there is no out-of-band token,
|
||||
# the ATM's nostr private key IS the credential. # pragma: allowlist secret
|
||||
#
|
||||
# The primary input is SPIRE_SEED — the pairing seed carries the relay, the
|
||||
# LNbits server pubkey AND the signing identity, so a seed-provisioned machine
|
||||
# needs nothing else (aiolabs/bitspire#70).
|
||||
#
|
||||
# Environment variables:
|
||||
# SPIRE_SEED RECOMMENDED. The spire pairing seed
|
||||
# (`spire-seed:v1:<base64url>`) minted by spirekeeper.
|
||||
# Carries relay + LNbits server pubkey + the production
|
||||
# identity under the NIP-46 bunker (aiolabs/bitspire#52 / #70).
|
||||
# RELAY_URL OPTIONAL override — pins VITE_RELAY_URL and WINS over the
|
||||
# seed's relay (env-first precedence). Leave unset to let the
|
||||
# seed drive it. Required only on the no-seed dev path
|
||||
# (default there: ws://$HOST_IP:5001/nostrrelay/test).
|
||||
# LNBITS_SERVER_PUBKEY OPTIONAL override (hex). Leave unset with a seed. On the
|
||||
# no-seed dev path it's scraped from
|
||||
# `docker logs lnbits | grep 'nostr_transport pubkey'`.
|
||||
# ATM_PRIVATE_KEY DEV-ONLY 32-byte hex nsec fallback, used only when
|
||||
# SPIRE_SEED is unset (no bunker). Generated if unset
|
||||
# AND no SPIRE_SEED is provided.
|
||||
# Required environment variables (or edit defaults below):
|
||||
# LNBITS_SERVER_PUBKEY Hex pubkey published by the LNbits server at startup.
|
||||
# From the LNbits compose:
|
||||
# docker logs lnbits | grep 'nostr_transport pubkey'
|
||||
# LNBITS_HTTP_URL Origin LNbits is reachable at over HTTP, used only
|
||||
# to compose the LNURL-withdraw callback URL that
|
||||
# customer wallets dereference. Default: http://10.0.2.2:5000
|
||||
# RELAY_URL Nostr relay LNbits subscribes on. Default uses host gateway.
|
||||
# ATM_PRIVATE_KEY 32-byte hex key, ATM's nostr identity. If unset, a
|
||||
# fresh key is generated and saved in the .env.
|
||||
#
|
||||
# Usage:
|
||||
# bash provision-atm.sh # defaults: SSH to localhost:2222 (QEMU)
|
||||
|
|
@ -64,58 +55,33 @@ else
|
|||
echo "--- LAN ATM: using $HOST_IP as dev machine address ---"
|
||||
fi
|
||||
|
||||
# Steps 2-4: transport config (relay + LNbits server pubkey) + signing identity.
|
||||
#
|
||||
# Under aiolabs/bitspire#70 the relay + server pubkey come from the pairing SEED,
|
||||
# so a seed-provisioned machine needs NEITHER in .env. We only pin them when the
|
||||
# operator EXPLICITLY passes RELAY_URL / LNBITS_SERVER_PUBKEY (a deliberate
|
||||
# override that WINS over the seed via env-first precedence), or when there is no
|
||||
# seed (the dev-nsec fallback has nothing else to supply them, so we scrape/default).
|
||||
TRANSPORT_LINES=""
|
||||
|
||||
if [ -n "${SPIRE_SEED:-}" ]; then
|
||||
case "$SPIRE_SEED" in
|
||||
spire-seed:v1:*) : ;;
|
||||
*) echo "ERROR: SPIRE_SEED must start with 'spire-seed:v1:'"; exit 1 ;;
|
||||
esac
|
||||
echo ""
|
||||
echo "--- Spire pairing seed: relay + LNbits pubkey come from the seed ---"
|
||||
if [ -n "${RELAY_URL:-}" ]; then
|
||||
echo " (pinning VITE_RELAY_URL=$RELAY_URL — overrides the seed's relay)"
|
||||
TRANSPORT_LINES="VITE_RELAY_URL=$RELAY_URL"
|
||||
fi
|
||||
if [ -n "${LNBITS_SERVER_PUBKEY:-}" ]; then
|
||||
TRANSPORT_LINES="${TRANSPORT_LINES:+$TRANSPORT_LINES
|
||||
}VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY"
|
||||
fi
|
||||
IDENTITY_LINES="# Spire pairing seed — bunker-backed identity (aiolabs/bitspire#52)
|
||||
VITE_SPIRE_SEED=$SPIRE_SEED"
|
||||
else
|
||||
# No seed → DEV-ONLY nsec fallback. Nothing else supplies the relay + pubkey,
|
||||
# so scrape/default them.
|
||||
# Step 2: Resolve the LNbits server pubkey. Prefer the env override; else
|
||||
# fall back to scraping the local docker compose stack.
|
||||
if [ -z "${LNBITS_SERVER_PUBKEY:-}" ]; then
|
||||
echo ""
|
||||
echo "--- No seed: extracting LNbits nostr-transport pubkey from docker logs ---"
|
||||
echo "--- Step 1: Extracting LNbits nostr-transport pubkey from docker logs ---"
|
||||
LNBITS_SERVER_PUBKEY=$(docker logs lnbits 2>&1 \
|
||||
| grep -oP 'nostr_transport pubkey:?\s*\K[a-f0-9]{64}' \
|
||||
| tail -1 || true)
|
||||
if [ -z "$LNBITS_SERVER_PUBKEY" ]; then
|
||||
echo "ERROR: no SPIRE_SEED, and could not extract the LNbits pubkey."
|
||||
echo "Provide a SPIRE_SEED (recommended — the seed carries relay + pubkey),"
|
||||
echo "or set LNBITS_SERVER_PUBKEY explicitly."
|
||||
echo "ERROR: Could not extract LNbits pubkey. Set LNBITS_SERVER_PUBKEY explicitly"
|
||||
echo "or start the LNbits stack first (docker compose -f docker/docker-compose.dev.yml up lnbits)."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
RELAY_URL="${RELAY_URL:-ws://$HOST_IP:5001/nostrrelay/test}"
|
||||
TRANSPORT_LINES="VITE_RELAY_URL=$RELAY_URL
|
||||
VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY"
|
||||
echo "LNbits server pubkey: ${LNBITS_SERVER_PUBKEY:0:16}..."
|
||||
|
||||
# Step 3: Pin LNbits HTTP origin.
|
||||
LNBITS_HTTP_URL="${LNBITS_HTTP_URL:-http://$HOST_IP:5000}"
|
||||
|
||||
# Step 4: Relay URL.
|
||||
RELAY_URL="${RELAY_URL:-ws://$HOST_IP:7777}"
|
||||
|
||||
# Step 5: ATM identity. Generate if unset.
|
||||
if [ -z "${ATM_PRIVATE_KEY:-}" ]; then
|
||||
ATM_PRIVATE_KEY=$(openssl rand -hex 32)
|
||||
echo ""
|
||||
echo "--- No SPIRE_SEED; generated a DEV-ONLY ATM_PRIVATE_KEY (no bunker) ---"
|
||||
fi
|
||||
IDENTITY_LINES="# DEV-ONLY local nsec (no bunker pairing) # pragma: allowlist secret
|
||||
VITE_ATM_PRIVATE_KEY=$ATM_PRIVATE_KEY"
|
||||
echo "--- Generated fresh ATM_PRIVATE_KEY (save this if you want it persisted) ---"
|
||||
fi
|
||||
|
||||
# Step 6: Write .env to the ATM via SSH.
|
||||
|
|
@ -124,16 +90,17 @@ echo "--- Step 2: Writing .env to ATM ---"
|
|||
ENV_CONTENT="# bitSpire Configuration
|
||||
# Auto-generated by provision-atm.sh on $(date -Iseconds)
|
||||
|
||||
# LNbits nostr-transport. Relay + server pubkey come from the pairing seed
|
||||
# (aiolabs/bitspire#70); present below only as an explicit override or the
|
||||
# no-seed dev fallback.
|
||||
$TRANSPORT_LINES
|
||||
# LNbits nostr-transport connection
|
||||
VITE_RELAY_URL=$RELAY_URL
|
||||
VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY
|
||||
VITE_LNBITS_HTTP_URL=$LNBITS_HTTP_URL
|
||||
|
||||
$IDENTITY_LINES
|
||||
# ATM identity (signing key IS the credential under nostr-transport)
|
||||
VITE_ATM_PRIVATE_KEY=$ATM_PRIVATE_KEY
|
||||
|
||||
# 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
|
||||
|
|
@ -146,6 +113,6 @@ echo ""
|
|||
echo "=== ATM provisioned successfully ==="
|
||||
echo ""
|
||||
echo "Credentials written to /var/lib/bitspire/.env"
|
||||
echo "ATM service restarted. Relay: ${RELAY_URL:-from the pairing seed}."
|
||||
echo "ATM service restarted. It should connect to LNbits via relay $RELAY_URL."
|
||||
echo ""
|
||||
echo "To check status: ssh -p $ATM_SSH_PORT $ATM_USER@$ATM_HOST 'sudo journalctl -u bitspire -f'"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# bitSpire ATM Hardware udev Rules
|
||||
# Lamassu ATM Hardware udev Rules
|
||||
# Place in /etc/udev/rules.d/ or use services.udev.extraRules in NixOS
|
||||
|
||||
# ============================================
|
||||
|
|
|
|||
491
devenv.nix
491
devenv.nix
|
|
@ -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
1087
docker/dev.sh
Executable file
File diff suppressed because it is too large
Load diff
226
docker/docker-compose.dev.yml
Normal file
226
docker/docker-compose.dev.yml
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
# Lamassu Next Development Infrastructure
|
||||
# Usage: docker compose -f docker-compose.dev.yml up -d
|
||||
|
||||
services:
|
||||
# Private Nostr relay for ATM communication
|
||||
strfry:
|
||||
image: ghcr.io/hoytech/strfry:latest
|
||||
container_name: lamassu-relay
|
||||
ports:
|
||||
- '7777:7777'
|
||||
volumes:
|
||||
- ./strfry.conf:/etc/strfry.conf:ro
|
||||
- strfry-data:/app/strfry-db
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 524288
|
||||
hard: 524288
|
||||
healthcheck:
|
||||
test: ['CMD', 'nc', '-z', 'localhost', '7777']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
# Bitcoin Core in regtest mode
|
||||
bitcoind:
|
||||
image: lncm/bitcoind:v27.0
|
||||
container_name: lamassu-bitcoind
|
||||
volumes:
|
||||
- bitcoind-data:/data/.bitcoin
|
||||
environment:
|
||||
BITCOIN_NETWORK: regtest
|
||||
command:
|
||||
- -regtest
|
||||
- -server
|
||||
- -rpcuser=lamassu
|
||||
- -rpcpassword=lamassu
|
||||
- -rpcallowip=0.0.0.0/0
|
||||
- -rpcbind=0.0.0.0
|
||||
- -zmqpubrawblock=tcp://0.0.0.0:28332
|
||||
- -zmqpubrawtx=tcp://0.0.0.0:28333
|
||||
- -fallbackfee=0.00001
|
||||
- -txindex=1
|
||||
ports:
|
||||
- '18443:18443' # RPC
|
||||
- '28332:28332' # ZMQ blocks
|
||||
- '28333:28333' # ZMQ tx
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD',
|
||||
'bitcoin-cli',
|
||||
'-regtest',
|
||||
'-rpcuser=lamassu',
|
||||
'-rpcpassword=lamassu',
|
||||
'getblockchaininfo',
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
# LND Lightning node
|
||||
lnd:
|
||||
image: lightninglabs/lnd:v0.18.0-beta
|
||||
container_name: lamassu-lnd
|
||||
depends_on:
|
||||
bitcoind:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- lnd-data:/root/.lnd
|
||||
environment:
|
||||
- NETWORK=regtest
|
||||
command:
|
||||
- --bitcoin.active
|
||||
- --bitcoin.regtest
|
||||
- --bitcoin.node=bitcoind
|
||||
- --bitcoind.rpchost=bitcoind:18443
|
||||
- --bitcoind.rpcuser=lamassu
|
||||
- --bitcoind.rpcpass=lamassu
|
||||
- --bitcoind.zmqpubrawblock=tcp://bitcoind:28332
|
||||
- --bitcoind.zmqpubrawtx=tcp://bitcoind:28333
|
||||
- --rpclisten=0.0.0.0:10009
|
||||
- --restlisten=0.0.0.0:8080
|
||||
- --tlsextradomain=lnd
|
||||
- --tlsextraip=0.0.0.0
|
||||
- --noseedbackup
|
||||
- --accept-keysend
|
||||
- --accept-amp
|
||||
ports:
|
||||
- '10009:10009' # gRPC
|
||||
- '8080:8080' # REST
|
||||
- '9735:9735' # P2P
|
||||
healthcheck:
|
||||
test: ['CMD', 'lncli', '--network=regtest', 'getinfo']
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 30
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
# Lightning.Pub - Nostr-native Lightning account system
|
||||
# Note: Lightning.Pub connects to LND for actual Lightning operations
|
||||
# Using patched image with Kind 0 profile publishing support
|
||||
lightning-pub:
|
||||
image: lightning-pub-patched:latest
|
||||
container_name: lamassu-lightning-pub
|
||||
depends_on:
|
||||
lnd:
|
||||
condition: service_healthy
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway' # Enable host.docker.internal on Linux
|
||||
ports:
|
||||
- '1776:1776'
|
||||
volumes:
|
||||
- lightning-pub-data:/root/lightning_pub
|
||||
- lnd-data:/root/.lnd:ro # Read-only access to LND data for macaroons/certs
|
||||
environment:
|
||||
- NETWORK=regtest
|
||||
- LND_ADDRESS=lnd:10009
|
||||
- LND_CERT_PATH=/root/.lnd/tls.cert
|
||||
- LND_MACAROON_PATH=/root/.lnd/data/chain/bitcoin/regtest/admin.macaroon
|
||||
# Relay URLs (space-separated). Docker-internal relay FIRST for publishing,
|
||||
# then backup. Mock ATM rewrites ndebit relay for browser access.
|
||||
- NOSTR_RELAYS=ws://strfry:7777
|
||||
# Disable external liquidity provider for regtest
|
||||
- DISABLE_LIQUIDITY_PROVIDER=true
|
||||
# Admin token for HTTP API access (development only)
|
||||
- ADMIN_TOKEN=lamassu-dev-admin-token
|
||||
restart: unless-stopped
|
||||
|
||||
# Second LND node (Alice) for payment testing
|
||||
# This node can pay invoices to Lightning.Pub's LND
|
||||
lnd-alice:
|
||||
image: lightninglabs/lnd:v0.18.0-beta
|
||||
container_name: lamassu-lnd-alice
|
||||
depends_on:
|
||||
bitcoind:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- lnd-alice-data:/root/.lnd
|
||||
environment:
|
||||
- NETWORK=regtest
|
||||
command:
|
||||
- --bitcoin.active
|
||||
- --bitcoin.regtest
|
||||
- --bitcoin.node=bitcoind
|
||||
- --bitcoind.rpchost=bitcoind:18443
|
||||
- --bitcoind.rpcuser=lamassu
|
||||
- --bitcoind.rpcpass=lamassu
|
||||
- --bitcoind.zmqpubrawblock=tcp://bitcoind:28332
|
||||
- --bitcoind.zmqpubrawtx=tcp://bitcoind:28333
|
||||
- --rpclisten=0.0.0.0:10009
|
||||
- --restlisten=0.0.0.0:8080
|
||||
- --tlsextradomain=lnd-alice
|
||||
- --tlsextraip=0.0.0.0
|
||||
- --noseedbackup
|
||||
- --accept-keysend
|
||||
- --accept-amp
|
||||
- --alias=alice
|
||||
ports:
|
||||
- '10010:10009' # gRPC (different host port)
|
||||
- '8081:8080' # REST (different host port)
|
||||
- '9736:9735' # P2P (different host port)
|
||||
healthcheck:
|
||||
test: ['CMD', 'lncli', '--network=regtest', 'getinfo']
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 30
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
# Automatic block miner for regtest (keeps LND synced)
|
||||
# Mines 1 block every 30 seconds
|
||||
miner:
|
||||
image: alpine:latest
|
||||
container_name: lamassu-miner
|
||||
depends_on:
|
||||
bitcoind:
|
||||
condition: service_healthy
|
||||
entrypoint: /bin/sh
|
||||
command:
|
||||
- -c
|
||||
- |
|
||||
apk add --no-cache curl jq
|
||||
echo "Starting auto-miner (1 block every 30 seconds)..."
|
||||
while true; do
|
||||
# Create wallet if not exists
|
||||
curl -s --user lamassu:lamassu --data-binary '{"jsonrpc":"1.0","method":"createwallet","params":["miner"]}' http://bitcoind:18443/ > /dev/null 2>&1
|
||||
# Mine a block
|
||||
ADDR=$$(curl -s --user lamassu:lamassu --data-binary '{"jsonrpc":"1.0","method":"getnewaddress","params":[]}' http://bitcoind:18443/ | jq -r '.result // empty')
|
||||
if [ -n "$$ADDR" ]; then
|
||||
curl -s --user lamassu:lamassu --data-binary "{\"jsonrpc\":\"1.0\",\"method\":\"generatetoaddress\",\"params\":[1,\"$$ADDR\"]}" http://bitcoind:18443/ > /dev/null
|
||||
fi
|
||||
sleep 30
|
||||
done
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- mining # Only starts with: docker compose --profile mining up -d
|
||||
|
||||
# PostgreSQL for optional server-side state
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: lamassu-postgres
|
||||
ports:
|
||||
- '5432:5432'
|
||||
environment:
|
||||
POSTGRES_DB: lamassu_dev
|
||||
POSTGRES_USER: lamassu
|
||||
POSTGRES_PASSWORD: lamassu_dev_password
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U lamassu -d lamassu_dev']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
strfry-data:
|
||||
bitcoind-data:
|
||||
lnd-data:
|
||||
lnd-alice-data:
|
||||
lightning-pub-data:
|
||||
postgres-data:
|
||||
166
docker/docker-compose.regtest.yml
Normal file
166
docker/docker-compose.regtest.yml
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
# Lamassu Next - Regtest Integration
|
||||
#
|
||||
# This overlay connects lamassu-next services to the comprehensive regtest
|
||||
# environment at ~/dev/local/docker/regtest
|
||||
#
|
||||
# Usage:
|
||||
# 1. Start regtest (minimal — only what LP needs):
|
||||
# cd ~/dev/local/docker/regtest && docker compose up -d bitcoind lnd-1 lnd-4
|
||||
#
|
||||
# 2. Start lamassu services:
|
||||
# cd docker && docker compose -f docker-compose.regtest.yml up -d
|
||||
#
|
||||
# 3. Bootstrap (funds LND, opens channels, creates LP app):
|
||||
# cd docker && ./regtest-bootstrap.sh
|
||||
#
|
||||
# 4. Configure apps/machine/.env:
|
||||
# VITE_RELAY_URL=ws://localhost:7777
|
||||
# VITE_EXTENSION_API_URL=http://localhost:1777
|
||||
#
|
||||
# Phone wallet testing (LNURL callbacks need LAN IP):
|
||||
# HOST_IP=192.168.1.100 docker compose -f docker-compose.regtest.yml up -d
|
||||
#
|
||||
# Services:
|
||||
# - strfry: Private Nostr relay (port 7777)
|
||||
# - lightning-pub: Nostr-native Lightning account system (port 1776)
|
||||
# - Uses lnd-4 from regtest as backend
|
||||
# - Withdraw extension on port 1777
|
||||
# - miner: Auto-mines blocks to keep Lightning channels active
|
||||
#
|
||||
|
||||
services:
|
||||
# Private Nostr relay for ATM communication
|
||||
strfry:
|
||||
image: ghcr.io/hoytech/strfry:latest
|
||||
container_name: lamassu-relay
|
||||
ports:
|
||||
- '7777:7777'
|
||||
volumes:
|
||||
- ./strfry.conf:/etc/strfry.conf:ro
|
||||
- strfry-data:/app/strfry-db
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 524288
|
||||
hard: 524288
|
||||
healthcheck:
|
||||
# Check port 7777 (0x1E61) is listening via procfs — BusyBox nc lacks -z flag
|
||||
test: ['CMD-SHELL', 'grep -q ":1E61 " /proc/net/tcp']
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- regtest
|
||||
|
||||
# Lightning.Pub - Nostr-native Lightning account system
|
||||
# Connects to lnd-4 from the regtest environment
|
||||
# Use LIGHTNING_PUB_IMAGE env var to specify image (default: lightning-pub-withdraw)
|
||||
lightning-pub:
|
||||
image: ${LIGHTNING_PUB_IMAGE:-lightning-pub-withdraw:latest}
|
||||
container_name: lamassu-lightning-pub
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
ports:
|
||||
- '1776:1776'
|
||||
- '1777:1777' # Withdraw extension HTTP API
|
||||
volumes:
|
||||
- lightning-pub-data:/root/lightning_pub
|
||||
# Override Dockerfile's anonymous /app/data volume with named volume
|
||||
- lightning-pub-appdata:/app/data
|
||||
# Mount lnd-4 data from regtest for macaroons/certs
|
||||
- ${REGTEST_DATA_DIR:-/home/padreug/dev/local/docker/regtest/data}/lnd-4:/root/.lnd:ro
|
||||
environment:
|
||||
- NETWORK=regtest
|
||||
# lnd-4 is accessible via Docker network
|
||||
- LND_ADDRESS=lnd-4:10009
|
||||
- LND_CERT_PATH=/root/.lnd/tls.cert
|
||||
- LND_MACAROON_PATH=/root/.lnd/data/chain/bitcoin/regtest/admin.macaroon
|
||||
# Use strfry from this compose
|
||||
- NOSTR_RELAYS=ws://strfry:7777
|
||||
# Disable external liquidity provider for regtest
|
||||
- DISABLE_LIQUIDITY_PROVIDER=true
|
||||
# Admin token for HTTP API access (development only)
|
||||
- ADMIN_TOKEN=lamassu-dev-admin-token
|
||||
# Extension HTTP API URL (for LNURL callbacks from external wallets)
|
||||
# Use HOST_IP env var for your machine's LAN IP (required for phone wallets)
|
||||
- EXTENSION_SERVICE_URL=http://${HOST_IP:-localhost}:1777
|
||||
depends_on:
|
||||
strfry:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'curl -so /dev/null -w "%{http_code}" http://localhost:1776 | grep -q .']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- regtest
|
||||
|
||||
# Auto-miner for regtest (mines 1 block every MINE_INTERVAL seconds)
|
||||
# Keeps Lightning channels active during development
|
||||
miner:
|
||||
image: boltz/bitcoin-core:25.0
|
||||
container_name: lamassu-miner
|
||||
entrypoint: /bin/sh
|
||||
command:
|
||||
- -c
|
||||
- |
|
||||
echo "Auto-miner started (interval: $${MINE_INTERVAL}s)"
|
||||
# Wait for bitcoind to be ready
|
||||
while ! bitcoin-cli -regtest -rpcconnect=bitcoind getblockchaininfo > /dev/null 2>&1; do
|
||||
echo "Waiting for bitcoind..."
|
||||
sleep 5
|
||||
done
|
||||
# Ensure a wallet is loaded (-generate requires one)
|
||||
bitcoin-cli -regtest -rpcconnect=bitcoind createwallet default 2>/dev/null \
|
||||
|| bitcoin-cli -regtest -rpcconnect=bitcoind loadwallet default 2>/dev/null \
|
||||
|| true
|
||||
echo "bitcoind ready, starting mining loop"
|
||||
while true; do
|
||||
bitcoin-cli -regtest -rpcconnect=bitcoind -generate 1 > /dev/null 2>&1 && echo "Mined block $(bitcoin-cli -regtest -rpcconnect=bitcoind getblockcount)"
|
||||
sleep $${MINE_INTERVAL}
|
||||
done
|
||||
environment:
|
||||
- MINE_INTERVAL=${MINE_INTERVAL:-30}
|
||||
volumes:
|
||||
- bitcoin-data:/root/.bitcoin
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- regtest
|
||||
|
||||
# PostgreSQL for optional server-side state
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: lamassu-postgres
|
||||
ports:
|
||||
- '5432:5432'
|
||||
environment:
|
||||
POSTGRES_DB: lamassu_dev
|
||||
POSTGRES_USER: lamassu
|
||||
POSTGRES_PASSWORD: lamassu_dev_password
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U lamassu -d lamassu_dev']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- regtest
|
||||
|
||||
volumes:
|
||||
strfry-data:
|
||||
lightning-pub-data:
|
||||
lightning-pub-appdata:
|
||||
postgres-data:
|
||||
# Mount the bitcoin-data volume from the regtest environment
|
||||
bitcoin-data:
|
||||
external: true
|
||||
name: regtest_bitcoin-data
|
||||
|
||||
networks:
|
||||
regtest:
|
||||
name: regtest_default
|
||||
external: true
|
||||
273
docker/regtest-bootstrap.sh
Executable file
273
docker/regtest-bootstrap.sh
Executable file
|
|
@ -0,0 +1,273 @@
|
|||
#!/usr/bin/env bash
|
||||
# regtest-bootstrap.sh — Bootstrap a working LP+LND regtest environment
|
||||
#
|
||||
# Prerequisites:
|
||||
# 1. Regtest running (minimal — only needs bitcoind + lnd-1 + lnd-4):
|
||||
# cd ~/dev/local/docker/regtest && docker compose up -d bitcoind lnd-1 lnd-4
|
||||
# 2. Lamassu services running:
|
||||
# cd docker && docker compose -f docker-compose.regtest.yml up -d
|
||||
#
|
||||
# What this script does:
|
||||
# 1. Waits for bitcoind and LND nodes to be synced
|
||||
# 2. Funds LND-1 (1 BTC from bitcoind)
|
||||
# 3. Opens a 5M sat channel from LND-1 → LND-4 (LP's backend)
|
||||
# 4. Mines blocks to confirm and announce the channel
|
||||
# 5. Waits for LP to be healthy
|
||||
# 6. Creates an LP app and funds it (10k sats via invoice from LND-1)
|
||||
# 7. Prints summary with pubkeys, app token, and channel status
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# -- Colors --
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
err() { echo -e "${RED}[ERROR]${NC} $*"; }
|
||||
|
||||
# -- Auto-detect container name prefix --
|
||||
# The regtest compose uses project name "lnbits" (via start-regtest) or "regtest" (direct).
|
||||
# Detect which prefix the RUNNING bitcoind container has (filter=running avoids stale containers).
|
||||
if docker ps -f name=lnbits-bitcoind-1 -f status=running --format '{{.Names}}' | grep -q .; then
|
||||
PREFIX="lnbits"
|
||||
elif docker ps -f name=regtest-bitcoind-1 -f status=running --format '{{.Names}}' | grep -q .; then
|
||||
PREFIX="regtest"
|
||||
else
|
||||
err "No running bitcoind container found (tried lnbits-bitcoind-1 and regtest-bitcoind-1)"
|
||||
err "Start regtest first: cd ~/dev/local/docker/regtest && docker compose up -d bitcoind lnd-1 lnd-4"
|
||||
exit 1
|
||||
fi
|
||||
info "Using container prefix: ${PREFIX}"
|
||||
|
||||
# -- Container helpers --
|
||||
bitcoin_cli() {
|
||||
docker exec "${PREFIX}-bitcoind-1" bitcoin-cli -regtest "$@"
|
||||
}
|
||||
|
||||
lncli_1() {
|
||||
docker exec "${PREFIX}-lnd-1-1" lncli --network regtest --rpcserver=lnd-1:10009 "$@"
|
||||
}
|
||||
|
||||
lncli_4() {
|
||||
docker exec "${PREFIX}-lnd-4-1" lncli --network regtest --rpcserver=lnd-4:10009 "$@"
|
||||
}
|
||||
|
||||
LP_URL="http://localhost:1776"
|
||||
LP_EXT_URL="http://localhost:1777"
|
||||
ADMIN_TOKEN="lamassu-dev-admin-token"
|
||||
CHANNEL_SIZE=5000000 # 5M sats
|
||||
FUND_AMOUNT=10000 # 10k sats for LP app
|
||||
|
||||
# -- Step 1: Wait for bitcoind --
|
||||
info "Waiting for bitcoind..."
|
||||
for i in $(seq 1 60); do
|
||||
if bitcoin_cli getblockchaininfo > /dev/null 2>&1; then
|
||||
ok "bitcoind ready (block $(bitcoin_cli getblockcount))"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
err "bitcoind not ready after 60s — is regtest running?"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Ensure a wallet exists
|
||||
bitcoin_cli createwallet default 2>/dev/null \
|
||||
|| bitcoin_cli loadwallet default 2>/dev/null \
|
||||
|| true
|
||||
|
||||
# Mine initial blocks if needed (coinbase needs 100 confirmations to be spendable)
|
||||
BLOCKS=$(bitcoin_cli getblockcount)
|
||||
if [ "$BLOCKS" -lt 101 ]; then
|
||||
NEEDED=$((101 - BLOCKS))
|
||||
info "Mining ${NEEDED} initial blocks (coinbase needs 100 confirmations)..."
|
||||
bitcoin_cli -generate "$NEEDED" > /dev/null
|
||||
ok "Mined to block $(bitcoin_cli getblockcount)"
|
||||
fi
|
||||
|
||||
# -- Step 2: Wait for LND-1 and LND-4 to sync --
|
||||
wait_lnd_sync() {
|
||||
local name=$1
|
||||
local cli_fn=$2
|
||||
info "Waiting for ${name} to sync..."
|
||||
for i in $(seq 1 120); do
|
||||
if [ "$($cli_fn getinfo 2>/dev/null | jq -r '.synced_to_chain' 2>/dev/null)" = "true" ]; then
|
||||
ok "${name} synced"
|
||||
return 0
|
||||
fi
|
||||
if [ "$i" -eq 120 ]; then
|
||||
err "${name} not synced after 120s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
wait_lnd_sync "LND-1" lncli_1
|
||||
wait_lnd_sync "LND-4" lncli_4
|
||||
|
||||
# -- Step 3: Fund LND-1 --
|
||||
info "Funding LND-1..."
|
||||
LND1_ADDR=$(lncli_1 newaddress p2wkh | jq -r '.address')
|
||||
bitcoin_cli -named sendtoaddress address="$LND1_ADDR" amount=1 fee_rate=100 > /dev/null
|
||||
info "Mining 6 blocks to confirm funding..."
|
||||
bitcoin_cli -generate 6 > /dev/null
|
||||
|
||||
# Wait for LND-1 to see the confirmed balance
|
||||
for i in $(seq 1 30); do
|
||||
BALANCE=$(lncli_1 walletbalance | jq -r '.confirmed_balance')
|
||||
if [ "$BALANCE" != "0" ] && [ -n "$BALANCE" ]; then
|
||||
ok "LND-1 funded: ${BALANCE} sats"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# -- Step 4: Connect LND-1 → LND-4 and open channel --
|
||||
LND4_PUBKEY=$(lncli_4 getinfo | jq -r '.identity_pubkey')
|
||||
info "Connecting LND-1 → LND-4 (${LND4_PUBKEY:0:16}...)..."
|
||||
lncli_1 connect "${LND4_PUBKEY}@${PREFIX}-lnd-4-1:9735" > /dev/null 2>&1 || true
|
||||
|
||||
# Check if channel already exists
|
||||
EXISTING=$(lncli_1 listchannels --peer "$LND4_PUBKEY" 2>/dev/null | jq '.channels | length')
|
||||
if [ "${EXISTING:-0}" -gt 0 ]; then
|
||||
warn "Channel to LND-4 already exists, skipping open"
|
||||
else
|
||||
info "Opening ${CHANNEL_SIZE} sat channel LND-1 → LND-4..."
|
||||
lncli_1 openchannel "$LND4_PUBKEY" "$CHANNEL_SIZE" > /dev/null
|
||||
|
||||
info "Mining 6 blocks to confirm channel..."
|
||||
bitcoin_cli -generate 6 > /dev/null
|
||||
|
||||
# Wait for channel to leave pending
|
||||
for i in $(seq 1 60); do
|
||||
PENDING=$(lncli_1 pendingchannels | jq '.pending_open_channels | length')
|
||||
if [ "$PENDING" = "0" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
# -- Step 5: Verify channel is active and in graph --
|
||||
info "Waiting for channel to become active..."
|
||||
for i in $(seq 1 60); do
|
||||
ACTIVE=$(lncli_1 listchannels --peer "$LND4_PUBKEY" 2>/dev/null | jq '[.channels[] | select(.active == true)] | length')
|
||||
if [ "${ACTIVE:-0}" -gt 0 ]; then
|
||||
ok "Channel active"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
warn "Channel not active after 60s — may need more blocks"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Mine a few more blocks to ensure graph announcement propagates
|
||||
bitcoin_cli -generate 3 > /dev/null
|
||||
|
||||
info "Checking graph..."
|
||||
for i in $(seq 1 30); do
|
||||
GRAPH_NODES=$(lncli_1 describegraph 2>/dev/null | jq '.nodes | length')
|
||||
GRAPH_EDGES=$(lncli_1 describegraph 2>/dev/null | jq '.edges | length')
|
||||
if [ "${GRAPH_EDGES:-0}" -gt 0 ]; then
|
||||
ok "Graph: ${GRAPH_NODES} nodes, ${GRAPH_EDGES} edges"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 30 ]; then
|
||||
warn "Graph has no edges yet — channel may not be announced"
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# -- Step 6: Wait for Lightning.Pub to be healthy --
|
||||
info "Waiting for Lightning.Pub..."
|
||||
for i in $(seq 1 120); do
|
||||
if curl -so /dev/null "${LP_URL}" 2>/dev/null; then
|
||||
ok "Lightning.Pub ready"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 120 ]; then
|
||||
err "Lightning.Pub not ready after 120s"
|
||||
err "Check: docker logs lamassu-lightning-pub"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Small delay to ensure LP has fully initialized its LND connection
|
||||
sleep 5
|
||||
|
||||
# -- Step 7: Create LP app --
|
||||
info "Creating LP app 'regtest-atm'..."
|
||||
APP_RESPONSE=$(curl -sf "${LP_URL}/api/admin/app/add" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "regtest-atm", "allow_user_creation": true}')
|
||||
|
||||
if [ -z "$APP_RESPONSE" ]; then
|
||||
err "Failed to create LP app — check LP logs"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APP_ID=$(echo "$APP_RESPONSE" | jq -r '.app.id')
|
||||
APP_NPUB=$(echo "$APP_RESPONSE" | jq -r '.app.npub')
|
||||
APP_TOKEN=$(echo "$APP_RESPONSE" | jq -r '.auth_token')
|
||||
|
||||
if [ "$APP_ID" = "null" ] || [ -z "$APP_ID" ]; then
|
||||
err "App creation returned unexpected response:"
|
||||
echo "$APP_RESPONSE" | jq .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ok "App created: id=${APP_ID}"
|
||||
|
||||
# -- Step 8: Fund the app (create invoice via LP, pay from LND-1) --
|
||||
info "Funding app with ${FUND_AMOUNT} sats..."
|
||||
INVOICE_RESPONSE=$(curl -sf "${LP_URL}/api/app/add/invoice" \
|
||||
-H "Authorization: Bearer ${APP_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"payer_identifier\": \"bootstrap\", \"http_callback_url\": \"\", \"invoice_req\": {\"amountSats\": ${FUND_AMOUNT}, \"memo\": \"regtest bootstrap\"}}")
|
||||
|
||||
INVOICE=$(echo "$INVOICE_RESPONSE" | jq -r '.invoice')
|
||||
if [ "$INVOICE" = "null" ] || [ -z "$INVOICE" ]; then
|
||||
warn "Failed to create invoice — app has 0 balance"
|
||||
warn "Response: $INVOICE_RESPONSE"
|
||||
else
|
||||
# Pay from LND-1
|
||||
PAY_RESULT=$(lncli_1 payinvoice --force "$INVOICE" 2>&1) || true
|
||||
PAY_STATUS=$(echo "$PAY_RESULT" | jq -r '.status' 2>/dev/null || echo "")
|
||||
if [ "$PAY_STATUS" = "SUCCEEDED" ] || echo "$PAY_RESULT" | grep -q "SUCCEEDED"; then
|
||||
ok "App funded with ${FUND_AMOUNT} sats"
|
||||
else
|
||||
warn "Payment may have failed — check manually"
|
||||
warn "Result: $(echo "$PAY_RESULT" | tail -3)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# -- Summary --
|
||||
# LP uses LND-4 as its backend, so LP pubkey = LND-4 pubkey
|
||||
echo ""
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN} Regtest Bootstrap Complete${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}LND-1 pubkey:${NC} $(lncli_1 getinfo | jq -r '.identity_pubkey')"
|
||||
echo -e " ${CYAN}LP (LND-4):${NC} ${LND4_PUBKEY}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}LP app ID:${NC} ${APP_ID}"
|
||||
echo -e " ${CYAN}LP app token:${NC} ${APP_TOKEN}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}LP URL:${NC} ${LP_URL}"
|
||||
echo -e " ${CYAN}Extension URL:${NC} ${LP_EXT_URL}"
|
||||
echo -e " ${CYAN}Relay URL:${NC} ws://localhost:7777"
|
||||
echo ""
|
||||
CHAN_INFO=$(lncli_1 listchannels --peer "$LND4_PUBKEY" 2>/dev/null | jq -r '.channels[0] | "\(.local_balance) / \(.capacity) sats (active: \(.active))"' 2>/dev/null || echo "unknown")
|
||||
echo -e " ${CYAN}Channel:${NC} LND-1 → LND-4: ${CHAN_INFO}"
|
||||
echo ""
|
||||
142
docker/regtest.sh
Executable file
142
docker/regtest.sh
Executable file
|
|
@ -0,0 +1,142 @@
|
|||
#!/usr/bin/env bash
|
||||
# regtest.sh — Manage the LP+LND regtest development environment
|
||||
#
|
||||
# Usage:
|
||||
# ./regtest.sh up Start everything + bootstrap
|
||||
# ./regtest.sh down Stop everything
|
||||
# ./regtest.sh reset Full teardown (volumes + data) and fresh start
|
||||
# ./regtest.sh status Show container status
|
||||
# ./regtest.sh logs [svc] Tail logs (lp, relay, miner, lnd1, lnd4, bitcoind)
|
||||
# ./regtest.sh lncli N Run lncli on LND-N (e.g. ./regtest.sh lncli 1 getinfo)
|
||||
# ./regtest.sh bitcoin Run bitcoin-cli (e.g. ./regtest.sh bitcoin getblockcount)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REGTEST_DIR="${REGTEST_DIR:-$HOME/dev/local/docker/regtest}"
|
||||
COMPOSE_FILE="${SCRIPT_DIR}/docker-compose.regtest.yml"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# -- Detect container prefix (regtest- or lnbits-) --
|
||||
detect_prefix() {
|
||||
if docker ps -f name=regtest-bitcoind-1 -f status=running --format '{{.Names}}' | grep -q .; then
|
||||
echo "regtest"
|
||||
elif docker ps -f name=lnbits-bitcoind-1 -f status=running --format '{{.Names}}' | grep -q .; then
|
||||
echo "lnbits"
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_up() {
|
||||
echo -e "${CYAN}Starting regtest (bitcoind + lnd-1 + lnd-4)...${NC}"
|
||||
cd "$REGTEST_DIR" && docker compose up -d bitcoind lnd-1 lnd-4
|
||||
|
||||
echo -e "${CYAN}Starting lamassu services...${NC}"
|
||||
docker compose -f "$COMPOSE_FILE" up -d
|
||||
|
||||
echo -e "${CYAN}Running bootstrap...${NC}"
|
||||
"$SCRIPT_DIR/regtest-bootstrap.sh"
|
||||
}
|
||||
|
||||
cmd_down() {
|
||||
echo -e "${CYAN}Stopping lamassu services...${NC}"
|
||||
docker compose -f "$COMPOSE_FILE" down 2>/dev/null || true
|
||||
|
||||
echo -e "${CYAN}Stopping regtest...${NC}"
|
||||
cd "$REGTEST_DIR" && docker compose down 2>/dev/null || true
|
||||
|
||||
echo -e "${GREEN}All stopped.${NC}"
|
||||
}
|
||||
|
||||
cmd_reset() {
|
||||
echo -e "${CYAN}Full reset: tearing down everything + wiping data...${NC}"
|
||||
|
||||
docker compose -f "$COMPOSE_FILE" down -v 2>/dev/null || true
|
||||
cd "$REGTEST_DIR" && docker compose down -v 2>/dev/null || true
|
||||
|
||||
# Clean LND data (root-owned from containers)
|
||||
docker run --rm -v "$REGTEST_DIR/data:/data" alpine sh -c 'rm -rf /data/lnd-1/* /data/lnd-4/*' 2>/dev/null || true
|
||||
|
||||
echo -e "${GREEN}Clean. Starting fresh...${NC}"
|
||||
cmd_up
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
echo -e "${CYAN}Containers:${NC}"
|
||||
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | grep -E 'regtest|lamassu|NAMES' | sort
|
||||
|
||||
PREFIX=$(detect_prefix)
|
||||
if [ -n "$PREFIX" ]; then
|
||||
echo ""
|
||||
BLOCK=$(docker exec "${PREFIX}-bitcoind-1" bitcoin-cli -regtest getblockcount 2>/dev/null || echo "?")
|
||||
echo -e " ${CYAN}Block height:${NC} ${BLOCK}"
|
||||
|
||||
for N in 1 4; do
|
||||
SYNCED=$(docker exec "${PREFIX}-lnd-${N}-1" lncli --network regtest --rpcserver="lnd-${N}:10009" getinfo 2>/dev/null | jq -r '.synced_to_chain' 2>/dev/null || echo "?")
|
||||
echo -e " ${CYAN}LND-${N} synced:${NC} ${SYNCED}"
|
||||
done
|
||||
|
||||
LP_STATUS=$(curl -so /dev/null -w "%{http_code}" http://localhost:1776 2>/dev/null || echo "down")
|
||||
echo -e " ${CYAN}LP status:${NC} ${LP_STATUS}"
|
||||
|
||||
EXT_STATUS=$(curl -so /dev/null -w "%{http_code}" http://localhost:1777 2>/dev/null || echo "down")
|
||||
echo -e " ${CYAN}Extension:${NC} ${EXT_STATUS}"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_logs() {
|
||||
local svc="${1:-lp}"
|
||||
case "$svc" in
|
||||
lp|lightning-pub) docker logs -f --tail 50 lamassu-lightning-pub ;;
|
||||
relay|strfry) docker logs -f --tail 50 lamassu-relay ;;
|
||||
miner) docker logs -f --tail 50 lamassu-miner ;;
|
||||
lnd1|lnd-1) PREFIX=$(detect_prefix); docker logs -f --tail 50 "${PREFIX}-lnd-1-1" ;;
|
||||
lnd4|lnd-4) PREFIX=$(detect_prefix); docker logs -f --tail 50 "${PREFIX}-lnd-4-1" ;;
|
||||
bitcoind) PREFIX=$(detect_prefix); docker logs -f --tail 50 "${PREFIX}-bitcoind-1" ;;
|
||||
*) echo "Unknown service: $svc (try: lp, relay, miner, lnd1, lnd4, bitcoind)"; exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
cmd_lncli() {
|
||||
local N="$1"; shift
|
||||
PREFIX=$(detect_prefix)
|
||||
if [ -z "$PREFIX" ]; then
|
||||
echo -e "${RED}No running regtest found${NC}"; exit 1
|
||||
fi
|
||||
docker exec "${PREFIX}-lnd-${N}-1" lncli --network regtest --rpcserver="lnd-${N}:10009" "$@"
|
||||
}
|
||||
|
||||
cmd_bitcoin() {
|
||||
PREFIX=$(detect_prefix)
|
||||
if [ -z "$PREFIX" ]; then
|
||||
echo -e "${RED}No running regtest found${NC}"; exit 1
|
||||
fi
|
||||
docker exec "${PREFIX}-bitcoind-1" bitcoin-cli -regtest "$@"
|
||||
}
|
||||
|
||||
# -- Main --
|
||||
case "${1:-help}" in
|
||||
up) cmd_up ;;
|
||||
down) cmd_down ;;
|
||||
reset) cmd_reset ;;
|
||||
status) cmd_status ;;
|
||||
logs) shift; cmd_logs "${1:-lp}" ;;
|
||||
lncli) shift; cmd_lncli "$@" ;;
|
||||
bitcoin) shift; cmd_bitcoin "$@" ;;
|
||||
*)
|
||||
echo "Usage: $0 {up|down|reset|status|logs|lncli|bitcoin}"
|
||||
echo ""
|
||||
echo " up Start everything + bootstrap"
|
||||
echo " down Stop everything"
|
||||
echo " reset Full teardown + fresh start"
|
||||
echo " status Show container & service status"
|
||||
echo " logs [s] Tail logs (lp, relay, miner, lnd1, lnd4, bitcoind)"
|
||||
echo " lncli N Run lncli on LND-N (e.g. $0 lncli 1 getinfo)"
|
||||
echo " bitcoin Run bitcoin-cli (e.g. $0 bitcoin getblockcount)"
|
||||
;;
|
||||
esac
|
||||
335
docker/start-with-regtest.sh
Executable file
335
docker/start-with-regtest.sh
Executable file
|
|
@ -0,0 +1,335 @@
|
|||
#!/bin/bash
|
||||
#
|
||||
# Start lamassu-next development environment with comprehensive regtest
|
||||
#
|
||||
# This script:
|
||||
# 1. Connects to the regtest environment at ~/dev/local/docker/regtest
|
||||
# 2. Starts Lightning.Pub with the specified image/worktree
|
||||
# 3. Creates and funds an ATM account
|
||||
# 4. Displays connection info for Zeus wallet and Lightning.Pub nprofile
|
||||
#
|
||||
# Prerequisites:
|
||||
# ~/dev/local/docker/regtest must be running:
|
||||
# cd ~/dev/local/docker/regtest && ./start-regtest
|
||||
#
|
||||
# Usage:
|
||||
# ./start-with-regtest.sh # Start with default image
|
||||
# ./start-with-regtest.sh --image myimage # Use specific Docker image
|
||||
# ./start-with-regtest.sh --worktree ~/path # Build from worktree
|
||||
# ./start-with-regtest.sh down # Stop services
|
||||
# ./start-with-regtest.sh logs # Follow logs
|
||||
# ./start-with-regtest.sh status # Show connection info
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REGTEST_DIR="${REGTEST_DIR:-$HOME/dev/local/docker/regtest}"
|
||||
DEFAULT_IMAGE="lightning-pub-withdraw:latest"
|
||||
ATM_FUNDING_SATS=100000
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${GREEN}[lamassu]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[lamassu]${NC} $1"; }
|
||||
error() { echo -e "${RED}[lamassu]${NC} $1"; }
|
||||
info() { echo -e "${CYAN}[lamassu]${NC} $1"; }
|
||||
|
||||
# Get local IP for external wallet access
|
||||
get_local_ip() {
|
||||
ip route get 1 2>/dev/null | awk '{print $7; exit}' || hostname -I | awk '{print $1}'
|
||||
}
|
||||
|
||||
# Check if regtest is running
|
||||
check_regtest() {
|
||||
if ! docker network inspect lnbits_default &>/dev/null; then
|
||||
error "Regtest network not found!"
|
||||
echo ""
|
||||
echo "Start the regtest environment first:"
|
||||
echo " cd $REGTEST_DIR && ./start-regtest"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if lnd-4 is running (Lightning.Pub's backend)
|
||||
if ! docker exec lnbits-lnd-4-1 lncli --network=regtest getinfo &>/dev/null 2>&1; then
|
||||
warn "lnd-4 not responding. Waiting..."
|
||||
sleep 5
|
||||
fi
|
||||
}
|
||||
|
||||
# Build Lightning.Pub from worktree
|
||||
build_from_worktree() {
|
||||
local worktree="$1"
|
||||
local image_name="lightning-pub-custom:latest"
|
||||
|
||||
if [[ ! -d "$worktree" ]]; then
|
||||
error "Worktree not found: $worktree"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Building Lightning.Pub from $worktree..."
|
||||
docker build -t "$image_name" "$worktree"
|
||||
echo "$image_name"
|
||||
}
|
||||
|
||||
# Wait for Lightning.Pub to be ready and get nprofile
|
||||
wait_for_lightning_pub() {
|
||||
log "Waiting for Lightning.Pub to start..."
|
||||
local max_attempts=30
|
||||
local attempt=0
|
||||
|
||||
while [[ $attempt -lt $max_attempts ]]; do
|
||||
if docker logs lamassu-lightning-pub 2>&1 | grep -q "LightningPub listening"; then
|
||||
sleep 2 # Extra time for Nostr middleware
|
||||
return 0
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
error "Lightning.Pub failed to start within 60 seconds"
|
||||
docker logs lamassu-lightning-pub 2>&1 | tail -20
|
||||
return 1
|
||||
}
|
||||
|
||||
# Get Lightning.Pub nprofile from logs
|
||||
get_nprofile() {
|
||||
docker logs lamassu-lightning-pub 2>&1 | grep -oP 'nprofile:\s*\K\S+' | tail -1
|
||||
}
|
||||
|
||||
# Get Lightning.Pub pubkey from logs
|
||||
get_pubkey() {
|
||||
docker logs lamassu-lightning-pub 2>&1 | grep -oP 'pubkey:\s*\K[a-f0-9]+' | tail -1
|
||||
}
|
||||
|
||||
# Generate lndconnect URL for Zeus (using lnd-3 which has REST exposed)
|
||||
generate_lndconnect() {
|
||||
local lnd_container="lnbits-lnd-3-1"
|
||||
local lnd_data="$REGTEST_DIR/data/lnd-3"
|
||||
local local_ip=$(get_local_ip)
|
||||
local rest_port=8082 # lnd-3's REST port
|
||||
|
||||
# Get cert and macaroon
|
||||
local cert_path="$lnd_data/tls.cert"
|
||||
local mac_path="$lnd_data/data/chain/bitcoin/regtest/admin.macaroon"
|
||||
|
||||
if [[ ! -f "$cert_path" ]] || [[ ! -f "$mac_path" ]]; then
|
||||
warn "lnd-3 credentials not found. Zeus connection string unavailable."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Base64url encode (replace + with -, / with _, remove =)
|
||||
local cert_b64=$(base64 -w0 "$cert_path" | tr '+/' '-_' | tr -d '=')
|
||||
local mac_b64=$(base64 -w0 "$mac_path" | tr '+/' '-_' | tr -d '=')
|
||||
|
||||
echo "lndconnect://${local_ip}:${rest_port}?cert=${cert_b64}&macaroon=${mac_b64}"
|
||||
}
|
||||
|
||||
# Create and fund ATM account
|
||||
setup_atm_account() {
|
||||
log "Setting up ATM account..."
|
||||
|
||||
# Create app
|
||||
local app_response=$(curl -s -X POST http://localhost:1776/api/admin/app/add \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer lamassu-dev-admin-token" \
|
||||
-d '{"name":"atm-app","allow_user_creation":true}' 2>/dev/null)
|
||||
|
||||
if echo "$app_response" | grep -q '"status":"OK"'; then
|
||||
local app_id=$(echo "$app_response" | grep -oP '"id":"\K[^"]+')
|
||||
local app_token=$(echo "$app_response" | grep -oP '"auth_token":"\K[^"]+')
|
||||
log "Created ATM app: $app_id"
|
||||
|
||||
# Store app token for later use
|
||||
echo "$app_token" > "$SCRIPT_DIR/.atm-app-token"
|
||||
echo "$app_id" > "$SCRIPT_DIR/.atm-app-id"
|
||||
|
||||
# TODO: Fund the app by creating an invoice and paying from lnd-3
|
||||
# This requires the app to support invoice creation via HTTP API
|
||||
# For now, the app starts with 0 balance
|
||||
|
||||
return 0
|
||||
else
|
||||
warn "Failed to create ATM app: $app_response"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Display connection information
|
||||
show_connection_info() {
|
||||
local nprofile=$(get_nprofile)
|
||||
local pubkey=$(get_pubkey)
|
||||
local local_ip=$(get_local_ip)
|
||||
local lndconnect=$(generate_lndconnect 2>/dev/null || echo "")
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}═══════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${BOLD} LAMASSU REGTEST ENVIRONMENT ${NC}"
|
||||
echo -e "${BOLD}═══════════════════════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
|
||||
echo -e "${CYAN}Lightning.Pub:${NC}"
|
||||
echo -e " Pubkey: ${GREEN}$pubkey${NC}"
|
||||
echo -e " nprofile: ${GREEN}$nprofile${NC}"
|
||||
echo ""
|
||||
|
||||
echo -e "${CYAN}Service URLs:${NC}"
|
||||
echo " Nostr Relay: ws://localhost:7777"
|
||||
echo " Lightning.Pub: http://localhost:1776"
|
||||
echo " Withdraw API: http://localhost:1777"
|
||||
echo " PostgreSQL: localhost:5432"
|
||||
echo ""
|
||||
|
||||
echo -e "${CYAN}External Access (from phone/other devices):${NC}"
|
||||
echo " Nostr Relay: ws://${local_ip}:7777"
|
||||
echo " Withdraw API: http://${local_ip}:1777"
|
||||
echo ""
|
||||
|
||||
if [[ -n "$lndconnect" ]]; then
|
||||
echo -e "${CYAN}Zeus Wallet Connection (lnd-3):${NC}"
|
||||
echo -e " ${GREEN}$lndconnect${NC}"
|
||||
echo ""
|
||||
echo " Scan this with Zeus to connect to lnd-3 for testing payments."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}ATM Configuration (apps/machine/.env):${NC}"
|
||||
echo " VITE_RELAY_URL=ws://localhost:7777"
|
||||
echo " VITE_LIGHTNING_PUB_PUBKEY=$pubkey"
|
||||
echo " VITE_EXTENSION_API_URL=http://localhost:1777"
|
||||
echo ""
|
||||
|
||||
echo -e "${CYAN}CLI Helpers:${NC}"
|
||||
echo " source $REGTEST_DIR/docker-scripts.sh"
|
||||
echo " bitcoin-cli-sim -generate 1 # Mine blocks"
|
||||
echo " lncli-sim 4 getinfo # lnd-4 (Lightning.Pub)"
|
||||
echo " lncli-sim 3 getinfo # lnd-3 (Zeus wallet)"
|
||||
echo ""
|
||||
echo -e "${BOLD}═══════════════════════════════════════════════════════════════${NC}"
|
||||
}
|
||||
|
||||
# Start services
|
||||
start() {
|
||||
local image="$DEFAULT_IMAGE"
|
||||
local worktree=""
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--image)
|
||||
image="$2"
|
||||
shift 2
|
||||
;;
|
||||
--worktree)
|
||||
worktree="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
log "Checking prerequisites..."
|
||||
check_regtest
|
||||
|
||||
# Build from worktree if specified
|
||||
if [[ -n "$worktree" ]]; then
|
||||
image=$(build_from_worktree "$worktree")
|
||||
fi
|
||||
|
||||
# Check if image exists
|
||||
if ! docker image inspect "$image" &>/dev/null; then
|
||||
error "Docker image not found: $image"
|
||||
echo ""
|
||||
echo "Either:"
|
||||
echo " 1. Build the image: docker build -t $image <path>"
|
||||
echo " 2. Use --worktree: ./start-with-regtest.sh --worktree ~/path/to/lightning-pub"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Using Lightning.Pub image: $image"
|
||||
|
||||
# Export environment variables
|
||||
export REGTEST_DATA_DIR="$REGTEST_DIR/data"
|
||||
export LIGHTNING_PUB_IMAGE="$image"
|
||||
export HOST_IP=$(get_local_ip)
|
||||
|
||||
log "Starting lamassu services..."
|
||||
docker compose -f "$SCRIPT_DIR/docker-compose.regtest.yml" up -d
|
||||
|
||||
# Wait for Lightning.Pub and setup
|
||||
if wait_for_lightning_pub; then
|
||||
setup_atm_account || true
|
||||
show_connection_info
|
||||
else
|
||||
error "Failed to start Lightning.Pub. Check logs with: $0 logs lightning-pub"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Stop services
|
||||
stop() {
|
||||
log "Stopping lamassu services..."
|
||||
docker compose -f "$SCRIPT_DIR/docker-compose.regtest.yml" down
|
||||
rm -f "$SCRIPT_DIR/.atm-app-token" "$SCRIPT_DIR/.atm-app-id"
|
||||
log "Services stopped."
|
||||
}
|
||||
|
||||
# Show logs
|
||||
logs() {
|
||||
docker compose -f "$SCRIPT_DIR/docker-compose.regtest.yml" logs -f "$@"
|
||||
}
|
||||
|
||||
# Show status/connection info
|
||||
status() {
|
||||
if ! docker ps --format '{{.Names}}' | grep -q lamassu-lightning-pub; then
|
||||
error "Services not running. Start with: $0 start"
|
||||
exit 1
|
||||
fi
|
||||
show_connection_info
|
||||
}
|
||||
|
||||
# Main
|
||||
case "${1:-start}" in
|
||||
start)
|
||||
shift || true
|
||||
start "$@"
|
||||
;;
|
||||
stop|down)
|
||||
stop
|
||||
;;
|
||||
logs)
|
||||
shift
|
||||
logs "$@"
|
||||
;;
|
||||
status|info)
|
||||
status
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [command] [options]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " start Start services (default)"
|
||||
echo " stop, down Stop services"
|
||||
echo " logs [service] Follow logs"
|
||||
echo " status, info Show connection info"
|
||||
echo ""
|
||||
echo "Options for 'start':"
|
||||
echo " --image <name> Use specific Docker image"
|
||||
echo " --worktree <path> Build from Lightning.Pub worktree"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # Start with default image"
|
||||
echo " $0 --worktree ~/dev/lightning-pub/withdraw # Build and use worktree"
|
||||
echo " $0 --image lightning-pub-custom:v1 # Use specific image"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
65
docker/strfry.conf
Normal file
65
docker/strfry.conf
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
##
|
||||
## strfry configuration for Lamassu ATM development
|
||||
##
|
||||
|
||||
relay {
|
||||
bind = "0.0.0.0"
|
||||
port = 7777
|
||||
|
||||
# Set to 0 to skip configuring nofiles limit (avoids container ulimit issues)
|
||||
nofiles = 0
|
||||
|
||||
info {
|
||||
name = "Lamassu Dev Relay"
|
||||
description = "Private Nostr relay for ATM development and testing"
|
||||
pubkey = ""
|
||||
contact = ""
|
||||
}
|
||||
|
||||
# Maximum message size in bytes
|
||||
maxWebsocketPayloadSize = 131072
|
||||
|
||||
# Connection limits
|
||||
maxWebsockets = 100
|
||||
maxConnections = 1000
|
||||
}
|
||||
|
||||
# Event policies
|
||||
events {
|
||||
# Maximum event size
|
||||
maxEventSize = 65536
|
||||
|
||||
# Rate limiting
|
||||
rejectEventsNewerThanSeconds = 900
|
||||
rejectEventsOlderThanSeconds = 94608000
|
||||
|
||||
# Event kinds we care about
|
||||
# 14: Private DMs (NIP-17)
|
||||
# 21000: Lightning.Pub RPC (generic request/response)
|
||||
# 21001-21003: CLINK events (Offer, Debit, Manage)
|
||||
# 22242: NIP-42 auth
|
||||
# 30078: Service Beacon (replaceable, service discovery)
|
||||
# 30079: Transaction records (replaceable)
|
||||
|
||||
# TODO: Review if these ephemeral event settings actually do anything useful.
|
||||
# CLINK events (21000-21003) are in the ephemeral range but need to persist
|
||||
# long enough for request/response flows. These settings may not be recognized
|
||||
# by strfry - verify against strfry documentation.
|
||||
# Allow ephemeral events up to 5 minutes old to be accepted
|
||||
rejectEphemeralEventsOlderThanSeconds = 300
|
||||
# Keep ephemeral events for 5 minutes before deletion
|
||||
ephemeralEventsLifetimeSeconds = 300
|
||||
}
|
||||
|
||||
# Negentropy sync (for relay federation)
|
||||
negentropy {
|
||||
enabled = true
|
||||
syncOnConnect = false
|
||||
}
|
||||
|
||||
# Plugins (for NIP-42 auth in production)
|
||||
# plugins {
|
||||
# authRequired = true
|
||||
# writePolicy = "accept"
|
||||
# readPolicy = "accept"
|
||||
# }
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
# ADR-002: Remote Access & Fleet Management — Three Planes, Operator-Owned Access via NetBird
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-14
|
||||
**Context:** Multi-operator bitSpire fleet — separating the payment, control, and recovery planes by who owns them.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Separate three planes by trust owner**, and never conflate them:
|
||||
- **Payment plane** — ATM ↔ LNbits over the nostr-native-transport. Owned by the **SaaS operator**. Implies *no* machine access.
|
||||
- **Fleet control plane** — routine ops/telemetry/enrollment over Nostr (see [#42](https://git.atitlan.io/aiolabs/bitspire/issues/42)). Authorized by the **machine operator's** key.
|
||||
- **Access / recovery plane** — SSH for the unanticipated and the broken. Owned by the **machine operator**.
|
||||
|
||||
2. **The machine operator's own recovery access is provisioned at install and is app-independent.** Their SSH key and their VPN/NetBird enrollment are established when the machine is set up, so they can always reach a box even when the bitSpire app or OS is broken. Access for *anyone else* is runtime-granted, scoped, and revocable — never the owner's own path.
|
||||
|
||||
3. **Adopt NetBird as the standard access/recovery plane**, chosen for fleet scale and a **self-hostable, fully FOSS control plane**. The platform may provide a default NetBird setup as a convenience; a machine operator who does not wish to trust whoever runs that control plane **disables it and provisions their own access plane** (self-hosted NetBird, or their own WireGuard hub).
|
||||
|
||||
4. **We will NOT build a "revoke SaaS-operator access" toggle in the operator dashboard.** It is a false promise of security: the SaaS operator runs LNbits (and, in the default deployment, the NetBird control plane), so a toggle they ultimately control cannot protect a machine operator against them. The honest boundary is **exclusion-by-ownership, not exclusion-by-toggle** — an operator who wants to exclude the SaaS operator takes ownership of the access plane.
|
||||
|
||||
## Context
|
||||
|
||||
### The players
|
||||
|
||||
A deployed bitSpire machine sits between two distinct principals:
|
||||
|
||||
- **SaaS operator** — runs the LNbits instance and provides the Lightning backend as a service.
|
||||
- **Machine operator** — owns the physical ATM(s) and is identified by a Nostr key (the operator pubkey in the [#42](https://git.atitlan.io/aiolabs/bitspire/issues/42) allow-list).
|
||||
|
||||
These are different parties with different interests. A machine operator will want to SSH to their own machine for support and recovery, and **may or may not want to grant the SaaS operator that same access.**
|
||||
|
||||
### Why the SaaS operator needs zero box access by design
|
||||
|
||||
The whole nostr-native architecture (no admin tokens on the kiosk, no inbound network surface, payment over Nostr) means the SaaS operator can deliver the full service **without ever touching the machine**. So "the machine operator may refuse the SaaS operator access" is not a constraint to engineer around — it is the **default that costs nothing**. SaaS-operator box access is a *support convenience*, never a service requirement. The natural posture is therefore **default-deny for the SaaS operator**.
|
||||
|
||||
### Why SSH can't be replaced by the Nostr control plane
|
||||
|
||||
The Nostr control plane (#42) is a fixed menu of structured, capability-scoped commands dispatched by a handler *inside the app*. It is excellent for routine, auditable, fleet-wide ops on **healthy** machines, and strictly better than SSH for those (signed, scoped, logged, fan-out). But:
|
||||
|
||||
- It can only do what a handler was written for; incidents are by definition unanticipated.
|
||||
- The listener lives in the app, so it dies exactly when the app dies — the case you most need recovery for.
|
||||
|
||||
SSH (arbitrary, interactive, app-independent) is therefore irreducible as the **recovery plane**. The two are complements, not substitutes.
|
||||
|
||||
### Why the recovery path must be app-independent
|
||||
|
||||
The whole point of a recovery path is to survive the failure of the thing it recovers. So it must not be gated by the bitSpire app, nor by a Nostr command the app dispatches. The kernel/agent that carries the tunnel and `sshd` must come up at boot independent of the app. (`allowedTCPPorts = []` already means `sshd` is unreachable except across the tunnel — the VPN handshake is the outer lock, the SSH key the inner one.)
|
||||
|
||||
### Why the single shared hub had to change
|
||||
|
||||
The pre-existing design used one WireGuard hub (`170.75.161.21`) run by platform infra. Whoever runs that hub has a standing network path to every enrolled box — i.e. the SaaS operator having access to machines they don't own. Multi-tenancy requires the access plane to be **per-operator or policy-isolated**, rooted in the machine operator, not the platform.
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Access-plane mechanism
|
||||
|
||||
#### Option A: Always-up minimal WireGuard hub
|
||||
|
||||
**Pros:** After boot, zero userspace dependency — the kernel holds the tunnel, nothing can crash it short of a kernel/networking fault; smallest, most battle-tested trusted-code surface; simplest possible recovery floor.
|
||||
**Cons:** Manual peer management; no policy/ACL/enrollment ergonomics; a single shared hub re-creates the multi-tenant trust problem (must be run per-operator to avoid it); does not scale operationally to many operators × many machines.
|
||||
|
||||
#### Option B: NetBird (Selected)
|
||||
|
||||
**Pros:** Policy/ACL-based, revocable, per-peer access control; enrollment + audit out of the box; **self-hostable, fully FOSS control plane** — we retain the ability to run and modify every layer; scales to the many-operators × many-machines world #42 anticipates.
|
||||
**Cons:** The NetBird agent is a userspace daemon, so the recovery path depends on that daemon being up (less bulletproof than kernel-level always-up WG) — mitigated by it being independent of the bitSpire app, mature, and systemd-restarted; running a control plane is operational weight (acceptable: the platform provides a default; sovereignty-seeking operators self-host).
|
||||
|
||||
#### Option C: Tailscale
|
||||
|
||||
**Pros:** Best-in-class ergonomics and NAT traversal.
|
||||
**Cons:** **Control plane is closed source with no FOSS alternative** (headscale only reimplements the coordination server, chasing an upstream we don't control). Fails the hard requirement that we can always self-host and modify any software we depend on. Rejected on that basis alone.
|
||||
|
||||
#### Option D: On-demand tunnel toggled by the Nostr control plane
|
||||
|
||||
**Pros:** No standing reachability; every access window is a signed, audited, time-boxed event.
|
||||
**Cons:** If the toggle is handled by the app, it fails in the exact recovery scenario (listener died with the app). If handled by a separate daemon, it reintroduces a privileged userspace listener into the recovery path and grows, rather than shrinks, the trusted-code surface. Acceptable only as an **audited convenience layer on top of** an always-available floor (and designed to fail open), never as the load-bearing gate. Not adopted as the primary mechanism.
|
||||
|
||||
### Trust model for excluding the SaaS operator
|
||||
|
||||
#### Option 1: Dashboard toggle to revoke SaaS-operator access (Rejected)
|
||||
|
||||
The SaaS operator controls LNbits (the machine's wallet/account is an LNbits user they can administer) and, in the default deployment, the NetBird control plane. A toggle whose enforcement they ultimately control gives the machine operator no real protection against them — it is security theater. **Rejected as a false promise.**
|
||||
|
||||
#### Option 2: Exclusion by ownership (Selected)
|
||||
|
||||
The only honest way for a machine operator to exclude the SaaS operator is to **own the access plane**: disable the default (platform-provided) NetBird enrollment and stand up their own — self-hosted NetBird, or their own WireGuard hub. The default deployment trusts whoever runs the control plane *and says so plainly*; operators who won't extend that trust take ownership. Control = ownership; we do not pretend otherwise.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Honest trust boundaries: the SaaS operator has no standing box access by default, and the limits of platform-provided convenience are stated rather than faked.
|
||||
- Scales to many operators × many machines via NetBird policy/enrollment, while preserving a self-hosting escape hatch for sovereignty.
|
||||
- The recovery plane survives app and OS failure because it is provisioned at install and independent of the runtime.
|
||||
- Every dependency remains FOSS and self-hostable — no closed control plane anywhere in the stack.
|
||||
|
||||
### Negative
|
||||
|
||||
- The NetBird agent is a standing userspace daemon; a box where *both* the app and the agent are down falls to the physical/LAN floor (same floor as any remote scheme — only pure kernel-WG narrows it, at the cost of NetBird's ergonomics). Operators who weight reliability over ergonomics can choose self-hosted plain WireGuard.
|
||||
- Sovereignty for a distrusting operator costs them operational work (running their own access plane). This is inherent to "control = ownership," not incidental.
|
||||
- Two enrollment surfaces at provisioning: app/payment identity (#42 seed URL) and system/access identity (this plane). They must be kept conceptually distinct.
|
||||
|
||||
### Future Considerations
|
||||
|
||||
- An **audited convenience layer** (Nostr `OpenAccess`/`CloseAccess` that opens a time-boxed SSH window and logs it as a signed event) may be added *on top of* the always-available floor, designed to fail open, for the routine "let me in" case. It is explicitly not the recovery gate.
|
||||
- The machine operator's Nostr key can become the single root of trust across all three planes — SSH `authorized_keys` + VPN enrollment at install, `AddOperator`/`RevokeOperator` (#42) for delegation — so granting/revoking any party (including the SaaS operator) is one scoped, revocable capability model.
|
||||
- `sshd` posture should be tightened to key-only for deployed boxes (password auth is currently forced on for installed configs for first-boot provisioning; scope it to the LAN/first-boot window). Tracks with [#51](https://git.atitlan.io/aiolabs/bitspire/issues/51).
|
||||
|
||||
## Amendment (2026-08-04): the access/recovery plane is not the *only* recovery
|
||||
|
||||
**Status:** Accepted · **Context:** the ATM app had no way to recover its own
|
||||
connectivity — a machine that booted with no internet (or whose init otherwise
|
||||
failed) sat on "ATM Unavailable" until a manual `systemctl restart bitspire`,
|
||||
even after the network came back.
|
||||
|
||||
This ADR's SSH/NetBird recovery plane stands — it is the operator's
|
||||
**app-and-OS-independent** path for the unanticipated and the broken, and may
|
||||
carry recovery *procedures* (restart the service, inspect logs, re-provision).
|
||||
But it is explicitly **not the first-line and not the only recovery method.**
|
||||
Recovery is layered, cheapest-first:
|
||||
|
||||
1. **App auto-recovery (first-line, no human).** The ATM app recovers its own
|
||||
relay/Lightning connectivity when possible: the nostr client already
|
||||
reconnects with backoff, and the app now re-initializes when connectivity
|
||||
returns (a fresh renderer reload — HAL is preserved in the main process),
|
||||
so "internet came back" self-heals without anyone touching the machine.
|
||||
2. **On-screen manual retry (operator at the machine).** The maintenance
|
||||
("ATM Unavailable") screen carries a **Retry** button so a person standing
|
||||
at the kiosk can force an immediate recovery attempt without shell access.
|
||||
3. **SSH/NetBird (operator remote, last resort).** This plane — for when the
|
||||
app *can't* self-heal or the box is genuinely broken. Unchanged by this
|
||||
amendment beyond the reframing: it is the floor, not the front line.
|
||||
|
||||
Rationale: the common failure (transient network / boot-before-network) must
|
||||
not require remote shell access to a public kiosk. Reserve the heavyweight
|
||||
recovery plane for genuine app/OS failure. Implemented on branch
|
||||
`feat/connection-recovery`.
|
||||
|
||||
## References
|
||||
|
||||
- [#41](https://git.atitlan.io/aiolabs/bitspire/issues/41) — Multi-location deployment: runtime site config (the access plane's per-machine identity is provisioned here, not baked into the closure).
|
||||
- [#42](https://git.atitlan.io/aiolabs/bitspire/issues/42) — Fleet management: Nostr-native remote control & telemetry (the control plane this ADR sits beside).
|
||||
- [#51](https://git.atitlan.io/aiolabs/bitspire/issues/51) — NixOS systemd hardening (sshd posture tightening).
|
||||
- [#52](https://git.atitlan.io/aiolabs/bitspire/issues/52) — Sidecar bunker for the ATM key (related key-handling direction).
|
||||
- `deploy/nixos/configuration.nix` — current WireGuard hub + `sshd` config (to be reworked per this decision).
|
||||
- NetBird — <https://github.com/netbirdio/netbird> (self-hostable, FOSS control plane).
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
# ADR-003: NFC Access-Control Layer (badge-to-enter) + Developer Bypass
|
||||
|
||||
**Status:** Accepted — amended 2026-09-20 (see [Amendment](#amendment-2026-09-20-what-shipped) below; the original text follows it unchanged)
|
||||
**Date:** 2026-07-29
|
||||
**Context:** batm3 gaining a physical access layer — an NFC card must be presented to unlock the machine before anyone can transact. Reader hardware is not yet on hand; this ADR defines the direction and a non-breaking skeleton that is fully testable without it.
|
||||
|
||||
## Amendment (2026-09-20): what shipped
|
||||
|
||||
The gate landed in aiolabs/bitspire#86 as **Bolt Card tap-to-enter**, not the npub-QR → UID → serial-reader path planned below. The decisions still stand (opt-in `locked` state, operator-owned authorization, hashed identities, fail-closed, audited); the mechanism differs:
|
||||
|
||||
- **Reader.** The batm3 / upboard reader is a USB CCID contactless reader (Feitian KP382, OMNIKEY 5022) driven by `pcscd` + `nfc-pcsc` in the **main process** (`electron/nfc-service.ts`, from #83), not the serial `/dev/ttyNFC` device or Web NFC. Taps reach the renderer over the existing `nfc:card-tapped` IPC and the store routes them by state (`locked` → enter; the cash screens → pay / receive). The renderer-side `AccessReader` abstraction, the camera npub-QR reader and the mock reader were never wired to anything and have been removed; `services/access/` now holds only `authorize`, the Bolt Card parser and the credential types.
|
||||
- **Credential.** Identity is the card's boltcards `external_id`, parsed **locally** from the tapped `lnurlw` (`AccessScan` kind `boltcard`), not the NFC UID. Only `hashId(external_id, salt)` is compared or logged. The `npub` variant (with its PIN second factor) stays in `authorize()` and its tests for a future non-card credential; `challenge` remains the v2 seam; the planned `uid` variant is gone.
|
||||
- **Verified entry via `/session`** (superseding #86's soft entry). A tap yields a single-use SUN `p`/`c`, and verifying it at entry would have spent the voucher Complete needed, so #86 made no server call at entry. The fork now exposes `/session/<id>?p=&c=` (`docs/boltcard-session.md`): it spends the SUN **once**, proves a genuine non-replayed card, returns the card wallet's balance + fiat, and hands back the hit-keyed LUD-03 / LUD-06 second steps so Complete still needs no second tap. The session is single-shot — after the first Complete attempt, accepted or declined, the store drops it and the customer re-taps. The balance is shown on the idle menu and cash screens **hidden by default** behind an eye toggle (`CardChip.vue`), priced the way the LNbits wallet page prices it (wallet currency, else instance default, else the ATM's fiat at its own rate).
|
||||
- **Open enrollment is still not a security boundary.** `/session` proves the card is genuine *to the server the card names*: the session URL is derived from the tapped `lnurlw`'s host, so with `openEnrollment` on a forged NDEF tag pointing at a server that answers `authenticated: true` still unlocks the terminal. Money is unaffected (cash-out never dispenses without `PAYMENT_RECEIVED`; cash-in pays where the holder pointed). Closing the gate means pinning the accepted card-server host(s) and/or a provisioned allow-list — tracked in aiolabs/bitspire#91.
|
||||
- **Session semantics.** One tap = one session; every transaction terminal state returns to `locked`. Inactivity (60 s on the idle menu) and the absolute cap (10 min) are measured at the DOM layer (`useSessionSecurity`) because an XState `after` cannot observe touches. Both send `END_SESSION`, which the machine accepts **from `idle` only**, so a timer can never abandon stacked bills or an in-flight dispense. An explicit End Session button re-locks immediately.
|
||||
- **Config.** `ACCESS_CONTROL_ENABLED` / `ACCESS_OPEN_ENROLLMENT` / `ACCESS_DEV_UNLOCK` / `ACCESS_SALT` from env, overridden by `/var/lib/bitspire/access.json` (pushed with `deploy/nixos/provision-access.sh`, no rebuild). `devUnlock` defaults **off**.
|
||||
- **Audit** is still a console stub; the state.db write promised in decision 7 is tracked in aiolabs/bitspire#90.
|
||||
- **Phased plan superseded.** PR3 (Web NFC) and PR4 (serial HAL) will not happen — the pcscd reader covers real hardware and there is no laptop dev path beyond the debug paste-box on `LockedView`. PR5's challenge-response idea survives as the `challenge` seam.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Add a top-level `locked` state to the ATM state machine, and make it the initial state.** It sits *below* the existing initialization gates (`unpaired` / `awaiting-fees` / `maintenance` / `signer-unreachable`, which live in `App.vue`). A healthy, paired machine boots into `locked` and only reveals `idle` (Buy/Sell) after an access grant.
|
||||
|
||||
2. **Access control is opt-in via runtime config (`accessControl.enabled`, default `false`).** When disabled, the machine behaves exactly as today (boots straight to `idle`). This makes the whole feature non-breaking for the current test unit and for production ATMs, and lets a half-built access layer never brick a working box. This is the single most important constraint on the design.
|
||||
|
||||
3. **The reader lives behind an `AccessReader` abstraction that mirrors the existing `PairingSource` seam.** First implementation is a `MockAccessReader` (dev button / hotkey), so the locked→idle→transaction path is exercisable today with zero hardware. Web NFC and serial-NFC implementations follow.
|
||||
|
||||
4. **Credential model is a discriminated union with an explicit upgrade path.** v1 = card UID matched against a hashed allow-list. v2 = challenge-response (card-held key signs a machine nonce), verified against an operator-authorized set. Ship v1; design the types so v2 is additive.
|
||||
|
||||
5. **Three-tier developer bypass**, following existing conventions: a build flag (`VITE_SKIP_ACCESS_GATE`), the config disable (`accessControl.enabled=false`), and a runtime operator/dev unlock gesture that dispatches a synthetic grant.
|
||||
|
||||
6. **Authorization is owned by the machine operator, not the SaaS operator** — consistent with [ADR-002](./002-remote-access-and-fleet-management.md). The card allow-list is authorized by the operator key (the [#42](https://git.atitlan.io/aiolabs/bitspire/issues/42) allow-list mechanism), local first, operator-synced later. When access control is *enabled* and the reader is absent/broken, the machine **fails closed** (with the operator/dev unlock as the escape hatch); when *disabled*, reader state is irrelevant.
|
||||
|
||||
7. **Every grant/deny is audited** to `state.db` (hashed credential + timestamp + role + outcome), with optional later publication as a Nostr event. No PII, consistent with the KYC-free principle.
|
||||
|
||||
## Context
|
||||
|
||||
### What this is (and what it is not)
|
||||
|
||||
This is the **end-user physical access plane**: a person must badge in to use the machine. It is distinct from the three planes in [ADR-002](./002-remote-access-and-fleet-management.md) — it is *not* the operator's SSH/NetBird recovery plane, and *not* the SaaS payment plane. It shares one idea with ADR-002: **the machine operator owns who is authorized**, expressed through the operator key / #42 allow-list.
|
||||
|
||||
### Why the codebase is well-shaped for this
|
||||
|
||||
Three seams already exist; we extend them rather than invent:
|
||||
|
||||
- **The idle→transaction transition is unguarded.** `packages/state-machine/src/machine.ts` starts at `initial: 'idle'` (~L431) and `idle` moves to `cashIn`/`cashOut` via plain `SELECT_CASH_IN` / `SELECT_CASH_OUT` transitions with no guards (~L457-464). Inserting a `locked` predecessor state is a localized change.
|
||||
- **`PairingSource` is a reader abstraction designed to grow.** Its doc (`apps/machine/src/services/pairing/types.ts`) explicitly anticipates *"an NFC reader or a HAL barcode scanner… a HAL-scanner source can be added the same way without touching the wizard."* `AccessReader` mirrors it: `qr-source.ts` / `nfc-source.ts` → `mock-reader.ts` / `web-nfc-reader.ts` / `serial-reader.ts`.
|
||||
- **Dev-flag and config conventions are established.** `import.meta.env.VITE_* === 'true'` (e.g. `VITE_MAINTENANCE_MODE`, `VITE_FORCE_MOCK`), plus Electron `get-config` fields that the renderer reads (`electron/main.ts` L280–312: `maintenanceMode`, `branding`). A new `accessControl` config field and a `VITE_SKIP_ACCESS_GATE` flag follow the same shape.
|
||||
|
||||
### Hardware reality check (important)
|
||||
|
||||
The batm3 already exposes an NFC device, but it is **serial**: `deploy/nixos/hardware/batm3.nix` L154 maps udev serial `A9ZF8ELY` → `/dev/ttyNFC`. The *existing* `pairing/nfc-source.ts` uses **Web NFC** (`NDEFReader`), which drives a phone/laptop NFC radio, **not** a serial reader. So the real batm3 access reader needs a **main-process serial driver** (HAL-style, per [ADR-001](./001-hal-architecture.md)) exposing card events to the renderer over IPC — the Web NFC path is only useful for laptop/phone dev. This ADR keeps that driver as a clearly-scoped later PR so the skeleton doesn't pretend the scaffold "just works" on the panel.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Boot / render layering
|
||||
|
||||
```
|
||||
Electron get-config ─┐
|
||||
▼
|
||||
App.vue init gates (unchanged):
|
||||
unpaired? → PairingWizard
|
||||
initError (maintenance / awaiting-fees / signer-unreachable)? → maintenance screen
|
||||
else ▼
|
||||
State machine (paired + healthy):
|
||||
┌───────────────────────────────────────────────┐
|
||||
│ locked ──ACCESS_GRANTED──▶ idle │ ← NEW initial state
|
||||
│ ▲ │ SELECT_CASH_* │
|
||||
│ │ re-lock (session end / ▼ │
|
||||
│ │ inactivity / complete) cashIn / cashOut │
|
||||
│ └──────────────────────────┘ │
|
||||
└───────────────────────────────────────────────┘
|
||||
(when accessControl.enabled === false,
|
||||
`locked` immediately `always`-bypasses to `idle`)
|
||||
```
|
||||
|
||||
The access gate is strictly below App.vue's init gates: a machine that is unpaired or in maintenance never reaches `locked`.
|
||||
|
||||
### 1. State machine (`packages/state-machine`)
|
||||
|
||||
- New top-level state `locked`, `initial: 'locked'`.
|
||||
- New events on the machine's event union: `ACCESS_GRANTED` (carries an authorized `CardCredential` + resolved role), `ACCESS_DENIED` (carries a reason), `DEV_UNLOCK`.
|
||||
- `locked` transitions:
|
||||
- `always: [{ guard: 'accessBypass', target: 'idle' }]` — instant pass-through when disabled/bypassed (no UI flicker; the view is gated on the same predicate).
|
||||
- `on: { ACCESS_GRANTED: { target: 'idle', actions: ['startSession', 'recordAccessGrant'] }, ACCESS_DENIED: { actions: 'recordAccessDeny' }, DEV_UNLOCK: { guard: 'devUnlockAllowed', target: 'idle', actions: 'startDevSession' } }`.
|
||||
- Re-lock: the existing `complete` auto-return (currently 60s → `idle`) and the inactivity timeouts (`INACTIVITY_TIMEOUT`/`TIMEOUT_MS`, ~L422-429) target `locked` instead of `idle`. Because `locked` `always`-bypasses when disabled, this is one code path for both modes.
|
||||
- Purity: the state-machine package must not read Vite env. `accessControl.enabled` and the bypass boolean are passed in as **actor input → context** (`context.accessControlEnabled`, `context.accessBypass`); guard `accessBypass` reads context only. New context fields: `accessControlEnabled`, `accessBypass`, `session` (`{ role, grantedAt, credentialIdHash } | null`).
|
||||
- Guards: `accessBypass`, `devUnlockAllowed`. Actions: `startSession`, `startDevSession`, `recordAccessGrant`, `recordAccessDeny`, and `resetContext` extended to clear `session`.
|
||||
|
||||
### 2. Config plumbing
|
||||
|
||||
- `apps/machine/src/types/electron.d.ts` — extend `RuntimeConfig` (L5) with:
|
||||
```ts
|
||||
accessControl: {
|
||||
enabled: boolean // default false
|
||||
devUnlock: boolean // allow the runtime operator/dev unlock gesture
|
||||
// v2: allowListSource, challengeRequired, …
|
||||
}
|
||||
```
|
||||
- `apps/machine/electron/main.ts` — the `get-config` handler (L280) returns `accessControl`, sourced from env for now (`ACCESS_CONTROL_ENABLED === 'true'`, `VITE_SKIP_ACCESS_GATE` → forces `enabled:false`), later from a provisioned file under `/var/lib/bitspire/` alongside branding.
|
||||
- `apps/machine/.env.example` — document `VITE_SKIP_ACCESS_GATE=true` (browser/dev straight to idle) and `ACCESS_CONTROL_ENABLED`.
|
||||
|
||||
### 3. Reader abstraction (`apps/machine/src/services/access/`)
|
||||
|
||||
Mirrors `services/pairing/`:
|
||||
|
||||
```
|
||||
services/access/
|
||||
types.ts # AccessReader, CardCredential (union), AccessRole, StopCapture
|
||||
mock-reader.ts # PR1 — fires a card event on demand (dev button / hotkey)
|
||||
web-nfc-reader.ts # PR3 — NDEFReader, dev on laptop/phone
|
||||
serial-reader.ts # PR4 — /dev/ttyNFC via main-process HAL + IPC
|
||||
authorize.ts # allow-list check + role resolution (hashed UID v1)
|
||||
index.ts # availableAccessReaders(): AccessReader[]
|
||||
__tests__/
|
||||
```
|
||||
|
||||
```ts
|
||||
export type AccessRole = 'user' | 'operator'
|
||||
|
||||
export type CardCredential =
|
||||
| { kind: 'uid'; uidHash: string } // v1
|
||||
| { kind: 'challenge'; pubkey: string; nonce: string; sig: string } // v2 (seam)
|
||||
|
||||
export interface AccessReader {
|
||||
readonly kind: 'mock' | 'nfc-web' | 'nfc-serial'
|
||||
readonly label: string
|
||||
isAvailable(): Promise<boolean>
|
||||
start(opts: {
|
||||
onCard: (cred: CardCredential) => void
|
||||
onError?: (e: unknown) => void
|
||||
}): Promise<StopCapture>
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Renderer wiring
|
||||
|
||||
- `apps/machine/src/views/LockedView.vue` (new) — the badge-in screen. Shows brand/logo + "Present your card", a live reader status, and (when `accessControl.devUnlock`) a discreet operator/dev unlock affordance (hidden long-press corner, or a button on the existing debug bar).
|
||||
- `apps/machine/src/App.vue` — add a `locked` render branch mirroring the `PairingWizard` branch (L179) and the `initError` branch (L183): `<LockedView v-else-if="atmStore.isLocked" />`, then the existing `<router-view>` only when unlocked. Keeps rendering state-driven and matches the current shape.
|
||||
- `apps/machine/src/stores/atm.ts` —
|
||||
- `createActor(machine, { input: { accessControlEnabled, accessBypass } })` at the existing `createActor(machine)` site (L452), seeded from `RuntimeConfig`.
|
||||
- `isLocked` computed off the snapshot (peer of `isIdle`, ~L422).
|
||||
- `grantAccess(cred, role)` / `denyAccess(reason)` / `devUnlock()` that `send({ type: 'ACCESS_GRANTED' | 'ACCESS_DENIED' | 'DEV_UNLOCK', … })` (peers of `selectCashIn` at L1382, using the existing `send` at L1378).
|
||||
- On init, when `accessControl.enabled`, subscribe to `availableAccessReaders()[0]`; on `onCard`, run `authorize()` → `grantAccess`/`denyAccess`. When disabled, do nothing (machine `always`-bypasses).
|
||||
|
||||
### 5. Audit
|
||||
|
||||
- Add `recordAccessEvent({ credentialIdHash, role, outcome, at })` alongside the existing state.db handlers (`state:record-transaction` etc. in `electron/main.ts`, exposed via `preload.ts`). v1 writes locally; a later PR can mirror to a replaceable Nostr event.
|
||||
|
||||
### Session semantics (decided)
|
||||
|
||||
**One badge = one transaction-scoped session.** A grant unlocks `idle`, the user runs a single transaction (Buy or Sell), and the machine re-locks on `complete`, on inactivity, or on an explicit "Done". The `session` context field is deliberately shaped as a general access session (`{ role, grantedAt, credentialIdHash }`), not a transaction handle, because this terminal may later handle **non-transaction functions** — so "unlock the terminal" and "authorize a transaction" stay separate concepts.
|
||||
|
||||
**Step-up authorization (future seam, not in PR1).** The badge tap grants *terminal access*; a specific sensitive action can independently *request re-authorization* — e.g. "tap your phone" or "enter a PIN" — without conflating the two. This is why the credential model is a union and the machine carries a `session` rather than a boolean "unlocked": a later `REQUIRE_STEPUP` event can gate an individual action against a fresh credential/PIN while the terminal session stays open. PR1 ships only the entry gate; step-up is a documented extension.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Gate between `idle` and the transaction** (idle visible, tap requires a card). Rejected: the requirement is "gain access to the exchange" — the whole machine should be locked, not just the transact button. A `locked` predecessor matches the mental model and gives a clean re-lock boundary.
|
||||
- **Web NFC only** (reuse `nfc-source.ts` as-is). Rejected: the batm3 reader is serial (`ttyNFC`); Web NFC can't drive it. Web NFC stays a dev-only convenience.
|
||||
- **Fail-open by default** (no card → allow). Rejected for an access-control feature; but note the *disabled* default sidesteps this — access is simply off until an operator turns it on, at which point it fails **closed**.
|
||||
- **OS/kiosk-level lock** (lock the desktop, not the app). Rejected: too coarse, no per-transaction audit, no role model, and it fights the existing state-driven UI.
|
||||
- **UID allow-list as the permanent model.** Rejected as an endpoint (UIDs clone trivially) but accepted as v1 behind a union type, so challenge-response is additive.
|
||||
|
||||
## Security considerations
|
||||
|
||||
- **UID cloning** — card UIDs are not secret and are cloneable; v1 is "better than nothing" and is explicitly labeled upgradeable. The v2 challenge-response path (card signs a machine nonce) is the real security boundary; design the credential union and the `authorize()` seam for it now.
|
||||
- **Data at rest** — store only a salted hash of the credential id; never raw UIDs or any PII (KYC-free). Never log a card secret or nsec (repo security priority #1).
|
||||
- **Fail-closed when enabled** — reader absent/broken + `enabled` ⇒ locked, escape hatch = operator/dev unlock. Reader problems on a *disabled* machine are inert.
|
||||
- **Operator ownership** — authorization derives from the operator key / #42 allow-list, not the SaaS operator (ADR-002 boundary). Local allow-list first; operator-published (NIP-51-style) sync later.
|
||||
- **Dev bypass blast radius** — `VITE_SKIP_ACCESS_GATE` is build-time and never set in a production image; `devUnlock` is gated by `accessControl.devUnlock` (off in a locked-down deployment) and every dev unlock is audited with role `operator`/`dev`.
|
||||
|
||||
## Resolved decisions (2026-07-29)
|
||||
|
||||
1. **Session model** — ✅ one badge = one **transaction-scoped session**, with the `session` context modeled generally (terminal access, not a transaction handle) to allow non-transaction functions and per-action **step-up auth** (tap phone / PIN) later. See *Session semantics* above.
|
||||
2. **v1 credential** — ✅ **UID allow-list first** (hashed), behind a `CardCredential` union so challenge-response is additive (PR5).
|
||||
3. **Allow-list home** — ✅ **local first** (`state.db` / provisioned `access.json`, peer of `branding/`); operator-Nostr sync is a later PR.
|
||||
|
||||
Still open (cosmetic, decide during PR2):
|
||||
|
||||
4. **Dev unlock affordance** — hidden long-press corner vs a labeled button on the existing debug bar.
|
||||
|
||||
---
|
||||
|
||||
## Implementation plan (phased PRs)
|
||||
|
||||
Each PR is independently mergeable. **PR1 changes nothing observable while `accessControl.enabled=false` (the default).**
|
||||
|
||||
### PR1 — Non-breaking skeleton (state + config + mock reader + dev bypass + audit stub)
|
||||
**Goal:** the locked→idle→transaction path is exercisable on the batm3 today, and the flag-off machine is byte-for-byte behavior-identical.
|
||||
- `packages/state-machine`: add `locked` state (`initial`), `ACCESS_GRANTED`/`ACCESS_DENIED`/`DEV_UNLOCK` events, `accessBypass`/`devUnlockAllowed` guards, `startSession`/`recordAccess*` actions, context fields + actor `input`. Re-point `complete`/inactivity re-lock targets to `locked`.
|
||||
- `apps/machine/src/types/electron.d.ts`: `RuntimeConfig.accessControl`.
|
||||
- `apps/machine/electron/main.ts`: `get-config` returns `accessControl` (env-sourced); `.env.example` documents `VITE_SKIP_ACCESS_GATE` + `ACCESS_CONTROL_ENABLED`.
|
||||
- `apps/machine/src/services/access/`: `types.ts`, `mock-reader.ts`, `authorize.ts` (UID allow-list, hashed), `index.ts`.
|
||||
- `apps/machine/src/stores/atm.ts`: actor `input`, `isLocked`, `grantAccess`/`denyAccess`/`devUnlock`, reader subscription (only when enabled).
|
||||
- `apps/machine/src/views/LockedView.vue` + `App.vue` `locked` render branch.
|
||||
- Audit stub: `recordAccessEvent` handler + preload exposure (local write only).
|
||||
- **Tests:** state-machine `locked → idle` on grant; `always`-bypass when disabled; re-lock from `complete`; `authorize()` allow/deny; `devUnlock` gated by config.
|
||||
- **Flag state on merge:** `enabled=false`. CI green = no behavior change.
|
||||
|
||||
### PR2 — Locked UX polish
|
||||
- Reader status/animation, brand-aware LockedView, denied-flash + reason, inactivity copy, operator/dev unlock affordance per open-question #4. Pure renderer.
|
||||
|
||||
### PR3 — Web NFC reader (dev)
|
||||
- `web-nfc-reader.ts` (`NDEFReader`), registered in `availableAccessReaders()` behind availability check. Lets a laptop/phone drive the gate for demos/dev. No hardware dependency.
|
||||
|
||||
### PR4 — Serial NFC HAL driver (real batm3 hardware)
|
||||
- Main-process serial driver for `/dev/ttyNFC` (HAL-style per ADR-001), IPC channel `access:watch-card` + `preload.ts` exposure; `serial-reader.ts` renderer client. Requires the physical reader to validate. Document the reader's protocol/baud in `docs/device-configuration.md`.
|
||||
|
||||
### PR5 — Challenge-response credential + operator allow-list sync
|
||||
- Extend `CardCredential` with the `challenge` variant; `authorize.ts` verifies a signature over a machine nonce against the operator-authorized set; allow-list synced from an operator-published event (#42 mechanism). This is the real security upgrade; v1 UID path stays as a fallback/dev mode.
|
||||
|
||||
### Cross-cutting
|
||||
- **Docs:** update `docs/machine-installation.md` (enabling access control, enrolling cards) and `deploy/nixos/README.md` (the `accessControl` config + `/dev/ttyNFC`) as PR4/PR5 land.
|
||||
- **Provisioning:** a later change can add an `access.json` under `/var/lib/bitspire/` (peer of `branding/`) with the allow-list + `enabled`, plus a `provision-access.sh` mirroring `provision-branding.sh`.
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
# ADR-004: Cassette-State Synchronization
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-09-22
|
||||
**Context:** Cassette counts exist on two machines that both write them, over a transport
|
||||
that cannot report a losing write. This has been load-bearing since #56 shipped, and until
|
||||
now its only specification was a closed issue and a chat log — which is how four separate
|
||||
divergence bugs went unnoticed.
|
||||
|
||||
## The problem
|
||||
|
||||
The ATM holds per-bay rows in `state.db` (`position` → `denomination`, `count`). spirekeeper
|
||||
holds its own `cassette_configs` view for the operator dashboard. They are kept in step over
|
||||
Nostr kind-30078, one addressable document per direction:
|
||||
|
||||
| d-tag | Direction | Author |
|
||||
| --------------------------------------- | --------------------- | -------- |
|
||||
| `bitspire-cassettes-state:<atm_pubkey>` | ATM reports counts up | ATM |
|
||||
| `bitspire-cassettes:<atm_pubkey>` | operator pushes down | operator |
|
||||
|
||||
Counts drive cash dispensing and the public availability beacon, so a wrong number either
|
||||
strands a customer at a machine that will not pay out or advertises cash that is not there.
|
||||
|
||||
The transport shapes everything else. Per NIP-01, an addressable event is identified by
|
||||
`kind:pubkey:d` and ordered by `created_at` at **second granularity**, ties broken by lowest
|
||||
event id. Relays MAY discard the loser, and a relay returns `OK` for an event it then
|
||||
discards — so **acceptance is not persistence, and a losing writer is never told**. That
|
||||
single fact rules out the obvious design.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. The ATM owns `count`. The operator publishes operations, not counts.
|
||||
|
||||
A value with one writer cannot be clobbered. Compare-and-swap was considered and rejected:
|
||||
CAS works because the writer learns it failed and retries, and every standard implementation
|
||||
of it — HTTP `412`, Kubernetes `409`, a zero rowcount, `CMPXCHG` returning false — delivers
|
||||
that signal. A kind-30078 publish cannot. Bolting a version onto the current design would let
|
||||
the ATM refuse a stale push but leave the operator believing they set a count they did not,
|
||||
trading a wrong number for a phantom edit.
|
||||
|
||||
So the operator publishes `refill`, `empty`, `recount` and `set_denomination` operations. The
|
||||
vocabulary mirrors lamassu-server's `cash_unit_operation_type`, which is the same shape the
|
||||
ancestor of this HAL arrived at. Absolute writes survive only as `recount`, which is what an
|
||||
operator opening a bay and counting actually does.
|
||||
|
||||
`denomination` stays operator-authoritative: the machine cannot know what was physically
|
||||
loaded into a bay.
|
||||
|
||||
### 2. Idempotency is explicit, because deltas are not idempotent.
|
||||
|
||||
Addressable events are re-delivered on reconnect, so a naive delta would be applied twice.
|
||||
Every operation carries an operator-minted `id`; the ATM records applied ids and ignores
|
||||
duplicates. This is lamassu-server's `pullNewBills` pattern — a client-minted UUID per unit of
|
||||
work, making resend free and ordering irrelevant — rather than a sequence number.
|
||||
|
||||
### 3. The operator publishes a window of recent operations, not one.
|
||||
|
||||
An event the ATM missed self-heals on the next publish, because the next event still carries
|
||||
the earlier operations. This is the same trick as Lightning.Pub piggybacking `latest_balance`
|
||||
on every incremental message so a client that missed events corrects itself.
|
||||
|
||||
### 4. The ATM echoes applied ids back, which is the acknowledgement.
|
||||
|
||||
The state document carries `applied_ops`, so the dashboard can render each published operation
|
||||
as applied or pending. This supplies the feedback leg a replaceable event cannot, without
|
||||
needing the transport to report failures.
|
||||
|
||||
### 4a. Superseded. Before decisions 1 to 4 shipped, the overwrite was warned about.
|
||||
|
||||
The dashboard's publish dialog stated the failure plainly — that the publish would overwrite
|
||||
the ATM's tracked counts, that decrements since the last baseline would be lost, and that it
|
||||
should follow a physical refill rather than a mid-day tweak.
|
||||
|
||||
Kept here rather than deleted, because it is the calibration for how much a warning is worth.
|
||||
It was a known, deliberately accepted risk carrying a human-factors mitigation, not an
|
||||
oversight, and the product had already reached the same conclusion these decisions formalise.
|
||||
It was also the weakest control available: it depended on an operator reading a dialog at the
|
||||
end of a refill round, and it could not help at all when the stale value was the one already
|
||||
in the form. Confirmed live on 2026-09-22 — a dispense moved a bay from 54 to 53 while a form
|
||||
loaded at 54 stayed open, and nothing but that dialog stood between the operator and
|
||||
discarding the decrement.
|
||||
|
||||
The dialog and the endpoint behind it are both gone. The operator dashboard no longer has a
|
||||
field that accepts a count, which is a stronger guarantee than any wording could be.
|
||||
|
||||
### 5. Ordering is decided by `created_at`, never by arrival order, on both sides.
|
||||
|
||||
The ATM forces each stamp strictly above its last published one, so a same-second publish or a
|
||||
clock stepping backwards cannot silently discard a report. spirekeeper applies an event only
|
||||
when strictly newer than the **oldest** stamp on file for that machine.
|
||||
|
||||
Oldest, not newest, because LNbits' `Connection.execute` commits per call: a multi-row apply
|
||||
cannot be made atomic through that data layer, so a crash mid-apply leaves some rows advanced.
|
||||
Gating on the oldest means a partial apply is re-applied rather than mistaken for a complete
|
||||
one, and the ATM's heartbeat makes it converge.
|
||||
|
||||
### 6. The machine's bay set is authoritative for layout.
|
||||
|
||||
Bay count is hardware-determined. spirekeeper deletes positions absent from a report rather
|
||||
than leaving them; the operator cannot add or remove bays.
|
||||
|
||||
### 7. Unverified counts are declared, not guessed.
|
||||
|
||||
When a dispense ends with no per-bay report — a driver throw, or the dispense timeout — bills
|
||||
may have reached the customer with nothing knowing how many. The ATM flags
|
||||
`counts_uncertain_since` and carries it in the state document rather than letting a number
|
||||
known to read high stand as measurement. An operator `recount` clears it.
|
||||
|
||||
### 8. State is published on every change and on a heartbeat.
|
||||
|
||||
A publish is one fire-and-forget event with no retry. The heartbeat is what makes the channel
|
||||
self-healing after a relay outage, and the only way an out-of-band edit to the table ever
|
||||
reaches the operator.
|
||||
|
||||
## What this replaces
|
||||
|
||||
The original design published a single hello-event gated on a one-shot flag, deduplicated on
|
||||
one remembered event id, and never compared `created_at` at all. In practice that produced:
|
||||
|
||||
| Failure | Issue |
|
||||
| --------------------------------------------------------------- | -------------- |
|
||||
| Layout changes after first boot never published | bitspire#94 |
|
||||
| Remediation dispenses debited HAL but not the rows | bitspire#76 |
|
||||
| Absent positions never deleted; publishes then rejected forever | spirekeeper#43 |
|
||||
| A stale dashboard publish overwriting a newer report | spirekeeper#43 |
|
||||
| A drained machine advertising bills it had already dispensed | found in audit |
|
||||
| A re-delivered A, B, A applied three times | found in audit |
|
||||
|
||||
The last of these is the only one decisions 5 to 8 do not close, because it is not a defect
|
||||
in the mechanism: the operator is permitted to write the count, so a stale write is
|
||||
indistinguishable from an intended one. Only decisions 1 to 4 remove it, by removing the
|
||||
operator's ability to write counts at all.
|
||||
|
||||
## Status of implementation
|
||||
|
||||
Decisions 5 through 8 shipped in bitspire#104 and spirekeeper#44, on the existing wire format,
|
||||
and were verified against the deployed code on sintra on 2026-09-22: a zeroed machine reported
|
||||
drained rather than freezing its beacon, a re-delivered operator config was dropped as stale on
|
||||
eight consecutive restarts, three heartbeat republishes carried strictly increasing stamps read
|
||||
back off the relay, and the machine, the relay and the operator dashboard agreed on the counts
|
||||
with timestamps correlated to the second.
|
||||
|
||||
Decisions 1 through 4 are the v2 operations wire and shipped in spirekeeper#46 and
|
||||
bitspire#106.
|
||||
|
||||
On the operator side there is no longer any endpoint that accepts a count: the absolute
|
||||
publish, its CRUD write and its request model were removed rather than deprecated. The
|
||||
dashboard records operations and renders each as applied or pending from the machine's
|
||||
`applied_ops` echo. On the machine side, schema v13 adds a `cassette_ops` dedup ledger, the
|
||||
`created_at` watermark on this path is retired in favour of per-op ids, and the state document
|
||||
carries `schema_version`, `seq` and `applied_ops`.
|
||||
|
||||
The wire shapes are those given under decisions 1 and 4 above.
|
||||
|
||||
Cutover for v2 is strict, no compatibility code: spirekeeper deploys first, machines follow on
|
||||
their nightly pull. During that window a not-yet-updated ATM ignores an ops payload, so an
|
||||
operator refill does not land until it updates — which fails safe, since the machine
|
||||
under-counts and will not dispense bills it believes it lacks. In the other direction an
|
||||
updated machine drops a v1 absolute-count payload on the missing `ops` array, which is the
|
||||
same safe direction: the machine keeps the counts it is now the only writer of.
|
||||
|
||||
One gap stays open deliberately. An operation recorded while the relay is unreachable waits
|
||||
for the operator's next action to be published, because only an operator action triggers a
|
||||
publish. The window makes that self-healing once anything is published, but nothing on the
|
||||
operator side republishes on its own. An operator-side heartbeat is the fix; it is not built.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Compare-and-swap on absolute writes.** Rejected: see decision 1. Viable only with a
|
||||
feedback leg the transport cannot provide, and decision 4 gets the same benefit without
|
||||
pretending the transport is something it is not.
|
||||
- **NIP-77 negentropy for reconciliation.** Rejected: it reconciles sets of event ids and
|
||||
still requires a separate fetch. For a single mutable document it costs more than
|
||||
re-reading it.
|
||||
- **One envelope carrying all operator config.** Rejected earlier and still right: a fee edit
|
||||
that republished a stale cassette inventory is a real failure mode. One d-tag per lifecycle.
|
||||
- **Publishing the operation log as kind-78.** Deferred. The operator authors the operations
|
||||
and the ATM records what it applied, so both sides already hold an audit trail.
|
||||
|
|
@ -1,521 +0,0 @@
|
|||
# ADR-005: Cash-Out Dispense Outcome and Settlement Capture
|
||||
|
||||
**Status:** Accepted (2026-10-10)
|
||||
**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` | nothing moves until the shortfall is resolved (below) |
|
||||
| `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 |
|
||||
|
||||
**A partial dispense distributes once, when the outcome is final.** Some notes reached the
|
||||
customer and some did not, so the sale's true amount is not yet known: it is the full amount
|
||||
if the shortfall is remediated (an on-machine `manual_dispense` against the `txid`, or an
|
||||
off-machine payout recorded with `settle_cash_owed`), and the scaled amount if the shortfall
|
||||
is vouchered or written off. `partial_pending` therefore holds *everything* — including the
|
||||
operator's and LPs' share of the notes that did dispense — until the operator records which
|
||||
of those it was. Then one distribution runs, at that amount, using the existing
|
||||
`apply_partial_dispense_and_redistribute` arithmetic for the scaled case (linear scale; the
|
||||
fee split by the ratio locked at landing; operator absorbs rounding). A vouchered remainder
|
||||
stays undistributed until redemption or expiry, per the voucher rules under *Future
|
||||
directions*.
|
||||
|
||||
The alternative — distribute the scaled part immediately and the remainder on resolution —
|
||||
pays the LPs the same afternoon but needs a second, additive distribution pass keyed to the
|
||||
same settlement, which the repo does not have and which the completed-legs guard in the
|
||||
current tool would fight. It is deferred, not rejected: a partial is almost always a
|
||||
terminal-class fault that has also latched cash-out off, so the operator is coming to the
|
||||
machine anyway and resolution is hours, not weeks. Revisit if prompt LP payout ever matters
|
||||
more than one-distribution-per-settlement. (Decided 2026-10-10.)
|
||||
|
||||
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 and the dispenser reported **no error**
|
||||
(an inventory refusal, or simply short). In the cash-out flow this state is reached *after*
|
||||
payment, so the customer **has paid** and is owed the shortfall exactly as below; the
|
||||
difference is the cause — no hardware fault, so the machine stays in service and nothing
|
||||
latches. (An earlier draft said "nothing was charged beyond what was dispensed"; that is
|
||||
only true of the inventory check *before* payment, which already prevents the sale.)
|
||||
- **`dispenseFault`** — the dispenser reported an error. The customer **has paid** and is
|
||||
owed the shortfall, and a `terminal` class also latches cash-out off (Decision 5).
|
||||
|
||||
Both screens therefore show the same evidence; the heading and the latch differ.
|
||||
|
||||
`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 `cash_owed` or `partial_pending` sends the operator
|
||||
a **NIP-17 gift-wrapped DM** (kind 14 → 13 → 1059) to their own LNbits-account pubkey, or to
|
||||
`super_config.alerts_pubkey` when set — a note to self any NIP-46 client renders. It is signed
|
||||
through the operator's signer (no key at rest), is best-effort (a failed publish is logged and
|
||||
the report is still acked — the worklist is the durable record), and sets
|
||||
`operator_notified_at` so a report resend never re-alerts. Not email, not NIP-04.
|
||||
*(Implemented: spirekeeper `notify.py`, slice 2.)*
|
||||
|
||||
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.
|
||||
`POST /settlements/{id}/settle-cash-owed` records provenance (free text, author, time) on the
|
||||
settlement, moves it to `pending` and distributes **at the full amount** (the customer is
|
||||
whole), and publishes a machine-wide `settle_transaction { id, at, txid, note }` operator op;
|
||||
the machine applies it through `remediateTransaction(txid, "settled-off-machine:<op id>:<note>")`,
|
||||
which only touches rows still in an error state, so re-delivery is harmless. Before slice 2
|
||||
there was no way to record this at all, and the machine's ledger asserted 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, so
|
||||
the machine's rule is **`dispensed > 0 → settle; dispensed == 0 → cancel`**. A fault with
|
||||
nothing presented cancels cleanly — and that includes an exit jam like sintra's 2026-10-09,
|
||||
where the note stopped in the transport and the counter read zero: the customer is charged
|
||||
nothing and the note is the operator's to recover, with `countsUncertainSince` covering the
|
||||
inventory side. A **partial** — notes actually in the customer's hand — cannot cancel without
|
||||
refunding someone holding cash, so it settles the full amount and from that instant is
|
||||
identical to a plain-BOLT11 partial: `partial_pending`, one distribution when the shortfall
|
||||
is resolved (Decision 1). Hold invoices eliminate owed-cash for full faults and exit jams,
|
||||
not for true partials.
|
||||
- **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.
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
# Bolt Card tap-to-receive — LNbits resolver endpoint
|
||||
|
||||
Spec for the small **custom endpoint** the ATM needs on the LNbits `boltcards`
|
||||
extension to support **tap-to-receive** (the cash-in / buy flow). The ATM side
|
||||
(`apps/machine/electron/lnurl-pay.ts`) is already built against this contract;
|
||||
this document is what to implement in the `omni-private` LNbits fork.
|
||||
|
||||
## Why a new endpoint
|
||||
|
||||
A Bolt Card only ever emits its `lnurlw://…/scan/<external_id>?p=&c=` voucher —
|
||||
a **withdraw** (spend) credential. You cannot push sats _into_ the card with it.
|
||||
To deposit to the card's wallet, the ATM uses the same tap as an **authenticated
|
||||
identity** (the `external_id` + the SUN `p`/`c`, verified exactly as `/scan`
|
||||
does) and needs the wallet's **pay** target back. Stock `boltcards` is
|
||||
withdraw-only, so we add a `pay` sibling of `scan`.
|
||||
|
||||
The card is **not re-written** — same NDEF, same keys, same `external_id`. Only
|
||||
the server learns a new way to answer the same tap.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
GET /boltcards/api/v1/pay/{external_id}?p={p}&c={c}
|
||||
```
|
||||
|
||||
- Same URL shape as `GET /boltcards/api/v1/scan/{external_id}?p=&c=`, with the
|
||||
path segment `scan` → `pay`. The ATM derives it by string-substitution on the
|
||||
tapped `lnurlw` (`scanUrlToResolver()` in `lnurl-pay.ts`).
|
||||
- **Verify `p`/`c` exactly like `/scan`**: decrypt the PICC (`p`) with the
|
||||
card's `k1`, recompute the CMAC (`c`) with `k2`, check the read counter is
|
||||
fresh (monotonic). Reject replays. Reuse the boltcards SUN verification path —
|
||||
do not fork it. A valid `p`/`c` is the authorization: it proves card
|
||||
possession and prevents a cloned UID from misdirecting a deposit.
|
||||
- No auth key/header — like `/scan`, this is a public LNURL-style endpoint
|
||||
gated solely by the SUN.
|
||||
|
||||
## Response
|
||||
|
||||
Return **one** of the following JSON shapes (the ATM accepts all three). Since
|
||||
each card wallet has a Lightning Address, either of the first two is simplest.
|
||||
|
||||
### (a) Lightning Address (recommended)
|
||||
|
||||
```json
|
||||
{ "lightningAddress": "cardname@l484.com" }
|
||||
```
|
||||
|
||||
The ATM resolves it via LUD-16 (`/.well-known/lnurlp/cardname`) → LUD-06 pay.
|
||||
|
||||
### (b) LUD-06 payRequest, inline
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "payRequest",
|
||||
"callback": "https://lnbits.l484.com/lnurlp/api/v1/lnurl/<id>",
|
||||
"minSendable": 1000,
|
||||
"maxSendable": 100000000,
|
||||
"metadata": "[[\"text/plain\",\"bolt card top-up\"]]"
|
||||
}
|
||||
```
|
||||
|
||||
Hand back the card wallet's existing `lnurlp` payRequest directly (no extra
|
||||
round-trip for the ATM).
|
||||
|
||||
### (c) lnurlp pointer
|
||||
|
||||
```json
|
||||
{ "lnurlp": "https://lnbits.l484.com/lnurlp/<id>" }
|
||||
```
|
||||
|
||||
An `https://` (or `lnurl://`) URL the ATM will fetch to get the payRequest.
|
||||
|
||||
### Error
|
||||
|
||||
```json
|
||||
{ "status": "ERROR", "reason": "invalid card" }
|
||||
```
|
||||
|
||||
Use for a failed SUN check, a disabled/unknown card, or a wallet with no pay
|
||||
target. `reason` is surfaced verbatim on the ATM screen, so keep it terse and
|
||||
non-sensitive.
|
||||
|
||||
## Flow, end to end
|
||||
|
||||
```
|
||||
customer inserts cash → ATM owes N sats → customer taps Bolt Card
|
||||
→ ATM reads lnurlw (external_id + fresh p/c)
|
||||
→ GET /boltcards/api/v1/pay/<external_id>?p=&c= ← THIS ENDPOINT
|
||||
→ { lightningAddress | payRequest | lnurlp }
|
||||
→ ATM: LUD-16/LUD-06 → GET callback?amount=<N*1000 msat> → BOLT11
|
||||
→ ATM pays the BOLT11 over its own nostr transport → card wallet credited
|
||||
→ PAYMENT_RECEIVED → cash-in completes
|
||||
```
|
||||
|
||||
Amounts are in **millisatoshis** on the LUD-06 callback (`amount=<msat>`), per
|
||||
spec. Make sure each card wallet's `minSendable`/`maxSendable` span the ATM's
|
||||
payout range or the tap will be declined with "amount is above/below the card
|
||||
wallet …".
|
||||
|
||||
## Notes
|
||||
|
||||
- **Tap-to-enter uses `/session` instead.** When the access gate is on, the
|
||||
card was already verified at entry and the ATM holds the LUD-06 second step
|
||||
from `/session` (see `boltcard-session.md`), so Complete calls `pay.callback`
|
||||
directly and never touches `/pay`. `/pay` remains the path for a card tapped
|
||||
directly on the cash-in screen (gate off, or a second card).
|
||||
|
||||
- **Double-payout:** the cash-in screen still shows the LNURL-withdraw QR as a
|
||||
fallback (customer _pulls_). A tap _pays_ instead. The ATM gates re-entry
|
||||
while a tap is in flight and leaves `displayingQR` on success; the withdraw
|
||||
link is `uses:1`. A customer would have to both tap and pull near-simultaneously
|
||||
to double-collect — acceptable for now, revisit if it bites.
|
||||
- **Future nostr transport:** `resolveCardPayTarget` (the `/pay` GET) is the one
|
||||
HTTPS-today / nostr-tomorrow seam. A nostr-native boltcard would answer the
|
||||
same `external_id + SUN` identity over the ATM's existing nostr connection,
|
||||
dropping the clearnet HTTPS call. The rest (standard LNURL-pay) is unchanged.
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
# Bolt Card session — one verified tap for a terminal visit
|
||||
|
||||
Wire contract for the `/session` endpoint the ATM's access gate (ADR-003
|
||||
tap-to-enter) uses on the LNbits `boltcards` extension. Implemented in the
|
||||
aiolabs fork (`git.atitlan.io/aiolabs/boltcards`, `v1.1.1-aio.3`+); consumed by
|
||||
`apps/machine/electron/boltcard-session.ts`.
|
||||
|
||||
## Why a session
|
||||
|
||||
A Bolt Card tap yields a single-use SUN `p`/`c`: the card server verifies it
|
||||
and advances the card's read counter, so any endpoint that checks it — `/scan`,
|
||||
`/pay`, `/verify` — spends it. The gate wants two things from one tap:
|
||||
|
||||
1. **Verify at entry** — a genuine, non-replayed card unlocks the terminal and
|
||||
we can show the holder their balance.
|
||||
2. **Complete without a second tap** — the buy or sell later in the visit
|
||||
moves sats with the same card.
|
||||
|
||||
`/session` does the verification once and hands back the _second steps_ of
|
||||
both LNURL flows, keyed by a single-use server-side `hit` — the same bearer
|
||||
`/scan` (as `k1`) and `/pay` already issue. The terminal holds no `p`/`c`
|
||||
afterwards.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
GET /boltcards/api/v1/session/{external_id}?p={p}&c={c}
|
||||
```
|
||||
|
||||
Same URL shape as `/scan/{external_id}?p=&c=` with `scan` → `session`; the ATM
|
||||
derives it by string substitution on the tapped `lnurlw`
|
||||
(`scanUrlToSessionUrl()`). SUN verification is byte-for-byte `/scan`'s (shared
|
||||
helper in the fork): unknown / disabled card, UID mismatch, bad CMAC, replayed
|
||||
counter all reject with `/scan`'s reasons. On success the counter advances and
|
||||
one `hit` is recorded.
|
||||
|
||||
## Response
|
||||
|
||||
```json
|
||||
{
|
||||
"authenticated": true,
|
||||
"external_id": "abc123",
|
||||
"card_name": "Alice",
|
||||
"balance_msat": 123456000,
|
||||
"currency": "USD",
|
||||
"fiat": 98.76,
|
||||
"withdraw": {
|
||||
"callback": "https://lnbits.l484.com/boltcards/api/v1/lnurl/cb/<hit>",
|
||||
"k1": "<hit>",
|
||||
"minWithdrawable": 1000,
|
||||
"maxWithdrawable": 50000000
|
||||
},
|
||||
"withdraw_blocked_reason": null,
|
||||
"pay": {
|
||||
"callback": "https://lnbits.l484.com/boltcards/api/v1/pay/cb/<hit>",
|
||||
"minSendable": 1000,
|
||||
"maxSendable": 50000000,
|
||||
"metadata": "[[\"text/plain\",\"Bolt Card top-up\"]]"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `balance_msat` — the card wallet's balance. Display only.
|
||||
- `currency` / `fiat` — the balance priced the way the LNbits wallet page does
|
||||
it: the wallet's own currency (per-wallet setting) first, else the instance's
|
||||
default accounting currency. `fiat` is filled **only from the server's
|
||||
already-warm rate cache** — this response gates the unlock, and a cold rate
|
||||
lookup queries external exchanges (~1 s). On a cache miss it is `null` and
|
||||
the ATM prices the sats itself: in `currency` from its own rate source, else
|
||||
in its own fiat at its display rate. No rate lookup ever blocks the session.
|
||||
- `withdraw` — the LUD-03 second step. The ATM calls
|
||||
`callback?k1=<hit>&pr=<bolt11>` at cash-out Complete. `null` with
|
||||
`withdraw_blocked_reason` set when `/scan` would have refused (daily limit
|
||||
spent); cash-in stays possible.
|
||||
- `pay` — the LUD-06 second step. The ATM calls `callback?amount=<msat>` at
|
||||
cash-in Complete and pays the returned BOLT11 over its own nostr transport.
|
||||
- Limits are the card's `tx_limit`, as on `/scan` and `/pay`.
|
||||
|
||||
Rejection:
|
||||
|
||||
```json
|
||||
{ "authenticated": false, "reason": "This link is already used." }
|
||||
```
|
||||
|
||||
`reason` is surfaced verbatim on the locked screen — terse, non-sensitive.
|
||||
|
||||
## Semantics of the hit
|
||||
|
||||
- The first withdraw that uses the hit spends it (`spent = true`), exactly as
|
||||
after a `/scan`; a second withdraw is refused with "Payment already claimed."
|
||||
- A top-up does not mark the hit spent (as `/pay` today).
|
||||
- The ATM treats the whole session as single-shot regardless: after the first
|
||||
Complete attempt, accepted or declined, it drops the session and asks for a
|
||||
re-tap (`completeWithCard` in `stores/atm.ts`).
|
||||
- Hits do not expire server-side. The ATM's session security (60 s idle,
|
||||
10 min cap, End Session) bounds how long one is held.
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
locked ── tap ─▶ GET /session/<id>?p=&c= (spends the SUN)
|
||||
├─ authenticated:false → stay locked, show reason
|
||||
└─ authenticated:true → authorize(external_id) → idle, session held
|
||||
CardChip: "Alice · ••c123 •••••• [eye]"
|
||||
sell: pick amount → Complete Sale → withdraw.callback?k1&pr → PAYMENT_RECEIVED → dispense
|
||||
buy: insert cash → Complete Purchase → pay.callback?amount → BOLT11 → ATM pays → complete
|
||||
… re-lock (End Session / idle / complete) drops the session
|
||||
```
|
||||
|
||||
## Trust boundary (read this)
|
||||
|
||||
The ATM derives the session URL from the **card's own `lnurlw` host**. With
|
||||
`openEnrollment` on, a forged NDEF tag pointing at an attacker's server that
|
||||
answers `{"authenticated": true, …}` still unlocks the terminal. Money is not
|
||||
at risk — a fake server can only make the ATM pay an invoice the holder chose
|
||||
(cash-in) or accept a pull it never honours (cash-out never dispenses without
|
||||
`PAYMENT_RECEIVED`) — but the _gate_ is only as trustworthy as the host it was
|
||||
told to ask. Closing that means pinning the card-server host(s) the gate
|
||||
accepts; tracked in aiolabs/bitspire#91.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
439
flake.nix
439
flake.nix
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
description = "bitSpire - Nostr-Native Lightning ATM";
|
||||
description = "Lamassu Next - Nostr-Native Lightning ATM";
|
||||
|
||||
inputs = {
|
||||
# Stable NixOS for the ATM OS base
|
||||
|
|
@ -53,55 +53,6 @@
|
|||
overlays = [ (import rust-overlay) ];
|
||||
};
|
||||
|
||||
# Kiosk launcher. The GPU-related Electron flags sit in a shell variable
|
||||
# rather than being baked into ExecStart, so they can be changed on a
|
||||
# running machine by editing /var/lib/bitspire/.env and restarting the
|
||||
# unit. No rebuild, no reboot, and a bad value is one edit away from
|
||||
# being undone — which matters on a box whose screen nobody can see.
|
||||
#
|
||||
# THE DEFAULT IS NOW HARDWARE ACCELERATION.
|
||||
#
|
||||
# From the first ISO commit (19d43c2) until today the kiosk launched with
|
||||
# --disable-gpu AND --disable-software-rasterizer, which turns off GPU
|
||||
# compositing and the SwiftShader fallback together and leaves Chromium
|
||||
# rasterising every pixel on the CPU. Nothing in git ever justified the
|
||||
# pair: no comment, no issue, no commit message. Meanwhile the
|
||||
# descriptive config at /etc/bitspire/config.env claimed
|
||||
# ELECTRON_DISABLE_GPU=false, contradicting the actual command line.
|
||||
#
|
||||
# Tested on sintra 2026-09-24. With the flags removed the GPU process is
|
||||
# stable (zero crashes, zero service restarts) and genuinely on hardware
|
||||
# — /proc/<gpu-pid>/maps shows libgallium, libGLX_mesa and dri_gbm, with
|
||||
# no swrast and no SwiftShader — rendering through crocus on Braswell.
|
||||
# Confirmed by eye on the panel.
|
||||
#
|
||||
# DOURO IS EXEMPT. It keeps the old flags. Bay Trail carries three
|
||||
# separate display workarounds already — a 5.15 kernel pin for an i915
|
||||
# eDP regression, i915.enable_psr=0, and vt.handoff=7 to preserve the
|
||||
# BIOS display init — so it is the most plausible machine for the
|
||||
# original flags to have been a real fix rather than scaffolding. It is
|
||||
# also down pending a reflash, so it cannot be tested. Drop this
|
||||
# exemption once douro is back and accelerates cleanly.
|
||||
#
|
||||
# To override per machine, in /var/lib/bitspire/.env:
|
||||
# BITSPIRE_ELECTRON_GPU_FLAGS= acceleration
|
||||
# BITSPIRE_ELECTRON_GPU_FLAGS=--disable-gpu no GPU
|
||||
# BITSPIRE_ELECTRON_GPU_FLAGS=--use-gl=egl force EGL
|
||||
# (line absent) model default
|
||||
#
|
||||
# Note `-` and not `:-`: an explicitly EMPTY value means "no GPU flags at
|
||||
# all", and must not fall back to the default. Unquoted on purpose so the
|
||||
# value word-splits into argv.
|
||||
mkKioskLauncher = machineModel: atm-app: pkgs.writeShellScript "bitspire-kiosk" ''
|
||||
default_gpu_flags="${
|
||||
if machineModel == "douro" then "--disable-gpu --disable-software-rasterizer" else ""
|
||||
}"
|
||||
exec ${pkgs-unstable.electron}/bin/electron \
|
||||
--no-sandbox --disable-gpu-sandbox --enable-logging \
|
||||
''${BITSPIRE_ELECTRON_GPU_FLAGS-$default_gpu_flags} \
|
||||
${atm-app}
|
||||
'';
|
||||
|
||||
# Pure ATM app builder (no --impure needed)
|
||||
mkAtmApp = import ./nix/mkAtmApp.nix {
|
||||
inherit pkgs pkgs-unstable;
|
||||
|
|
@ -116,79 +67,6 @@
|
|||
batm3 = "USD";
|
||||
};
|
||||
|
||||
# Nightly auto-upgrade window per machine model, as a systemd calendar
|
||||
# spec. An upgrade restarts the app, and the Fujitsu dispenser runs an
|
||||
# audible init routine when it does, so this wants to land in the middle
|
||||
# of the machine's own night rather than its business hours.
|
||||
#
|
||||
# The timezone suffix (systemd 252+) is what makes that work WITHOUT
|
||||
# setting the system clock: the timer follows the named zone and its DST,
|
||||
# while time.timeZone stays a fleet-wide default nobody has to maintain
|
||||
# per host. Verified on sintra with systemd-analyze — `04:00
|
||||
# Europe/Paris` resolves to 02:00 UTC in summer, `04:00
|
||||
# America/Guatemala` to 10:00 UTC.
|
||||
#
|
||||
# Do NOT check a spec like this under `nix-shell -p systemd`. The sandbox
|
||||
# cannot resolve named zones and silently computes EVERY one of them as
|
||||
# UTC, while still echoing the zone back in its "Normalized form" line.
|
||||
# It looks accepted and is wrong. Test on a real system.
|
||||
#
|
||||
# Keyed on model like fiatCodeForModel above, and inheriting the same
|
||||
# limitation: model is a hardware model, and it only doubles as host
|
||||
# identity while there is one machine of each. A second sintra in another
|
||||
# country needs this keyed on host instead, along with the fiat code and
|
||||
# the app build that bakes it in.
|
||||
#
|
||||
# Unlisted models get 04:00 in whatever time.timeZone says, unchanged.
|
||||
upgradeWindowForModel = {
|
||||
sintra = "04:00 Europe/Paris";
|
||||
};
|
||||
|
||||
# Which models have a contactless (CCID) card reader fitted, for Bolt
|
||||
# Card taps. Same keying caveat as the two tables above.
|
||||
#
|
||||
# This can't live in a hardware file: hardware/upboard.nix is shared by
|
||||
# sintra (HID Global OMNIKEY 5022) and tejo (nothing fitted), so before
|
||||
# this table the tejo inherited pcscd it had no use for while the douro,
|
||||
# with its own hardware file, got none and wedged on boot — nfc-pcsc
|
||||
# busy-spins Electron's main thread when pcscd is absent (see
|
||||
# apps/machine/electron/nfc-service.ts). Flip a model to true when a
|
||||
# reader is actually fitted; douro and tejo are planned.
|
||||
nfcReaderForModel = {
|
||||
batm3 = true; # Feitian KP382
|
||||
sintra = true; # HID Global OMNIKEY 5022
|
||||
};
|
||||
|
||||
# WireGuard address on the 10.0.0.0/24 management tunnel to the VPS
|
||||
# (peer + listenPort live in configuration.nix; only the address is
|
||||
# per-machine). Same keying caveat as the three tables above.
|
||||
#
|
||||
# This cannot live in a hardware file for the UP Board models, and the
|
||||
# reason it now lives here for ALL of them is tejo: hardware/upboard.nix
|
||||
# is shared by tejo and sintra, so an address set there would be claimed
|
||||
# by both machines on the same /24. tejo had no address at all as a
|
||||
# result — `wg0.ips = [ ]` brings the interface up with no IP and the
|
||||
# tunnel is dead, which is a silent way to lose remote access to a
|
||||
# machine that has no other route in. Keeping douro's and batm3's
|
||||
# addresses here too means there is one list to read when allocating the
|
||||
# next one, rather than three files plus the VPS peer config.
|
||||
#
|
||||
# An unlisted model gets no address and no tunnel. That is deliberate for
|
||||
# sintra, which is reachable on the LAN (192.168.0.252) and has never had
|
||||
# a tunnel address.
|
||||
#
|
||||
# NOTE: the address is only half of it. The VPS maps peer PUBLIC KEY to
|
||||
# pragma: allowlist secret
|
||||
# tunnel IP, so a machine also needs its private key at
|
||||
# /var/lib/wireguard/wg0.key — carried over from the machine's previous
|
||||
# install, or newly generated with its pubkey added to the VPS peer list.
|
||||
# The key is operator-provisioned and deliberately not in the image.
|
||||
wireguardIpForModel = {
|
||||
tejo = "10.0.0.3/24";
|
||||
douro = "10.0.0.4/24";
|
||||
batm3 = "10.0.0.5/24";
|
||||
};
|
||||
|
||||
lib = nixpkgs.lib;
|
||||
|
||||
# Helper to create a live USB NixOS config for a specific machine model
|
||||
|
|
@ -203,7 +81,6 @@
|
|||
inherit system;
|
||||
specialArgs = {
|
||||
inherit pkgs-unstable nixpkgs machineModel atm-app;
|
||||
kioskLauncher = mkKioskLauncher machineModel atm-app;
|
||||
};
|
||||
modules = [
|
||||
./deploy/nixos/live.nix
|
||||
|
|
@ -245,14 +122,8 @@
|
|||
services.bitspire = {
|
||||
enable = true;
|
||||
appDir = "${atm-app}";
|
||||
nfc.enable = nfcReaderForModel.${machineModel} or false;
|
||||
};
|
||||
|
||||
# Management-tunnel address; see wireguardIpForModel.
|
||||
networking.wireguard.interfaces.wg0.ips =
|
||||
lib.optional (wireguardIpForModel ? ${machineModel})
|
||||
wireguardIpForModel.${machineModel};
|
||||
|
||||
# Operator TUI and CLI tools
|
||||
environment.systemPackages = [
|
||||
atm-tui.packages.${system}.default
|
||||
|
|
@ -298,60 +169,45 @@
|
|||
};
|
||||
|
||||
# Auto-upgrade: pulls latest flake and runs nixos-rebuild switch.
|
||||
# NOTE: bitSpire machines pull from the `aiolabs/bitspire` repo —
|
||||
# the post-migration home of this code. This branch (dev) pins the
|
||||
# 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.)
|
||||
# To update manually: sudo nixos-rebuild switch --flake git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=dev#<model>-installed
|
||||
# NOTE: this branch (dev) pins the upgrade source to ?ref=dev so
|
||||
# any ATM flashed from `dev` stays on `dev`. Without the explicit
|
||||
# ref, nix would resolve to the repo's default branch (main),
|
||||
# which would silently regress a dev-deployed Sintra back to the
|
||||
# lamassu-next production code at 04:00. The `main` branch's
|
||||
# flake.nix continues to omit ?ref= so production ATMs (batm3,
|
||||
# douro) keep pulling main HEAD as before.
|
||||
# To update manually: sudo nixos-rebuild switch --flake git+ssh://forgejo@git.atitlan.io/aiolabs/lamassu-next.git?ref=dev#<model>-installed
|
||||
system.autoUpgrade = {
|
||||
enable = true;
|
||||
flake = "git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=dev#${machineModel}-installed";
|
||||
flake = "git+ssh://forgejo@git.atitlan.io/aiolabs/lamassu-next.git?ref=dev#${machineModel}-installed";
|
||||
flags = [ "--refresh" ];
|
||||
# Daily at 4am in the machine's own zone; see
|
||||
# upgradeWindowForModel above.
|
||||
dates = upgradeWindowForModel.${machineModel} or "04:00";
|
||||
dates = "04:00"; # daily at 4am
|
||||
allowReboot = false;
|
||||
};
|
||||
|
||||
# Minimal env template (aiolabs/bitspire#70 remnant hygiene).
|
||||
# Seed ONLY image-baked, non-maskable values. Everything else the
|
||||
# ATM needs comes from the pairing SEED (relay, lnbits_npub, bunker)
|
||||
# or from LNbits over the transport (operator pubkey, fee config) —
|
||||
# so we must NOT pre-seed those keys. A present-but-empty
|
||||
# VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY / VITE_OPERATOR_PUBKEYS
|
||||
# is a masking hazard: env WINS over the seed, and this activation
|
||||
# only writes when .env is ABSENT, so any value written at first
|
||||
# boot is frozen for the life of the disk. Leaving the keys out
|
||||
# entirely lets the seed/transport be the sole source.
|
||||
# Env template — runtime secrets provisioned via provision-atm.sh.
|
||||
# Identity fields are intentionally empty so a fresh disk image
|
||||
# boots cleanly into the "needs provisioning" state; provision-
|
||||
# atm.sh SSHes in and overwrites with real values.
|
||||
#
|
||||
# VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY are emitted ONLY when
|
||||
# the operator deliberately pins them via the Nix options (non-empty
|
||||
# default ""), which is an explicit override that wins over the seed.
|
||||
# VITE_RELAY_URL seeds from `config.services.bitspire.relayUrl`
|
||||
# so the NixOS module's `relayUrl` option becomes the default
|
||||
# without losing the operator's ability to override via .env
|
||||
# (edit the file or re-run provision-atm.sh).
|
||||
system.activationScripts.bitspire-env = ''
|
||||
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_SPIRE_SEED=
|
||||
cp ${pkgs.writeText "bitspire-env-default" ''
|
||||
VITE_RELAY_URL=${config.services.bitspire.relayUrl}
|
||||
VITE_LNBITS_SERVER_PUBKEY=
|
||||
VITE_ATM_PRIVATE_KEY=
|
||||
VITE_APP_ID=
|
||||
VITE_OPERATOR_PUBKEYS=
|
||||
VITE_LAMASSU_MACHINE_MODEL=${machineModel}
|
||||
VITE_LAMASSU_FIAT_CODE=${fiatCode}
|
||||
ELECTRON_FORCE_PROD=1
|
||||
DISPLAY=:0
|
||||
# Uncomment to change Electron's GPU flags without a
|
||||
# rebuild, then `systemctl restart bitspire`. An empty
|
||||
# value means full GPU acceleration; the line being absent
|
||||
# means the shipped default (GPU and software rasterizer
|
||||
# both off). Commented rather than set, because a present
|
||||
# -but-empty value here would silently enable the GPU on
|
||||
# every machine that regenerates its .env.
|
||||
# BITSPIRE_ELECTRON_GPU_FLAGS=
|
||||
'' + pkgs.lib.optionalString (config.services.bitspire.relayUrl != "") ''
|
||||
VITE_RELAY_URL=${config.services.bitspire.relayUrl}
|
||||
'' + pkgs.lib.optionalString (config.services.bitspire.lnbitsServerPubkey != "") ''
|
||||
VITE_LNBITS_SERVER_PUBKEY=${config.services.bitspire.lnbitsServerPubkey}
|
||||
'')} /var/lib/bitspire/.env
|
||||
''} /var/lib/bitspire/.env
|
||||
chmod 600 /var/lib/bitspire/.env
|
||||
chown bitspire:bitspire /var/lib/bitspire/.env
|
||||
fi
|
||||
|
|
@ -362,7 +218,7 @@
|
|||
serviceConfig = {
|
||||
EnvironmentFile = lib.mkForce "/var/lib/bitspire/.env";
|
||||
Environment = "LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib";
|
||||
ExecStart = lib.mkForce "${mkKioskLauncher machineModel atm-app}";
|
||||
ExecStart = lib.mkForce "${pkgs-unstable.electron}/bin/electron --no-sandbox --disable-gpu-sandbox --disable-gpu --disable-software-rasterizer --enable-logging ${atm-app}";
|
||||
MemoryMax = lib.mkForce "1G";
|
||||
NoNewPrivileges = lib.mkForce false;
|
||||
ProtectSystem = lib.mkForce false;
|
||||
|
|
@ -399,134 +255,6 @@
|
|||
})
|
||||
];
|
||||
};
|
||||
|
||||
# Module that turns an <model>-installed config into the one a dd'd USB
|
||||
# stick actually runs. Shared by every *-usb variant so the USB-boot
|
||||
# hazards are solved once:
|
||||
# - distinct fs labels (nixos-usb / ESP-USB) so stage-1 can't latch an
|
||||
# internal drive that already holds a generic nixos/ESP-labelled install;
|
||||
# - nofail /boot: the firmware already loaded the bootloader before Linux;
|
||||
# without nofail a slow/late ESP-USB enumeration (BOT is slower than UAS)
|
||||
# blows past systemd's 90s device-timeout into emergency mode with root
|
||||
# locked — a dead end. nofail + short timeout lets the already-mounted
|
||||
# root carry the boot; /boot mounts if/when it shows;
|
||||
# - NO growPartition/autoResize: sfdisk rewriting the partition table on
|
||||
# first boot is the single most bus-stressing write, and flaky USB
|
||||
# bridges drop off the bus mid-rewrite (sfdisk wedges in D-state and
|
||||
# ESP-USB vanishes with the device). Persistent state is a few MB and
|
||||
# the image ships ~2GB free. The internal-disk images keep it;
|
||||
# - autoUpgrade off: no scheduled nix-store churn or bootloader writes on
|
||||
# the stick. Updates go in-place via `nix copy` + switch-to-configuration
|
||||
# against the named <model>-usb config (preserves pairing + /var/lib).
|
||||
usbBootModule = { lib, ... }: {
|
||||
fileSystems."/".device = lib.mkForce "/dev/disk/by-label/nixos-usb";
|
||||
fileSystems."/boot".device = lib.mkForce "/dev/disk/by-label/ESP-USB";
|
||||
fileSystems."/boot".options = [ "nofail" "x-systemd.device-timeout=10s" ];
|
||||
system.autoUpgrade.enable = lib.mkForce false;
|
||||
};
|
||||
|
||||
# Bus hardening that makes a USB stick a reliable boot medium: keep the
|
||||
# flash drive off the flaky UAS driver (many bridges advertise UAS then
|
||||
# drop off the bus under sustained write load — "device offline error,
|
||||
# dev sdb"), and stop USB autosuspend cutting power mid-I/O. douro.nix
|
||||
# and batm3.nix carry this in their hardware files because those machines
|
||||
# boot from USB exclusively; hardware/upboard.nix is SHARED with sintra's
|
||||
# eMMC install, so for the UP Board models it is scoped to the -usb
|
||||
# config here rather than changing a production machine's cmdline.
|
||||
usbBusHardening = {
|
||||
boot.blacklistedKernelModules = [ "uas" ];
|
||||
boot.kernelParams = [ "usbcore.autosuspend=-1" ];
|
||||
};
|
||||
|
||||
# Aaeon UP Board firmware (tejo, sintra) USB-boots in Legacy/BIOS mode —
|
||||
# it boots the live ISO via its isolinux (BIOS) El Torito image, not the
|
||||
# UEFI ESP. systemd-boot is UEFI-only, so a dd'd systemd-boot image is not
|
||||
# recognised as bootable at all. Switch the UP Board USB configs to GRUB
|
||||
# with BOTH BIOS (MBR + bios_grub partition, via mkUsbDiskImage's
|
||||
# partitionTableType = "hybrid") and UEFI (removable
|
||||
# /EFI/BOOT/BOOTX64.EFI) — mirroring the live ISO's dual boot — so the
|
||||
# stick boots on Legacy and UEFI alike. Scoped to the USB configs; the
|
||||
# eMMC installs keep systemd-boot.
|
||||
#
|
||||
# devices = [ "nodev" ] here, NOT the image's disk. This config is also
|
||||
# what in-place updates (`nix copy` + switch-to-configuration) run against
|
||||
# on a LIVE stick, where the build VM's /dev/vda does not exist and a BIOS
|
||||
# grub-install against it would fail the switch. "nodev" regenerates
|
||||
# grub.cfg and skips the MBR write, which is the correct behaviour for an
|
||||
# update: GRUB's embedded core.img reads grub.cfg off the partition, so
|
||||
# the MBR stage never needs rewriting per generation. mkUsbDiskImage's
|
||||
# grubBiosDevice overrides this for the image build, where the BIOS stage
|
||||
# genuinely has to be written.
|
||||
usbGrubHybridModule = { lib, ... }: {
|
||||
boot.loader.systemd-boot.enable = lib.mkForce false;
|
||||
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
|
||||
boot.loader.grub = {
|
||||
enable = lib.mkForce true;
|
||||
efiSupport = true;
|
||||
efiInstallAsRemovable = true;
|
||||
# Plain definition, NOT mkForce: grubBiosDevice overrides it with
|
||||
# mkForce, and two mkForce list definitions would merge (both
|
||||
# priority 50) into [ "/dev/vda" "nodev" ] instead of replacing.
|
||||
# Nothing else in the module stack defines grub.devices.
|
||||
devices = [ "nodev" ];
|
||||
};
|
||||
};
|
||||
|
||||
# dd-able USB image of a <model>-usb config. make-disk-image gives the
|
||||
# ext4 root the nixos-usb label directly (-L) but hardcodes the ESP FAT
|
||||
# label to "ESP", so the volume is relabelled to ESP-USB afterwards —
|
||||
# volume label only; bootloader files are untouched and UEFI loads
|
||||
# /EFI/BOOT/BOOTX64.EFI regardless.
|
||||
#
|
||||
# partitionTableType: "efi" (GPT + ESP, systemd-boot) for batm3 and douro,
|
||||
# whose firmware UEFI-USB-boots fine via that removable fallback;
|
||||
# "hybrid" (GPT + bios_grub + ESP) for the UP Board models, paired with
|
||||
# usbGrubHybridModule. In BOTH layouts the ESP is partition 1 — the hybrid
|
||||
# table creates the ESP first and the bios_grub partition second — so the
|
||||
# parted/mlabel relabel below is layout-independent.
|
||||
#
|
||||
# grubBiosDevice: the build VM's disk, for hybrid images only. GRUB must
|
||||
# write its BIOS stage to that disk's MBR at image-build time, while the
|
||||
# config itself says "nodev" so in-place updates on a live stick work;
|
||||
# see usbGrubHybridModule.
|
||||
mkUsbDiskImage =
|
||||
{ machineModel
|
||||
, usbConfig
|
||||
, partitionTableType ? "efi"
|
||||
, grubBiosDevice ? null
|
||||
}:
|
||||
let
|
||||
imageConfig =
|
||||
if grubBiosDevice == null then
|
||||
usbConfig
|
||||
else
|
||||
usbConfig.extendModules {
|
||||
modules = [
|
||||
({ lib, ... }: {
|
||||
boot.loader.grub.devices = lib.mkForce [ grubBiosDevice ];
|
||||
})
|
||||
];
|
||||
};
|
||||
baseImage = import (nixpkgs + "/nixos/lib/make-disk-image.nix") {
|
||||
inherit pkgs lib partitionTableType;
|
||||
config = imageConfig.config;
|
||||
format = "raw";
|
||||
diskSize = "auto";
|
||||
label = "nixos-usb"; # ext4 root label (make-disk-image -L)
|
||||
};
|
||||
in
|
||||
pkgs.runCommand "nixos-disk-image-${machineModel}-usb"
|
||||
{ nativeBuildInputs = [ pkgs.parted pkgs.mtools ]; }
|
||||
''
|
||||
mkdir -p $out
|
||||
cp --sparse=always ${baseImage}/nixos.img $out/nixos.img
|
||||
chmod +w $out/nixos.img
|
||||
espStart=$(parted -sm "$out/nixos.img" unit B print | awk -F: '$1==1 {gsub("B","",$2); print $2}')
|
||||
echo "ESP partition starts at byte $espStart — relabelling to ESP-USB"
|
||||
export MTOOLS_SKIP_CHECK=1
|
||||
mlabel -i "$out/nixos.img@@$espStart" ::ESP-USB
|
||||
printf 'verify ESP label: '; mlabel -i "$out/nixos.img@@$espStart" -s :: || true
|
||||
'';
|
||||
in
|
||||
{
|
||||
# ── NixOS Configurations (top-level, not per-system) ──────────
|
||||
|
|
@ -538,12 +266,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;
|
||||
|
|
@ -552,42 +286,6 @@
|
|||
# at ttyJ5, dispenser at ttyJ7 layout) — reuse the same hw module.
|
||||
sintra-installed = mkInstalledConfig "sintra" ./deploy/nixos/hardware/upboard.nix;
|
||||
batm3-installed = mkInstalledConfig "batm3" ./deploy/nixos/hardware/batm3.nix;
|
||||
|
||||
# USB-bootable variants of <model>-installed (see usbBootModule for
|
||||
# what changes). These are the configs a flashed stick actually runs.
|
||||
# Exposed as named configs (not just inline in the disk-image targets)
|
||||
# so their system closures can be built here and deployed in-place with
|
||||
# `nix copy` + `switch-to-configuration` — updating the app on a running
|
||||
# stick WITHOUT reflashing (preserves pairing + /var/lib state).
|
||||
# disk-image-<model>-usb builds its filesystem image from the same config.
|
||||
batm3-usb = self.nixosConfigurations.batm3-installed.extendModules {
|
||||
modules = [ usbBootModule ];
|
||||
};
|
||||
# douro: the production unit's internal drive is not NixOS, so the
|
||||
# label disambiguation is moot today, but the nofail /boot and the
|
||||
# uas/autosuspend hardening in douro.nix are what make a stick a
|
||||
# reliable boot medium on the Bay Trail box. Same in-place update flow.
|
||||
douro-usb = self.nixosConfigurations.douro-installed.extendModules {
|
||||
modules = [ usbBootModule ];
|
||||
};
|
||||
# UP Board models get the same run-from-USB shape plus two things the
|
||||
# Bay Trail / OptiPlex boxes don't need: GRUB on a hybrid table, because
|
||||
# the Aaeon firmware USB-boots in Legacy/BIOS mode (usbGrubHybridModule),
|
||||
# and the uas/autosuspend hardening from here instead of
|
||||
# hardware/upboard.nix, which sintra's eMMC install also reads.
|
||||
#
|
||||
# tejo: the unit still runs its factory Debian (ubilinux4) on internal
|
||||
# storage, so the stick has to BE the system, same as douro. Its
|
||||
# internal install is not NixOS and carries no nixos/ESP labels, which
|
||||
# makes the label disambiguation moot there today — but the hardened
|
||||
# /boot and the bus settings are what make a stick a reliable boot
|
||||
# medium, so it takes the identical module set as sintra.
|
||||
tejo-usb = self.nixosConfigurations.tejo-installed.extendModules {
|
||||
modules = [ usbBootModule usbBusHardening usbGrubHybridModule ];
|
||||
};
|
||||
sintra-usb = self.nixosConfigurations.sintra-installed.extendModules {
|
||||
modules = [ usbBootModule usbBusHardening usbGrubHybridModule ];
|
||||
};
|
||||
};
|
||||
|
||||
# ── Standalone NixOS module ───────────────────────────────────
|
||||
|
|
@ -627,69 +325,6 @@
|
|||
diskSize = "auto";
|
||||
};
|
||||
|
||||
# BATM3 (OptiPlex 9030 AIO board-swap) installed image, dd-able to
|
||||
# its SATA drive. Unlike the douro/sintra images, this one grows
|
||||
# itself: growPartition expands the root partition to fill whatever
|
||||
# drive it lands on (16GB today) at first boot and autoResize
|
||||
# stretches the ext4 to match — no manual parted/resize2fs step
|
||||
# after flashing, and all the drive's headroom is available to the
|
||||
# nix store from day one (cf. #55). Image-only override: once
|
||||
# grown, subsequent nixos-rebuilds against plain batm3-installed
|
||||
# are unaffected.
|
||||
disk-image-batm3 =
|
||||
let
|
||||
cfg = self.nixosConfigurations.batm3-installed.extendModules {
|
||||
modules = [
|
||||
{
|
||||
boot.growPartition = true;
|
||||
fileSystems."/".autoResize = true;
|
||||
}
|
||||
];
|
||||
};
|
||||
in
|
||||
import (nixpkgs + "/nixos/lib/make-disk-image.nix") {
|
||||
inherit pkgs lib;
|
||||
config = cfg.config;
|
||||
format = "raw";
|
||||
partitionTableType = "efi";
|
||||
diskSize = "auto";
|
||||
};
|
||||
|
||||
# USB-bootable images (see usbBootModule / mkUsbDiskImage in the let
|
||||
# block). Flash with dd or balenaEtcher, boot the stick, done — no
|
||||
# installer step. The plain disk-image-<model> reuses the generic
|
||||
# nixos/ESP labels, so a stick carrying it, booted on a machine whose
|
||||
# internal drive ALREADY holds a nixos/ESP-labelled install, makes
|
||||
# stage-1's by-label/nixos resolve to the internal drive instead of the
|
||||
# stick — the stage-2 init path baked into the USB's boot entry isn't on
|
||||
# that root, so stage 1 aborts. These variants can't hit that.
|
||||
disk-image-batm3-usb = mkUsbDiskImage {
|
||||
machineModel = "batm3";
|
||||
usbConfig = self.nixosConfigurations.batm3-usb;
|
||||
};
|
||||
disk-image-douro-usb = mkUsbDiskImage {
|
||||
machineModel = "douro";
|
||||
usbConfig = self.nixosConfigurations.douro-usb;
|
||||
};
|
||||
|
||||
# UP Board models: hybrid table + GRUB, so one stick boots on the Aaeon
|
||||
# firmware's Legacy/BIOS USB path as well as UEFI. Distinct labels also
|
||||
# matter more here than on douro — a sintra's eMMC already holds a
|
||||
# nixos/ESP-labelled install, and stage-1 would otherwise race the two
|
||||
# roots and likely mount the eMMC.
|
||||
disk-image-tejo-usb = mkUsbDiskImage {
|
||||
machineModel = "tejo";
|
||||
usbConfig = self.nixosConfigurations.tejo-usb;
|
||||
partitionTableType = "hybrid";
|
||||
grubBiosDevice = "/dev/vda";
|
||||
};
|
||||
disk-image-sintra-usb = mkUsbDiskImage {
|
||||
machineModel = "sintra";
|
||||
usbConfig = self.nixosConfigurations.sintra-usb;
|
||||
partitionTableType = "hybrid";
|
||||
grubBiosDevice = "/dev/vda";
|
||||
};
|
||||
|
||||
# Backwards compat
|
||||
iso = self.nixosConfigurations.douro.config.system.build.isoImage;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Pure Nix derivation for the bitSpire ATM Electron app.
|
||||
# Pure Nix derivation for the Lamassu ATM Electron app.
|
||||
#
|
||||
# Uses fetchPnpmDeps + pnpmConfigHook to build entirely inside the Nix sandbox,
|
||||
# eliminating the need for --impure or a local pnpm install.
|
||||
|
|
@ -38,7 +38,7 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
|||
inherit (finalAttrs) pname version src pnpmWorkspaces;
|
||||
inherit pnpm;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-HIMjQx0REauknZkIU50M4KMU0lyNQ41UG6AUM/3Hn54=";
|
||||
hash = "sha256-Jv5p62E40DtCSZvN/+LTzkhRYJBTyyUOVSBlQyxzcEw=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = [
|
||||
|
|
@ -57,18 +57,13 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
|||
pkgs.sqlite.dev # better-sqlite3
|
||||
pkgs.libudev-zero # serialport
|
||||
pkgs.stdenv.cc.cc.lib # libstdc++
|
||||
# @pokusew/pcsclite (nfc-pcsc): the `lib` output carries libpcsclite.so so
|
||||
# autoPatchelf wires it into the .node RPATH at runtime. Compile/link paths
|
||||
# are injected via CPATH/LIBRARY_PATH in buildPhase (its binding.gyp
|
||||
# hardcodes Debian /usr paths instead of using pkg-config).
|
||||
pkgs.pcsclite.lib
|
||||
];
|
||||
|
||||
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 = ''
|
||||
|
|
@ -88,19 +83,6 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
|||
--arch=x64
|
||||
popd
|
||||
|
||||
# @pokusew/pcsclite (nfc-pcsc's native addon) — also V8 C++ API, so it too
|
||||
# must be rebuilt against Electron's headers. Its binding.gyp hardcodes
|
||||
# /usr/include/PCSC + /usr/lib, so point the compiler/linker at nixpkgs'
|
||||
# pcsclite explicitly (winscard.h lives under include/PCSC).
|
||||
echo "=== Rebuilding @pokusew/pcsclite against Electron ${electron.version} headers ==="
|
||||
pushd node_modules/.pnpm/@pokusew+pcsclite@*/node_modules/@pokusew/pcsclite
|
||||
CPATH="${pkgs.pcsclite.dev}/include/PCSC''${CPATH:+:$CPATH}" \
|
||||
LIBRARY_PATH="${pkgs.pcsclite.lib}/lib''${LIBRARY_PATH:+:$LIBRARY_PATH}" \
|
||||
HOME=$TMPDIR ${nodejs}/bin/npx --yes node-gyp rebuild \
|
||||
--nodedir="$electron_nodedir" \
|
||||
--arch=x64
|
||||
popd
|
||||
|
||||
# Build the Electron app (turbo builds all workspace deps + app)
|
||||
pnpm --filter="@bitSpire/machine..." build
|
||||
|
||||
|
|
@ -113,7 +95,7 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
|||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/node_modules/{@lamassu,@serialport,@pokusew}
|
||||
mkdir -p $out/node_modules/{@lamassu,@serialport}
|
||||
|
||||
# Helper: find a package dir inside the pnpm virtual store.
|
||||
# pnpm store dirs look like: node_modules/.pnpm/<name>@<ver>[_<peer-suffix>]/node_modules/<name>
|
||||
|
|
@ -150,12 +132,6 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
|||
copy_pnpm_pkg bindings $out/node_modules/bindings
|
||||
copy_pnpm_pkg file-uri-to-path $out/node_modules/file-uri-to-path
|
||||
|
||||
# nfc-pcsc + @pokusew/pcsclite (Bolt Card reader). The compiled
|
||||
# pcsclite.node (from the rebuild above) rides along in the package dir and
|
||||
# loads via `bindings` (already copied). autoPatchelf wires libpcsclite.
|
||||
copy_pnpm_pkg nfc-pcsc $out/node_modules/nfc-pcsc
|
||||
copy_pnpm_pkg @pokusew/pcsclite $out/node_modules/@pokusew/pcsclite
|
||||
|
||||
# @bitSpire/hal (workspace package, dynamically imported for hardware access)
|
||||
mkdir -p $out/node_modules/@bitSpire/hal/dist
|
||||
cp -rL packages/hal/dist/* $out/node_modules/@bitSpire/hal/dist/
|
||||
|
|
@ -190,33 +166,6 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
|||
copy_pnpm_pkg "@serialport/$parser" "$out/node_modules/@serialport/$parser"
|
||||
done
|
||||
|
||||
# ── Strip node-gyp build detritus ──────────────────────────────────
|
||||
# node-gyp leaves its scaffolding beside the compiled addons, and several
|
||||
# of those files embed absolute /nix/store paths to the BUILD toolchain:
|
||||
# build/node_gyp_bins/python3 an ELF copy of python3 with an RPATH
|
||||
# build/config.gypi python3 + nodejs + npm paths
|
||||
# build/Release/.deps/**.o.d pcsclite.dev include paths
|
||||
# Nix scans $out for store hashes, so each becomes a RUNTIME reference and
|
||||
# drags python311 + nodejs + npm + pcsclite.dev (~212MB of closure) onto
|
||||
# every ATM. Nothing reads them at runtime — only build/Release/*.node is
|
||||
# loaded, via `bindings` / `node-gyp-build`. Keep the addons, drop the
|
||||
# scaffolding. obj.target/*.node is node-gyp's pre-copy of the same addon;
|
||||
# the loaded one at build/Release/*.node is untouched.
|
||||
find $out/node_modules -type d \
|
||||
\( -name node_gyp_bins -o -name .deps -o -name obj.target -o -name obj \) \
|
||||
-prune -exec rm -rf {} +
|
||||
find $out/node_modules -path '*/build/*' -type f \
|
||||
\( -name config.gypi -o -name '*.mk' -o -name Makefile \
|
||||
-o -name binding.Makefile -o -name '*.a' -o -name '*.o' \) -delete
|
||||
|
||||
# pnpm/node-gyp rewrote these CLI helpers' shebangs to the build nodejs,
|
||||
# which alone retains the full nodejs (not the slim one Electron needs).
|
||||
# They are build-time utilities — the runtime entry of each package
|
||||
# (index.js) carries no shebang — so point them at PATH instead of
|
||||
# deleting files a package might still require.
|
||||
find $out/node_modules -type f -name '*.js' \
|
||||
-exec sed -i '1s|^#!/nix/store/[^ ]*/bin/node$|#!/usr/bin/env node|' {} +
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue