diff --git a/.claude/skills/docs.md b/.claude/skills/docs.md index e9909ba..a9052d5 100644 --- a/.claude/skills/docs.md +++ b/.claude/skills/docs.md @@ -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 diff --git a/.claude/skills/hal-check.md b/.claude/skills/hal-check.md index 9f38232..4f28482 100644 --- a/.claude/skills/hal-check.md +++ b/.claude/skills/hal-check.md @@ -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. diff --git a/.claude/skills/nostr-check.md b/.claude/skills/nostr-check.md index 63d8bbb..aad13fb 100644 --- a/.claude/skills/nostr-check.md +++ b/.claude/skills/nostr-check.md @@ -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 | diff --git a/.devenv.flake.nix b/.devenv.flake.nix new file mode 100644 index 0000000..aeeee91 --- /dev/null +++ b/.devenv.flake.nix @@ -0,0 +1,513 @@ +{ + inputs = + let + vars = { + version = "1.11.2"; + system = "x86_64-linux"; + devenv_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next"; + project_input_ref = "path:/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next"; + devenv_dotfile = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next/.devenv"; + devenv_dotfile_path = ./.devenv; + devenv_tmpdir = "/run/user/1000"; + devenv_runtime = "/run/user/1000/devenv-f4ba770"; + devenv_istesting = false; + devenv_direnvrc_latest_version = 1; + container_name = null; + active_profiles = [ + ]; + hostname = "gizmo"; + username = "padreug"; + git_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages"; + secretspec = null; +}; + in + { + git-hooks.url = "github:cachix/git-hooks.nix"; + git-hooks.inputs.nixpkgs.follows = "nixpkgs"; + pre-commit-hooks.follows = "git-hooks"; + nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling"; + devenv.url = "github:cachix/devenv?dir=src/modules"; + } + // ( + if builtins.pathExists (vars.devenv_dotfile_path + "/flake.json") then + builtins.fromJSON (builtins.readFile (vars.devenv_dotfile_path + "/flake.json")) + else + { } + ); + + outputs = + { nixpkgs, ... }@inputs: + let + vars = { + version = "1.11.2"; + system = "x86_64-linux"; + devenv_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next"; + project_input_ref = "path:/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next"; + devenv_dotfile = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next/.devenv"; + devenv_dotfile_path = ./.devenv; + devenv_tmpdir = "/run/user/1000"; + devenv_runtime = "/run/user/1000/devenv-f4ba770"; + devenv_istesting = false; + devenv_direnvrc_latest_version = 1; + container_name = null; + active_profiles = [ + ]; + hostname = "gizmo"; + username = "padreug"; + git_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages"; + secretspec = null; +}; + devenv = + if builtins.pathExists (vars.devenv_dotfile_path + "/devenv.json") then + builtins.fromJSON (builtins.readFile (vars.devenv_dotfile_path + "/devenv.json")) + else + { }; + + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + + # Function to create devenv configuration for a specific system with profiles support + mkDevenvForSystem = + targetSystem: + let + getOverlays = + inputName: inputAttrs: + map ( + overlay: + let + input = + inputs.${inputName} or (throw "No such input `${inputName}` while trying to configure overlays."); + in + input.overlays.${overlay} + or (throw "Input `${inputName}` has no overlay called `${overlay}`. Supported overlays: ${nixpkgs.lib.concatStringsSep ", " (builtins.attrNames input.overlays)}") + ) inputAttrs.overlays or [ ]; + overlays = nixpkgs.lib.flatten (nixpkgs.lib.mapAttrsToList getOverlays (devenv.inputs or { })); + permittedUnfreePackages = + devenv.nixpkgs.per-platform."${targetSystem}".permittedUnfreePackages + or devenv.nixpkgs.permittedUnfreePackages or [ ]; + pkgs = import nixpkgs { + system = targetSystem; + config = { + allowUnfree = + devenv.nixpkgs.per-platform."${targetSystem}".allowUnfree or devenv.nixpkgs.allowUnfree + or devenv.allowUnfree or false; + allowBroken = + devenv.nixpkgs.per-platform."${targetSystem}".allowBroken or devenv.nixpkgs.allowBroken + or devenv.allowBroken or false; + cudaSupport = + devenv.nixpkgs.per-platform."${targetSystem}".cudaSupport or devenv.nixpkgs.cudaSupport or false; + cudaCapabilities = + devenv.nixpkgs.per-platform."${targetSystem}".cudaCapabilities or devenv.nixpkgs.cudaCapabilities + or [ ]; + permittedInsecurePackages = + devenv.nixpkgs.per-platform."${targetSystem}".permittedInsecurePackages + or devenv.nixpkgs.permittedInsecurePackages or devenv.permittedInsecurePackages or [ ]; + allowUnfreePredicate = + if (permittedUnfreePackages != [ ]) then + (pkg: builtins.elem (nixpkgs.lib.getName pkg) permittedUnfreePackages) + else + (_: false); + }; + inherit overlays; + }; + inherit (pkgs) lib; + importModule = + path: + if lib.hasPrefix "./" path then + if lib.hasSuffix ".nix" path then + ./. + (builtins.substring 1 255 path) + else + ./. + (builtins.substring 1 255 path) + "/devenv.nix" + else if lib.hasPrefix "../" path then + # For parent directory paths, concatenate with /. + # ./. refers to the directory containing this file (project root) + # So ./. + "/../shared" = /../shared + if lib.hasSuffix ".nix" path then ./. + "/${path}" else ./. + "/${path}/devenv.nix" + else + let + paths = lib.splitString "/" path; + name = builtins.head paths; + input = inputs.${name} or (throw "Unknown input ${name}"); + subpath = "/${lib.concatStringsSep "/" (builtins.tail paths)}"; + devenvpath = "${input}" + subpath; + devenvdefaultpath = devenvpath + "/devenv.nix"; + in + if lib.hasSuffix ".nix" devenvpath then + devenvpath + else if builtins.pathExists devenvdefaultpath then + devenvdefaultpath + else + throw (devenvdefaultpath + " file does not exist for input ${name}."); + + # Phase 1: Base evaluation to extract profile definitions + baseProject = pkgs.lib.evalModules { + specialArgs = inputs // { + inherit inputs; + }; + modules = [ + ( + { config, ... }: + { + _module.args.pkgs = pkgs.appendOverlays (config.overlays or [ ]); + } + ) + (inputs.devenv.modules + /top-level.nix) + ( + { options, ... }: + { + config.devenv = lib.mkMerge [ + { + cliVersion = vars.version; + root = vars.devenv_root; + dotfile = vars.devenv_dotfile; + } + (pkgs.lib.optionalAttrs (builtins.hasAttr "tmpdir" options.devenv) { + tmpdir = vars.devenv_tmpdir; + }) + (pkgs.lib.optionalAttrs (builtins.hasAttr "isTesting" options.devenv) { + isTesting = vars.devenv_istesting; + }) + (pkgs.lib.optionalAttrs (builtins.hasAttr "runtime" options.devenv) { + runtime = vars.devenv_runtime; + }) + (pkgs.lib.optionalAttrs (builtins.hasAttr "direnvrcLatestVersion" options.devenv) { + direnvrcLatestVersion = vars.devenv_direnvrc_latest_version; + }) + ]; + } + ) + ( + { options, ... }: + { + config = lib.mkMerge [ + (pkgs.lib.optionalAttrs (builtins.hasAttr "git" options) { + git.root = vars.git_root; + }) + ]; + } + ) + (pkgs.lib.optionalAttrs (vars.container_name != null) { + container.isBuilding = pkgs.lib.mkForce true; + containers.${vars.container_name}.isBuilding = true; + }) + ] + ++ (map importModule (devenv.imports or [ ])) + ++ [ + (if builtins.pathExists ./devenv.nix then ./devenv.nix else { }) + (devenv.devenv or { }) + (if builtins.pathExists ./devenv.local.nix then ./devenv.local.nix else { }) + ( + if builtins.pathExists (vars.devenv_dotfile_path + "/cli-options.nix") then + import (vars.devenv_dotfile_path + "/cli-options.nix") + else + { } + ) + ]; + }; + + # Phase 2: Extract and apply profiles using extendModules with priority overrides + project = + let + # Build ordered list of profile names: hostname -> user -> manual + manualProfiles = vars.active_profiles; + currentHostname = vars.hostname; + currentUsername = vars.username; + hostnameProfiles = lib.optional ( + currentHostname != "" + && builtins.hasAttr currentHostname (baseProject.config.profiles.hostname or { }) + ) "hostname.${currentHostname}"; + userProfiles = lib.optional ( + currentUsername != "" && builtins.hasAttr currentUsername (baseProject.config.profiles.user or { }) + ) "user.${currentUsername}"; + + # Ordered list of profiles to activate + orderedProfiles = hostnameProfiles ++ userProfiles ++ manualProfiles; + + # Resolve profile extends with cycle detection + resolveProfileExtends = + profileName: visited: + if builtins.elem profileName visited then + throw "Circular dependency detected in profile extends: ${lib.concatStringsSep " -> " visited} -> ${profileName}" + else + let + profile = getProfileConfig profileName; + extends = profile.extends or [ ]; + newVisited = visited ++ [ profileName ]; + extendedProfiles = lib.flatten (map (name: resolveProfileExtends name newVisited) extends); + in + extendedProfiles ++ [ profileName ]; + + # Get profile configuration by name from baseProject + getProfileConfig = + profileName: + if lib.hasPrefix "hostname." profileName then + let + name = lib.removePrefix "hostname." profileName; + in + baseProject.config.profiles.hostname.${name} + else if lib.hasPrefix "user." profileName then + let + name = lib.removePrefix "user." profileName; + in + baseProject.config.profiles.user.${name} + else + let + availableProfiles = builtins.attrNames (baseProject.config.profiles or { }); + hostnameProfiles = map (n: "hostname.${n}") ( + builtins.attrNames (baseProject.config.profiles.hostname or { }) + ); + userProfiles = map (n: "user.${n}") (builtins.attrNames (baseProject.config.profiles.user or { })); + allAvailableProfiles = availableProfiles ++ hostnameProfiles ++ userProfiles; + in + baseProject.config.profiles.${profileName} + or (throw "Profile '${profileName}' not found. Available profiles: ${lib.concatStringsSep ", " allAvailableProfiles}"); + + # Fold over ordered profiles to build final list with extends + expandedProfiles = lib.foldl' ( + acc: profileName: + let + allProfileNames = resolveProfileExtends profileName [ ]; + in + acc ++ allProfileNames + ) [ ] orderedProfiles; + + # Map over expanded profiles and apply priorities + allPrioritizedModules = lib.imap0 ( + index: profileName: + let + # Decrement priority for each profile (lower = higher precedence) + # Start with the next lowest priority after the default priority for values (100) + profilePriority = (lib.modules.defaultOverridePriority - 1) - index; + profileConfig = getProfileConfig profileName; + + # Check if an option type needs explicit override to resolve conflicts + # Only apply overrides to LEAF values (scalars), not collection types that can merge + typeNeedsOverride = + type: + if type == null then + false + else + let + typeName = type.name or type._type or ""; + + # True leaf types that need priority resolution when they conflict + isLeafType = builtins.elem typeName [ + "str" + "int" + "bool" + "enum" + "path" + "package" + "float" + "anything" + ]; + in + if isLeafType then + true + else if typeName == "nullOr" then + # For nullOr, check the wrapped type recursively + let + innerType = + type.elemType + or (if type ? nestedTypes && type.nestedTypes ? elemType then type.nestedTypes.elemType else null); + in + if innerType != null then typeNeedsOverride innerType else false + else + # Everything else (collections, submodules, etc.) should merge naturally + false; + + # Check if a config path needs explicit override + pathNeedsOverride = + optionPath: + let + # Try direct option first + directOption = lib.attrByPath optionPath null baseProject.options; + in + if directOption != null && lib.isOption directOption then + typeNeedsOverride directOption.type + else if optionPath != [ ] then + # Check parent for freeform type + let + parentPath = lib.init optionPath; + parentOption = lib.attrByPath parentPath null baseProject.options; + in + if parentOption != null && lib.isOption parentOption then + let + # Look for freeform type: + # 1. Standard location: type.freeformType (primary) + # 2. Nested location: type.nestedTypes.freeformType (evaluated form) + freeformType = parentOption.type.freeformType or parentOption.type.nestedTypes.freeformType or null; + elementType = + if freeformType ? elemType then + freeformType.elemType + else if freeformType ? nestedTypes && freeformType.nestedTypes ? elemType then + freeformType.nestedTypes.elemType + else + freeformType; + in + typeNeedsOverride elementType + else + false + else + false; + + # Support overriding both plain attrset modules and functions + applyModuleOverride = + config: + if builtins.isFunction config then + let + wrapper = args: applyOverrideRecursive (config args) [ ]; + in + lib.mirrorFunctionArgs config wrapper + else + applyOverrideRecursive config [ ]; + + # Apply overrides recursively based on option types + applyOverrideRecursive = + config: optionPath: + if lib.isAttrs config && config ? _type then + config # Don't touch values with existing type metadata + else if lib.isAttrs config then + lib.mapAttrs (name: value: applyOverrideRecursive value (optionPath ++ [ name ])) config + else if pathNeedsOverride optionPath then + lib.mkOverride profilePriority config + else + config; + + # Apply priority overrides recursively to the deferredModule imports structure + prioritizedConfig = ( + profileConfig.module + // { + imports = lib.map ( + importItem: + importItem + // { + imports = lib.map (nestedImport: applyModuleOverride nestedImport) (importItem.imports or [ ]); + } + ) (profileConfig.module.imports or [ ]); + } + ); + in + prioritizedConfig + ) expandedProfiles; + in + if allPrioritizedModules == [ ] then + baseProject + else + baseProject.extendModules { modules = allPrioritizedModules; }; + + config = project.config; + + options = pkgs.nixosOptionsDoc { + options = builtins.removeAttrs project.options [ "_module" ]; + warningsAreErrors = false; + # Unpack Nix types, e.g. literalExpression, mDoc. + transformOptions = + let + isDocType = + v: + builtins.elem v [ + "literalDocBook" + "literalExpression" + "literalMD" + "mdDoc" + ]; + in + lib.attrsets.mapAttrs ( + _: v: + if v ? _type && isDocType v._type then + v.text + else if v ? _type && v._type == "derivation" then + v.name + else + v + ); + }; + + # Recursively search for outputs in the config. + # This is used when not building a specific output by attrpath. + build = + options: config: + lib.concatMapAttrs ( + name: option: + if lib.isOption option then + let + typeName = option.type.name or ""; + in + if + builtins.elem typeName [ + "output" + "outputOf" + ] + then + { ${name} = config.${name}; } + else + { } + else if builtins.isAttrs option && !lib.isDerivation option then + let + v = build option config.${name}; + in + if v != { } then + { + ${name} = v; + } + else + { } + else + { } + ) options; + in + { + inherit + config + options + build + project + ; + shell = config.shell; + packages = { + optionsJSON = options.optionsJSON; + # deprecated + inherit (config) + info + procfileScript + procfileEnv + procfile + ; + ci = config.ciDerivation; + }; + }; + + # Generate per-system devenv configurations + perSystem = nixpkgs.lib.genAttrs systems mkDevenvForSystem; + + # Default devenv for the current system + currentSystemDevenv = perSystem.${vars.system}; + in + { + devShell = nixpkgs.lib.genAttrs systems (s: perSystem.${s}.shell); + packages = nixpkgs.lib.genAttrs systems (s: perSystem.${s}.packages); + + # Per-system devenv configurations + devenv = { + # Default devenv for the current system + inherit (currentSystemDevenv) + config + options + build + shell + packages + project + ; + # Per-system devenv configurations + inherit perSystem; + }; + + # Legacy build output + build = currentSystemDevenv.build currentSystemDevenv.options currentSystemDevenv.config; + }; +} diff --git a/.gitignore b/.gitignore index 3feb238..ae58eb9 100644 --- a/.gitignore +++ b/.gitignore @@ -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-* diff --git a/CLAUDE.md b/CLAUDE.md index 387e108..20ba0df 100644 --- a/CLAUDE.md +++ b/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 .#-installed --target-host --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:`) 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 diff --git a/README.md b/README.md index 38b34c5..fcd43b9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/machine/.env.example b/apps/machine/.env.example index 792fddd..73b6281 100644 --- a/apps/machine/.env.example +++ b/apps/machine/.env.example @@ -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: -# 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/ 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 diff --git a/apps/machine/electron/__tests__/state-store-bunker.test.ts b/apps/machine/electron/__tests__/state-store-bunker.test.ts deleted file mode 100644 index a668059..0000000 --- a/apps/machine/electron/__tests__/state-store-bunker.test.ts +++ /dev/null @@ -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() - }) -}) diff --git a/apps/machine/electron/__tests__/state-store-transactions.test.ts b/apps/machine/electron/__tests__/state-store-transactions.test.ts deleted file mode 100644 index 02dd87e..0000000 --- a/apps/machine/electron/__tests__/state-store-transactions.test.ts +++ /dev/null @@ -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 { - const out: Record = {} - 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) - }) -}) diff --git a/apps/machine/electron/boltcard-session.test.ts b/apps/machine/electron/boltcard-session.test.ts deleted file mode 100644 index 58e30f4..0000000 --- a/apps/machine/electron/boltcard-session.test.ts +++ /dev/null @@ -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' }) - }) -}) diff --git a/apps/machine/electron/boltcard-session.ts b/apps/machine/electron/boltcard-session.ts deleted file mode 100644 index a799178..0000000 --- a/apps/machine/electron/boltcard-session.ts +++ /dev/null @@ -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/?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/?p=&c=`; the session endpoint is its sibling - * `…/boltcards/api/v1/session/?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 => 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 { - 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, - }, - } -} diff --git a/apps/machine/electron/fund-atm.ts b/apps/machine/electron/fund-atm.ts index cd0f901..ce8b1e0 100644 --- a/apps/machine/electron/fund-atm.ts +++ b/apps/machine/electron/fund-atm.ts @@ -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] diff --git a/apps/machine/electron/hal-service.ts b/apps/machine/electron/hal-service.ts index 636d7f2..27508b0 100644 --- a/apps/machine/electron/hal-service.ts +++ b/apps/machine/electron/hal-service.ts @@ -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 { 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 { 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 { } }) - // 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 { }, 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 => { console.log('[HAL] Dispensing:', amounts) @@ -290,8 +227,6 @@ export async function initializeHal(config: HalConfig): Promise { 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 { 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 { 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 { } 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 { 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 { setCassettes: async (cassettes: CassetteConfig[]): Promise => { 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 { 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 () => { - validator?.disable() - validator?.lightOff() - await dispenser.close() - if (!validator) return - await new Promise((resolve) => { - validator?.close((err?: Error) => { - if (err) console.error('[HAL] Validator close error:', err) + return new Promise((resolve) => { + validator?.disable() + validator?.lightOff() + dispenser.close() + if (validator) { + validator.close((err?: Error) => { + if (err) console.error('[HAL] Validator close error:', err) + resolve() + }) + } else { resolve() - }) + } }) }, } diff --git a/apps/machine/electron/lnurl-pay.test.ts b/apps/machine/electron/lnurl-pay.test.ts deleted file mode 100644 index 4dbe150..0000000 --- a/apps/machine/electron/lnurl-pay.test.ts +++ /dev/null @@ -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.' }) - }) -}) diff --git a/apps/machine/electron/lnurl-pay.ts b/apps/machine/electron/lnurl-pay.ts deleted file mode 100644 index cb408e6..0000000 --- a/apps/machine/electron/lnurl-pay.ts +++ /dev/null @@ -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/?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=` → 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 { - 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/?p=&c=` (a withdraw voucher); - * the receive resolver is its sibling `…/boltcards/api/v1/pay/?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 { - 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 { - 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 { - const ctx: Ctx = { doFetch: opts.fetchImpl ?? fetch, timeoutMs: opts.timeoutMs ?? 15_000 } - return requestInvoice(step, amountMsat, ctx) -} diff --git a/apps/machine/electron/lnurl-withdraw.test.ts b/apps/machine/electron/lnurl-withdraw.test.ts deleted file mode 100644 index cceda64..0000000 --- a/apps/machine/electron/lnurl-withdraw.test.ts +++ /dev/null @@ -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', - }) - }) -}) diff --git a/apps/machine/electron/lnurl-withdraw.ts b/apps/machine/electron/lnurl-withdraw.ts deleted file mode 100644 index 35429ce..0000000 --- a/apps/machine/electron/lnurl-withdraw.ts +++ /dev/null @@ -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=` → 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 { - 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 { - 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 { - 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' } -} diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 68b9f88..59cbcd5 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -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 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).filter(([, v]) => typeof v === 'string') + Object.entries(dark as Record).filter( + ([, v]) => typeof v === 'string' + ) ) as Record 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 => { - 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 = { 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) { diff --git a/apps/machine/electron/nfc-service.test.ts b/apps/machine/electron/nfc-service.test.ts deleted file mode 100644 index f694758..0000000 --- a/apps/machine/electron/nfc-service.test.ts +++ /dev/null @@ -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() - }) -}) diff --git a/apps/machine/electron/nfc-service.ts b/apps/machine/electron/nfc-service.ts deleted file mode 100644 index b128321..0000000 --- a/apps/machine/electron/nfc-service.ts +++ /dev/null @@ -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, - fid: [number, number] -): Promise { - 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 -): Promise { - 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 - } - 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 -} diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index bc75046..836b98d 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -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 => ipcRenderer.invoke('state:get-last-known-config-created-at'), - getLastStatePublishedAt: (): Promise => - ipcRenderer.invoke('state:get-last-state-published-at'), - getCountsUncertainSince: (): Promise => - ipcRenderer.invoke('state:get-counts-uncertain-since'), - markCountsUncertain: (unixTimestamp: number): Promise => - ipcRenderer.invoke('state:mark-counts-uncertain', unixTimestamp), - // Cash-out hold (ADR-005 §5) - getCashOutHold: (): Promise => ipcRenderer.invoke('state:get-cash-out-hold'), - setCashOutHold: (hold: CashOutHold): Promise => - ipcRenderer.invoke('state:set-cash-out-hold', hold), - clearCashOutHold: (): Promise => ipcRenderer.invoke('state:clear-cash-out-hold'), - // Dispense-report outbox (ADR-005 §2) - pendingDispenseReports: (limit?: number): Promise => - ipcRenderer.invoke('state:pending-dispense-reports', limit), - ackDispenseReport: (txid: string): Promise => - ipcRenderer.invoke('state:ack-dispense-report', txid), - noteDispenseReportAttempt: (txid: string, error: string | null): Promise => - ipcRenderer.invoke('state:note-dispense-report-attempt', txid, error), - markStatePublished: (unixTimestamp: number): Promise => - ipcRenderer.invoke('state:mark-state-published', unixTimestamp), - - // Bunker binding persistence (aiolabs/bitspire#52) - saveBunkerBinding: (binding: BunkerBindingRecord): Promise => - ipcRenderer.invoke('state:save-bunker-binding', binding), - clearBunkerBinding: (): Promise => ipcRenderer.invoke('state:clear-bunker-binding'), - resetStatePublishWatermark: (): Promise => - ipcRenderer.invoke('state:reset-state-publish-watermark'), - resetForRepair: (): Promise => 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 => ipcRenderer.invoke('state:save-spire-seed', seed), - relaunchApp: (): Promise => ipcRenderer.invoke('app:relaunch'), - // Reload the renderer to re-attempt initialization (connectivity recovery). - recoverApp: (): Promise => 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 => - 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 => - ipcRenderer.invoke('state:get-applied-op-ids', limit), - getCassetteStateSeq: (): Promise => ipcRenderer.invoke('state:get-cassette-state-seq'), + getBootstrapPublishedAt: (): Promise => + ipcRenderer.invoke('state:get-bootstrap-published-at'), + markBootstrapPublished: (unixTimestamp: number): Promise => + ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp), + applyOperatorCassettesConfig: ( + payload: { + positions: Record + }, + 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 remediateTransaction: (txid: string, remediatedByTxid: string) => Promise getLastKnownConfigCreatedAt: () => Promise - getLastStatePublishedAt: () => Promise - getCountsUncertainSince: () => Promise - markCountsUncertain: (unixTimestamp: number) => Promise - getCashOutHold: () => Promise - setCashOutHold: (hold: CashOutHold) => Promise - clearCashOutHold: () => Promise - pendingDispenseReports: (limit?: number) => Promise - ackDispenseReport: (txid: string) => Promise - noteDispenseReportAttempt: (txid: string, error: string | null) => Promise - markStatePublished: (unixTimestamp: number) => Promise - saveBunkerBinding: (binding: BunkerBindingRecord) => Promise - clearBunkerBinding: () => Promise - resetStatePublishWatermark: () => Promise - resetForRepair: () => Promise - saveSpireSeed: (seed: string) => Promise - relaunchApp: () => Promise - recoverApp: () => Promise - 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 - getCassetteStateSeq: () => Promise + getBootstrapPublishedAt: () => Promise + markBootstrapPublished: (unixTimestamp: number) => Promise + applyOperatorCassettesConfig: ( + payload: { positions: Record }, + 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 platform: NodeJS.Platform diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index 67afd49..fcf83e5 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -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:` 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 - 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 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. - */ - relays?: string[] - /** LNbits nostr-transport server pubkey (hex) from the seed (#70). */ - lnbitsServerPubkey?: string +export type OperatorCassettesPayload = { + positions: Record } -/** Read the persisted bunker binding, or null if the ATM is unpaired. */ -export function getBunkerBinding(): StoredBunkerBinding | null { +export type ApplyResult = + | { applied: true } + | { applied: false; reason: string } + +/** + * 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). + */ +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 + + const watermark = getLastKnownConfigCreatedAt() + if (eventCreatedAt <= watermark) { + return { + applied: false, + reason: `event.created_at (${eventCreatedAt}) <= lastKnownConfigCreatedAt (${watermark})`, + } + } + + 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}`, + } + } + 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}` } + } + } + + 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})`, } - | undefined - if (!row) return null - 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, - } -} - -/** 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[] } - } catch { - // fall through - } - return undefined -} - -/** 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 - ) -} - -/** 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): 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 (!Number.isInteger(entry.count) || entry.count < 0) { + return { + applied: false, + reason: `count must be non-negative int (position ${posKey}, got ${entry.count})`, + } } } - 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 updateCassette = db.prepare( + 'UPDATE cassettes SET denomination = ?, count = ? WHERE position = ?' ) - const seen = database.prepare('SELECT 1 FROM cassette_ops WHERE id = ?') + const setWatermark = db.prepare('UPDATE meta SET value = ? WHERE key = ?') - 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 ?? '', reason }) - continue + const run = db.transaction(() => { + for (const [posKey, entry] of Object.entries(payload.positions)) { + updateCassette.run(entry.denomination, entry.count, Number(posKey)) } - 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', '') - } - })() + setWatermark.run(String(eventCreatedAt), 'lastKnownConfigCreatedAt') + }) + 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,14 +694,9 @@ export function getInventory(): Record { const rows = loadCassettes() const inv: Record = {} 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). - inv[row.denomination] = (inv[row.denomination] ?? 0) + row.count + 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,48 +838,20 @@ 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') { diff --git a/apps/machine/index.html b/apps/machine/index.html index 81f3e36..cc57859 100644 --- a/apps/machine/index.html +++ b/apps/machine/index.html @@ -2,7 +2,7 @@ - + - -

- {{ maintenanceScreen.title }} + {{ + atmStore.initError === 'maintenance' + ? 'Under Service' + : atmStore.initError === 'awaiting-fees' + ? 'Awaiting Configuration' + : 'ATM Unavailable' + }}

- {{ 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.' + }}

{{ atmStore.initError }}

- - -
- - -