diff --git a/.gitignore b/.gitignore index 345bc6a..52e9ea4 100644 --- a/.gitignore +++ b/.gitignore @@ -32,5 +32,8 @@ temp/ .worktrees/ .routstrd/ +# Graphify output +graphify-out/ + # Standalone prototype scripts index.ts diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md new file mode 100644 index 0000000..9e1f571 --- /dev/null +++ b/IMPLEMENTATION.md @@ -0,0 +1,253 @@ +# Migrate wallet storage from `~/.cocod` to `~/.routstrd/wallet` + +Task: [`routstrd-migrate-cocod-to-routstrd-files`](https://github.com/nodestrich/routstrd) +Event ID: `2f420644005154c5b106017c4a753854bb2660ba834cde7e6e267532cb5448e9` +Priority: 997 · Status: open + +--- + +## Baseline + +This plan is based on `coco-integration` at `86ac688` (after the default-mint, NPC/npubx.cash, and Windows-support changes). + +The task branch is currently based on `04f8c22`; rebase it onto the latest `coco-integration` before implementing this plan. + +## Goal + +Move the in-process Cashu wallet's persistent data from: + +```text +~/.cocod/config.json +~/.cocod/coco.db +``` + +to: + +```text +~/.routstrd/wallet/config.json +~/.routstrd/wallet/coco.db +``` + +The migration must preserve the mnemonic, proofs, default mint, and NPC identity. Existing users must not accidentally get a new wallet, and `cocod` and routstrd must never open the same database concurrently. + +Fresh installs must use `~/.routstrd/wallet` immediately. Existing installs must be migrated automatically and safely; merely continuing to run from `~/.cocod` is not sufficient for this task. + +--- + +## Important corrections to the previous plan + +### 1. Do not rename or migrate `cocod.sock` + +The in-process `coco-core` wallet does not expose a Unix socket. `cocod.sock` belongs to the legacy external `cocod` daemon and is used only to determine whether that daemon is still alive. + +There should therefore be no `wallet.sock`. Do not copy a socket inode into the new directory. + +### 2. Keep legacy-process paths separate from wallet-data paths + +On the current base, `src/daemon/wallet/coco-client.ts` derives all of these from one `CONFIG_DIR`: + +- wallet config and database; +- legacy cocod socket; +- legacy cocod PID/process lock. + +That coupling must be removed. After migration: + +- wallet data comes from `~/.routstrd/wallet`; +- probes and shutdown logic for an external legacy cocod continue to use `~/.cocod/cocod.sock` and `~/.cocod/cocod.pid`; +- routstrd uses `~/.routstrd/wallet/wallet.pid` as the in-process wallet lock. + +While routstrd owns the migrated wallet, it should also retain the existing legacy-cocod exclusion mechanism (claiming the legacy `cocod.pid`) so an old cocod cannot be started against a stale or partially migrated legacy wallet. Acquisition and release of both locks must be rollback-safe. + +### 3. Do not rename `cocodPath` + +`RoutstrdConfig.cocodPath` is an executable path for the external compatibility client, not a wallet storage path. Renaming it to `walletPath` would change its meaning and would not help this migration. + +The current daemon creates `createCocoClient()` directly and injects it into `createWalletAdapter()`, so `cocodPath` is effectively bypassed on the normal in-process path. Cleanup or removal of that compatibility setting is a separate task. + +### 4. Do not implement fallback as the steady state + +A resolver that permanently falls back to `~/.cocod` does not move the data. Legacy detection is needed to initiate migration, but successful startup should resolve to the canonical directory afterward. + +### 5. Account for the updated base + +The latest `coco-integration` adds: + +- `defaultMintUrl` persistence in the wallet's `config.json`; +- the NPC plugin, whose Nostr identity is derived from the same mnemonic; +- `src/daemon/wallet/coco-client.npc.test.ts`; +- Windows support and `USERPROFILE` fallback. + +Migration must preserve the complete config JSON rather than reconstructing selected fields. Path construction must use `path.join` rather than hard-coded `/` separators. + +--- + +## Current runtime path references on the latest base + +### Wallet data and legacy process coordination + +| File | Current responsibility | Required change | +|---|---|---| +| `src/daemon/wallet/coco-client.ts` | Uses one `.cocod` directory for config, DB, socket, and PID; owns the in-process wallet and legacy-cocod guard | Split canonical data paths from legacy process paths; migrate before opening the DB; use `wallet.pid` for the in-process lock | +| `src/daemon/wallet/cocod-client.ts` | External cocod compatibility client; defaults to `.cocod/cocod.sock` | Keep legacy defaults and clarify that they are external-cocod paths | +| `src/cli.ts` | Initializes `.cocod`; two restart paths wait on `.cocod/cocod.pid` | Initialize/migrate the canonical wallet and wait on `wallet.pid` | +| `src/utils/config.ts` | Defines `~/.routstrd`, but no wallet subdirectory | Export canonical and legacy wallet/process path helpers or constants | +| `src/daemon/index.ts` | Calls `createCocoClient()` before building the adapter | Ensure migration occurs before the client opens the DB | + +### Tests and documentation + +| File | Required update | +|---|---| +| `src/cli.test.ts` | Change fresh-wallet expectations and add migration coverage | +| `src/daemon/wallet/coco-client.test.ts` | Update data/lock paths and add migration/dual-lock tests | +| `src/daemon/wallet/coco-client.npc.test.ts` | Use the canonical wallet layout in fixtures; verify migrated mnemonic/config still drives NPC identity | +| `README.md` | Document the new location and migration behavior; retain legacy cocod terminology only where discussing compatibility | +| `SKILL.md` | Update wallet storage and environment-variable documentation | + +The fixture `src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz` keeps its name because it records the fixture's provenance. + +--- + +## Target path model + +Define the path model in one dependency-light module (for example `src/daemon/wallet/paths.ts`, or in `src/utils/config.ts` if that does not introduce a cycle): + +```text +ROUTSTRD config root process.env.ROUTSTRD_DIR || ~/.routstrd +canonical wallet directory process.env.ROUTSTRD_WALLET_DIR || /wallet +canonical wallet config /config.json +canonical wallet database /coco.db +canonical wallet lock process.env.ROUTSTRD_WALLET_PID || /wallet.pid + +legacy cocod directory process.env.COCOD_DIR || ~/.cocod +legacy cocod socket process.env.COCOD_SOCKET || /cocod.sock +legacy cocod PID process.env.COCOD_PID || /cocod.pid +``` + +Use `HOME` with `USERPROFILE` fallback, matching the updated Windows-support base. Use functions or injectable path objects where tests need to change environment variables after module import. + +`COCOD_DIR`, `COCOD_SOCKET`, and `COCOD_PID` remain compatibility controls for locating an old external cocod. They must not redirect the new in-process wallet away from `~/.routstrd/wallet`. `ROUTSTRD_WALLET_DIR` is the new explicit data-directory override. + +--- + +## Implementation plan + +### Phase 1 — Centralize and separate paths + +1. Add the canonical wallet and legacy cocod path definitions above. +2. Remove the local `.cocod` path construction from `coco-client.ts` and `cli.ts`. +3. Change `CreateCocoClientOptions` so tests/tooling can independently override: + - wallet data directory; + - wallet lock path; + - legacy cocod socket and PID paths. +4. Keep `cocod-client.ts` pointed at the legacy socket. Do not make it import a resolver that prefers the new wallet directory. + +### Phase 2 — Add a safe automatic migration primitive + +Add an idempotent `migrateLegacyWallet()` helper with injectable paths/filesystem operations for tests. + +Preconditions and behavior: + +1. If the canonical wallet already contains both `config.json` and `coco.db`, return `already-current` and do not touch legacy data. +2. If neither canonical nor legacy wallet data exists, return `fresh`; initialization will create the canonical directory. +3. If only the legacy wallet exists: + - first verify that legacy cocod is not running using the existing PID and socket guard; + - create a private staging directory under `~/.routstrd` with mode `0700`; + - copy the complete `config.json` and `coco.db` into staging without parsing or rewriting them; + - apply `0600` to both files; + - validate that the staged files exist and have the expected byte sizes; + - atomically rename the staged directory to `wallet`; + - only after the canonical directory is committed, remove the legacy `config.json` and `coco.db`; + - never copy `cocod.sock` or `cocod.pid`. +4. If canonical storage is partial, legacy storage is partial, both contain wallet data, or files conflict, stop with an actionable error. Never merge databases and never generate a new mnemonic over an ambiguous state. +5. Clean up an uncommitted staging directory after failure. A committed canonical wallet remains authoritative if cleanup of the old files fails; report that cleanup warning clearly. + +The migration should preserve unknown config fields, including `defaultMintUrl`, and preserve the database byte-for-byte. This also preserves the NPC identity because that identity is derived from the mnemonic. + +A staging copy plus atomic directory rename is preferred over two independent file renames: a crash must not expose a half-created canonical wallet. Copying also permits a clear rollback before commit and supports a legacy directory located on another filesystem through `COCOD_DIR`. + +### Phase 3 — Run migration from every wallet-opening path + +1. **CLI onboarding/init:** run migration before `initializeWallet()`. Only initialize a new mnemonic when migration reports `fresh`. +2. **Daemon direct startup:** run migration before `createCocoClient()` opens `coco.db`. This covers users who invoke the daemon without rerunning onboarding. +3. **Start/restart/update/service paths:** retain `stopLegacyCocod()` before migration/startup where those paths already stop legacy cocod. Direct daemon startup should refuse with the existing actionable error rather than silently running two wallet engines. +4. Print a concise success message showing old and new directories, but never print config contents or the mnemonic during migration. + +A separate `routstrd wallet migrate` command is optional as a manual recovery/preview entry point, but it must call the same migration primitive. It is not a substitute for automatic migration. + +### Phase 4 — Separate process locking + +Refactor the current `claimLegacyCocodPidFile()` behavior into explicit responsibilities: + +1. Claim `wallet.pid` for the lifetime of the in-process wallet to prevent two routstrd wallet instances from opening `coco.db`. +2. Continue claiming the legacy `cocod.pid` while routstrd is active, after verifying no real cocod owns it, to prevent an old cocod from starting. +3. If either claim fails, release any claim already acquired before returning the error. +4. On `CocodClient.dispose()`, startup failure, and daemon shutdown, release only PID files still owned by the current process. +5. Keep `stopLegacyCocod()` and `assertLegacyCocodNotRunning()` operating only on legacy cocod paths. +6. Update both PID-release waits in `src/cli.ts` (`restartDaemonsAfterUpdate` and `restart`) to wait for the canonical `wallet.pid` rather than `.cocod/cocod.pid`. + +There is no canonical wallet socket. + +### Phase 5 — Update initialization and client defaults + +1. Make `initializeWallet()` default to the canonical wallet directory. +2. Preserve directory mode `0700` and config mode `0600` on both migrated and fresh wallets. +3. In `createCocoClient()`, derive `config.json` and `coco.db` from the canonical wallet directory, but take legacy guard paths separately. +4. Update the wallet-access comment near `unlock()` to reference `~/.routstrd/wallet/config.json`. +5. Leave `CocodClient`, `resolveCocodExecutable()`, and `cocodPath` compatibility behavior unchanged. + +### Phase 6 — Tests + +Add or update tests for: + +- fresh initialization creates `~/.routstrd/wallet`, not `.cocod`; +- a legacy config and database migrate byte-for-byte; +- `defaultMintUrl` and unknown config fields survive migration; +- the NPC-derived identity is unchanged after migration; +- migration never copies socket or PID files; +- migration refuses while a real legacy cocod is running; +- stale legacy socket/PID handling remains safe; +- an existing complete canonical wallet wins without modifying it; +- partial canonical, partial legacy, and conflicting dual-wallet states fail without generating a mnemonic; +- staging cleanup and retry after an interrupted migration; +- custom `ROUTSTRD_DIR`, `ROUTSTRD_WALLET_DIR`, and legacy `COCOD_DIR` paths; +- Windows-compatible path construction; +- acquiring the second process lock rolls back the first on failure; +- `dispose()` releases both owned locks and does not unlink another process's lock; +- both CLI restart paths wait for `wallet.pid`. + +Keep `cocod-0.0.24-wallet.db.gz` unchanged and continue using it to prove the migrated database opens successfully with the current in-process wallet. + +### Phase 7 — Documentation + +Update `README.md` and `SKILL.md` to state: + +- wallet data is stored in `~/.routstrd/wallet`; +- existing `~/.cocod` data is migrated automatically on first startup; +- users must back up the mnemonic before migration; +- `ROUTSTRD_WALLET_DIR` overrides the canonical wallet directory; +- `COCOD_DIR`, `COCOD_SOCKET`, `COCOD_PID`, and `cocodPath` refer only to legacy external-cocod compatibility. + +Do not replace every use of the word `cocod`: references to the external daemon, compatibility client, legacy guard, package, and fixture are still accurate. + +--- + +## Acceptance criteria + +- A fresh install creates wallet data only under `~/.routstrd/wallet`. +- Starting from a valid `.cocod` wallet results in the same config and database under the canonical directory without changing balances, mnemonic-derived NPC identity, or default mint. +- No startup path silently creates a new mnemonic when recoverable legacy data exists. +- A running legacy cocod blocks migration and database opening. +- The new wallet directory contains no copied Unix socket and uses `wallet.pid` only as an in-process lock. +- Legacy cocod probing and exclusion continue to use `.cocod/cocod.sock` and `.cocod/cocod.pid`. +- The implementation works with `USERPROFILE`/Windows path construction and custom directory overrides. +- All wallet, CLI, typecheck, and build tests pass on the rebased `coco-integration` branch. + +--- + +## Suggested commit sequence + +1. `refactor(wallet): separate routstrd wallet paths from legacy cocod paths` +2. `feat(wallet): atomically migrate legacy cocod wallet data` +3. `fix(wallet): use independent routstrd and legacy cocod process locks` +4. `test(wallet): cover migration, conflicts, NPC identity, and lock rollback` +5. `docs: document routstrd wallet storage and legacy migration` diff --git a/README.md b/README.md index 094f30a..f776e15 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,13 @@ With custom port: routstrd start --port 9000 ``` +The daemon binds to `127.0.0.1` by default. To expose it on another interface: +```sh +routstrd start --host 0.0.0.0 +``` + +Only expose the daemon behind appropriate network controls. + With specific provider: ```sh routstrd start --provider https://your-provider.com @@ -101,6 +108,28 @@ Stop the daemon: routstrd stop ``` +### NPC (Lightning Address) + +The in-process wallet registers the NPC (npubx.cash) plugin, which gives the +daemon a persistent Lightning address backed by the wallet's Cashu mints. +Payments to the address are imported into the wallet automatically (websocket +push, plus manual sync on demand). + +```sh +# Show your NPC Lightning address (username@npubx.cash, or npub fallback) +routstrd wallet npc address + +# Claim a username (quote first, then confirm to pay the claim fee from the wallet) +routstrd wallet npc username myname +routstrd wallet npc username myname --confirm + +# Manually sync paid NPC quotes into the wallet +routstrd wallet npc sync +``` + +Equivalent daemon endpoints: `GET /wallet/npc/address`, +`POST /wallet/npc/username`, `POST /wallet/npc/sync`. + ### Daemon API The daemon exposes an HTTP server (default port 8008) with the following endpoints: @@ -132,6 +161,17 @@ Response: } ``` +## Wallet storage + +The in-process Cashu wallet stores its mnemonic and proof database in +`~/.routstrd/wallet/`. On first startup, an existing wallet in `~/.cocod/` is +migrated automatically after routstrd verifies that the legacy cocod daemon is +not running. Back up your mnemonic before upgrading. + +Set `ROUTSTRD_WALLET_DIR` to override the canonical wallet directory. The +`COCOD_DIR`, `COCOD_SOCKET`, and `COCOD_PID` variables are retained only for +locating and excluding a legacy external cocod process. + ## Configuration Configuration is stored in `~/.routstrd/config.json`: @@ -139,6 +179,7 @@ Configuration is stored in `~/.routstrd/config.json`: ```json { "port": 8008, + "host": "127.0.0.1", "provider": null, "cocodPath": null } diff --git a/SKILL.md b/SKILL.md index 050bf41..8d78da8 100644 --- a/SKILL.md +++ b/SKILL.md @@ -170,7 +170,9 @@ Refresh routstr21 models from Nostr and re-run integrations for all registered c | Variable | Default | Description | |----------|---------|-------------| | `ROUTSTRD_DIR` | `~/.routstrd` | Config directory | -| `COCOD_DIR` | `~/.cocod` | Wallet config directory | +| `ROUTSTRD_WALLET_DIR` | `~/.routstrd/wallet` | In-process Cashu wallet data directory | +| `ROUTSTRD_WALLET_PID` | `/wallet.pid` | In-process wallet lock path | +| `COCOD_DIR` | `~/.cocod` | Legacy external cocod compatibility directory | ### `routstrd mode` @@ -191,12 +193,15 @@ View daemon logs. | Option | Default | Description | |--------|---------|-------------| | `-f, --follow` | false | Follow log output (like `tail -f`) | +| `-c, --coco` | false | Show Cashu wallet-engine (coco) logs instead of daemon logs | | `-n, --lines ` | 50 | Number of lines to show | -Log files are stored at `~/.routstrd/logs/YYYY-MM-DD.log`. +Log files are stored at `~/.routstrd/logs/YYYY-MM-DD.log`. Wallet-engine (Cashu/coco) diagnostics go to a separate `~/.routstrd/coco-logs/YYYY-MM-DD.log` so they don't pollute the main daemon logs. ## Wallet Commands +New wallets automatically trust `https://mint.cubabitcoin.org` as their default mint. The default is used when a wallet command does not include `--mint-url`. + ### `routstrd wallet status` Check wallet status. @@ -245,6 +250,10 @@ List configured wallet mints. Add a new mint by URL. +### `routstrd wallet mints set-default ` + +Set the persistent default mint. If necessary, the mint is added as trusted first. + ### `routstrd wallet mints info ` Get info about a specific mint. @@ -311,4 +320,5 @@ When `routstrd onboard` runs, it automatically configures a `routstr` provider i | `~/.routstrd/routstr.db` | SQLite database | | `~/.routstrd/routstrd.sock` | IPC socket | | `~/.routstrd/routstrd.pid` | PID file | -| `~/.routstrd/logs/YYYY-MM-DD.log` | Daily log files | +| `~/.routstrd/logs/YYYY-MM-DD.log` | Daily daemon log files | +| `~/.routstrd/coco-logs/YYYY-MM-DD.log` | Daily Cashu wallet-engine (coco) log files | diff --git a/bun.lock b/bun.lock index b56af5b..52ef082 100644 --- a/bun.lock +++ b/bun.lock @@ -1,14 +1,20 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "routstrd", "dependencies": { "@cashu/cashu-ts": "^4.3.0", - "@routstr/sdk": "^0.3.17", + "@cashu/coco-core": "^1.0.1", + "@cashu/coco-sqlite-bun": "^1.0.1", + "@routstr/sdk": "^0.3.19", + "@scure/bip39": "^2.2.0", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", "applesauce-wallet-connect": "^6.0.0", + "coco-cashu-core": "1.1.2-rc.50", + "coco-cashu-plugin-npc": "2.4.1", "commander": "^14.0.2", "nostr-tools": "^2.12.0", "qrcode": "^1.5.4", @@ -26,23 +32,27 @@ }, }, "packages": { - "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.30" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A6qfyaVs4lxmRxRux6U3ViOa8mMbsSd0OaHghpei2MpiBT6791J4zFH5MN7kaW1tLfQ246rSC2DVTMOavsycjQ=="], + "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.59", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CFsUizO+jL+jSlN13rW2nQE9EbWx+8PSFBdB3TtD0UGYOxtefCVa5hsrNo5NUOJJGX5f4xHiXE1i2m5nQ80QPg=="], - "@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.30", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-VO7I+vPffqI5sMnPoUq5DCSqKIgQIk/naJWRdQVpz2ma2zoprC/lqiJiUEl2s6DfvTD76TbhD3q39ROjlA6rGw=="], + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], - "@cashu/cashu-ts": ["@cashu/cashu-ts@4.5.1", "", { "dependencies": { "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@scure/base": "^2.2.0", "@scure/bip32": "^2.2.0" } }, "sha512-AGB7wh1SC0iANRSsdwM091eoS1uVhSf/Qf2VM3plw8EzlYjiFkwSa9X0Nh6NpsE1VVeAuPpNInvpznvElp2Q0A=="], + "@cashu/cashu-ts": ["@cashu/cashu-ts@4.7.0", "", { "dependencies": { "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@scure/base": "^2.2.0", "@scure/bip32": "^2.2.0" } }, "sha512-u/R9nHCBFugQeTKf1NCZscKp6Zblo3ubX8mFA0Ar8+lpGaHSndWmsQUsSQ7vrNGmdGtqOTMiR7hErEqVCVOY+A=="], - "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@cashu/coco-core": ["@cashu/coco-core@1.0.1", "", { "dependencies": { "@cashu/cashu-ts": "^3.5.0", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "@scure/bip32": "^2.0.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-Nv0T9+K49X+jqnrqn65Vgn/4Id+271JyHTgjwP/URdgShIJX3O8aGH4uidNphKnuBEa0EZKWPiFRn5K4mXh9jA=="], - "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@cashu/coco-sqlite-bun": ["@cashu/coco-sqlite-bun@1.0.1", "", { "peerDependencies": { "@cashu/coco-core": "1.0.1", "typescript": "^5" } }, "sha512-u6lnC3c8+qopD1CuLkcScbxcU1O+STrTz9+fwN09filD4PS+vYPCQNgygIfzKWnCaF8gXFRUuJsXSMqHDImJTw=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@freedomofpress/crypto-browser": ["@freedomofpress/crypto-browser@0.1.7", "", { "dependencies": { "@noble/curves": "^1.6.0" } }, "sha512-zjWmZDKdAu8g0Zq1IjBQ+sKQ/NpfzStBDFjy/qHUSMVEL4wNlNGtA7lhtw8v8asXa0yqF2QTYQ3rq6xCTQeADw=="], - "@freedomofpress/sigstore-browser": ["@freedomofpress/sigstore-browser@0.1.13", "", { "dependencies": { "@freedomofpress/crypto-browser": "^0.1.7", "@freedomofpress/tuf-browser": "^0.1.11", "@noble/curves": "^2.0.1" } }, "sha512-3YfmP9JQ5h8CAO/vKJEGYrFdzss6xWxYi5ZkbR4KoHlxIaLGrnu+5TQDoZVWhuhyfDBdLQqj6ypdN0W6PsH3Jw=="], + "@freedomofpress/sigstore-browser": ["@freedomofpress/sigstore-browser@0.1.14", "", { "dependencies": { "@freedomofpress/crypto-browser": "^0.1.7", "@freedomofpress/tuf-browser": "^0.1.11", "@noble/curves": "^2.0.1" } }, "sha512-1dqc7HojiBcr/sJSAXjBwNz4+WGeeAJP8ENQnDxm+idVV0/YIxpfwXRNSJdNw6EXJuHviq/5kSPEBCxamPcrpQ=="], "@freedomofpress/tuf-browser": ["@freedomofpress/tuf-browser@0.1.11", "", { "dependencies": { "@freedomofpress/crypto-browser": "^0.1.7" } }, "sha512-d76ohB/AS5+zI+lnbiFMX/BIK3nT18hZ8q3Na6X4vyYaSYjXkeknzhAnbx1da+8ObWLwsaZLue46haEch28qtQ=="], @@ -74,7 +84,7 @@ "@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.5.29", "", { "os": "win32", "cpu": "x64" }, "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], "@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="], @@ -86,19 +96,19 @@ "@noble/post-quantum": ["@noble/post-quantum@0.6.1", "", { "dependencies": { "@noble/ciphers": "~2.2.0", "@noble/curves": "~2.2.0", "@noble/hashes": "~2.2.0" } }, "sha512-+pormrDZwjRw05U8ADK4JpHejo87+gBd+muRBB/ozztH5yhDLMDF4jHQWN3NQQAsu1zBNPWTG0ZwVI0CR29H0A=="], - "@panva/hpke-noble": ["@panva/hpke-noble@1.1.2", "", { "dependencies": { "@noble/ciphers": "^2.2.0", "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@noble/post-quantum": "^0.6.1" }, "peerDependencies": { "hpke": "^1.0.0" } }, "sha512-Gr/jKr/ErpRow8aHK+SAp0sAaWy7iJGHlFsXsdTjD8ULcN4vvsVVWR3fiD6Ogf+VoSB28XY/xWRDUEIGXw+9aA=="], + "@panva/hpke-noble": ["@panva/hpke-noble@1.1.3", "", { "dependencies": { "@noble/ciphers": "^2.2.0", "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@noble/post-quantum": "^0.6.1" }, "peerDependencies": { "hpke": "^1.0.0" } }, "sha512-zPG7MR9x7QE7+KdYsKBO9H0vp3AdYt9/4AT3ab7T7W6SL0fdRqhgNRu8q4OGTJNLeKpdbkkRb6LhBDaA9+9xWQ=="], - "@routstr/sdk": ["@routstr/sdk@0.3.17", "", { "dependencies": { "@cashu/cashu-ts": "^3.1.1", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", "applesauce-sqlite": "^6.0.0", "ehbp": "^0.2.3", "rxjs": "^7.8.1", "tinfoil": "^1.1.6", "zustand": "^5.0.5" }, "optionalDependencies": { "better-sqlite3": "^12.10.0" }, "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-3rLsVo8bo3pQG+KzACNfRo3dz6XqfsCoFLpvuqkVNgXUyxSQEQPFRiDtUeACf+a6Xp3f9LSLknVoTeL78s28Tg=="], + "@routstr/sdk": ["@routstr/sdk@0.3.19", "", { "dependencies": { "@cashu/cashu-ts": "^3.1.1", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", "applesauce-sqlite": "^6.0.0", "ehbp": "^0.2.3", "rxjs": "^7.8.1", "tinfoil": "^1.1.6", "zustand": "^5.0.5" }, "optionalDependencies": { "better-sqlite3": "^12.10.0" }, "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-48HsKUxzINDRXohFC55ud/x7gmz6rkUB15LZEOgoiLyIkD1L7nfTcwY2B91c1nhrhWVg0sP0W3wYXIMidmaUHg=="], "@scure/base": ["@scure/base@2.2.0", "", {}, "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg=="], "@scure/bip32": ["@scure/bip32@2.2.0", "", { "dependencies": { "@noble/curves": "2.2.0", "@noble/hashes": "2.2.0", "@scure/base": "2.2.0" } }, "sha512-zFr7t2F+a9+5tB7QbarF2HQNYrgjCNaoLAupZdKkrFMYMozJf5zqH2WJCQibMzm1qQ0QogrxVGO3qXfQDYMaQg=="], - "@scure/bip39": ["@scure/bip39@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg=="], + "@scure/bip39": ["@scure/bip39@2.2.0", "", { "dependencies": { "@noble/hashes": "2.2.0", "@scure/base": "2.2.0" } }, "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@tinfoilsh/verifier": ["@tinfoilsh/verifier@1.1.6", "", { "dependencies": { "@freedomofpress/crypto-browser": "^0.1.7", "@freedomofpress/sigstore-browser": "^0.1.13", "@freedomofpress/tuf-browser": "^0.1.11" } }, "sha512-egZlLFU98kh48GKV5B8Gh1abGHCUGEcSijWmTv3hn5lqAMXdLsqyehKDaUen8/98CrNxiOP+mzeqVbKSyAs5ZQ=="], + "@tinfoilsh/verifier": ["@tinfoilsh/verifier@1.1.8", "", { "dependencies": { "@freedomofpress/crypto-browser": "^0.1.7", "@freedomofpress/sigstore-browser": "^0.1.13", "@freedomofpress/tuf-browser": "^0.1.11" } }, "sha512-4L09Xr0SqVGzByzs3bnDqL4+U9kwgX8nmv2JMt4xydeTKjKtHZUosAt0jphtN7jT9h6mhfXhX0Yim5bCyEJc/Q=="], "@tursodatabase/database": ["@tursodatabase/database@0.2.2", "", { "dependencies": { "@tursodatabase/database-common": "^0.2.2" }, "optionalDependencies": { "@tursodatabase/database-darwin-arm64": "0.2.2", "@tursodatabase/database-linux-arm64-gnu": "0.2.2", "@tursodatabase/database-linux-x64-gnu": "0.2.2", "@tursodatabase/database-win32-x64-msvc": "0.2.2" } }, "sha512-BlvoyiwIWIIQA65KEYlCqLDpRKIOM1AN99tBMjz1B+ydO9h0cDb+lCbfBf6f7+Lk696KB1ODpePiF5EfGm9qpw=="], @@ -116,11 +126,11 @@ "@tursodatabase/database-win32-x64-msvc": ["@tursodatabase/database-win32-x64-msvc@0.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-C1Z1xOUSln1/qtzR60xZYHgmfrQIhNm0glgqsnZt2wQI354dDtQGZH/oG/tKWCHrvtFvWCbnd9DEZif/VCK56A=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], - "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], @@ -130,7 +140,7 @@ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "applesauce-common": ["applesauce-common@6.1.0", "", { "dependencies": { "@scure/base": "^1.2.4", "applesauce-core": "^6.1.0", "hash-sum": "^2.0.0", "light-bolt11-decoder": "^3.2.0", "nanoid": "^5.0.9", "rxjs": "^7.8.1" } }, "sha512-z5azXCIrSknGw57hzUN8F30Xd4clvLCfRQYM0asyyIQJtwxuEl0N/zrXX7RqdiJN/9yFCuLzBro2XoFezoeOMA=="], + "applesauce-common": ["applesauce-common@6.2.0", "", { "dependencies": { "@scure/base": "^2.2.0", "applesauce-core": "^6.2.0", "hash-sum": "^2.0.0", "light-bolt11-decoder": "^3.2.0", "nanoid": "^5.0.9", "rxjs": "^7.8.1" } }, "sha512-meXbDCdqA1LKy1tr04BW7wMfnBBkHetLv+wlXYjAj5iR4Sji5KMxLFdAjUe6cSvrxrk9LxFcP+0P9BcZQxjpaA=="], "applesauce-core": ["applesauce-core@5.2.0", "", { "dependencies": { "debug": "^4.4.0", "fast-deep-equal": "^3.1.3", "hash-sum": "^2.0.0", "nanoid": "^5.0.9", "nostr-tools": "~2.19", "rxjs": "^7.8.1" } }, "sha512-aSuM6q6/Gs2FGUqytlHDjKZpSst2xKaT0vMXUQFWUctECNIxvwy6/hTDDInukMuI9mrQdjnO781ZJJgghI7RNw=="], @@ -138,11 +148,11 @@ "applesauce-sqlite": ["applesauce-sqlite@6.0.0", "", { "dependencies": { "applesauce-core": "^6.0.0" }, "peerDependencies": { "@libsql/client": "^0.15.15", "@tursodatabase/database": "^0.2.2", "@tursodatabase/database-wasm": "^0.2.2", "better-sqlite3": "^12.8.0" } }, "sha512-OwuJ0ch8UW2T2JmcW0FHImdh5zjpDKrIb5BrDXSpLhzN3mexT9M2IXhRZlq1xU4wtzQoMkmsgYX2r7QmgeLArw=="], - "applesauce-wallet-connect": ["applesauce-wallet-connect@6.0.0", "", { "dependencies": { "@noble/hashes": "^1.7.1", "applesauce-common": "^6.0.0", "applesauce-core": "^6.0.0", "rxjs": "^7.8.1" } }, "sha512-rdVvc2NIa6VqnJh2JucyMjhA+S9bohPfXxChJtM4TkWwTFxkhkao9W+7Fuoe9ctelubGKXBbmtOSX3WowjgUOg=="], + "applesauce-wallet-connect": ["applesauce-wallet-connect@6.2.0", "", { "dependencies": { "@noble/hashes": "^2.2.0", "applesauce-common": "^6.2.0", "applesauce-core": "^6.2.0", "rxjs": "^7.8.1" } }, "sha512-vI3FD3Oyil5QtEM1IRx1Q/lWEfmXMHHO4YsHRUtUx3fcid4uG4xe2KIKsMpduGH9t46Bxz4k+vjp2WBHtkgT1g=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "better-sqlite3": ["better-sqlite3@12.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ=="], + "better-sqlite3": ["better-sqlite3@12.11.1", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA=="], "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], @@ -158,6 +168,10 @@ "cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + "coco-cashu-core": ["coco-cashu-core@1.1.2-rc.50", "", { "dependencies": { "@cashu/cashu-ts": "^3.5.0", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "@scure/bip32": "^2.0.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-eK5YuwvCWpeCwF/GEkMo90FCyek2mS+smQ51wKjkjTKzpkVVlNln52l2h1Wwce4VMnW6GSeL2IgT8IksM8Umuw=="], + + "coco-cashu-plugin-npc": ["coco-cashu-plugin-npc@2.4.1", "", { "dependencies": { "npubcash-sdk": "^0.3.2" }, "peerDependencies": { "coco-cashu-core": "^1.1.2-rc.50", "typescript": "^5.0.0" } }, "sha512-0OhqXXRLBq/2VkeuyqHrTINCEQYEk9+dCD9Vq7+M94CDf7kLKQZmoDL6leTuHJ45pIVnALcOFnjIcRoJwV7iiA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -178,7 +192,7 @@ "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], - "ehbp": ["ehbp@0.2.3", "", { "dependencies": { "@panva/hpke-noble": "^1.0.3", "hpke": "^1.0.1" } }, "sha512-f7iLlhGpeE5xNOYASR3d529PzmBD7Pq5Yb/z0R7vQcxalwNuVTHhX6i4wDJRIVai18MznwoOQ3OWyT/EWaDJ3w=="], + "ehbp": ["ehbp@0.2.5", "", { "dependencies": { "@panva/hpke-noble": "^1.0.3", "hpke": "^1.0.1" } }, "sha512-Fjos3ct9f1Fxf2xckV+dXUez9vRa+k+pn3gqkivg0WaJ4WIQwXBCdT8iphr/+DmAQE9JKo4FdedwR3gwlA2kvA=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -206,7 +220,7 @@ "hash-sum": ["hash-sum@2.0.0", "", {}, "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg=="], - "hpke": ["hpke@1.1.2", "", {}, "sha512-6Z8JoG/zYXly+MfsgQ5aNibBx/HDq2F74254Tv8VK4DR3/Mmf6zVrGakqsodfy9pF0XGKSYh5MA9x6D30gbhbg=="], + "hpke": ["hpke@1.1.3", "", {}, "sha512-nViXp6Au3UmGSIHlKIz8GQSuHJsscLTqZciHnssYMyuxHJvj3M4jBYpN3CbffTNPhhz2NQuUiC+TIaDxxP7NCw=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], @@ -216,7 +230,7 @@ "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], + "js-base64": ["js-base64@3.8.1", "", {}, "sha512-5xVjhUZlHHeuO2W7w2rDFj/Kl1xLX+HjZxdOQwCsUOifl6UaoH1o1wsbsTMz+r0aeC7gCijvru02j6TfKZWzKg=="], "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], @@ -234,23 +248,27 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@5.1.11", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="], + "nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="], "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], - "node-abi": ["node-abi@3.92.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ=="], + "node-abi": ["node-abi@3.94.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g=="], "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], - "nostr-tools": ["nostr-tools@2.23.5", "", { "dependencies": { "@noble/ciphers": "2.1.1", "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0", "@scure/bip32": "2.0.1", "@scure/bip39": "2.0.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-Fa7ZlUdjfUW1P4E7H3yBexhOHYi18XNyvd2n7eNHkYR085xADX6Y8V8Vm7nT/XQajaFOBrptXmVIGkJ2E4vfVw=="], + "nostr-tools": ["nostr-tools@2.23.9", "", { "dependencies": { "@noble/ciphers": "2.1.1", "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0", "@scure/bip32": "2.0.1", "@scure/bip39": "2.0.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-PBN3TpGh+EszlTZWpQdvcqEMqhcKcY1vTw0Xkkccg+TyP7y/icu9/iXJw72aFZ/ETm35ehU0hneGTXLaTSkImw=="], "nostr-wasm": ["nostr-wasm@0.1.0", "", {}, "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="], + "npubcash-sdk": ["npubcash-sdk@0.3.2", "", { "dependencies": { "@cashu/cashu-ts": "^3.2.2", "nostr-tools": "^2.19.4", "npubcash-types": "^0.1.1" } }, "sha512-mJwvWW6rAq04b/AHbd76T4gTeUqP2uprc01Y0ToU9ZK5co3n1PmBKWLBLbPN+VgpHhE0LubzjLq/qm70YWUugA=="], + + "npubcash-types": ["npubcash-types@0.1.1", "", {}, "sha512-/HGfes2cvQpkrWOuUrdemJJhJUQu+xiXl4x+AxQtKUMGCB8uEhfrXJfAQ0n+DgtkIX1YVLOh0a/e6E5LX+fUew=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "openai": ["openai@6.44.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"] }, "sha512-09/gH+8jH0RgUwsgWHAaxsKGRT5zVZ95IaJUnqAWj6XejIBmnFRwq2WUIF37VtDEsmGrtPmvCs5+yBSeZGWvkA=="], + "openai": ["openai@6.46.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "ws", "zod"] }, "sha512-DFg6jEPT2RO+oAyXtddeUJU8zkGy1OQ1AjGzNIJUMQG03TTqvCpy9tBpQ+2VVVnvrl3E56F8GEin2JYtWpITtA=="], "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], @@ -282,7 +300,7 @@ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], @@ -298,11 +316,11 @@ "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], - "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + "tar-fs": ["tar-fs@2.1.5", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw=="], "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], - "tinfoil": ["tinfoil@1.1.6", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.41", "@freedomofpress/sigstore-browser": "^0.1.13", "@tinfoilsh/verifier": "1.1.6", "@types/ws": "^8.18.1", "ehbp": "^0.2.0", "openai": "^6.34.0", "ws": "^8.21.0" }, "peerDependencies": { "ai": "^6.0.168" }, "optionalPeers": ["ai"] }, "sha512-kAgVPuvdydLSF72GH8I5Awnc0835MQCNHb9cS/J2TxGgMF2LFgqaJhe9l1iPf7Si64h3s+Gebz0XfTJ80QZ8wQ=="], + "tinfoil": ["tinfoil@1.1.8", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.41", "@freedomofpress/sigstore-browser": "^0.1.13", "@tinfoilsh/verifier": "1.1.8", "@types/ws": "^8.18.1", "ehbp": "^0.2.0", "openai": "^6.34.0", "ws": "^8.21.0" }, "peerDependencies": { "ai": "^6.0.168" }, "optionalPeers": ["ai"] }, "sha512-wD6DlDwnzquRqQcmfUNVYdQWM1kdzESwt/ksta2R78+9Zuk7NNQb2HinCkROJ0AZCHJZN0vHpQr/n98SxS3H7A=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -310,7 +328,7 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -336,21 +354,17 @@ "zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="], + "@cashu/coco-core/@cashu/cashu-ts": ["@cashu/cashu-ts@3.7.1", "", { "dependencies": { "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@scure/base": "^2.2.0", "@scure/bip32": "^2.2.0" } }, "sha512-EdUdjR6D0eY1YT6irNh/d5SBRc/ug4WNpnlLBXbRI9iOb8UNnQeGfk9CJQWL9ElQMjQ/4g582SehKXzEAZ2vnw=="], + "@freedomofpress/crypto-browser/@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], "@noble/post-quantum/@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], "@panva/hpke-noble/@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], - "@routstr/sdk/@cashu/cashu-ts": ["@cashu/cashu-ts@3.7.0", "", { "dependencies": { "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@scure/base": "^2.2.0", "@scure/bip32": "^2.2.0" } }, "sha512-Jy4Hek+Wouh94xS4InmTJRLpBPXA6fddVOAHR5lfNyTsCk9r3desY0vnwUGvOZBZWsSKZXedqdD/YpjWb3zngQ=="], + "@routstr/sdk/@cashu/cashu-ts": ["@cashu/cashu-ts@3.7.1", "", { "dependencies": { "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@scure/base": "^2.2.0", "@scure/bip32": "^2.2.0" } }, "sha512-EdUdjR6D0eY1YT6irNh/d5SBRc/ug4WNpnlLBXbRI9iOb8UNnQeGfk9CJQWL9ElQMjQ/4g582SehKXzEAZ2vnw=="], - "@scure/bip39/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], - - "@scure/bip39/@scure/base": ["@scure/base@2.0.0", "", {}, "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="], - - "applesauce-common/@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="], - - "applesauce-common/applesauce-core": ["applesauce-core@6.1.0", "", { "dependencies": { "debug": "^4.4.0", "fast-deep-equal": "^3.1.3", "hash-sum": "^2.0.0", "nanoid": "^5.0.9", "nostr-tools": "~2.19", "rxjs": "^7.8.1" } }, "sha512-+tOFNP/54Zz3Z2tKeCqQZRdFQp0bwwPRiYO4A/PKOmx9iHO2XOFMrPEuRGtNqaHeBmDQQn0pFNB9mif0t94g8w=="], + "applesauce-common/applesauce-core": ["applesauce-core@6.2.0", "", { "dependencies": { "debug": "^4.4.0", "fast-deep-equal": "^3.1.3", "hash-sum": "^2.0.0", "nanoid": "^5.0.9", "nostr-tools": "~2.19", "rxjs": "^7.8.1" } }, "sha512-O6AlVyzqcuIhTOIuexm6UWmx7mRIa2D98gJP7K7vFGf90YdtvSH78AsKQWZXJ0Pk/K14at+9zkwfCkFr7ghgNw=="], "applesauce-core/nostr-tools": ["nostr-tools@2.19.4", "", { "dependencies": { "@noble/ciphers": "^0.5.1", "@noble/curves": "1.2.0", "@noble/hashes": "1.3.1", "@scure/base": "1.1.1", "@scure/bip32": "1.3.1", "@scure/bip39": "1.2.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg=="], @@ -358,11 +372,11 @@ "applesauce-relay/nostr-tools": ["nostr-tools@2.19.4", "", { "dependencies": { "@noble/ciphers": "^0.5.1", "@noble/curves": "1.2.0", "@noble/hashes": "1.3.1", "@scure/base": "1.1.1", "@scure/bip32": "1.3.1", "@scure/bip39": "1.2.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg=="], - "applesauce-sqlite/applesauce-core": ["applesauce-core@6.1.0", "", { "dependencies": { "debug": "^4.4.0", "fast-deep-equal": "^3.1.3", "hash-sum": "^2.0.0", "nanoid": "^5.0.9", "nostr-tools": "~2.19", "rxjs": "^7.8.1" } }, "sha512-+tOFNP/54Zz3Z2tKeCqQZRdFQp0bwwPRiYO4A/PKOmx9iHO2XOFMrPEuRGtNqaHeBmDQQn0pFNB9mif0t94g8w=="], + "applesauce-sqlite/applesauce-core": ["applesauce-core@6.2.0", "", { "dependencies": { "debug": "^4.4.0", "fast-deep-equal": "^3.1.3", "hash-sum": "^2.0.0", "nanoid": "^5.0.9", "nostr-tools": "~2.19", "rxjs": "^7.8.1" } }, "sha512-O6AlVyzqcuIhTOIuexm6UWmx7mRIa2D98gJP7K7vFGf90YdtvSH78AsKQWZXJ0Pk/K14at+9zkwfCkFr7ghgNw=="], - "applesauce-wallet-connect/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "applesauce-wallet-connect/applesauce-core": ["applesauce-core@6.2.0", "", { "dependencies": { "debug": "^4.4.0", "fast-deep-equal": "^3.1.3", "hash-sum": "^2.0.0", "nanoid": "^5.0.9", "nostr-tools": "~2.19", "rxjs": "^7.8.1" } }, "sha512-O6AlVyzqcuIhTOIuexm6UWmx7mRIa2D98gJP7K7vFGf90YdtvSH78AsKQWZXJ0Pk/K14at+9zkwfCkFr7ghgNw=="], - "applesauce-wallet-connect/applesauce-core": ["applesauce-core@6.1.0", "", { "dependencies": { "debug": "^4.4.0", "fast-deep-equal": "^3.1.3", "hash-sum": "^2.0.0", "nanoid": "^5.0.9", "nostr-tools": "~2.19", "rxjs": "^7.8.1" } }, "sha512-+tOFNP/54Zz3Z2tKeCqQZRdFQp0bwwPRiYO4A/PKOmx9iHO2XOFMrPEuRGtNqaHeBmDQQn0pFNB9mif0t94g8w=="], + "coco-cashu-core/@cashu/cashu-ts": ["@cashu/cashu-ts@3.7.1", "", { "dependencies": { "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@scure/base": "^2.2.0", "@scure/bip32": "^2.2.0" } }, "sha512-EdUdjR6D0eY1YT6irNh/d5SBRc/ug4WNpnlLBXbRI9iOb8UNnQeGfk9CJQWL9ElQMjQ/4g582SehKXzEAZ2vnw=="], "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], @@ -376,6 +390,10 @@ "nostr-tools/@scure/bip32": ["@scure/bip32@2.0.1", "", { "dependencies": { "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA=="], + "nostr-tools/@scure/bip39": ["@scure/bip39@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg=="], + + "npubcash-sdk/@cashu/cashu-ts": ["@cashu/cashu-ts@3.7.1", "", { "dependencies": { "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@scure/base": "^2.2.0", "@scure/bip32": "^2.2.0" } }, "sha512-EdUdjR6D0eY1YT6irNh/d5SBRc/ug4WNpnlLBXbRI9iOb8UNnQeGfk9CJQWL9ElQMjQ/4g582SehKXzEAZ2vnw=="], + "@freedomofpress/crypto-browser/@noble/curves/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], "applesauce-common/applesauce-core/nostr-tools": ["nostr-tools@2.19.4", "", { "dependencies": { "@noble/ciphers": "^0.5.1", "@noble/curves": "1.2.0", "@noble/hashes": "1.3.1", "@scure/base": "1.1.1", "@scure/bip32": "1.3.1", "@scure/bip39": "1.2.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg=="], @@ -424,10 +442,14 @@ "applesauce-core/nostr-tools/@scure/bip32/@noble/curves": ["@noble/curves@1.1.0", "", { "dependencies": { "@noble/hashes": "1.3.1" } }, "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA=="], + "applesauce-core/nostr-tools/@scure/bip39/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], + "applesauce-relay/nostr-tools/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], "applesauce-relay/nostr-tools/@scure/bip32/@noble/curves": ["@noble/curves@1.1.0", "", { "dependencies": { "@noble/hashes": "1.3.1" } }, "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA=="], + "applesauce-relay/nostr-tools/@scure/bip39/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], + "applesauce-sqlite/applesauce-core/nostr-tools/@noble/ciphers": ["@noble/ciphers@0.5.3", "", {}, "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="], "applesauce-sqlite/applesauce-core/nostr-tools/@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="], @@ -456,12 +478,18 @@ "applesauce-common/applesauce-core/nostr-tools/@scure/bip32/@noble/curves": ["@noble/curves@1.1.0", "", { "dependencies": { "@noble/hashes": "1.3.1" } }, "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA=="], + "applesauce-common/applesauce-core/nostr-tools/@scure/bip39/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], + "applesauce-sqlite/applesauce-core/nostr-tools/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], "applesauce-sqlite/applesauce-core/nostr-tools/@scure/bip32/@noble/curves": ["@noble/curves@1.1.0", "", { "dependencies": { "@noble/hashes": "1.3.1" } }, "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA=="], + "applesauce-sqlite/applesauce-core/nostr-tools/@scure/bip39/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], + "applesauce-wallet-connect/applesauce-core/nostr-tools/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], "applesauce-wallet-connect/applesauce-core/nostr-tools/@scure/bip32/@noble/curves": ["@noble/curves@1.1.0", "", { "dependencies": { "@noble/hashes": "1.3.1" } }, "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA=="], + + "applesauce-wallet-connect/applesauce-core/nostr-tools/@scure/bip39/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], } } diff --git a/package.json b/package.json index 39d4eab..43491d6 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,15 @@ }, "dependencies": { "@cashu/cashu-ts": "^4.3.0", - "@routstr/sdk": "^0.3.18", + "@cashu/coco-core": "^1.0.1", + "@cashu/coco-sqlite-bun": "^1.0.1", + "@routstr/sdk": "^0.3.20", + "@scure/bip39": "^2.2.0", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", "applesauce-wallet-connect": "^6.0.0", + "coco-cashu-core": "1.1.2-rc.50", + "coco-cashu-plugin-npc": "2.4.1", "commander": "^14.0.2", "nostr-tools": "^2.12.0", "qrcode": "^1.5.4", diff --git a/src/cli.test.ts b/src/cli.test.ts new file mode 100644 index 0000000..0886893 --- /dev/null +++ b/src/cli.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { initializeWallet } from "./cli"; + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "routstrd-wallet-test-")); + tempDirs.push(dir); + return dir; +} + +function permissions(path: string): number { + return statSync(path).mode & 0o777; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("initializeWallet", () => { + test("creates the wallet directory and config with restrictive permissions", () => { + const walletDir = join(makeTempDir(), "wallet"); + + initializeWallet(walletDir); + + const walletConfig = join(walletDir, "config.json"); + expect(permissions(walletDir)).toBe(0o700); + expect(permissions(walletConfig)).toBe(0o600); + + const config = JSON.parse(readFileSync(walletConfig, "utf8")); + expect(config.encrypted).toBe(false); + expect(typeof config.mnemonic).toBe("string"); + expect(config.mnemonic.length).toBeGreaterThan(0); + }); + + test("repairs permissions without replacing an existing wallet", () => { + const walletDir = join(makeTempDir(), "wallet"); + const walletConfig = join(walletDir, "config.json"); + const existingConfig = JSON.stringify({ mnemonic: "existing seed" }); + + mkdirSync(walletDir, { mode: 0o755 }); + writeFileSync(walletConfig, existingConfig, { mode: 0o644 }); + + initializeWallet(walletDir); + + expect(readFileSync(walletConfig, "utf8")).toBe(existingConfig); + expect(permissions(walletDir)).toBe(0o700); + expect(permissions(walletConfig)).toBe(0o600); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 6121151..7775b1f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,8 +15,9 @@ import { deleteClientAction, addClientAction, } from "./utils/clients"; -import { existsSync, mkdirSync, readFileSync, statSync } from "fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs"; import { execSync } from "child_process"; +import { dirname, join } from "path"; import { CONFIG_DIR, DB_PATH, @@ -25,16 +26,26 @@ import { LOGS_DIR, type RoutstrdConfig, } from "./utils/config"; -import { logger } from "./utils/logger"; +import { COCO_LOGS_DIR, logger } from "./utils/logger"; import { setupIntegration, runIntegrationsForClients } from "./integrations"; +import { + assertLegacyCocodNotRunning, + claimLegacyCocodPidFile, + stopLegacyCocod, +} from "./daemon/wallet/coco-client"; +import { migrateLegacyWallet } from "./daemon/wallet/migration"; +import { + legacyCocodPidPath, + legacyCocodSocketPath, + walletDir as defaultWalletDir, + walletPidPath, +} from "./daemon/wallet/paths"; import { getClientsList } from "./utils/clients"; import * as QRCode from "qrcode"; import { normalizeNostrPubkey, npubFromPubkey, npubFromSecretKey } from "./utils/nip98"; import { generateSecretKey, nip19 } from "nostr-tools"; -import { - isCocodInstalled, - resolveCocodExecutable, -} from "./daemon/wallet/cocod-client"; +import { generateMnemonic } from "@scure/bip39"; +import { wordlist } from "@scure/bip39/wordlists/english.js"; import packageJson from "../package.json" with { type: "json" }; import { compareVersions, @@ -86,32 +97,42 @@ async function printLightningInvoice(invoice: string): Promise { console.log(`${qr}\nInvoice:\n${invoice}`); } -async function installCocodOrExit(): Promise { - console.log("cocod not found. Installing globally with bun..."); +export function initializeWallet(walletDir = defaultWalletDir()): void { + const walletConfig = join(walletDir, "config.json"); - const installProc = Bun.spawn( - ["bun", "install", "--global", "@routstr/cocod"], - { - stdout: "inherit", - stderr: "inherit", - }, - ); + // The wallet directory and config contain the plaintext seed phrase. Correct + // permissions on existing installations as well as newly created ones. + mkdirSync(walletDir, { recursive: true, mode: 0o700 }); + chmodSync(walletDir, 0o700); - const installCode = await installProc.exited; - if (installCode !== 0 || !(await isCocodInstalled())) { - console.error( - "Failed to install cocod. Please run 'bun install --global @routstr/cocod' manually.", - ); - throw new Error("cocod installation failed"); + if (existsSync(walletConfig)) { + chmodSync(walletConfig, 0o600); + console.log("Wallet already initialized."); + return; } - console.log("cocod installed successfully."); + const mnemonic = generateMnemonic(wordlist); + const config = { + version: 1, + mnemonic, + encrypted: false, + createdAt: new Date().toISOString(), + }; + writeFileSync(walletConfig, JSON.stringify(config, null, 2), { + mode: 0o600, + flag: "wx", + }); + console.log("Initialized. Mnemonic:", mnemonic); + console.log("IMPORTANT: Write down this mnemonic and keep it safe!"); } /** - * Restart the routstrd and cocod daemons after an update so the new - * binaries take effect immediately. Failures are collected and reported - * but never roll back the update itself. + * Restart the routstrd daemon after an update so the new binary takes + * effect immediately. Failures are collected and reported but never + * roll back the update itself. + * + * Note: cocod is no longer a separate process — the wallet now runs + * in-process via coco-core, so there is nothing else to restart. */ async function restartDaemonsAfterUpdate(): Promise { const config = await loadConfig(); @@ -129,23 +150,28 @@ async function restartDaemonsAfterUpdate(): Promise { } else { console.log("\nRestarting routstrd daemon..."); - // Graceful stop — the /stop endpoint closes the HTTP server - // (draining active connections) then exits. await callDaemon("/stop", { method: "POST" }); - for (let i = 0; i < 50; i++) { + // Wait for HTTP health check to fail AND wallet lock to be released. + const pidFilePath = walletPidPath(); + for (let i = 0; i < 100; i++) { await new Promise((resolve) => setTimeout(resolve, 100)); - if (!(await isDaemonRunning())) break; + const healthDown = !(await isDaemonRunning()); + const pidFileReleased = !existsSync(pidFilePath); + if (healthDown && pidFileReleased) break; } if (await isDaemonRunning()) { - throw new Error("routstrd did not stop within 5 seconds"); + throw new Error("routstrd did not stop within 10 seconds"); } console.log("routstrd daemon stopped."); + await stopLegacyCocod(); + console.log("Starting routstrd daemon..."); await startDaemon({ port: String(config.port || 8008), + host: config.host || undefined, provider: config.provider || undefined, }); console.log("routstrd daemon restarted."); @@ -156,80 +182,19 @@ async function restartDaemonsAfterUpdate(): Promise { } } - // --- cocod daemon --- - try { - const cocodExecutable = resolveCocodExecutable(config.cocodPath); - - // Check whether cocod is currently running. - const pingProc = Bun.spawn([cocodExecutable, "ping"], { - stdout: "ignore", - stderr: "ignore", - }); - const cocodWasRunning = (await pingProc.exited) === 0; - - if (!cocodWasRunning) { - console.log("\ncocod daemon was not running — skipping restart."); - } else { - console.log("\nRestarting cocod daemon..."); - - const stopProc = Bun.spawn([cocodExecutable, "stop"], { - stdout: "inherit", - stderr: "inherit", - }); - const stopCode = await stopProc.exited; - if (stopCode !== 0) { - throw new Error(`cocod stop exited with code ${stopCode}`); - } - console.log("cocod daemon stopped."); - - // Start cocod in the background (detached, like the wallet client does). - const env = { ...process.env }; - const startProc = Bun.spawn([cocodExecutable, "daemon"], { - stdout: "ignore", - stderr: "ignore", - stdin: "ignore", - detached: true, - env, - }); - startProc.unref?.(); - - // Poll until cocod responds to ping (max ~10 s). - let started = false; - for (let i = 0; i < 100; i++) { - await new Promise((resolve) => setTimeout(resolve, 100)); - const poll = Bun.spawn([cocodExecutable, "ping"], { - stdout: "ignore", - stderr: "ignore", - }); - if ((await poll.exited) === 0) { - started = true; - break; - } - } - - if (!started) { - throw new Error("cocod did not come back up within 10 seconds"); - } - console.log("cocod daemon restarted."); - } - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - failures.push(`cocod daemon: ${msg}`); - } - // --- report --- if (failures.length > 0) { - console.error("\n⚠ Some daemons failed to restart:"); + console.error("\n⚠ Failed to restart daemon:"); for (const f of failures) { console.error(` - ${f}`); } console.error( - "The update was applied but may not take effect until daemons are manually restarted.", + "The update was applied but may not take effect until the daemon is manually restarted.", ); process.exit(1); } - console.log("\n✓ All daemons restarted successfully."); + console.log("\n✓ Daemon restarted successfully."); } async function requireLocalDaemon(): Promise { @@ -274,76 +239,39 @@ async function initDaemon(): Promise { console.log(`You can view it in the config file at: ${CONFIG_FILE}\n`); } - if (!(await isCocodInstalled(config.cocodPath))) { - if (config.cocodPath) { - logger.error( - `Configured cocod executable was not found: ${config.cocodPath}`, - ); - return; - } - - await installCocodOrExit(); - } - - const cocodExecutable = resolveCocodExecutable(config.cocodPath); - console.log(`Database will be stored at: ${DB_PATH}`); - console.log("\nInitializing cocod..."); + await stopLegacyCocod(); - const initProc = Bun.spawn([cocodExecutable, "init"], { - stdout: "pipe", - stderr: "pipe", + let migrationLockOwner: number | undefined; + const migration = await migrateLegacyWallet({ + assertLegacyStopped: () => + assertLegacyCocodNotRunning({ + socketPath: legacyCocodSocketPath(), + pidFilePath: legacyCocodPidPath(), + ignorePid: migrationLockOwner, + }), + acquireLegacyLock: () => { + mkdirSync(dirname(legacyCocodPidPath()), { + recursive: true, + mode: 0o700, + }); + const release = claimLegacyCocodPidFile({ + pidFilePath: legacyCocodPidPath(), + }); + migrationLockOwner = process.pid; + return () => { + migrationLockOwner = undefined; + release(); + }; + }, }); - - let initStdout = ""; - let initStderr = ""; - - const stdoutDone = initProc.stdout - ? initProc.stdout.pipeTo( - new WritableStream({ - write(chunk) { - const text = new TextDecoder().decode(chunk); - initStdout += text; - process.stdout.write(text); - }, - }), - ) - : Promise.resolve(); - - const stderrDone = initProc.stderr - ? initProc.stderr.pipeTo( - new WritableStream({ - write(chunk) { - const text = new TextDecoder().decode(chunk); - initStderr += text; - process.stderr.write(text); - }, - }), - ) - : Promise.resolve(); - - const [initCode] = await Promise.all([ - initProc.exited, - stdoutDone, - stderrDone, - ]); - const combinedOutput = `${initStdout}\n${initStderr}`.toLowerCase(); - const alreadyInitialized = combinedOutput.includes("already initialized"); - - if (initCode !== 0 && !alreadyInitialized) { - console.error( - "Failed to initialize cocod. Please run 'cocod init' manually.", - ); - return; + if (migration.status === "migrated") { + console.log(`Migrated wallet from ${migration.from} to ${migration.to}.`); + for (const warning of migration.cleanupWarnings) console.warn(warning); } + initializeWallet(); - if (alreadyInitialized) { - console.log("cocod is already initialized."); - } else { - console.log("cocod initialized successfully."); - } - - await startDaemon({ port: String(config.port || 8008) }); + await startDaemon({ port: String(config.port || 8008), host: config.host || undefined }); await setupIntegration(config); @@ -366,7 +294,7 @@ program program .command("update") - .description("Update routstrd and cocod to the latest versions") + .description("Update routstrd to the latest version") .action(async () => { const packages = [ { name: "routstrd", label: "routstrd" }, @@ -623,7 +551,7 @@ program program .command("onboard") .description( - "Initialize routstrd (creates config directory and initializes cocod)", + "Initialize routstrd (creates config directory and initializes wallet)", ) .action(async () => { await requireLocalDaemon(); @@ -635,19 +563,15 @@ program .command("start") .description("Start the background daemon") .option("--port ", "Port to listen on") + .option("--host ", "Bind address (default: 127.0.0.1)") .option("-p, --provider ", "Default provider to use") - .action(async (options: { port?: string; provider?: string }) => { + .action(async (options: { port?: string; host?: string; provider?: string }) => { await requireLocalDaemon(); const config = await loadConfig(); - if (!(await isCocodInstalled(config.cocodPath))) { - const installHint = config.cocodPath - ? `Configured cocod executable was not found: ${config.cocodPath}` - : "cocod is not installed. Run 'routstrd onboard' first to install cocod."; - logger.error(installHint); - process.exit(1); - } + await stopLegacyCocod(); await startDaemon({ port: options.port || String(config.port || 8008), + host: options.host || config.host || undefined, provider: options.provider, }); }); @@ -687,9 +611,93 @@ program program .command("balance") .description("Get wallet and API key balances") - .action(async () => { + .option("--api-keys", "List all stored API keys (baseUrl + key + balance)", false) + .option( + "--delete-api-keys ", + "Delete the API key stored for the given provider base URL (refunds balance first)", + ) + .option( + "--mint-url ", + "Mint URL to refund the deleted API key balance to (defaults to first mint in wallet)", + ) + .action(async (options: { apiKeys: boolean; deleteApiKeys?: string; mintUrl?: string }) => { await ensureDaemonRunning(); + if (options.deleteApiKeys) { + const baseUrl = options.deleteApiKeys; + const queryParts = [`baseUrl=${encodeURIComponent(baseUrl)}`]; + if (options.mintUrl) { + queryParts.push(`mintUrl=${encodeURIComponent(options.mintUrl)}`); + } + const result = await callDaemon( + `/keys/api/delete?${queryParts.join("&")}`, + { method: "DELETE" }, + ); + if (result.error) { + console.log(result.error); + process.exit(1); + } + const out = result.output as + | { + baseUrl?: string; + removed?: boolean; + refunded?: boolean; + refundedAmount?: number; + refundMessage?: string; + message?: string; + } + | undefined; + if (out) { + console.log(out.message ?? `Removed API key for ${baseUrl}`); + if (out.refunded && out.refundedAmount !== undefined) { + console.log(` Refunded: ${out.refundedAmount} sats`); + } else if (out.refundMessage) { + console.log(` Refund: ${out.refundMessage}`); + } + } + return; + } + + if (options.apiKeys) { + const result = await callDaemon("/keys/api"); + if (result.error) { + console.log(result.error); + process.exit(1); + } + + const data = result.output as + | { + apiKeys: Array<{ + baseUrl: string; + key: string; + balance: number; + lastUsed: number | null; + }>; + count: number; + total: number; + unit: string; + } + | undefined; + + console.log("=== API Keys ===\n"); + if (!data || data.apiKeys.length === 0) { + console.log(" No API keys stored."); + return; + } + for (const k of data.apiKeys) { + const lastUsed = k.lastUsed + ? new Date(k.lastUsed).toISOString() + : "never"; + console.log(` ${k.baseUrl}`); + console.log(` key: ${k.key}`); + console.log(` balance: ${k.balance} ${data.unit}`); + console.log(` lastUsed: ${lastUsed}`); + console.log(""); + } + console.log(` Total: ${data.apiKeys.length} key(s), ${data.total} ${data.unit}`); + return; + } + const [walletResult, keysResult] = await Promise.all([ callDaemon("/balance"), callDaemon("/keys/balance"), @@ -813,6 +821,7 @@ program context_length?: number; providers: Array<{ baseUrl: string; + disabled?: boolean; pricing: { prompt: number; completion: number; @@ -839,7 +848,9 @@ program } console.log(`\n Providers (${modelData.providers.length}):`); for (const provider of modelData.providers) { - console.log(`\n ${provider.baseUrl}`); + console.log( + `\n ${provider.baseUrl}${provider.disabled ? " [Disabled]" : ""}`, + ); console.log( ` Prompt: ${(provider.pricing.prompt * 1000000).toFixed(2)} sats/M tokens`, ); @@ -1303,6 +1314,103 @@ npubsCmd ); }); +program + .command("history") + .description("Show wallet transaction history") + .option("-n, --limit ", "Number of entries to show", "50") + .option("--offset ", "Number of entries to skip", "0") + .option("-v, --verbose", "Show full details including encoded Cashu tokens") + .option("--json", "Output raw JSON with token objects (no encoding)") + .action(async (options: { limit: string; offset: string; verbose: boolean; json: boolean }) => { + await ensureDaemonRunning(); + + const limit = Math.min(parseInt(options.limit, 10) || 50, 1000); + const offset = parseInt(options.offset, 10) || 0; + + const result = await callDaemon( + `/wallet/history?offset=${offset}&limit=${limit}`, + ); + + if (result.error) { + console.log(result.error); + process.exit(1); + } + + const data = result.output as + | { entries?: Record[]; offset?: number; limit?: number } + | undefined; + const entries = data?.entries || []; + + if (entries.length === 0) { + console.log("No transaction history yet."); + return; + } + + if (options.json) { + const jsonOutput = entries.map((e: Record) => { + const entry = { ...e }; + delete entry.encodedToken; + return entry; + }); + console.log(JSON.stringify(jsonOutput, null, 2)); + return; + } + + if (options.verbose) { + for (const entry of entries) { + const e = entry as Record; + const display: Record = {}; + for (const key of Object.keys(e).sort()) { + display[key] = e[key]; + } + if (e.encodedToken && e.token) { + display.token = e.encodedToken; + delete display.encodedToken; + } + console.log(JSON.stringify(display, null, 2)); + } + return; + } + + const idCol = "ID"; + const timeCol = "Date/Time"; + const typeCol = "Type"; + const mintCol = "Mint"; + const amtCol = "Amount"; + + const rows = entries.map((entry: Record) => { + const id = String(entry.id ?? ""); + const time = new Date(Number(entry.createdAt)).toISOString().replace("T", " ").slice(0, 19); + const type = String(entry.type ?? "").toUpperCase(); + const mint = String(entry.mintUrl ?? ""); + const unit = String(entry.unit ?? "sat"); + const amount = `${entry.amount} ${unit}`; + return { id, time, type, mint, amount }; + }); + + const widths = { + id: Math.max(idCol.length, ...rows.map((r) => r.id.length)), + time: Math.max(timeCol.length, ...rows.map((r) => r.time.length)), + type: Math.max(typeCol.length, ...rows.map((r) => r.type.length)), + mint: Math.max(mintCol.length, ...rows.map((r) => r.mint.length)), + amount: Math.max(amtCol.length, ...rows.map((r) => r.amount.length)), + }; + + const pad = (s: string, w: number) => s.padEnd(w); + const sep = Object.values(widths).map((w) => "-".repeat(w)).join(" | "); + + console.log( + `${pad(idCol, widths.id)} | ${pad(timeCol, widths.time)} | ${pad(typeCol, widths.type)} | ${pad(mintCol, widths.mint)} | ${pad(amtCol, widths.amount)}`, + ); + console.log(sep); + + for (const row of rows) { + console.log( + `${pad(row.id, widths.id)} | ${pad(row.time, widths.time)} | ${pad(row.type, widths.type)} | ${pad(row.mint, widths.mint)} | ${pad(row.amount, widths.amount)}`, + ); + } + }); + // Monitor - interactive TUI program .command("monitor") @@ -1528,6 +1636,47 @@ walletMintsCmd }); }); +walletMintsCmd + .command("set-default ") + .description("Set the default mint for wallet operations") + .action(async (url: string) => { + await handleDaemonCommand("/wallet/mints/default", { + method: "POST", + body: { url }, + }); + }); + +const walletNpcCmd = walletCmd + .command("npc") + .description("NPC (npubx.cash) Lightning address operations"); + +walletNpcCmd + .command("address") + .description("Show this wallet's NPC Lightning address") + .action(async () => { + await handleDaemonCommand("/wallet/npc/address"); + }); + +walletNpcCmd + .command("username ") + .description( + "Claim an NPC username (pays the claim fee from the wallet with --confirm)", + ) + .option("--confirm", "Confirm payment of the username claim fee") + .action(async (name: string, options: { confirm?: boolean }) => { + await handleDaemonCommand("/wallet/npc/username", { + method: "POST", + body: { username: name, confirm: options.confirm === true }, + }); + }); + +walletNpcCmd + .command("sync") + .description("Manually sync paid NPC quotes into the wallet") + .action(async () => { + await handleDaemonCommand("/wallet/npc/sync", { method: "POST" }); + }); + // ── NWC (Nostr Wallet Connect) commands ───────────────────────── const nwcCmd = program @@ -1694,6 +1843,8 @@ serviceCmd console.log("Starting routstrd via PM2..."); try { + await stopLegacyCocod(); + // Use --interpreter bun to ensure it runs with bun execSync(`pm2 start "${daemonPath}" --name routstrd --interpreter bun`, { stdio: "inherit", @@ -1741,8 +1892,9 @@ program .command("restart") .description("Restart the background daemon") .option("--port ", "Port to listen on") + .option("--host ", "Bind address (default: 127.0.0.1)") .option("-p, --provider ", "Default provider to use") - .action(async (options: { port?: string; provider?: string }) => { + .action(async (options: { port?: string; host?: string; provider?: string }) => { await requireLocalDaemon(); const config = await loadConfig(); const wasRunning = await isDaemonRunning(); @@ -1751,16 +1903,19 @@ program console.log("Stopping daemon..."); await callDaemon("/stop", { method: "POST" }); - // Wait for daemon to fully stop - for (let i = 0; i < 50; i++) { + // Wait for HTTP health check to fail AND wallet lock to be released. + const pidFilePath = walletPidPath(); + for (let i = 0; i < 100; i++) { await new Promise((resolve) => setTimeout(resolve, 100)); - if (!(await isDaemonRunning())) { + const healthDown = !(await isDaemonRunning()); + const pidFileReleased = !existsSync(pidFilePath); + if (healthDown && pidFileReleased) { break; } } if (await isDaemonRunning()) { - logger.error("Daemon failed to stop within 5 seconds"); + logger.error("Daemon failed to stop within 10 seconds"); process.exit(1); } console.log("Daemon stopped."); @@ -1768,9 +1923,12 @@ program console.log("Daemon was not running."); } + await stopLegacyCocod(); + console.log("Starting daemon..."); await startDaemon({ port: options.port || String(config.port || 8008), + host: options.host || config.host || undefined, provider: options.provider, }); console.log("Daemon restarted."); @@ -1853,17 +2011,18 @@ program console.log("Starting daemon..."); await startDaemon({ port: String(config.port || 8008), + host: config.host || undefined, provider: config.provider || undefined, }); console.log(`Daemon restarted with mode '${selectedMode}'.`); }); // Logs -function getLogFileForDate(date: Date = new Date()): string { +function getLogFileForDate(logsDir: string, date: Date = new Date()): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); - return `${LOGS_DIR}/${year}-${month}-${day}.log`; + return `${logsDir}/${year}-${month}-${day}.log`; } function readLastLines(file: string, lines: number): string { @@ -1904,17 +2063,19 @@ program .command("logs") .description("View daemon logs") .option("-f, --follow", "Follow log output", false) + .option("-c, --coco", "Show Cashu wallet-engine (coco) logs instead of daemon logs", false) .option("-n, --lines ", "Number of lines to show", "50") - .action(async (options: { follow: boolean; lines: string }) => { + .action(async (options: { follow: boolean; lines: string; coco: boolean }) => { await requireLocalDaemon(); - const todayFile = getLogFileForDate(); + const logsDir = options.coco ? COCO_LOGS_DIR : LOGS_DIR; + const todayFile = getLogFileForDate(logsDir); const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); - const yesterdayFile = getLogFileForDate(yesterday); + const yesterdayFile = getLogFileForDate(logsDir, yesterday); if (!existsSync(todayFile) && !existsSync(yesterdayFile)) { console.log("No log files found. Daemon may not have started yet."); - console.log(`Logs directory: ${LOGS_DIR}`); + console.log(`Logs directory: ${logsDir}`); process.exit(1); } diff --git a/src/daemon/args.ts b/src/daemon/args.ts index 659f35d..79edeb4 100644 --- a/src/daemon/args.ts +++ b/src/daemon/args.ts @@ -1,8 +1,10 @@ export function parseArgs(argv: string[]): { port: number; + host: string | null; provider: string | null; } { const portFlagIndex = argv.findIndex((arg) => arg === "--port"); + const hostFlagIndex = argv.findIndex((arg) => arg === "--host"); const providerFlagIndex = argv.findIndex( (arg) => arg === "--provider" || arg === "-p", ); @@ -11,9 +13,14 @@ export function parseArgs(argv: string[]): { portFlagIndex !== -1 ? Number.parseInt(argv[portFlagIndex + 1] || "8008", 10) : 8008; + + const hostValue = + hostFlagIndex !== -1 ? argv[hostFlagIndex + 1] : undefined; + const host = hostValue?.trim() || null; + const providerValue = providerFlagIndex !== -1 ? argv[providerFlagIndex + 1] : undefined; const provider = providerValue ? providerValue.trim() : null; - return { port, provider }; + return { port, host, provider }; } diff --git a/src/daemon/http/index.ts b/src/daemon/http/index.ts index 96b4d59..cbf9d2e 100644 --- a/src/daemon/http/index.ts +++ b/src/daemon/http/index.ts @@ -8,6 +8,8 @@ import { } from "@routstr/sdk"; import type { UsageTrackingDriver, SdkLogger } from "@routstr/sdk"; import type { RequestResponseLogSink } from "../request-response-log-sink"; +import { getEncodedToken } from "@cashu/coco-core"; +import type { HistoryEntry } from "@cashu/coco-core"; import { logger } from "../../utils/logger"; import { loadDaemonConfig, saveDaemonConfig } from "../config-store"; import { @@ -15,10 +17,28 @@ import { type CocodClient, type CocodState, } from "../wallet/cocod-client"; -import { decodeCashuTokenAmount } from "../wallet"; +import { receiveCashuToken } from "../wallet"; import { getClientsFromStore } from "../../utils/clients"; import { getUsageSummary } from "./usage-summary"; +// Hop-by-hop headers describe the *upstream* connection, not this one, and must +// never be copied onto our response. In particular, copying the upstream's +// `Transfer-Encoding: chunked` while Node also frames the streamed body as +// chunked produces a duplicated `Transfer-Encoding: chunked, chunked` header, +// which strict HTTP/1.x clients (e.g. Go's net/http) reject with +// "too many transfer encodings". Node derives Content-Length for buffered +// bodies and Transfer-Encoding: chunked for streams itself. +const HOP_BY_HOP_HEADERS = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + type ClientMode = "xcashu" | "lazyrefund" | "apikeys"; type WalletStatusOutput = { @@ -33,6 +53,7 @@ type WalletStatusOutput = { type DaemonDeps = { provider: string | null; server: { close(cb?: () => void): void }; + shutdown?: () => void; store: any; walletClient: CocodClient; walletAdapter: any; @@ -255,19 +276,24 @@ async function buildWalletDetails(deps: DaemonDeps): Promise<{ balances?: Record; unit?: "sat"; activeMint?: string | null; + defaultMint?: string | null; }> { const state = await deps.walletClient.getStatus(); if (state !== "UNLOCKED") { return { state, ready: false }; } - const balances = await deps.walletAdapter.getBalances(); + const [balances, defaultMint] = await Promise.all([ + deps.walletAdapter.getBalances(), + deps.walletClient.getDefaultMint(), + ]); return { state, ready: true, balances, unit: "sat", activeMint: deps.walletAdapter.getActiveMintUrl(), + defaultMint, }; } @@ -287,6 +313,7 @@ const sdkLogger: SdkLogger = makeSdkLogger(); export function createDaemonRequestHandler(deps: { provider: string | null; server: { close(cb?: () => void): void }; + shutdown?: () => void; store: any; walletClient: CocodClient; walletAdapter: any; @@ -362,9 +389,9 @@ export function createDaemonRequestHandler(deps: { await respond(res, async () => { const body = await readJsonBody(req); const token = getRequiredStringField(body, "token"); - const message = await deps.walletClient.receiveCashu(token); - const { amount, unit } = decodeCashuTokenAmount(token); - return { output: { message, amount, unit } }; + return { + output: await receiveCashuToken(deps.walletClient, token), + }; }); return; } @@ -404,11 +431,15 @@ export function createDaemonRequestHandler(deps: { if (req.method === "GET" && url.pathname === "/wallet/mints") { await respond(res, async () => { - const mints = await deps.walletClient.listMints(); + const [mints, defaultMint] = await Promise.all([ + deps.walletClient.listMints(), + deps.walletClient.getDefaultMint(), + ]); return { output: { mints, - activeMint: mints[0] || null, + activeMint: defaultMint, + defaultMint, }, }; }); @@ -435,6 +466,93 @@ export function createDaemonRequestHandler(deps: { return; } + if (req.method === "GET" && url.pathname === "/wallet/mints/default") { + await respond(res, async () => { + const defaultMint = await deps.walletClient.getDefaultMint(); + return { output: { defaultMint } }; + }); + return; + } + + if (req.method === "POST" && url.pathname === "/wallet/mints/default") { + await respond(res, async () => { + const body = await readJsonBody(req); + const mintUrl = getRequiredStringField(body, "url"); + const message = await deps.walletClient.setDefaultMint(mintUrl); + return { output: { message, url: mintUrl } }; + }); + return; + } + + if (req.method === "GET" && url.pathname === "/wallet/history") { + await respond(res, async () => { + const offsetParam = url.searchParams.get("offset"); + const limitParam = url.searchParams.get("limit"); + const offset = offsetParam ? parseInt(offsetParam, 10) || 0 : 0; + const limit = limitParam ? parseInt(limitParam, 10) || 50 : 50; + const entries = await deps.walletClient.getHistory(offset, limit); + + const encoded = entries.map((entry: HistoryEntry) => { + const base = { ...entry } as Record; + if ( + (entry.type === "send" || entry.type === "receive") && + entry.token + ) { + base.encodedToken = getEncodedToken(entry.token); + } + return base; + }); + + return { output: { entries: encoded, offset, limit } }; + }); + return; + } + + // ── NPC (npubx.cash) Lightning address endpoints ────────────── + + if (req.method === "GET" && url.pathname === "/wallet/npc/address") { + await respond(res, async () => { + const info = await deps.walletClient.getNpcAddress(); + return { output: info }; + }); + return; + } + + if (req.method === "POST" && url.pathname === "/wallet/npc/username") { + await respond(res, async () => { + const body = await readJsonBody(req); + const username = getRequiredStringField(body, "username"); + const confirm = body.confirm === true; + const result = await deps.walletClient.setNpcUsername(username, confirm); + if (!result.success) { + const pr = result.paymentRequest ?? {}; + const amount = typeof pr.amount === "number" ? pr.amount : 0; + const mints = Array.isArray(pr.mints) ? pr.mints.join(", ") : ""; + if (confirm) { + throw new CocodHttpError( + 402, + `Failed to set username. Required amount: ${amount} SATS. Required mints: ${mints}`, + ); + } + throw new CocodHttpError( + 402, + `Payment required to set username: ${amount} SATS. ` + + `Use 'routstrd wallet npc username ${username} --confirm' to proceed`, + ); + } + return { output: { success: true, username } }; + }); + return; + } + + if (req.method === "POST" && url.pathname === "/wallet/npc/sync") { + await respond(res, async () => { + await deps.walletClient.syncNpc(); + return { output: { message: "NPC sync completed" } }; + }); + return; + } + // ── NWC endpoints ───────────────────────────────────────────── if (req.method === "GET" && url.pathname === "/nwc/status") { @@ -627,9 +745,11 @@ export function createDaemonRequestHandler(deps: { if (req.method === "POST" && url.pathname === "/stop") { sendJson(res, 200, { output: "stopping" }); setTimeout(() => { - deps.server.close(() => { - process.exit(0); - }); + if (deps.shutdown) { + deps.shutdown(); + } else { + deps.server.close(() => process.exit(0)); + } }, 50); return; } @@ -781,6 +901,133 @@ export function createDaemonRequestHandler(deps: { return; } + if (req.method === "GET" && url.pathname === "/keys/api") { + try { + const apiKeys = deps.storageAdapter.getAllApiKeys() as Array<{ + baseUrl: string; + key: string; + balance: number; + lastUsed: number | null; + }>; + const totalApiKeys = apiKeys.reduce( + (sum: number, k) => sum + (k.balance || 0), + 0, + ); + sendJson(res, 200, { + output: { + apiKeys, + count: apiKeys.length, + total: totalApiKeys, + unit: "sat", + }, + }); + } catch (error) { + respondWithError(res, error); + } + return; + } + + // Refund remaining balance to a mint before removing the key. + const apiKeyDeleteMatch = + (req.method === "DELETE" || req.method === "POST") && + url.pathname === "/keys/api/delete"; + if (apiKeyDeleteMatch) { + try { + let baseUrl: string | undefined; + let mintUrl: string | undefined; + if (req.method === "DELETE") { + baseUrl = url.searchParams.get("baseUrl") || undefined; + mintUrl = url.searchParams.get("mintUrl") || undefined; + } else { + const body = await readJsonBody(req); + baseUrl = getRequiredStringField(body, "baseUrl"); + mintUrl = body.mintUrl as string | undefined; + } + if (!baseUrl) { + sendJson(res, 400, { + error: "Missing required 'baseUrl' field.", + }); + return; + } + + const existing = deps.storageAdapter.getApiKey(baseUrl); + if (!existing) { + sendJson(res, 200, { + output: { + baseUrl, + removed: false, + refunded: false, + message: `No API key found for ${baseUrl}`, + }, + }); + return; + } + + if (!mintUrl) { + try { + const walletBalances = + await deps.walletAdapter.getBalances(); + const mintUrls = Object.keys(walletBalances); + if (mintUrls.length > 0) { + mintUrl = mintUrls[0]; + } + } catch { + // ignore + } + } + + let refunded = false; + let refundMessage: string | undefined; + let refundedAmount: number | undefined; + + if (mintUrl) { + try { + const balanceManager = deps.refundClient.getBalanceManager(); + if (balanceManager) { + const refundResult = await balanceManager.refundApiKey({ + mintUrl, + baseUrl: existing.baseUrl, + apiKey: existing.key, + forceRefund: true, + }); + refunded = refundResult.success; + refundMessage = refundResult.message; + if (refundResult.refundedAmount) { + refundedAmount = Math.floor( + refundResult.refundedAmount / 1000, + ); + } + } + } catch (error) { + refundMessage = `Refund error: ${toErrorMessage(error)}`; + } + } else { + refundMessage = "No mint available to refund to"; + } + + // refundApiKey removes the key on success; remove manually on failure. + if (!refunded) { + deps.storageAdapter.removeApiKey(existing.baseUrl); + } + + sendJson(res, 200, { + output: { + baseUrl: existing.baseUrl, + removed: true, + refunded, + refundedAmount, + refundMessage, + message: refunded + ? `Refunded and removed API key for ${existing.baseUrl}` + : `Removed API key for ${existing.baseUrl} (refund failed: ${refundMessage ?? "unknown"})`, + }, + }); + } catch (error) { + respondWithError(res, error); + } + return; + } + if (req.method === "POST" && url.pathname === "/providers/disable") { try { const bodyText = await readBody(req); @@ -1210,8 +1457,11 @@ export function createDaemonRequestHandler(deps: { return; } - // Allow client management endpoints through - if (req.method !== "POST" && !url.pathname.startsWith("/clients")) { + if ( + req.method !== "POST" && + !url.pathname.startsWith("/clients") && + !url.pathname.startsWith("/keys/api") + ) { res.writeHead(405, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Only POST is supported." })); return; @@ -1280,10 +1530,13 @@ export function createDaemonRequestHandler(deps: { ...(deps.routstrPubkey ? { routstrPubkey: deps.routstrPubkey } : {}), }); - // Bridge the Web `Response` to the Node `ServerResponse` with no - // transforms: status + headers + pipe(body → res). + // Bridge the Web `Response` to the Node `ServerResponse`: status + + // headers + pipe(body → res). Hop-by-hop headers are dropped (see + // HOP_BY_HOP_HEADERS) so Node computes correct framing for THIS + // connection. res.statusCode = response.status; response.headers.forEach((value, key) => { + if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) return; res.setHeader(key, value); }); diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 4ebee0a..6475874 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -33,6 +33,13 @@ function makeSdkLogger(prefix?: string): SdkLogger { }; } const daemonSdkLogger: SdkLogger = makeSdkLogger(); +const STARTUP_LOG_PREFIX = "[routstrd:start]"; + +function startupProgress(message: string): void { + logger.info(message); + console.log(`${STARTUP_LOG_PREFIX} ${message}`); +} + import { parseArgs } from "./args"; import { ensureDirs, loadDaemonConfig, loadDaemonConfigSync, saveDaemonConfig } from "./config-store"; import { @@ -42,12 +49,24 @@ import { } from "@routstr/sdk/storage/bun"; import { createWalletAdapter } from "./wallet"; import type { AutoRefillConfig } from "./wallet/auto-refill"; -import { createCocodClient } from "./wallet/cocod-client"; import { createModelService } from "./models"; import { createDaemonRequestHandler } from "./http"; import { FileRequestResponseLogSink } from "./request-response-log-sink"; import { refreshModelsAndIntegrations } from "../integrations"; import { RoutstrClient } from "@routstr/sdk"; +import { mkdirSync } from "fs"; +import { dirname } from "path"; +import { + assertLegacyCocodNotRunning, + claimLegacyCocodPidFile, + createCocoClient, + stopLegacyCocod, +} from "./wallet/coco-client"; +import { migrateLegacyWallet } from "./wallet/migration"; +import { + legacyCocodPidPath, + legacyCocodSocketPath, +} from "./wallet/paths"; // Global error handlers — the daemon is spawned detached with stdout/stderr // redirected to a file, so without these, uncaught async errors would kill @@ -61,10 +80,12 @@ process.on("unhandledRejection", (reason) => { }); async function main(): Promise { + startupProgress("Loading configuration..."); const args = parseArgs(process.argv); const config = await loadDaemonConfig(); const port = args.port; + const host = args.host || config.host || "127.0.0.1"; const provider = args.provider || config.provider; const requestResponseLogDir = process.env.ROUTSTRD_REQUEST_RESPONSE_LOG_DIR || @@ -80,9 +101,10 @@ async function main(): Promise { await ensureDirs(); - const updatedConfig = { ...config, port, provider }; + const updatedConfig = { ...config, port, host, provider }; saveDaemonConfig(updatedConfig); + startupProgress("Opening Routstr databases..."); const sqliteDriver = await createBunSqliteDriver(DB_PATH, { logger: daemonSdkLogger }); const { store, hydrate } = createSdkStore({ driver: sqliteDriver }); await hydrate; @@ -93,6 +115,7 @@ async function main(): Promise { const discoveryAdapter = await createShardedDiscoveryAdapter({ driver: sqliteDriver }); const storageAdapter = createStorageAdapterFromStore(store); + startupProgress("Routstr databases ready."); const modelManager = new ModelManager(discoveryAdapter, { logger: daemonSdkLogger, eventStoreDbPath: `${CONFIG_DIR}/events.db`, @@ -102,9 +125,44 @@ async function main(): Promise { // Create shared ProviderManager for consistent failure tracking across all requests const providerManager = new ProviderManager(discoveryAdapter, store, daemonSdkLogger); const { ensureProvidersBootstrapped, getRoutstr21Models, getModelProviders, refreshProvidersAndModels } = - createModelService(modelManager, store); + createModelService(modelManager, providerManager, store); - const walletClient = createCocodClient({ cocodPath: config.cocodPath }); + // The daemon may be launched directly (or by an older/global CLI), so do + // not rely on the parent command having stopped the external wallet first. + await stopLegacyCocod({ + socketPath: legacyCocodSocketPath(), + pidFilePath: legacyCocodPidPath(), + }); + + let migrationLockOwner: number | undefined; + const migration = await migrateLegacyWallet({ + assertLegacyStopped: () => + assertLegacyCocodNotRunning({ + socketPath: legacyCocodSocketPath(), + pidFilePath: legacyCocodPidPath(), + ignorePid: migrationLockOwner, + }), + acquireLegacyLock: () => { + mkdirSync(dirname(legacyCocodPidPath()), { + recursive: true, + mode: 0o700, + }); + const release = claimLegacyCocodPidFile({ + pidFilePath: legacyCocodPidPath(), + }); + migrationLockOwner = process.pid; + return () => { + migrationLockOwner = undefined; + release(); + }; + }, + }); + if (migration.status === "migrated") { + startupProgress(`Wallet migrated from ${migration.from} to ${migration.to}.`); + for (const warning of migration.cleanupWarnings) logger.warn(warning); + } + + const walletClient = await createCocoClient(); // ── Auto-refill configuration ──────────────────────────────── // Uses a getter that reads config from disk each cycle, so @@ -139,11 +197,15 @@ async function main(): Promise { ); const server = createServer(); + let shutdownDaemon: () => void = () => { + server.close(() => process.exit(0)); + }; server.on( "request", createDaemonRequestHandler({ provider, server, + shutdown: () => shutdownDaemon(), store, walletClient, walletAdapter, @@ -272,14 +334,37 @@ async function main(): Promise { } }; + let walletDisposePromise: Promise | undefined; + const disposeWallet = (): Promise => { + walletDisposePromise ??= walletClient.dispose?.() || Promise.resolve(); + return walletDisposePromise; + }; + server.on("close", () => { stopModelRefreshJob(); stopRefundJob(); - + void disposeWallet().catch((error) => { + logger.error("Failed to dispose wallet:", error); + }); }); - server.listen(port, async () => { - logger.log(`Routstr daemon listening on http://localhost:${port}/v1`); + shutdownDaemon = () => { + server.close(() => { + void disposeWallet().finally(() => process.exit(0)); + }); + }; + + const shutdownForSignal = (signal: NodeJS.Signals) => { + logger.log(`Received ${signal}; shutting down...`); + shutdownDaemon(); + }; + + process.once("SIGINT", shutdownForSignal); + process.once("SIGTERM", shutdownForSignal); + + startupProgress("Starting HTTP server..."); + server.listen(port, host, async () => { + logger.log(`Routstr daemon listening on http://${host}:${port}/v1`); if (requestResponseLogDir) { logger.log(`Raw request/response logs: ${requestResponseLogDir}`); } @@ -309,6 +394,12 @@ async function main(): Promise { if (import.meta.main) { main().catch((error) => { logger.error("Failed to start Routstr daemon:", error); + // Also write to stderr so the spawning CLI can surface the real error + // (stdout/stderr are redirected to debug.log by start-daemon.ts). + console.error( + "Failed to start Routstr daemon:", + error instanceof Error ? error.message : error, + ); process.exit(1); }); } diff --git a/src/daemon/models.ts b/src/daemon/models.ts index 702dd18..2099396 100644 --- a/src/daemon/models.ts +++ b/src/daemon/models.ts @@ -1,9 +1,10 @@ -import { ModelManager, type SdkStore } from "@routstr/sdk"; +import { ModelManager, ProviderManager, type SdkStore } from "@routstr/sdk"; import type { ExposedModel } from "./types"; import { logger } from "../utils/logger"; export type ModelProviderInfo = { baseUrl: string; + disabled: boolean; pricing: { prompt: number; completion: number; @@ -16,7 +17,11 @@ export type ModelWithProviders = ExposedModel & { providers: ModelProviderInfo[]; }; -export function createModelService(modelManager: ModelManager, store: SdkStore) { +export function createModelService( + modelManager: ModelManager, + providerManager: ProviderManager, + store: SdkStore, +) { let providerBootstrapPromise: Promise | null = null; const ensureProvidersBootstrapped = (): Promise => { @@ -77,33 +82,33 @@ export function createModelService(modelManager: ModelManager, store: SdkStore) ): Promise => { await ensureProvidersBootstrapped(); - const allModels = modelManager.getAllCachedModels(); - const providers: ModelProviderInfo[] = []; + const disabledSet = new Set(store.getState().disabledProviders || []); - for (const [baseUrl, models] of Object.entries(allModels)) { - const model = models.find((m) => m.id === modelId); - if (model && model.sats_pricing) { - providers.push({ - baseUrl, - pricing: { - prompt: model.sats_pricing.prompt, - completion: model.sats_pricing.completion, - request: model.sats_pricing.request, - max_cost: model.sats_pricing.max_cost, - }, - }); - } - } + // Use the SDK ranking (sorted by prompt+completion per million tokens) + // so the display order matches real routing. includeDisabled keeps + // disabled providers visible so we can annotate them. + const ranking = providerManager.getProviderPriceRankingForModel(modelId, { + includeDisabled: true, + }); - // Sort by max_cost (cheapest first) - providers.sort((a, b) => a.pricing.max_cost - b.pricing.max_cost); + const providers: ModelProviderInfo[] = ranking.map((entry: any) => ({ + baseUrl: entry.baseUrl, + disabled: disabledSet.has(entry.baseUrl), + pricing: { + prompt: entry.promptPerMillion / 1_000_000, + completion: entry.completionPerMillion / 1_000_000, + request: entry.model.sats_pricing?.request ?? 0, + max_cost: entry.model.sats_pricing?.max_cost ?? 0, + }, + })); if (providers.length === 0) { return null; } - // Get model metadata from first provider that has it + // Get model metadata from first (cheapest) provider const cheapest = providers[0]!; + const allModels = modelManager.getAllCachedModels(); const firstProvider = allModels[cheapest.baseUrl]; const modelInfo = firstProvider?.find((m: { id: string }) => m.id === modelId); diff --git a/src/daemon/wallet/coco-client.npc.test.ts b/src/daemon/wallet/coco-client.npc.test.ts new file mode 100644 index 0000000..b1ae0fc --- /dev/null +++ b/src/daemon/wallet/coco-client.npc.test.ts @@ -0,0 +1,252 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +// The real NPCPlugin opens a websocket to npubx.cash once the wallet is ready. +// Mock the module before importing coco-client so tests never touch the network. +const npcState = { + info: { name: "alice" as string | null, pubkey: "ab".repeat(32) }, + usernameResult: { success: true } as + | { success: true } + | { success: false; pr: { amount: number; mints: string[] } }, + setUsernameCalls: [] as Array<{ username: string; attemptPayment?: boolean }>, + syncCalls: 0, +}; + +class MockNPCPlugin { + readonly name = "npc"; + readonly required = [] as const; + private readonly api = { + getInfo: async () => npcState.info, + setUsername: async (username: string, attemptPayment?: boolean) => { + npcState.setUsernameCalls.push({ username, attemptPayment }); + return npcState.usernameResult; + }, + sync: async () => { + npcState.syncCalls += 1; + }, + }; + + onInit(ctx: { registerExtension: (key: string, api: unknown) => void }) { + ctx.registerExtension("npc", this.api); + return () => {}; + } +} + +mock.module("coco-cashu-plugin-npc", () => ({ + NPCPlugin: MockNPCPlugin, +})); + +const { createCocoClient } = await import("./coco-client"); +const { createCocodClient, CocodHttpError } = await import("./cocod-client"); + +const TEST_MNEMONIC = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +const tempDirs: string[] = []; + +function makeWalletDir(): string { + const dir = mkdtempSync(join(tmpdir(), "routstrd-coco-npc-test-")); + tempDirs.push(dir); + const walletDir = join(dir, "wallet"); + mkdirSync(walletDir, { recursive: true }); + writeFileSync( + join(walletDir, "config.json"), + JSON.stringify({ version: 1, mnemonic: TEST_MNEMONIC, encrypted: false }), + ); + return walletDir; +} + +function testClientOptions(walletDir = makeWalletDir()) { + return { + walletDir, + legacySocketPath: join(walletDir, "legacy-cocod.sock"), + legacyPidPath: join(walletDir, "legacy-cocod.pid"), + }; +} + +function resetNpcState(): void { + npcState.info = { name: "alice", pubkey: "ab".repeat(32) }; + npcState.usernameResult = { success: true }; + npcState.setUsernameCalls = []; + npcState.syncCalls = 0; +} + +afterEach(() => { + resetNpcState(); + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("in-process coco client NPC integration", () => { + it("registers the plugin and exposes the wallet's NPC lightning address", async () => { + const client = await createCocoClient(testClientOptions()); + try { + const info = await client.getNpcAddress(); + expect(info).toEqual({ + address: "alice@npubx.cash", + name: "alice", + pubkey: "ab".repeat(32), + }); + } finally { + await client.dispose?.(); + } + }); + + it("falls back to the npub address when no username is set", async () => { + npcState.info = { name: null, pubkey: "ab".repeat(32) }; + const client = await createCocoClient(testClientOptions()); + try { + const info = await client.getNpcAddress(); + expect(info.address).toStartWith("npub1"); + expect(info.address).toEndWith("@npubx.cash"); + expect(info.name).toBeUndefined(); + expect(info.pubkey).toBe("ab".repeat(32)); + } finally { + await client.dispose?.(); + } + }); + + it("maps payment-required username claims without paying", async () => { + npcState.usernameResult = { + success: false, + pr: { amount: 21, mints: ["https://mint.example.com"] }, + }; + const client = await createCocoClient(testClientOptions()); + try { + const result = await client.setNpcUsername("bob"); + expect(result).toEqual({ + success: false, + paymentRequest: { amount: 21, mints: ["https://mint.example.com"] }, + }); + expect(npcState.setUsernameCalls).toEqual([ + { username: "bob", attemptPayment: false }, + ]); + } finally { + await client.dispose?.(); + } + }); + + it("confirms the claim fee payment when requested", async () => { + const client = await createCocoClient(testClientOptions()); + try { + const result = await client.setNpcUsername("bob", true); + expect(result).toEqual({ success: true }); + expect(npcState.setUsernameCalls).toEqual([ + { username: "bob", attemptPayment: true }, + ]); + } finally { + await client.dispose?.(); + } + }); + + it("triggers a manual quote sync", async () => { + const client = await createCocoClient(testClientOptions()); + try { + await client.syncNpc(); + expect(npcState.syncCalls).toBe(1); + } finally { + await client.dispose?.(); + } + }); + + it("rejects NPC calls with a clear error when the plugin is disabled", async () => { + const client = await createCocoClient({ + ...testClientOptions(), + enableNpc: false, + }); + try { + await expect(client.getNpcAddress()).rejects.toThrow( + "NPC plugin is not enabled", + ); + await expect(client.syncNpc()).rejects.toThrow( + "NPC plugin is not enabled", + ); + } finally { + await client.dispose?.(); + } + }); +}); + +describe("legacy cocod client NPC passthroughs", () => { + type FetchImpl = ( + input: string | URL | Request, + init?: RequestInit & { unix: string }, + ) => Promise; + + function legacyClient(handler: (path: string, body?: unknown) => Response) { + const requests: Array<{ path: string; body?: unknown }> = []; + const fetchImpl: FetchImpl = async (input, init) => { + const path = String(input).replace("http://localhost", ""); + const body = + typeof init?.body === "string" ? JSON.parse(init.body) : undefined; + if (path !== "/ping") requests.push({ path, body }); + if (path === "/ping") { + return new Response(JSON.stringify({ output: "pong" })); + } + return handler(path, body); + }; + const client = createCocodClient({ + socketPath: "/tmp/routstrd-test-legacy-npc/cocod.sock", + fetchImpl, + }); + return { client, requests }; + } + + it("fetches and parses a username NPC address", async () => { + const { client, requests } = legacyClient( + () => new Response(JSON.stringify({ output: "alice@npubx.cash" })), + ); + const info = await client.getNpcAddress(); + expect(info).toEqual({ address: "alice@npubx.cash", name: "alice" }); + expect(requests[0]?.path).toBe("/npc/address"); + }); + + it("omits the name for npub fallback addresses", async () => { + const { client } = legacyClient( + () => + new Response(JSON.stringify({ output: "npub1abc123@npubx.cash" })), + ); + const info = await client.getNpcAddress(); + expect(info).toEqual({ address: "npub1abc123@npubx.cash" }); + }); + + it("posts username claims with the confirm flag", async () => { + const { client, requests } = legacyClient( + () => new Response(JSON.stringify({ output: { success: true } })), + ); + const result = await client.setNpcUsername("bob", true); + expect(result).toEqual({ success: true }); + expect(requests[0]).toEqual({ + path: "/npc/username", + body: { username: "bob", confirm: true }, + }); + }); + + it("preserves the 402 payment-required error from cocod", async () => { + const { client } = legacyClient( + () => + new Response( + JSON.stringify({ error: "Payment required to set username" }), + { status: 402 }, + ), + ); + try { + await client.setNpcUsername("bob"); + expect.unreachable("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(CocodHttpError); + expect((error as InstanceType).status).toBe(402); + expect((error as Error).message).toContain("Payment required"); + } + }); + + it("reports manual sync as unsupported", async () => { + const { client } = legacyClient( + () => new Response(JSON.stringify({ output: {} })), + ); + await expect(client.syncNpc()).rejects.toThrow("not supported"); + }); +}); diff --git a/src/daemon/wallet/coco-client.test.ts b/src/daemon/wallet/coco-client.test.ts new file mode 100644 index 0000000..0749c42 --- /dev/null +++ b/src/daemon/wallet/coco-client.test.ts @@ -0,0 +1,508 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import { gunzipSync } from "bun"; +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { + assertLegacyCocodNotRunning, + claimLegacyCocodPidFile, + createCocoClient, + stopLegacyCocod, +} from "./coco-client"; + +type GuardOptions = NonNullable< + Parameters[0] +>; +type LegacyFetch = NonNullable; + +const SOCKET_PATH = "/tmp/routstrd-test/cocod.sock"; +const PID_FILE_PATH = "/tmp/routstrd-test/cocod.pid"; +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "routstrd-coco-migration-test-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function socketOnly(path: string): boolean { + return path === SOCKET_PATH; +} + +describe("default mint functionality", () => { + it("automatically adds default mint when no mints exist", async () => { + const walletDir = join(makeTempDir(), "wallet"); + mkdirSync(walletDir, { recursive: true }); + writeFileSync( + join(walletDir, "config.json"), + JSON.stringify({ + version: 1, + mnemonic: + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + encrypted: false, + }), + ); + + const client = await createCocoClient({ + walletDir, + legacySocketPath: join(walletDir, "legacy-cocod.sock"), + legacyPidPath: join(walletDir, "legacy-cocod.pid"), + }); + try { + expect(readFileSync(join(walletDir, "wallet.pid"), "utf8")).toBe( + String(process.pid), + ); + expect(readFileSync(join(walletDir, "legacy-cocod.pid"), "utf8")).toBe( + String(process.pid), + ); + + const mints = await client.listMints(); + expect(mints).toContain("https://mint.cubabitcoin.org"); + + const defaultMint = await client.getDefaultMint(); + expect(defaultMint).toBe("https://mint.cubabitcoin.org"); + } finally { + await client.dispose?.(); + } + expect(existsSync(join(walletDir, "wallet.pid"))).toBe(false); + expect(existsSync(join(walletDir, "legacy-cocod.pid"))).toBe(false); + }); + + it("respects existing default mint in config", async () => { + const walletDir = join(makeTempDir(), "wallet"); + const configuredDefault = "https://mint.cubabitcoin.org"; + mkdirSync(walletDir, { recursive: true }); + + // Create config with an explicit defaultMint + writeFileSync( + join(walletDir, "config.json"), + JSON.stringify({ + version: 1, + mnemonic: + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + encrypted: false, + defaultMintUrl: configuredDefault, + }), + ); + + const client = await createCocoClient({ + walletDir, + legacySocketPath: join(walletDir, "legacy-cocod.sock"), + legacyPidPath: join(walletDir, "legacy-cocod.pid"), + }); + try { + // Should respect the configured default mint + const defaultMint = await client.getDefaultMint(); + expect(defaultMint).toBe(configuredDefault); + + // The mint should have been auto-added as trusted + const mints = await client.listMints(); + expect(mints).toContain(configuredDefault); + } finally { + await client.dispose?.(); + } + }); + + it("allows setting default mint to an already trusted mint", async () => { + const walletDir = join(makeTempDir(), "wallet"); + mkdirSync(walletDir, { recursive: true }); + writeFileSync( + join(walletDir, "config.json"), + JSON.stringify({ + version: 1, + mnemonic: + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + encrypted: false, + }), + ); + + const client = await createCocoClient({ + walletDir, + legacySocketPath: join(walletDir, "legacy-cocod.sock"), + legacyPidPath: join(walletDir, "legacy-cocod.pid"), + }); + try { + // The initial default should be the auto-added Cuba mint + const initialDefault = await client.getDefaultMint(); + expect(initialDefault).toBe("https://mint.cubabitcoin.org"); + + // Setting the same mint should work + const message = await client.setDefaultMint( + "https://mint.cubabitcoin.org", + ); + expect(message).toContain("https://mint.cubabitcoin.org"); + + const defaultMint = await client.getDefaultMint(); + expect(defaultMint).toBe("https://mint.cubabitcoin.org"); + } finally { + await client.dispose?.(); + } + }); +}); + +describe("legacy cocod wallet migration", () => { + it("opens an existing unencrypted config and preserves database balances", async () => { + const walletDir = join(makeTempDir(), "wallet"); + const mintUrl = "https://mint.example.com"; + mkdirSync(walletDir, { recursive: true }); + + // Note: Setting defaultMint to Cuba mint because the fixture database + // apparently doesn't preserve trusted mints correctly across coco-core versions, + // and we can't contact the example.com mint. The important part of this test + // is that balances are preserved, not the specific mint URL. + writeFileSync( + join(walletDir, "config.json"), + JSON.stringify({ + version: 1, + mnemonic: + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + encrypted: false, + defaultMintUrl: "https://mint.cubabitcoin.org", + }), + ); + + // This fixture was generated with @routstr/cocod 0.0.24 using its + // coco-cashu-sqlite-bun 1.1.2-rc.50 adapter. Keeping it frozen prevents + // this test from accidentally creating its "legacy" database with the + // same current adapter that createCocoClient uses to read it. + const fixture = readFileSync( + join(import.meta.dir, "fixtures", "cocod-0.0.24-wallet.db.gz"), + ); + writeFileSync(join(walletDir, "coco.db"), gunzipSync(fixture)); + + // NPC is disabled here: this test verifies database migration, and the + // real plugin would otherwise open a websocket to npubx.cash. + const client = await createCocoClient({ + walletDir, + legacySocketPath: join(walletDir, "legacy-cocod.sock"), + legacyPidPath: join(walletDir, "legacy-cocod.pid"), + enableNpc: false, + }); + try { + expect(await client.getStatus()).toBe("UNLOCKED"); + expect(await client.getBalances()).toEqual({ [mintUrl]: 10 }); + } finally { + await client.dispose?.(); + } + + // Prove the migrated schema remains reopenable and the pre-existing + // proofs survive a complete in-process wallet restart. + const reopenedClient = await createCocoClient({ + walletDir, + legacySocketPath: join(walletDir, "legacy-cocod.sock"), + legacyPidPath: join(walletDir, "legacy-cocod.pid"), + enableNpc: false, + }); + try { + expect(await reopenedClient.getBalances()).toEqual({ [mintUrl]: 10 }); + } finally { + await reopenedClient.dispose?.(); + } + }); +}); + +describe("assertLegacyCocodNotRunning", () => { + it("does not probe when the legacy socket and PID file do not exist", async () => { + const fetchImpl = mock(async () => new Response("pong")); + + await assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: () => false, + fetchImpl, + }); + + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("refuses to continue when cocod responds on the legacy socket", async () => { + const fetchImpl = mock(async () => + Response.json({ output: "pong" }), + ); + + await expect( + assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: socketOnly, + fetchImpl, + }), + ).rejects.toThrow("Legacy cocod daemon is still running"); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(fetchImpl.mock.calls[0]?.[0]).toBe("http://localhost/ping"); + expect(fetchImpl.mock.calls[0]?.[1]).toMatchObject({ unix: SOCKET_PATH }); + }); + + it.each(["ENOENT", "ECONNREFUSED", "FailedToOpenSocket"])( + "allows startup for a stale socket that fails with %s", + async (code) => { + const fetchImpl = mock(async () => { + throw Object.assign(new Error("socket unavailable"), { code }); + }); + + await expect( + assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: socketOnly, + fetchImpl, + }), + ).resolves.toBeUndefined(); + }, + ); + + it("recognizes stale socket errors nested under cause", async () => { + const fetchImpl = mock(async () => { + throw new TypeError("fetch failed", { + cause: Object.assign(new Error("connection refused"), { + code: "ECONNREFUSED", + }), + }); + }); + + await expect( + assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: socketOnly, + fetchImpl, + }), + ).resolves.toBeUndefined(); + }); + + it("refuses to continue when the legacy PID is still running", async () => { + const fetchImpl = mock(async () => + Response.json({ output: "pong" }), + ); + + await expect( + assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: (path) => path === PID_FILE_PATH, + readFile: () => "4242\n", + isProcessRunning: (pid) => pid === 4242, + fetchImpl, + }), + ).rejects.toThrow("Legacy cocod daemon is still running with PID 4242"); + + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("ignores a stale PID file when no socket exists", async () => { + await expect( + assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: (path) => path === PID_FILE_PATH, + readFile: () => "4242\n", + isProcessRunning: () => false, + }), + ).resolves.toBeUndefined(); + }); + + it("ignores the caller's own migration lock PID", async () => { + await expect( + assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: (path) => path === PID_FILE_PATH, + readFile: () => "4242\n", + isProcessRunning: () => true, + ignorePid: 4242, + }), + ).resolves.toBeUndefined(); + }); + + it("fails closed when the socket cannot be probed safely", async () => { + const fetchImpl = mock(async () => { + throw Object.assign(new Error("permission denied"), { code: "EACCES" }); + }); + + await expect( + assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: socketOnly, + fetchImpl, + }), + ).rejects.toThrow("Cannot verify whether the legacy cocod daemon has stopped"); + }); +}); + +describe("stopLegacyCocod", () => { + it("stops a live PID only when legacy cocod responds on its socket", async () => { + let running = true; + const killProcess = mock((_pid: number, _signal: NodeJS.Signals) => { + running = false; + }); + const fetchImpl = mock(async () => + Response.json({ output: "pong" }), + ); + + await stopLegacyCocod({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: () => true, + readFile: () => "4242\n", + isProcessRunning: () => running, + fetchImpl, + killProcess, + pollIntervalMs: 1, + timeoutMs: 50, + }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(killProcess).toHaveBeenCalledWith(4242, "SIGTERM"); + }); + + it("does not kill a routstrd PID that owns the shared pidfile with a stale socket", async () => { + const killProcess = mock((_pid: number, _signal: NodeJS.Signals) => {}); + const fetchImpl = mock(async () => { + throw Object.assign(new Error("socket unavailable"), { + code: "ECONNREFUSED", + }); + }); + + await stopLegacyCocod({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: () => true, + readFile: () => "4242\n", + isProcessRunning: () => true, + fetchImpl, + killProcess, + }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(killProcess).not.toHaveBeenCalled(); + }); + + it("does not kill a live pidfile owner when no legacy socket exists", async () => { + const killProcess = mock((_pid: number, _signal: NodeJS.Signals) => {}); + const fetchImpl = mock(async () => + Response.json({ output: "pong" }), + ); + + await stopLegacyCocod({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: (path) => path === PID_FILE_PATH, + readFile: () => "4242\n", + isProcessRunning: () => true, + fetchImpl, + killProcess, + }); + + expect(fetchImpl).not.toHaveBeenCalled(); + expect(killProcess).not.toHaveBeenCalled(); + }); +}); + +describe("claimLegacyCocodPidFile", () => { + it("claims the PID file exclusively and releases its own claim", () => { + let storedPid: string | undefined; + let removed = false; + const opened: Array<{ path: string; fd: number }> = []; + + const release = claimLegacyCocodPidFile({ + pidFilePath: PID_FILE_PATH, + pid: 4242, + openExclusive: (path) => { + opened.push({ path, fd: 7 }); + return 7; + }, + writePid: (fd, pid) => { + expect(fd).toBe(7); + storedPid = String(pid); + }, + closeFile: (fd) => expect(fd).toBe(7), + readFile: () => storedPid || "", + removeFile: () => { + removed = true; + }, + }); + + expect(opened).toEqual([{ path: PID_FILE_PATH, fd: 7 }]); + expect(storedPid).toBe("4242"); + release(); + expect(removed).toBe(true); + }); + + it("refuses startup when another process wins the atomic claim", () => { + expect(() => + claimLegacyCocodPidFile({ + pidFilePath: PID_FILE_PATH, + openExclusive: () => { + throw Object.assign(new Error("exists"), { code: "EEXIST" }); + }, + readFile: () => "4242", + isProcessRunning: () => true, + }), + ).toThrow("Another cocod or routstrd process may be starting"); + }); + + it("replaces a confirmed stale PID file before claiming it", () => { + let openAttempts = 0; + let removed = false; + let storedPid = "4242"; + + const release = claimLegacyCocodPidFile({ + pidFilePath: PID_FILE_PATH, + pid: 9001, + openExclusive: () => { + openAttempts++; + if (openAttempts === 1) { + throw Object.assign(new Error("exists"), { code: "EEXIST" }); + } + return 7; + }, + readFile: () => storedPid, + isProcessRunning: () => false, + removeFile: () => { + removed = true; + }, + writePid: (_fd, pid) => { + storedPid = String(pid); + }, + closeFile: () => {}, + }); + + expect(openAttempts).toBe(2); + expect(removed).toBe(true); + expect(storedPid).toBe("9001"); + release(); + }); + + it("does not remove a PID file that no longer belongs to this process", () => { + let removed = false; + const release = claimLegacyCocodPidFile({ + pidFilePath: PID_FILE_PATH, + pid: 4242, + openExclusive: () => 7, + writePid: () => {}, + closeFile: () => {}, + readFile: () => "9001", + removeFile: () => { + removed = true; + }, + }); + + release(); + expect(removed).toBe(false); + }); +}); diff --git a/src/daemon/wallet/coco-client.ts b/src/daemon/wallet/coco-client.ts new file mode 100644 index 0000000..683abe9 --- /dev/null +++ b/src/daemon/wallet/coco-client.ts @@ -0,0 +1,829 @@ +import { + initializeCoco, + getEncodedToken, + normalizeMintUrl, +} from "@cashu/coco-core"; +import type { + HistoryEntry, + Logger as CocoLogger, + Plugin as CocoPlugin, +} from "@cashu/coco-core"; +import { SqliteRepositories } from "@cashu/coco-sqlite-bun"; +import { Database } from "bun:sqlite"; +import { NPCPlugin, type PluginApi as NpcPluginApi } from "coco-cashu-plugin-npc"; +import { privateKeyFromSeedWords } from "nostr-tools/nip06"; +import { finalizeEvent, nip19, type EventTemplate } from "nostr-tools"; +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, +} from "fs"; +import { dirname, join } from "path"; +import { mnemonicToSeedSync } from "@scure/bip39"; +import type { + CocodClient, + CocodState, + NpcAddress, + NpcUsernameResult, +} from "./cocod-client"; +import { cocoLogger, logger } from "../../utils/logger"; +import { + legacyCocodPidPath, + legacyCocodSocketPath, + walletDir as defaultWalletDir, + walletPidPath as defaultWalletPidPath, +} from "./paths"; + +const NPC_DEFAULT_BASE_URL = "https://npubx.cash"; + +const STALE_SOCKET_ERROR_CODES = new Set([ + "ECONNREFUSED", + "ENOENT", + // Bun's Unix-socket fetch error for an abandoned socket inode. + "FailedToOpenSocket", +]); + +type UnixRequestInit = RequestInit & { unix: string }; +type LegacyCocodFetch = ( + input: string | URL | Request, + init: UnixRequestInit, +) => Promise; + +export interface LegacyCocodGuardOptions { + socketPath?: string; + pidFilePath?: string; + pathExists?: (path: string) => boolean; + readFile?: (path: string) => string; + isProcessRunning?: (pid: number) => boolean; + /** PID owned by the caller's already-acquired legacy exclusion lock. */ + ignorePid?: number; + fetchImpl?: LegacyCocodFetch; + timeoutMs?: number; +} + +export interface LegacyCocodPidClaimOptions { + pidFilePath?: string; + pid?: number; + openExclusive?: (path: string) => number; + writePid?: (fd: number, pid: number) => void; + closeFile?: (fd: number) => void; + readFile?: (path: string) => string; + removeFile?: (path: string) => void; + isProcessRunning?: (pid: number) => boolean; +} + +export interface LegacyCocodStopOptions { + socketPath?: string; + pidFilePath?: string; + pathExists?: (path: string) => boolean; + readFile?: (path: string) => string; + isProcessRunning?: (pid: number) => boolean; + fetchImpl?: LegacyCocodFetch; + killProcess?: (pid: number, signal: NodeJS.Signals) => void; + /** Total time to wait for cocod to exit after SIGTERM. */ + timeoutMs?: number; + /** Interval between exit checks. */ + pollIntervalMs?: number; + /** Timeout for identifying cocod through its Unix socket. */ + socketTimeoutMs?: number; +} + +interface CocodConfig { + mnemonic: string; + encrypted: boolean; + defaultMintUrl?: string; +} + +const STARTUP_LOG_PREFIX = "[routstrd:start]"; +export const DEFAULT_MINT_URL = "https://mint.cubabitcoin.org"; + +function startupProgress(message: string): void { + logger.info(message); + // The daemon is detached and stdout is captured by start-daemon.ts. The + // prefix lets the CLI surface only safe, user-facing startup progress while + // the full diagnostic stream remains in the normal log file. + console.log(`${STARTUP_LOG_PREFIX} ${message}`); +} + +const SAFE_COCO_LOG_FIELDS = new Set([ + "module", + "mintUrl", + "operationId", + "quoteId", + "state", + "count", + "total", + "filterCount", + "subId", + "initOperations", + "executingOperations", + "pendingOperations", + "rollingBackOperations", + "orphanedReservations", +]); + +function safeCocoMetadata(values: unknown[]): Record { + const safe: Record = {}; + for (const value of values) { + if (!value || typeof value !== "object" || Array.isArray(value)) continue; + for (const [key, fieldValue] of Object.entries(value)) { + if (SAFE_COCO_LOG_FIELDS.has(key)) safe[key] = fieldValue; + } + } + return safe; +} + +function createCocoLogger(bindings: Record = {}): CocoLogger { + const write = ( + level: "error" | "warn" | "info" | "debug", + message: string, + meta: unknown[], + ) => { + // Coco diagnostics may contain proof secrets or encoded tokens. Keep only + // an explicit metadata allowlist; startup counts and operation IDs remain + // useful without copying wallet material into routstrd's logs. Written to + // ~/.routstrd/coco-logs/ so wallet-engine noise stays out of the main logs. + const metadata = safeCocoMetadata([bindings, ...meta]); + cocoLogger[level]( + `[coco] ${message}`, + ...(Object.keys(metadata).length > 0 ? [metadata] : []), + ); + }; + + return { + error: (message, ...meta) => write("error", message, meta), + warn: (message, ...meta) => write("warn", message, meta), + info: (message, ...meta) => write("info", message, meta), + debug: (message, ...meta) => write("debug", message, meta), + log: (level, message, ...meta) => write(level, message, meta), + child: (childBindings) => + createCocoLogger({ ...bindings, ...childBindings }), + }; +} + +function loadConfig(configFile: string): CocodConfig { + if (!existsSync(configFile)) { + throw new Error( + `Config file not found at ${configFile}. Run 'routstrd onboard' first.`, + ); + } + const config = JSON.parse(readFileSync(configFile, "utf-8")) as CocodConfig; + if (config.encrypted) { + throw new Error( + "Encrypted wallets are not supported yet. Please use an unencrypted wallet.", + ); + } + return config; +} + +function saveConfig(config: CocodConfig, configFile: string): void { + const temporaryFile = `${configFile}.${process.pid}.tmp`; + try { + writeFileSync(temporaryFile, JSON.stringify(config, null, 2), { + mode: 0o600, + flag: "wx", + }); + renameSync(temporaryFile, configFile); + } catch (error) { + try { + unlinkSync(temporaryFile); + } catch { + // The temporary file may not have been created. + } + throw error; + } +} + +function defaultIsProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function hasErrorCode(error: unknown, codes: Set): boolean { + let current: unknown = error; + const visited = new Set(); + + while (current && typeof current === "object" && !visited.has(current)) { + visited.add(current); + const candidate = current as { code?: unknown; cause?: unknown }; + if (typeof candidate.code === "string" && codes.has(candidate.code)) { + return true; + } + current = candidate.cause; + } + + return false; +} + +/** + * Refuse to open coco.db while the legacy cocod daemon owns its Unix socket. + * Two independent wallet engines must never operate on the same proof database. + * + * A socket left behind after a crash is safe to ignore only when connecting + * fails with ENOENT or ECONNREFUSED. Other probe failures are treated as unsafe + * because they do not prove that cocod has stopped. + */ +export async function assertLegacyCocodNotRunning( + options: LegacyCocodGuardOptions = {}, +): Promise { + const socketPath = options.socketPath || legacyCocodSocketPath(); + const pidFilePath = options.pidFilePath || legacyCocodPidPath(); + const pathExists = options.pathExists || existsSync; + const readFile = options.readFile || ((path) => readFileSync(path, "utf-8")); + const isProcessRunning = options.isProcessRunning || defaultIsProcessRunning; + + const getRunningLegacyPid = (): number | null => { + if (!pathExists(pidFilePath)) return null; + + try { + const pid = Number.parseInt(readFile(pidFilePath).trim(), 10); + return Number.isInteger(pid) && + pid > 0 && + pid !== options.ignorePid && + isProcessRunning(pid) + ? pid + : null; + } catch { + // An unreadable or malformed PID file does not prove that cocod is alive; + // the socket probe below remains the authoritative fallback. + return null; + } + }; + + const runningPid = getRunningLegacyPid(); + if (runningPid !== null) { + throw new Error( + `Legacy cocod daemon is still running with PID ${runningPid}. ` + + "Refusing to open the wallet database because cocod and coco-core cannot safely use it at the same time. " + + `Run 'cocod stop' or 'kill ${runningPid}' and try again.`, + ); + } + + if (!pathExists(socketPath)) return; + + const fetchImpl = options.fetchImpl || (fetch as LegacyCocodFetch); + const timeoutMs = options.timeoutMs ?? 1_000; + + try { + const response = await fetchImpl("http://localhost/ping", { + unix: socketPath, + signal: AbortSignal.timeout(timeoutMs), + }); + await response.body?.cancel(); + } catch (error) { + if (hasErrorCode(error, STALE_SOCKET_ERROR_CODES)) { + // Recheck after the failed probe in case cocod started concurrently. + const newlyRunningPid = getRunningLegacyPid(); + if (newlyRunningPid === null) { + logger.debug(`Ignoring stale legacy cocod socket at ${socketPath}`); + return; + } + + throw new Error( + `Legacy cocod daemon is still running with PID ${newlyRunningPid}. ` + + "Refusing to open the wallet database because cocod and coco-core cannot safely use it at the same time. " + + `Run 'cocod stop' or 'kill ${newlyRunningPid}' and try again.`, + { cause: error }, + ); + } + + throw new Error( + `Cannot verify whether the legacy cocod daemon has stopped at ${socketPath}. ` + + "Refusing to open the wallet database to prevent concurrent access. " + + "Run 'cocod stop', verify the daemon has exited, and try again.", + { cause: error }, + ); + } + + throw new Error( + `Legacy cocod daemon is still running at ${socketPath}. ` + + "Refusing to open the wallet database because cocod and coco-core cannot safely use it at the same time. " + + "Run 'cocod stop' and try again.", + ); +} + +/** + * Gracefully stop a legacy cocod daemon that is still running, so the new + * in-process coco wallet can safely open the shared database. + * + * Sends SIGTERM to the PID recorded in cocod's PID file, then polls until the + * process exits and the PID file is removed (cocod cleans up both on graceful + * shutdown). On timeout it refuses rather than escalating to SIGKILL, because + * killing a wallet engine mid-proof-recovery risks corrupting coco.db — the + * exact failure the guard exists to prevent. + */ +export async function stopLegacyCocod( + options: LegacyCocodStopOptions = {}, +): Promise { + const socketPath = options.socketPath || legacyCocodSocketPath(); + const pidFilePath = options.pidFilePath || legacyCocodPidPath(); + const pathExists = options.pathExists || existsSync; + const readFile = + options.readFile || ((path: string) => readFileSync(path, "utf-8")); + const isProcessRunning = options.isProcessRunning || defaultIsProcessRunning; + const fetchImpl = options.fetchImpl || (fetch as LegacyCocodFetch); + const killProcess = + options.killProcess || ((pid, signal) => process.kill(pid, signal)); + const timeoutMs = options.timeoutMs ?? 30_000; + const pollIntervalMs = options.pollIntervalMs ?? 500; + const socketTimeoutMs = options.socketTimeoutMs ?? 1_000; + + const readPid = (): number | null => { + if (!pathExists(pidFilePath)) return null; + try { + const pid = Number.parseInt(readFile(pidFilePath).trim(), 10); + return Number.isInteger(pid) && pid > 0 && isProcessRunning(pid) + ? pid + : null; + } catch { + return null; + } + }; + + const pid = readPid(); + if (pid === null) { + logger.debug( + "stopLegacyCocod: no running legacy cocod found, nothing to stop.", + ); + return; + } + + // routstrd intentionally writes its own PID to cocod.pid while the in-process + // wallet is open. Never identify the owner from the shared PID file alone: + // only a process responding through cocod's Unix socket is safe to terminate. + if (!pathExists(socketPath)) { + logger.debug( + `PID ${pid} owns ${pidFilePath}, but no legacy cocod socket exists; leaving it running.`, + ); + return; + } + + try { + const response = await fetchImpl("http://localhost/ping", { + unix: socketPath, + signal: AbortSignal.timeout(socketTimeoutMs), + }); + await response.body?.cancel(); + } catch (error) { + if (hasErrorCode(error, STALE_SOCKET_ERROR_CODES)) { + logger.debug( + `PID ${pid} owns ${pidFilePath}, but the legacy cocod socket is stale; leaving it running.`, + ); + return; + } + + throw new Error( + `Cannot verify whether PID ${pid} is the legacy cocod daemon at ${socketPath}. ` + + "Refusing to stop an unidentified process.", + { cause: error }, + ); + } + + logger.log(`Stopping legacy cocod daemon (PID ${pid})…`); + killProcess(pid, "SIGTERM"); + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + if (!isProcessRunning(pid) || readPid() !== pid) { + logger.log(`Legacy cocod daemon (PID ${pid}) stopped.`); + return; + } + } + + throw new Error( + `Legacy cocod daemon (PID ${pid}) did not stop within ${Math.round( + timeoutMs / 1000, + )}s of SIGTERM. ` + `Run 'kill ${pid}' and try again.`, + ); +} + +/** + * Atomically claim cocod's PID file for the lifetime of the in-process wallet. + * Legacy cocod checks this same file before opening coco.db, so a live routstrd + * owner prevents cocod from starting after the initial socket/PID probe. + */ +export function claimLegacyCocodPidFile( + options: LegacyCocodPidClaimOptions = {}, +): () => void { + return claimPidFile({ + ...options, + pidFilePath: options.pidFilePath || legacyCocodPidPath(), + }); +} + +function claimPidFile(options: LegacyCocodPidClaimOptions & { pidFilePath: string }): () => void { + const pidFilePath = options.pidFilePath; + const pid = options.pid ?? process.pid; + const openExclusive = + options.openExclusive || ((path: string) => openSync(path, "wx", 0o600)); + const writePid = + options.writePid || + ((fd: number, ownerPid: number) => writeFileSync(fd, String(ownerPid))); + const closeFile = options.closeFile || closeSync; + const readFile = + options.readFile || ((path: string) => readFileSync(path, "utf-8")); + const removeFile = options.removeFile || unlinkSync; + const isProcessRunning = options.isProcessRunning || defaultIsProcessRunning; + + let fd: number; + try { + fd = openExclusive(pidFilePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + + // The earlier guard permits a dead PID file. Remove only a parseable, + // confirmed-dead owner; an empty/malformed file may belong to a process + // that has created the file but has not written its PID yet. + let stalePid: number; + try { + stalePid = Number.parseInt(readFile(pidFilePath).trim(), 10); + } catch { + throw new Error( + `Cannot claim the wallet process lock at ${pidFilePath}. ` + + "Another cocod or routstrd process may be starting. Stop it and try again.", + { cause: error }, + ); + } + + if ( + !Number.isInteger(stalePid) || + stalePid <= 0 || + isProcessRunning(stalePid) + ) { + throw new Error( + `Cannot claim the wallet process lock at ${pidFilePath}. ` + + "Another cocod or routstrd process may be starting. Stop it and try again.", + { cause: error }, + ); + } + + try { + removeFile(pidFilePath); + fd = openExclusive(pidFilePath); + } catch (retryError) { + throw new Error( + `Cannot claim the wallet process lock at ${pidFilePath}. ` + + "Another cocod or routstrd process may be starting. Stop it and try again.", + { cause: retryError }, + ); + } + } + + try { + writePid(fd, pid); + } catch (error) { + try { + removeFile(pidFilePath); + } catch { + // Preserve the original write failure. + } + throw error; + } finally { + closeFile(fd); + } + + let released = false; + return () => { + if (released) return; + released = true; + + try { + if (readFile(pidFilePath).trim() === String(pid)) { + removeFile(pidFilePath); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + logger.warn( + `Failed to release wallet process lock at ${pidFilePath}:`, + error, + ); + } + } + }; +} + +export interface CreateCocoClientOptions { + /** Override the canonical wallet data directory. */ + walletDir?: string; + /** Deprecated alias retained for existing callers during migration. */ + configDir?: string; + /** Override the in-process wallet lock path. */ + walletPidPath?: string; + /** Override legacy external-cocod coordination paths. */ + legacySocketPath?: string; + legacyPidPath?: string; + /** Set to false to skip NPC (npubx.cash) plugin registration. Default: true. */ + enableNpc?: boolean; + /** NPC server base URL. Default: https://npubx.cash */ + npcBaseUrl?: string; +} + +export async function createCocoClient( + options: CreateCocoClientOptions = {}, +): Promise { + const configDir = options.walletDir || options.configDir || defaultWalletDir(); + const configFile = join(configDir, "config.json"); + const dbPath = join(configDir, "coco.db"); + const walletPidFile = + options.walletPidPath || + (options.walletDir || options.configDir + ? join(configDir, "wallet.pid") + : defaultWalletPidPath()); + const legacySocket = options.legacySocketPath || legacyCocodSocketPath(); + const legacyPidFile = options.legacyPidPath || legacyCocodPidPath(); + const npcBaseUrl = options.npcBaseUrl || NPC_DEFAULT_BASE_URL; + const npcAddressDomain = new URL(npcBaseUrl).host; + + await assertLegacyCocodNotRunning({ + socketPath: legacySocket, + pidFilePath: legacyPidFile, + }); + // The canonical wallet directory is created by initialization/migration. + // Keep a legacy PID claim as an exclusion fence for old cocod binaries. + mkdirSync(dirname(legacyPidFile), { recursive: true, mode: 0o700 }); + const releaseWalletPidClaim = claimPidFile({ pidFilePath: walletPidFile }); + let releaseLegacyPidClaim: () => void; + try { + releaseLegacyPidClaim = claimLegacyCocodPidFile({ + pidFilePath: legacyPidFile, + }); + } catch (error) { + releaseWalletPidClaim(); + throw error; + } + + let database: Database | undefined; + let coco: Awaited> | undefined; + let walletConfig = loadConfig(configFile); + + try { + startupProgress("Opening Cashu wallet database..."); + + // Read and validate the existing cocod config during startup rather than + // deferring failure until coco-core first needs wallet key material. + const mnemonic = walletConfig.mnemonic; + const seed = mnemonicToSeedSync(mnemonic); + database = new Database(dbPath); + const repo = new SqliteRepositories({ database }); + await repo.init(); + + const [pendingSends, inflightProofs, pendingMints] = await Promise.all([ + repo.sendOperationRepository.getPending(), + repo.proofRepository.getInflightProofs(), + repo.mintOperationRepository.getPending(), + ]); + const recoveryCount = + pendingSends.length + inflightProofs.length + pendingMints.length; + if (recoveryCount > 0) { + startupProgress( + `Recovering wallet state: ${pendingSends.length} pending sends, ` + + `${inflightProofs.length} in-flight proofs, ${pendingMints.length} pending mints. ` + + "This may take a few minutes while Cashu mints are contacted.", + ); + } else { + startupProgress("Initializing Cashu wallet..."); + } + + coco = await initializeCoco({ + repo, + seedGetter: async () => seed, + logger: createCocoLogger(), + }); + + const trustedMints = await coco.mint.getAllTrustedMints(); + const configuredDefault = walletConfig.defaultMintUrl; + const defaultMintUrl = normalizeMintUrl( + configuredDefault || trustedMints[0]?.mintUrl || DEFAULT_MINT_URL, + ); + + if (!trustedMints.some((mint) => mint.mintUrl === defaultMintUrl)) { + startupProgress(`Adding default mint: ${defaultMintUrl}`); + await coco.mint.addMint(defaultMintUrl, { trusted: true }); + } + + // Persist only after the mint was successfully fetched and trusted. A failed + // network request must not leave config pointing at an unusable default. + walletConfig.defaultMintUrl = defaultMintUrl; + if (configuredDefault !== defaultMintUrl) { + saveConfig(walletConfig, configFile); + } + + if (options.enableNpc !== false) { + startupProgress("Registering NPC (npubx.cash) plugin..."); + // NPC authenticates with a Nostr key derived from the same wallet seed + // (NIP-06). The signer only produces JWT auth events for the NPC + // server; it never signs anything that moves funds by itself. + const npcSecretKey = privateKeyFromSeedWords(mnemonic); + const npcSigner = async (template: EventTemplate) => + finalizeEvent(template, npcSecretKey); + const npcPlugin = new NPCPlugin(npcBaseUrl, npcSigner, { + useWebsocket: true, + logger: createCocoLogger({ module: "npc" }), + }); + // coco-cashu-plugin-npc implements the plugin contract from the + // coco-cashu-core package while routstrd runs the equivalent + // @cashu/coco-core build. The plugin host API is structurally identical + // in both (verified: mintService.addMintByUrl, + // mintOperationService.importQuote/getOperationByQuote), so this cast + // only bridges the duplicate package names, not a real API gap. + coco.use(npcPlugin as unknown as CocoPlugin); + } + + startupProgress("Cashu wallet ready."); + } catch (error) { + database?.close(); + releaseLegacyPidClaim(); + releaseWalletPidClaim(); + throw error; + } + + const npcApi = (): NpcPluginApi => { + // The plugin augments coco-cashu-core's PluginExtensions; the equivalent + // registration lives on manager.ext here. Guard for enableNpc=false. + const api = coco + ? (coco.ext as { npc?: NpcPluginApi }).npc + : undefined; + if (!api) { + throw new Error("NPC plugin is not enabled for this wallet."); + } + return api; + }; + + let disposed = false; + return { + async ping(): Promise { + try { + await coco.wallet.balances.total(); + return true; + } catch { + return false; + } + }, + + async getStatus(): Promise { + try { + await coco.wallet.balances.total(); + return "UNLOCKED"; + } catch { + return "ERROR"; + } + }, + + async unlock(_passphrase: string): Promise { + // coco-core does not support passphrase locking. + // Wallet access is controlled by ~/.routstrd/wallet/config.json. + return "wallet does not require unlocking"; + }, + + async getBalances(): Promise> { + const byMint = await coco.wallet.balances.byMint(); + return Object.fromEntries( + Object.entries(byMint).map(([mintUrl, snapshot]) => [ + mintUrl, + snapshot.spendable, + ]), + ); + }, + + async receiveCashu(token: string): Promise { + await coco.wallet.receive(token); + return "Token received successfully"; + }, + + async receiveBolt11(amount: number, mintUrl?: string): Promise { + const targetMint = mintUrl || walletConfig.defaultMintUrl; + if (!targetMint) { + throw new Error("No trusted mint available for Lightning invoice"); + } + const op = await coco.ops.mint.prepare({ + mintUrl: targetMint, + amount, + method: "bolt11", + }); + if (!("request" in op)) { + throw new Error("mint prepare did not return a payment request"); + } + return op.request as string; + }, + + async sendCashu(amount: number, mintUrl?: string): Promise { + const targetMint = mintUrl || walletConfig.defaultMintUrl; + if (!targetMint) { + throw new Error("No trusted mint available for sending"); + } + const prepared = await coco.ops.send.prepare({ + mintUrl: targetMint, + amount, + }); + const { token } = await coco.ops.send.execute(prepared.id); + return getEncodedToken(token); + }, + + async sendBolt11(invoice: string, mintUrl?: string): Promise { + const targetMint = mintUrl || walletConfig.defaultMintUrl; + if (!targetMint) { + throw new Error("No trusted mint available for Lightning payment"); + } + const prepared = await coco.ops.melt.prepare({ + mintUrl: targetMint, + method: "bolt11", + methodData: { invoice }, + }); + await coco.ops.melt.execute(prepared.id); + return "Payment sent successfully"; + }, + + async listMints(): Promise { + const mints = await coco.mint.getAllTrustedMints(); + return mints.map((m) => m.mintUrl); + }, + + async addMint(url: string): Promise { + const mintUrl = normalizeMintUrl(url); + await coco.mint.addMint(mintUrl, { trusted: true }); + return `Mint ${mintUrl} added successfully`; + }, + + async getMintInfo(url: string): Promise { + return coco.mint.getMintInfo(normalizeMintUrl(url)); + }, + + async getDefaultMint(): Promise { + return walletConfig.defaultMintUrl || null; + }, + + async setDefaultMint(url: string): Promise { + const mintUrl = normalizeMintUrl(url); + const trustedMints = await coco.mint.getAllTrustedMints(); + if (!trustedMints.some((mint) => mint.mintUrl === mintUrl)) { + await coco.mint.addMint(mintUrl, { trusted: true }); + } + + walletConfig.defaultMintUrl = mintUrl; + saveConfig(walletConfig, configFile); + return `Default mint set to ${mintUrl}`; + }, + + async dispose(): Promise { + if (disposed) return; + disposed = true; + try { + await coco.dispose(); + } finally { + try { + database.close(); + } finally { + releaseLegacyPidClaim(); + releaseWalletPidClaim(); + } + } + }, + + async getHistory(offset?: number, limit?: number): Promise { + return coco.history.getPaginatedHistory(offset, limit); + }, + + async getNpcAddress(): Promise { + const info = await npcApi().getInfo(); + const name = + typeof info?.name === "string" && info.name.trim() + ? info.name.trim() + : undefined; + const localPart = name ?? nip19.npubEncode(info.pubkey); + return { + address: `${localPart}@${npcAddressDomain}`, + ...(name ? { name } : {}), + pubkey: info.pubkey, + }; + }, + + async setNpcUsername( + username: string, + confirm?: boolean, + ): Promise { + const result = await npcApi().setUsername(username, confirm === true); + if (result.success) { + return { success: true }; + } + return { + success: false, + paymentRequest: + result.pr as NpcUsernameResult["paymentRequest"], + }; + }, + + async syncNpc(): Promise { + await npcApi().sync(); + }, + }; +} diff --git a/src/daemon/wallet/cocod-client.ts b/src/daemon/wallet/cocod-client.ts index 357a99e..6725812 100644 --- a/src/daemon/wallet/cocod-client.ts +++ b/src/daemon/wallet/cocod-client.ts @@ -3,6 +3,7 @@ import { createHash } from "crypto"; import { isAbsolute } from "path"; import { logger } from "../../utils/logger"; import { withCrossProcessLock } from "../../utils/process-lock"; +import type { HistoryEntry } from "@cashu/coco-core"; const DEFAULT_CONFIG_DIR = process.env.COCOD_DIR || `${process.env.HOME || process.env.USERPROFILE || ""}/.cocod`; @@ -35,6 +36,27 @@ export type CocodState = "UNINITIALIZED" | "LOCKED" | "UNLOCKED" | "ERROR"; export type CocodBalanceOutput = Record; +/** NPC (npubx.cash) Lightning address details for this wallet. */ +export interface NpcAddress { + /** Full Lightning address, e.g. "alice@npubx.cash" (npub fallback when no username is set). */ + address: string; + /** NPC username, when one has been claimed. */ + name?: string; + /** Nostr hex pubkey of the NPC account (only available from the in-process wallet). */ + pubkey?: string; +} + +/** Result of an NPC username claim attempt. */ +export interface NpcUsernameResult { + success: boolean; + /** Present when NPC requires payment to claim the username. */ + paymentRequest?: { + amount?: number; + mints?: string[]; + [key: string]: unknown; + }; +} + export class CocodHttpError extends Error { status: number; @@ -57,6 +79,17 @@ export interface CocodClient { listMints(): Promise; addMint(url: string): Promise; getMintInfo(url: string): Promise; + getDefaultMint(): Promise; + setDefaultMint(url: string): Promise; + /** Release resources held by in-process wallet implementations. */ + dispose?(): Promise; + getHistory(offset?: number, limit?: number): Promise; + /** NPC (npubx.cash) Lightning address for this wallet. */ + getNpcAddress(): Promise; + /** Claim an NPC username; pass confirm=true to pay the claim fee from the wallet. */ + setNpcUsername(username: string, confirm?: boolean): Promise; + /** Manually trigger an NPC quote sync into the wallet. */ + syncNpc(): Promise; } export function resolveCocodExecutable(cocodPath?: string | null): string { @@ -359,5 +392,50 @@ export function createCocodClient( async getMintInfo(url: string): Promise { return post("/mints/info", { url }); }, + async getDefaultMint(): Promise { + return callDaemon("/mints/default"); + }, + async setDefaultMint(url: string): Promise { + return post("/mints/default", { url }); + }, + async getHistory(_offset?: number, _limit?: number): Promise { + return []; + }, + async getNpcAddress(): Promise { + const address = await callDaemon("/npc/address"); + if (typeof address !== "string" || !address.trim()) { + throw new CocodHttpError( + 502, + "Unexpected response from cocod while fetching NPC address.", + ); + } + const trimmed = address.trim(); + const localPart = trimmed.split("@")[0]; + return { + address: trimmed, + ...(localPart && !localPart.startsWith("npub1") + ? { name: localPart } + : {}), + }; + }, + async setNpcUsername( + username: string, + confirm?: boolean, + ): Promise { + // cocod answers 402 with a payment-required message when the claim fee + // has not been confirmed; fetchJson preserves that status on the thrown + // CocodHttpError, so callers can surface it unchanged. + const result = await post<{ success?: boolean }>("/npc/username", { + username, + confirm: confirm === true, + }); + return { success: result?.success !== false }; + }, + async syncNpc(): Promise { + throw new CocodHttpError( + 501, + "Manual NPC sync is not supported by the legacy cocod client.", + ); + }, }; } diff --git a/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz b/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz new file mode 100644 index 0000000..a7fc1aa Binary files /dev/null and b/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz differ diff --git a/src/daemon/wallet/index.ts b/src/daemon/wallet/index.ts index 4d2f96f..a031a10 100644 --- a/src/daemon/wallet/index.ts +++ b/src/daemon/wallet/index.ts @@ -1,4 +1,4 @@ -import { getDecodedToken, Amount } from "@cashu/cashu-ts"; +import { getTokenMetadata } from "@cashu/cashu-ts"; import { InsufficientBalanceError } from "@routstr/sdk"; import { WalletConnect } from "applesauce-wallet-connect"; import { RelayPool } from "applesauce-relay"; @@ -10,13 +10,28 @@ export function decodeCashuTokenAmount(token: string): { amount: number; unit: "sat" | "msat"; } { - const decoded = getDecodedToken(token, []); - const amount = - decoded?.proofs?.reduce((sum, proof) => sum + proof.amount.toNumber(), 0) ?? 0; - const unit = decoded?.unit === "msat" ? "msat" : "sat"; + // TokenV4 may contain an 8-byte short keyset ID. Fully decoding its + // proofs requires the mint's full keyset IDs, but amount and unit do not. + // getTokenMetadata intentionally extracts those fields without trying to + // map short IDs, unlike getDecodedToken(token, []). + const metadata = getTokenMetadata(token); + const amount = metadata.amount.toNumber(); + const unit = metadata.unit === "msat" ? "msat" : "sat"; return { amount, unit }; } +export async function receiveCashuToken( + client: Pick, + token: string, +): Promise<{ message: string; amount: number; unit: "sat" | "msat" }> { + // Validate the token before handing it to a state-changing wallet call. This + // prevents a successful receive from being reported as a failure if local + // metadata parsing ever rejects a future token format. + const { amount, unit } = decodeCashuTokenAmount(token); + const message = await client.receiveCashu(token); + return { message, amount, unit }; +} + export interface WalletAdapterOptions { cocodPath?: string | null; walletClient?: CocodClient; @@ -50,10 +65,11 @@ export async function createWalletAdapter( ); try { - const mints = await client.listMints(); - activeMintUrl = mints[0] || Object.keys(nextBalances)[0] || null; + // Use default mint as active mint, fall back to first mint in list + const defaultMint = await client.getDefaultMint(); + activeMintUrl = defaultMint || Object.keys(nextBalances)[0] || null; } catch (error) { - logger.error("Failed to list cocod mints:", error); + logger.error("Failed to get default mint:", error); if (!activeMintUrl) { activeMintUrl = Object.keys(nextBalances)[0] || null; } @@ -151,12 +167,12 @@ export async function createWalletAdapter( return { success: false, invoice: "", error: "NWC not connected" }; } - // Ensure we have an active mint - await syncMintState(); - const mintUrl = activeMintUrl; + // Use default mint for NWC funding + const defaultMint = await client.getDefaultMint(); + const mintUrl = defaultMint; if (!mintUrl) { - logger.error("[nwc] No active mint configured"); - return { success: false, invoice: "", error: "No active mint configured" }; + logger.error("[nwc] No default mint configured"); + return { success: false, invoice: "", error: "No default mint configured" }; } try { @@ -295,8 +311,7 @@ export async function createWalletAdapter( message?: string; }> { try { - const message = await client.receiveCashu(token); - const { amount, unit } = decodeCashuTokenAmount(token); + const { amount, unit, message } = await receiveCashuToken(client, token); return { success: true, amount, unit, message }; } catch (error) { const errorMessage = @@ -329,17 +344,17 @@ export async function createWalletAdapter( } try { - const [balances, mints] = await Promise.all([ + const [balances, defaultMint] = await Promise.all([ client.getBalances(), - client.listMints().catch(() => []), + client.getDefaultMint().catch(() => null), ]); mintUnits = Object.fromEntries( Object.keys(balances).map((mintUrl) => [mintUrl, "sat"]), ); - activeMintUrl = mints[0] || Object.keys(balances)[0] || null; + activeMintUrl = defaultMint || Object.keys(balances)[0] || null; } catch (error) { logger.error("Failed to initialize wallet adapter state:", error); } return walletAdapter; -} \ No newline at end of file +} diff --git a/src/daemon/wallet/migration.test.ts b/src/daemon/wallet/migration.test.ts new file mode 100644 index 0000000..b053bcd --- /dev/null +++ b/src/daemon/wallet/migration.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import { Database } from "bun:sqlite"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { migrateLegacyWallet } from "./migration"; + +const roots: string[] = []; +function root(): string { + const path = mkdtempSync(join(tmpdir(), "routstrd-migration-")); + roots.push(path); + return path; +} +afterEach(() => { + for (const path of roots.splice(0)) rmSync(path, { recursive: true, force: true }); +}); + +describe("migrateLegacyWallet", () => { + it("snapshots committed WAL data and leaves process files behind", async () => { + const base = root(); + const legacyDir = join(base, ".cocod"); + const walletDir = join(base, ".routstrd", "wallet"); + mkdirSync(legacyDir); + const config = JSON.stringify({ + mnemonic: "seed words", + encrypted: false, + defaultMintUrl: "https://mint.example", + unknown: { preserved: true }, + }); + writeFileSync(join(legacyDir, "config.json"), config); + writeFileSync(join(legacyDir, "cocod.pid"), "123"); + writeFileSync(join(legacyDir, "cocod.sock"), "not-a-real-socket"); + + const source = new Database(join(legacyDir, "coco.db")); + source.exec("PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=0"); + source.exec(` + CREATE TABLE coco_cashu_proofs (mintUrl TEXT, state TEXT, amount INTEGER); + CREATE TABLE coco_cashu_counters (mintUrl TEXT, keysetId TEXT, counter INTEGER); + CREATE TABLE coco_cashu_mint_operations (state TEXT); + CREATE TABLE coco_cashu_send_operations (state TEXT); + CREATE TABLE coco_cashu_melt_operations (state TEXT); + CREATE TABLE coco_cashu_mints (mintUrl TEXT, trusted INTEGER); + INSERT INTO coco_cashu_proofs VALUES ('https://mint.example', 'ready', 21); + INSERT INTO coco_cashu_counters VALUES ('https://mint.example', 'keyset', 3); + INSERT INTO coco_cashu_mints VALUES ('https://mint.example', 1); + `); + expect(existsSync(join(legacyDir, "coco.db-wal"))).toBe(true); + + const assertStopped = mock(async () => {}); + const releaseLock = mock(() => {}); + const acquireLock = mock(() => releaseLock); + const result = await migrateLegacyWallet({ + walletDir, + legacyDir, + assertLegacyStopped: assertStopped, + acquireLegacyLock: acquireLock, + }); + source.close(); + + expect(result.status).toBe("migrated"); + expect(assertStopped).toHaveBeenCalledTimes(2); + expect(acquireLock).toHaveBeenCalledTimes(1); + expect(releaseLock).toHaveBeenCalledTimes(1); + expect(readFileSync(join(walletDir, "config.json"), "utf8")).toBe(config); + const migrated = new Database(join(walletDir, "coco.db"), { readonly: true }); + expect(migrated.query("PRAGMA quick_check").values()).toEqual([["ok"]]); + expect(migrated.query("SELECT state, amount FROM coco_cashu_proofs").values()).toEqual([ + ["ready", 21], + ]); + expect(migrated.query("SELECT counter FROM coco_cashu_counters").get()).toEqual({ + counter: 3, + }); + migrated.close(); + expect(existsSync(join(walletDir, "coco.db-wal"))).toBe(false); + expect(statSync(walletDir).mode & 0o777).toBe(0o700); + expect(statSync(join(walletDir, "config.json")).mode & 0o777).toBe(0o600); + expect(existsSync(join(walletDir, "cocod.pid"))).toBe(false); + expect(existsSync(join(walletDir, "cocod.sock"))).toBe(false); + expect(existsSync(join(legacyDir, "config.json"))).toBe(false); + expect(existsSync(join(legacyDir, "coco.db"))).toBe(false); + expect(existsSync(join(legacyDir, "cocod.pid"))).toBe(true); + expect(existsSync(join(legacyDir, "cocod.sock"))).toBe(true); + const archive = readdirSync(legacyDir).find((name) => name.startsWith("wallet-migrated-")); + expect(archive).toBeDefined(); + expect(existsSync(join(legacyDir, archive!, "config.json"))).toBe(true); + expect(existsSync(join(legacyDir, archive!, "coco.db"))).toBe(true); + }); + + it("treats a config-only initialized wallet as migratable", async () => { + const base = root(); + const legacyDir = join(base, ".cocod"); + const walletDir = join(base, ".routstrd", "wallet"); + mkdirSync(legacyDir); + writeFileSync(join(legacyDir, "config.json"), '{"mnemonic":"seed"}'); + + expect((await migrateLegacyWallet({ walletDir, legacyDir })).status).toBe("migrated"); + expect(existsSync(join(walletDir, "config.json"))).toBe(true); + expect(existsSync(join(walletDir, "coco.db"))).toBe(false); + }); + + it("refuses a database without its mnemonic config", async () => { + const base = root(); + const legacyDir = join(base, ".cocod"); + mkdirSync(legacyDir); + writeFileSync(join(legacyDir, "coco.db"), "database"); + + await expect(migrateLegacyWallet({ walletDir: join(base, "wallet"), legacyDir })).rejects.toThrow( + "without config.json", + ); + }); + + it("refuses orphaned SQLite sidecars", async () => { + const base = root(); + const legacyDir = join(base, ".cocod"); + mkdirSync(legacyDir); + writeFileSync(join(legacyDir, "coco.db-wal"), "orphaned WAL"); + + await expect(migrateLegacyWallet({ walletDir: join(base, "wallet"), legacyDir })).rejects.toThrow( + "sidecar files without coco.db", + ); + }); + + it("refuses when both canonical and legacy configs exist", async () => { + const base = root(); + const legacyDir = join(base, ".cocod"); + const walletDir = join(base, ".routstrd", "wallet"); + mkdirSync(legacyDir, { recursive: true }); + mkdirSync(walletDir, { recursive: true }); + writeFileSync(join(legacyDir, "config.json"), "legacy"); + writeFileSync(join(walletDir, "config.json"), "current"); + + await expect(migrateLegacyWallet({ walletDir, legacyDir })).rejects.toThrow( + "both", + ); + expect(readFileSync(join(walletDir, "config.json"), "utf8")).toBe("current"); + expect(readFileSync(join(legacyDir, "config.json"), "utf8")).toBe("legacy"); + }); +}); diff --git a/src/daemon/wallet/migration.ts b/src/daemon/wallet/migration.ts new file mode 100644 index 0000000..e4e5bc2 --- /dev/null +++ b/src/daemon/wallet/migration.ts @@ -0,0 +1,209 @@ +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + statSync, +} from "fs"; +import { Database } from "bun:sqlite"; +import { basename, dirname, join } from "path"; +import { legacyCocodDir, walletDir } from "./paths"; + +export type WalletMigrationResult = + | { status: "fresh" } + | { status: "already-current" } + | { status: "migrated"; from: string; to: string; cleanupWarnings: string[] }; + +export interface WalletMigrationOptions { + walletDir?: string; + legacyDir?: string; + /** Called after state validation and before legacy files are copied. */ + assertLegacyStopped?: () => void | Promise; + /** Claims the legacy cocod exclusion lock for the duration of the copy. */ + acquireLegacyLock?: () => (() => void) | Promise<() => void>; +} + +type WalletState = "absent" | "database-only" | "initialized"; + +function state(configPath: string, dbPath: string): WalletState { + const hasConfig = existsSync(configPath); + const hasDb = existsSync(dbPath); + if (hasConfig) return "initialized"; + if (hasDb) return "database-only"; + return "absent"; +} + +function filesEqual(left: string, right: string): boolean { + if (!existsSync(left) || !existsSync(right)) return false; + if (statSync(left).size !== statSync(right).size) return false; + return readFileSync(left).equals(readFileSync(right)); +} + +function sqlString(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +type WalletSummary = Record; +const SUMMARY_QUERIES: Record = { + proofs: + "SELECT mintUrl, state, COUNT(*) count, COALESCE(SUM(amount), 0) amount FROM coco_cashu_proofs GROUP BY mintUrl, state ORDER BY mintUrl, state", + counters: + "SELECT mintUrl, keysetId, counter FROM coco_cashu_counters ORDER BY mintUrl, keysetId", + mintOperations: + "SELECT state, COUNT(*) count FROM coco_cashu_mint_operations GROUP BY state ORDER BY state", + sendOperations: + "SELECT state, COUNT(*) count FROM coco_cashu_send_operations GROUP BY state ORDER BY state", + meltOperations: + "SELECT state, COUNT(*) count FROM coco_cashu_melt_operations GROUP BY state ORDER BY state", + mints: "SELECT mintUrl, trusted FROM coco_cashu_mints ORDER BY mintUrl", +}; + +function verifyDatabase(database: Database, label: string): WalletSummary { + const checks = database.query("PRAGMA quick_check").values() as unknown[][]; + if (checks.length !== 1 || checks[0]?.[0] !== "ok") { + throw new Error(`${label} failed PRAGMA quick_check: ${JSON.stringify(checks)}`); + } + + const tables = new Set( + (database + .query("SELECT name FROM sqlite_master WHERE type = 'table'") + .values() as string[][]).map(([name]) => name), + ); + const summary: WalletSummary = {}; + for (const [name, query] of Object.entries(SUMMARY_QUERIES)) { + const table = query.match(/FROM\s+(coco_cashu_\w+)/i)?.[1]; + summary[name] = table && tables.has(table) ? database.query(query).all() : []; + } + return summary; +} + +/** Write a standalone SQLite snapshot containing committed WAL frames. */ +function snapshotDatabase(sourcePath: string, destinationPath: string): void { + const source = new Database(sourcePath); + let sourceSummary: WalletSummary; + try { + sourceSummary = verifyDatabase(source, "Legacy wallet database"); + source.exec(`VACUUM INTO ${sqlString(destinationPath)}`); + } finally { + source.close(); + } + + const destination = new Database(destinationPath, { readonly: true }); + try { + const destinationSummary = verifyDatabase(destination, "Staged wallet database"); + if (JSON.stringify(sourceSummary) !== JSON.stringify(destinationSummary)) { + throw new Error( + "Staged wallet database does not contain the same proofs, counters, operations, and mints as the legacy database.", + ); + } + } finally { + destination.close(); + } +} + +/** + * Copy a legacy cocod wallet into the canonical routstrd wallet directory. + * The staging directory is renamed atomically, so the canonical path is never + * exposed with only one of its two required files. + */ +export async function migrateLegacyWallet( + options: WalletMigrationOptions = {}, +): Promise { + const targetDir = options.walletDir || walletDir(); + const sourceDir = options.legacyDir || legacyCocodDir(); + const targetConfig = join(targetDir, "config.json"); + const targetDb = join(targetDir, "coco.db"); + const sourceConfig = join(sourceDir, "config.json"); + const sourceDb = join(sourceDir, "coco.db"); + const sourceWal = `${sourceDb}-wal`; + const sourceShm = `${sourceDb}-shm`; + const targetState = state(targetConfig, targetDb); + const sourceState = state(sourceConfig, sourceDb); + + // config.json is sufficient for a newly initialized wallet; coco.db is + // created on first open. A database without its mnemonic is never usable. + if (targetState === "initialized" && sourceState === "initialized") { + // A prior migration may have committed successfully but failed to remove + // its source files. Identical leftovers are safe; divergent wallets are not. + const configsMatch = filesEqual(targetConfig, sourceConfig); + const databasesMatch = + !existsSync(targetDb) && !existsSync(sourceDb) + ? true + : filesEqual(targetDb, sourceDb); + if (configsMatch && databasesMatch) return { status: "already-current" }; + throw new Error( + `Cannot migrate wallet: both ${targetDir} and ${sourceDir} contain different wallet data. ` + + "Refusing to choose a mnemonic or merge wallet databases automatically.", + ); + } + if (targetState === "initialized") return { status: "already-current" }; + if (!existsSync(sourceDb) && (existsSync(sourceWal) || existsSync(sourceShm))) { + throw new Error( + `Cannot migrate wallet: ${sourceDir} contains SQLite sidecar files without coco.db. ` + + "Restore the matching main database before migration.", + ); + } + if (targetState === "database-only" || sourceState === "database-only") { + throw new Error( + `Cannot migrate wallet: ${targetDir} is ${targetState} and ${sourceDir} is ${sourceState}. ` + + "A coco.db without config.json cannot be opened; restore the matching config first.", + ); + } + if (sourceState === "absent") return { status: "fresh" }; + + await options.assertLegacyStopped?.(); + const releaseLegacyLock = await options.acquireLegacyLock?.(); + const stagingDir = join( + dirname(targetDir), + `.${targetDir.split(/[\\/]/).at(-1) || "wallet"}.staging-${process.pid}-${Date.now()}`, + ); + + try { + // Recheck after claiming the exclusion lock to close the probe/claim race. + await options.assertLegacyStopped?.(); + mkdirSync(dirname(targetDir), { recursive: true, mode: 0o700 }); + mkdirSync(stagingDir, { mode: 0o700 }); + const stagedConfig = join(stagingDir, "config.json"); + const stagedDb = join(stagingDir, "coco.db"); + copyFileSync(sourceConfig, stagedConfig); + chmodSync(stagedConfig, 0o600); + const hasSourceDb = existsSync(sourceDb); + if (hasSourceDb) { + // A SQLite WAL is part of the logical database. Raw-copying coco.db can + // silently omit proofs and counters that have not yet been checkpointed. + snapshotDatabase(sourceDb, stagedDb); + chmodSync(stagedDb, 0o600); + } + + if (statSync(stagedConfig).size !== statSync(sourceConfig).size) { + throw new Error("Cannot migrate wallet: staged config failed size validation."); + } + + renameSync(stagingDir, targetDir); + } catch (error) { + rmSync(stagingDir, { recursive: true, force: true }); + throw error; + } finally { + releaseLegacyLock?.(); + } + + // Preserve the complete legacy SQLite file set for manual recovery instead + // of deleting the only rollback copy immediately after migration. + const cleanupWarnings: string[] = []; + const sourceFiles = [sourceConfig, sourceDb, sourceWal, sourceShm].filter(existsSync); + if (sourceFiles.length > 0) { + const archiveDir = join(sourceDir, `wallet-migrated-${Date.now()}`); + try { + mkdirSync(archiveDir, { mode: 0o700 }); + for (const path of sourceFiles) renameSync(path, join(archiveDir, basename(path))); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + cleanupWarnings.push(`Could not archive legacy wallet files: ${message}`); + } + } + + return { status: "migrated", from: sourceDir, to: targetDir, cleanupWarnings }; +} diff --git a/src/daemon/wallet/paths.ts b/src/daemon/wallet/paths.ts new file mode 100644 index 0000000..bdbd3e7 --- /dev/null +++ b/src/daemon/wallet/paths.ts @@ -0,0 +1,33 @@ +import { join } from "path"; + +function homeDir(): string { + return process.env.HOME || process.env.USERPROFILE || ""; +} + +/** Root directory for routstrd state. Evaluated lazily so env overrides work in tests. */ +export function routstrdConfigDir(): string { + return process.env.ROUTSTRD_DIR || join(homeDir(), ".routstrd"); +} + +/** Canonical directory for the in-process Cashu wallet. */ +export function walletDir(): string { + return process.env.ROUTSTRD_WALLET_DIR || join(routstrdConfigDir(), "wallet"); +} + +/** Lock owned by an in-process routstrd wallet instance. */ +export function walletPidPath(): string { + return process.env.ROUTSTRD_WALLET_PID || join(walletDir(), "wallet.pid"); +} + +/** Legacy external cocod directory. This is not the routstrd wallet directory. */ +export function legacyCocodDir(): string { + return process.env.COCOD_DIR || join(homeDir(), ".cocod"); +} + +export function legacyCocodSocketPath(): string { + return process.env.COCOD_SOCKET || join(legacyCocodDir(), "cocod.sock"); +} + +export function legacyCocodPidPath(): string { + return process.env.COCOD_PID || join(legacyCocodDir(), "cocod.pid"); +} diff --git a/src/start-daemon.ts b/src/start-daemon.ts index 68e767a..a9c58a5 100644 --- a/src/start-daemon.ts +++ b/src/start-daemon.ts @@ -1,18 +1,113 @@ -import { openSync } from "fs"; +import { + closeSync, + existsSync, + fstatSync, + openSync, + readSync, +} from "fs"; import { logger } from "./utils/logger"; import { CONFIG_DIR, LOGS_DIR } from "./utils/config"; import { withCrossProcessLock } from "./utils/process-lock"; import { fileURLToPath } from "url"; -import { existsSync } from "fs"; const DAEMON_STARTUP_LOCK_PATH = `${CONFIG_DIR}/routstrd-startup.lock`; const DEBUG_LOG_PATH = `${CONFIG_DIR}/debug.log`; -async function isDaemonHealthy(port: string): Promise { +/** + * The spawned daemon's stdout/stderr is redirected to DEBUG_LOG_PATH, so when + * it exits early we can surface its actual error from there instead of just + * telling the user to go read logs. + */ +function readDaemonOutput(offset: number): string { + let fd: number | undefined; + try { + if (!existsSync(DEBUG_LOG_PATH)) return ""; + fd = openSync(DEBUG_LOG_PATH, "r"); + const size = fstatSync(fd).size; + if (size <= offset) return ""; + const bytesToRead = Math.min(size - offset, 256 * 1024); + const buffer = Buffer.allocUnsafe(bytesToRead); + const bytesRead = readSync(fd, buffer, 0, bytesToRead, offset); + return buffer + .toString("utf8", 0, bytesRead) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .slice(-30) + .join("\n"); + } catch { + return ""; + } finally { + if (fd !== undefined) closeSync(fd); + } +} + +const STARTUP_LOG_PREFIX = "[routstrd:start]"; + +/** + * Print only explicitly tagged, user-safe progress emitted by the detached + * daemon. Return the next byte offset so each line is shown exactly once. + */ +function printStartupProgress(offset: number): { + offset: number; + lastMessage?: string; +} { + let fd: number | undefined; + try { + if (!existsSync(DEBUG_LOG_PATH)) return { offset }; + fd = openSync(DEBUG_LOG_PATH, "r"); + const size = fstatSync(fd).size; + if (size <= offset) return { offset }; + + // Startup messages are short. Bound each read so a noisy daemon cannot + // make the waiting CLI repeatedly load a large debug log into memory. + const bytesToRead = Math.min(size - offset, 64 * 1024); + const buffer = Buffer.allocUnsafe(bytesToRead); + const bytesRead = readSync(fd, buffer, 0, bytesToRead, offset); + const lastNewlineIndex = buffer.subarray(0, bytesRead).lastIndexOf(0x0a); + const completeBytes = lastNewlineIndex + 1; + if (completeBytes === 0) return { offset }; + const appended = buffer.toString("utf8", 0, completeBytes); + + let lastMessage: string | undefined; + for (const line of appended.split("\n")) { + const markerIndex = line.indexOf(STARTUP_LOG_PREFIX); + if (markerIndex === -1) continue; + const message = line.slice(markerIndex + STARTUP_LOG_PREFIX.length).trim(); + if (message) { + console.log(` ${message}`); + lastMessage = message; + } + } + return { offset: offset + completeBytes, lastMessage }; + } catch { + return { offset }; + } finally { + if (fd !== undefined) closeSync(fd); + } +} + +function formatElapsed(elapsedMs: number): string { + const seconds = Math.floor(elapsedMs / 1000); + if (seconds < 60) return `${seconds}s`; + return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; +} + +function fileSize(path: string): number { + let fd: number | undefined; + try { + fd = openSync(path, "r"); + return fstatSync(fd).size; + } finally { + if (fd !== undefined) closeSync(fd); + } +} + +async function isDaemonHealthy(port: string, host = "127.0.0.1"): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 2000); try { - const existing = await fetch(`http://localhost:${port}/health`, { + const existing = await fetch(`http://${host}:${port}/health`, { signal: controller.signal, }); return existing.ok; @@ -23,22 +118,31 @@ async function isDaemonHealthy(port: string): Promise { } } +function clientHost(host?: string): string { + return !host || host === "0.0.0.0" ? "127.0.0.1" : host; +} + async function startDaemonUnlocked( - options: { port?: string; provider?: string }, + options: { port?: string; host?: string; provider?: string }, ): Promise { const args: string[] = []; const port = options.port || "8008"; + const host = options.host || "127.0.0.1"; + const ch = clientHost(host); const pollIntervalMs = 250; const startupTimeoutMs = 10 * 60 * 1000; - if (await isDaemonHealthy(port)) { - console.log(`Routstr daemon already running on http://localhost:${port}/v1`); + if (await isDaemonHealthy(port, ch)) { + console.log(`Routstr daemon already running on http://${ch}:${port}/v1`); return; } if (options.port) { args.push("--port", options.port); } + if (options.host) { + args.push("--host", options.host); + } if (options.provider) { args.push("--provider", options.provider); } @@ -48,6 +152,9 @@ async function startDaemonUnlocked( daemonScript = fileURLToPath(new URL("./daemon/index.ts", import.meta.url)); } + const debugLogOffset = existsSync(DEBUG_LOG_PATH) + ? fileSize(DEBUG_LOG_PATH) + : 0; const debugLogFd = openSync(DEBUG_LOG_PATH, "a"); const proc = Bun.spawn(["bun", daemonScript, ...args], { @@ -64,18 +171,47 @@ async function startDaemonUnlocked( exitCode = code; }); + const startedAt = Date.now(); + const heartbeatIntervalMs = 10_000; + let nextHeartbeatAt = heartbeatIntervalMs; + let progressLogOffset = debugLogOffset; + let currentPhase = "starting daemon"; + const maxPolls = Math.ceil(startupTimeoutMs / pollIntervalMs); for (let i = 0; i < maxPolls; i++) { await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + const progress = printStartupProgress(progressLogOffset); + progressLogOffset = progress.offset; + if (progress.lastMessage) { + currentPhase = progress.lastMessage + .replace(/\.$/, "") + .toLowerCase(); + } + + const elapsedMs = Date.now() - startedAt; + if (elapsedMs >= nextHeartbeatAt) { + console.log( + ` Still ${currentPhase} (${formatElapsed(elapsedMs)} elapsed)...`, + ); + nextHeartbeatAt += heartbeatIntervalMs; + } + if (exitCode !== null) { + const daemonOutput = readDaemonOutput(debugLogOffset); throw new Error( - `Daemon process exited early with code ${exitCode}. Check logs in ${LOGS_DIR}`, + `Daemon process exited early with code ${exitCode}.` + + (daemonOutput + ? `\n\nDaemon output:\n${daemonOutput}` + : ` Check logs in ${LOGS_DIR}`), ); } - if (await isDaemonHealthy(port)) { - console.log(`Routstr daemon started (PID: ${proc.pid}).`); + if (await isDaemonHealthy(port, ch)) { + printStartupProgress(progressLogOffset); + console.log( + `Routstr daemon started (PID: ${proc.pid}, ${formatElapsed(Date.now() - startedAt)}).`, + ); return; } } @@ -86,13 +222,15 @@ async function startDaemonUnlocked( } export async function startDaemon( - options: { port?: string; provider?: string } = {}, + options: { port?: string; host?: string; provider?: string } = {}, ): Promise { const port = options.port || "8008"; + const host = options.host || "127.0.0.1"; + const ch = clientHost(host); const startupTimeoutMs = 10 * 60 * 1000; - if (await isDaemonHealthy(port)) { - console.log(`Routstr daemon already running on http://localhost:${port}/v1`); + if (await isDaemonHealthy(port, ch)) { + console.log(`Routstr daemon already running on http://${ch}:${port}/v1`); return; } diff --git a/src/utils/config.ts b/src/utils/config.ts index 202b2ca..2ebe28f 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -32,6 +32,7 @@ export interface NwcConfig { export interface RoutstrdConfig { port: number; + host: string; provider: string | null; cocodPath: string | null; mode?: "xcashu" | "apikeys"; @@ -58,6 +59,7 @@ export interface RoutstrdConfig { export const DEFAULT_CONFIG: RoutstrdConfig = { port: 8008, + host: "127.0.0.1", provider: null, cocodPath: null, mode: "apikeys", diff --git a/src/utils/daemon-client.ts b/src/utils/daemon-client.ts index 1086072..ff27906 100644 --- a/src/utils/daemon-client.ts +++ b/src/utils/daemon-client.ts @@ -30,9 +30,11 @@ export async function loadConfig(): Promise { } export function getDaemonBaseUrl(config: RoutstrdConfig): string { - return ( - config.daemonUrl?.replace(/\/$/, "") || `http://localhost:${config.port}` - ); + if (config.daemonUrl) { + return config.daemonUrl.replace(/\/$/, ""); + } + const host = config.host === "0.0.0.0" ? "127.0.0.1" : config.host; + return `http://${host}:${config.port}`; } export function getAuthBaseUrl(config: RoutstrdConfig): string { @@ -145,6 +147,7 @@ export async function startDaemonProcess(): Promise { const config = await loadConfig(); await startDaemon({ port: String(config.port || 8008), + host: config.host || undefined, provider: config.provider || undefined, }); } diff --git a/src/utils/logger.ts b/src/utils/logger.ts index cc317e0..fa77fb6 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,26 +1,30 @@ -import { appendFile, mkdir } from "fs/promises"; -import { existsSync } from "fs"; +import { appendFileSync, existsSync, mkdirSync } from "fs"; import { join } from "path"; const HOME = process.env.HOME || process.env.USERPROFILE || ""; const LOG_DIR = process.env.ROUTSTRD_DIR || `${HOME}/.routstrd`; const LOGS_DIR = join(LOG_DIR, "logs"); +/** Wallet-engine (coco-core / Cashu) diagnostics land in their own directory. */ +export const COCO_LOGS_DIR = join(LOG_DIR, "coco-logs"); -function getLogFileForDate(date: Date = new Date()): string { +function getLogFileForDate(logsDir: string, date: Date = new Date()): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); - return join(LOGS_DIR, `${year}-${month}-${day}.log`); + return join(logsDir, `${year}-${month}-${day}.log`); } -async function ensureLogDir() { - if (!existsSync(LOGS_DIR)) { - await mkdir(LOGS_DIR, { recursive: true }); +function ensureDir(dir: string) { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); } } -async function writeLog(level: string, ...args: unknown[]) { - await ensureLogDir(); +// NOTE: writes are synchronous on purpose — the daemon calls process.exit() +// right after logger.error(...) on fatal paths, and async writes were being +// silently dropped, making startup failures invisible. +function writeLog(logsDir: string, level: string, ...args: unknown[]) { + ensureDir(logsDir); const timestamp = new Date().toISOString(); const message = args .map((a) => { @@ -38,28 +42,31 @@ async function writeLog(level: string, ...args: unknown[]) { }) .join(" "); const line = `[${timestamp}] [${level}] ${message}\n`; - const logFile = getLogFileForDate(new Date(timestamp)); + const logFile = getLogFileForDate(logsDir, new Date(timestamp)); try { - await appendFile(logFile, line); + appendFileSync(logFile, line); } catch (error) { console.error("Failed to write log:", error); } } -export const logger = { - log: (...args: unknown[]) => { - writeLog("INFO", ...args); - }, - debug: (...args: unknown[]) => { - writeLog("DEBUG", ...args); - }, - warn: (...args: unknown[]) => { - writeLog("WARN", ...args); - }, - error: (...args: unknown[]) => { - writeLog("ERROR", ...args); - }, - info: (...args: unknown[]) => { - writeLog("INFO", ...args); - }, -}; +export interface FileLogger { + log: (...args: unknown[]) => void; + debug: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + info: (...args: unknown[]) => void; +} + +function createLogger(logsDir: string): FileLogger { + return { + log: (...args) => writeLog(logsDir, "INFO", ...args), + debug: (...args) => writeLog(logsDir, "DEBUG", ...args), + warn: (...args) => writeLog(logsDir, "WARN", ...args), + error: (...args) => writeLog(logsDir, "ERROR", ...args), + info: (...args) => writeLog(logsDir, "INFO", ...args), + }; +} + +export const logger = createLogger(LOGS_DIR); +export const cocoLogger = createLogger(COCO_LOGS_DIR); diff --git a/tests/wallet/short-keyset-token.test.ts b/tests/wallet/short-keyset-token.test.ts new file mode 100644 index 0000000..cc3ee30 --- /dev/null +++ b/tests/wallet/short-keyset-token.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, mock } from "bun:test"; +import { + Amount, + getDecodedToken, + getEncodedToken, +} from "@cashu/cashu-ts"; +import { + createWalletAdapter, + decodeCashuTokenAmount, +} from "../../src/daemon/wallet"; +import type { CocodClient } from "../../src/daemon/wallet/cocod-client"; + +// A full modern keyset ID with the same format as Minibits' post-migration +// active keyset. getEncodedToken stores only its first eight bytes in TokenV4. +const FULL_KEYSET_ID = + "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821"; +const LEGACY_KEYSET_ID = "00107937db0cc865"; + +function makeToken({ + amounts = [5], + keysetId = FULL_KEYSET_ID, + unit = "sat", +}: { + amounts?: number[]; + keysetId?: string; + unit?: string; +} = {}): string { + return getEncodedToken({ + mint: "https://mint.minibits.cash/Bitcoin", + unit, + proofs: amounts.map((amount, index) => ({ + id: keysetId, + amount: Amount.from(amount), + secret: `short-keyset-regression-fixture-${index}`, + C: `02${(index + 1).toString(16).padStart(2, "0").repeat(32)}`, + })), + }); +} + +function makeWalletClient( + receiveCashu: CocodClient["receiveCashu"], +): CocodClient { + // createWalletAdapter is intentionally lazy; this receive-path test only + // needs the one capability exercised by receiveToken. + return { receiveCashu } as CocodClient; +} + +describe("short keyset TokenV4 compatibility", () => { + it("reads amount metadata without resolving the shortened proof keyset ID", () => { + const token = makeToken({ amounts: [1, 4] }); + + // Guard the fixture itself: a full proof decode with no mint keysets must + // exercise the same short-ID failure that triggered this regression. + expect(() => getDecodedToken(token, [])).toThrow(/short keyset ID/i); + expect(decodeCashuTokenAmount(token)).toEqual({ + amount: 5, + unit: "sat", + }); + }); + + for (const tokenCase of [ + { + name: "modern keyset with msat unit", + input: { amounts: [500, 1000], unit: "msat" }, + expected: { amount: 1500, unit: "msat" as const }, + }, + { + name: "legacy v0 keyset", + input: { amounts: [2, 8], keysetId: LEGACY_KEYSET_ID }, + expected: { amount: 10, unit: "sat" as const }, + }, + ]) { + it(`reads ${tokenCase.name} metadata`, () => { + expect(decodeCashuTokenAmount(makeToken(tokenCase.input))).toEqual( + tokenCase.expected, + ); + }); + } + + it("reports success after cocod receives a short-keyset token", async () => { + const receiveCashu = mock(async () => "Received 5"); + const walletClient = makeWalletClient(receiveCashu); + const adapter = await createWalletAdapter({ walletClient }); + + const result = await adapter.receiveToken(makeToken()); + + expect(receiveCashu).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + success: true, + amount: 5, + unit: "sat", + message: "Received 5", + }); + }); + + it("does not call the wallet when token metadata is invalid", async () => { + const receiveCashu = mock(async () => "should not be called"); + const walletClient = makeWalletClient(receiveCashu); + const adapter = await createWalletAdapter({ walletClient }); + + const result = await adapter.receiveToken("cashuBnot-a-valid-token"); + + expect(receiveCashu).not.toHaveBeenCalled(); + expect(result.success).toBe(false); + }); +});