mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 12:28:23 +00:00
Merge pull request #61 from Routstr/coco-integration
feat: Coco integration — migrate from cocod IPC to coco-core library
This commit is contained in:
@@ -32,5 +32,8 @@ temp/
|
||||
.worktrees/
|
||||
.routstrd/
|
||||
|
||||
# Graphify output
|
||||
graphify-out/
|
||||
|
||||
# Standalone prototype scripts
|
||||
index.ts
|
||||
|
||||
@@ -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`
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user