Merge pull request #61 from Routstr/coco-integration

feat: Coco integration — migrate from cocod IPC to coco-core library
This commit is contained in:
redshift
2026-08-15 13:02:18 +01:00
committed by GitHub
26 changed files with 3572 additions and 333 deletions
+3
View File
@@ -32,5 +32,8 @@ temp/
.worktrees/
.routstrd/
# Graphify output
graphify-out/
# Standalone prototype scripts
index.ts
+253
View File
@@ -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 || <config root>/wallet
canonical wallet config <wallet directory>/config.json
canonical wallet database <wallet directory>/coco.db
canonical wallet lock process.env.ROUTSTRD_WALLET_PID || <wallet directory>/wallet.pid
legacy cocod directory process.env.COCOD_DIR || ~/.cocod
legacy cocod socket process.env.COCOD_SOCKET || <legacy directory>/cocod.sock
legacy cocod PID process.env.COCOD_PID || <legacy directory>/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`
+41
View File
@@ -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
}
+13 -3
View File
@@ -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>/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 <number>` | 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 <url>`
Set the persistent default mint. If necessary, the mint is added as trusted first.
### `routstrd wallet mints info <url>`
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 |
+69 -41
View File
@@ -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=="],
}
}
+6 -1
View File
@@ -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",
+55
View File
@@ -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);
});
});
+343 -182
View File
@@ -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<void> {
console.log(`${qr}\nInvoice:\n${invoice}`);
}
async function installCocodOrExit(): Promise<void> {
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<void> {
const config = await loadConfig();
@@ -129,23 +150,28 @@ async function restartDaemonsAfterUpdate(): Promise<void> {
} 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<void> {
}
}
// --- 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<void> {
@@ -274,76 +239,39 @@ async function initDaemon(): Promise<void> {
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<Uint8Array>({
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<Uint8Array>({
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>", "Port to listen on")
.option("--host <host>", "Bind address (default: 127.0.0.1)")
.option("-p, --provider <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 <baseUrl>",
"Delete the API key stored for the given provider base URL (refunds balance first)",
)
.option(
"--mint-url <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>", "Number of entries to show", "50")
.option("--offset <number>", "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<string, unknown>[]; 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<string, unknown>) => {
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<string, unknown>;
const display: Record<string, unknown> = {};
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<string, unknown>) => {
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 <url>")
.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 <name>")
.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>", "Port to listen on")
.option("--host <host>", "Bind address (default: 127.0.0.1)")
.option("-p, --provider <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>", "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);
}
+8 -1
View File
@@ -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 };
}
+267 -14
View File
@@ -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<string, number>;
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<string, unknown>;
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);
});
+98 -7
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
);
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<void> {
}
};
let walletDisposePromise: Promise<void> | undefined;
const disposeWallet = (): Promise<void> => {
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<void> {
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);
});
}
+26 -21
View File
@@ -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<void> | null = null;
const ensureProvidersBootstrapped = (): Promise<void> => {
@@ -77,33 +82,33 @@ export function createModelService(modelManager: ModelManager, store: SdkStore)
): Promise<ModelWithProviders | null> => {
await ensureProvidersBootstrapped();
const allModels = modelManager.getAllCachedModels();
const providers: ModelProviderInfo[] = [];
const disabledSet = new Set<string>(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);
+252
View File
@@ -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<Response>;
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<typeof CocodHttpError>).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");
});
});
+508
View File
@@ -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<typeof assertLegacyCocodNotRunning>[0]
>;
type LegacyFetch = NonNullable<GuardOptions["fetchImpl"]>;
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<LegacyFetch>(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<LegacyFetch>(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<LegacyFetch>(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<LegacyFetch>(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<LegacyFetch>(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<LegacyFetch>(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<LegacyFetch>(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<LegacyFetch>(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<LegacyFetch>(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);
});
});
+829
View File
@@ -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<Response>;
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<string, unknown> {
const safe: Record<string, unknown> = {};
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<string, unknown> = {}): 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<string>): boolean {
let current: unknown = error;
const visited = new Set<unknown>();
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<void> {
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<void> {
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<CocodClient> {
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<ReturnType<typeof initializeCoco>> | 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<boolean> {
try {
await coco.wallet.balances.total();
return true;
} catch {
return false;
}
},
async getStatus(): Promise<CocodState> {
try {
await coco.wallet.balances.total();
return "UNLOCKED";
} catch {
return "ERROR";
}
},
async unlock(_passphrase: string): Promise<string> {
// 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<Record<string, number>> {
const byMint = await coco.wallet.balances.byMint();
return Object.fromEntries(
Object.entries(byMint).map(([mintUrl, snapshot]) => [
mintUrl,
snapshot.spendable,
]),
);
},
async receiveCashu(token: string): Promise<string> {
await coco.wallet.receive(token);
return "Token received successfully";
},
async receiveBolt11(amount: number, mintUrl?: string): Promise<string> {
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<string> {
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<string> {
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<string[]> {
const mints = await coco.mint.getAllTrustedMints();
return mints.map((m) => m.mintUrl);
},
async addMint(url: string): Promise<string> {
const mintUrl = normalizeMintUrl(url);
await coco.mint.addMint(mintUrl, { trusted: true });
return `Mint ${mintUrl} added successfully`;
},
async getMintInfo(url: string): Promise<unknown> {
return coco.mint.getMintInfo(normalizeMintUrl(url));
},
async getDefaultMint(): Promise<string | null> {
return walletConfig.defaultMintUrl || null;
},
async setDefaultMint(url: string): Promise<string> {
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<void> {
if (disposed) return;
disposed = true;
try {
await coco.dispose();
} finally {
try {
database.close();
} finally {
releaseLegacyPidClaim();
releaseWalletPidClaim();
}
}
},
async getHistory(offset?: number, limit?: number): Promise<HistoryEntry[]> {
return coco.history.getPaginatedHistory(offset, limit);
},
async getNpcAddress(): Promise<NpcAddress> {
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<NpcUsernameResult> {
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<void> {
await npcApi().sync();
},
};
}
+78
View File
@@ -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<string, { sats?: number } | number>;
/** 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<string[]>;
addMint(url: string): Promise<string>;
getMintInfo(url: string): Promise<unknown>;
getDefaultMint(): Promise<string | null>;
setDefaultMint(url: string): Promise<string>;
/** Release resources held by in-process wallet implementations. */
dispose?(): Promise<void>;
getHistory(offset?: number, limit?: number): Promise<HistoryEntry[]>;
/** NPC (npubx.cash) Lightning address for this wallet. */
getNpcAddress(): Promise<NpcAddress>;
/** Claim an NPC username; pass confirm=true to pay the claim fee from the wallet. */
setNpcUsername(username: string, confirm?: boolean): Promise<NpcUsernameResult>;
/** Manually trigger an NPC quote sync into the wallet. */
syncNpc(): Promise<void>;
}
export function resolveCocodExecutable(cocodPath?: string | null): string {
@@ -359,5 +392,50 @@ export function createCocodClient(
async getMintInfo(url: string): Promise<unknown> {
return post<unknown>("/mints/info", { url });
},
async getDefaultMint(): Promise<string | null> {
return callDaemon<string | null>("/mints/default");
},
async setDefaultMint(url: string): Promise<string> {
return post<string>("/mints/default", { url });
},
async getHistory(_offset?: number, _limit?: number): Promise<HistoryEntry[]> {
return [];
},
async getNpcAddress(): Promise<NpcAddress> {
const address = await callDaemon<string>("/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<NpcUsernameResult> {
// 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<void> {
throw new CocodHttpError(
501,
"Manual NPC sync is not supported by the legacy cocod client.",
);
},
};
}
Binary file not shown.
+34 -19
View File
@@ -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<CocodClient, "receiveCashu">,
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;
}
}
+147
View File
@@ -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");
});
});
+209
View File
@@ -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<void>;
/** 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<string, unknown[]>;
const SUMMARY_QUERIES: Record<string, string> = {
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<WalletMigrationResult> {
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 };
}
+33
View File
@@ -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");
}
+151 -13
View File
@@ -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<boolean> {
/**
* 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<boolean> {
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<boolean> {
}
}
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<void> {
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<void> {
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;
}
+2
View File
@@ -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",
+6 -3
View File
@@ -30,9 +30,11 @@ export async function loadConfig(): Promise<RoutstrdConfig> {
}
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<void> {
const config = await loadConfig();
await startDaemon({
port: String(config.port || 8008),
host: config.host || undefined,
provider: config.provider || undefined,
});
}
+35 -28
View File
@@ -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);
+106
View File
@@ -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);
});
});