From e87860be9f4a0aed0ef1b2a67f9d1bf97bdf5d6e Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:32:51 +0100 Subject: [PATCH 1/3] feat(wallet): migrate cocod data to routstrd wallet directory --- IMPLEMENTATION.md | 253 ++++++++++++++++++++++ README.md | 11 + SKILL.md | 4 +- src/cli.test.ts | 4 +- src/cli.ts | 48 +++- src/daemon/index.ts | 32 ++- src/daemon/wallet/coco-client.npc.test.ts | 22 +- src/daemon/wallet/coco-client.test.ts | 44 +++- src/daemon/wallet/coco-client.ts | 93 +++++--- src/daemon/wallet/migration.test.ts | 107 +++++++++ src/daemon/wallet/migration.ts | 136 ++++++++++++ src/daemon/wallet/paths.ts | 33 +++ src/start-daemon.ts | 1 - 13 files changed, 721 insertions(+), 67 deletions(-) create mode 100644 IMPLEMENTATION.md create mode 100644 src/daemon/wallet/migration.test.ts create mode 100644 src/daemon/wallet/migration.ts create mode 100644 src/daemon/wallet/paths.ts diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md new file mode 100644 index 0000000..9e1f571 --- /dev/null +++ b/IMPLEMENTATION.md @@ -0,0 +1,253 @@ +# Migrate wallet storage from `~/.cocod` to `~/.routstrd/wallet` + +Task: [`routstrd-migrate-cocod-to-routstrd-files`](https://github.com/nodestrich/routstrd) +Event ID: `2f420644005154c5b106017c4a753854bb2660ba834cde7e6e267532cb5448e9` +Priority: 997 · Status: open + +--- + +## Baseline + +This plan is based on `coco-integration` at `86ac688` (after the default-mint, NPC/npubx.cash, and Windows-support changes). + +The task branch is currently based on `04f8c22`; rebase it onto the latest `coco-integration` before implementing this plan. + +## Goal + +Move the in-process Cashu wallet's persistent data from: + +```text +~/.cocod/config.json +~/.cocod/coco.db +``` + +to: + +```text +~/.routstrd/wallet/config.json +~/.routstrd/wallet/coco.db +``` + +The migration must preserve the mnemonic, proofs, default mint, and NPC identity. Existing users must not accidentally get a new wallet, and `cocod` and routstrd must never open the same database concurrently. + +Fresh installs must use `~/.routstrd/wallet` immediately. Existing installs must be migrated automatically and safely; merely continuing to run from `~/.cocod` is not sufficient for this task. + +--- + +## Important corrections to the previous plan + +### 1. Do not rename or migrate `cocod.sock` + +The in-process `coco-core` wallet does not expose a Unix socket. `cocod.sock` belongs to the legacy external `cocod` daemon and is used only to determine whether that daemon is still alive. + +There should therefore be no `wallet.sock`. Do not copy a socket inode into the new directory. + +### 2. Keep legacy-process paths separate from wallet-data paths + +On the current base, `src/daemon/wallet/coco-client.ts` derives all of these from one `CONFIG_DIR`: + +- wallet config and database; +- legacy cocod socket; +- legacy cocod PID/process lock. + +That coupling must be removed. After migration: + +- wallet data comes from `~/.routstrd/wallet`; +- probes and shutdown logic for an external legacy cocod continue to use `~/.cocod/cocod.sock` and `~/.cocod/cocod.pid`; +- routstrd uses `~/.routstrd/wallet/wallet.pid` as the in-process wallet lock. + +While routstrd owns the migrated wallet, it should also retain the existing legacy-cocod exclusion mechanism (claiming the legacy `cocod.pid`) so an old cocod cannot be started against a stale or partially migrated legacy wallet. Acquisition and release of both locks must be rollback-safe. + +### 3. Do not rename `cocodPath` + +`RoutstrdConfig.cocodPath` is an executable path for the external compatibility client, not a wallet storage path. Renaming it to `walletPath` would change its meaning and would not help this migration. + +The current daemon creates `createCocoClient()` directly and injects it into `createWalletAdapter()`, so `cocodPath` is effectively bypassed on the normal in-process path. Cleanup or removal of that compatibility setting is a separate task. + +### 4. Do not implement fallback as the steady state + +A resolver that permanently falls back to `~/.cocod` does not move the data. Legacy detection is needed to initiate migration, but successful startup should resolve to the canonical directory afterward. + +### 5. Account for the updated base + +The latest `coco-integration` adds: + +- `defaultMintUrl` persistence in the wallet's `config.json`; +- the NPC plugin, whose Nostr identity is derived from the same mnemonic; +- `src/daemon/wallet/coco-client.npc.test.ts`; +- Windows support and `USERPROFILE` fallback. + +Migration must preserve the complete config JSON rather than reconstructing selected fields. Path construction must use `path.join` rather than hard-coded `/` separators. + +--- + +## Current runtime path references on the latest base + +### Wallet data and legacy process coordination + +| File | Current responsibility | Required change | +|---|---|---| +| `src/daemon/wallet/coco-client.ts` | Uses one `.cocod` directory for config, DB, socket, and PID; owns the in-process wallet and legacy-cocod guard | Split canonical data paths from legacy process paths; migrate before opening the DB; use `wallet.pid` for the in-process lock | +| `src/daemon/wallet/cocod-client.ts` | External cocod compatibility client; defaults to `.cocod/cocod.sock` | Keep legacy defaults and clarify that they are external-cocod paths | +| `src/cli.ts` | Initializes `.cocod`; two restart paths wait on `.cocod/cocod.pid` | Initialize/migrate the canonical wallet and wait on `wallet.pid` | +| `src/utils/config.ts` | Defines `~/.routstrd`, but no wallet subdirectory | Export canonical and legacy wallet/process path helpers or constants | +| `src/daemon/index.ts` | Calls `createCocoClient()` before building the adapter | Ensure migration occurs before the client opens the DB | + +### Tests and documentation + +| File | Required update | +|---|---| +| `src/cli.test.ts` | Change fresh-wallet expectations and add migration coverage | +| `src/daemon/wallet/coco-client.test.ts` | Update data/lock paths and add migration/dual-lock tests | +| `src/daemon/wallet/coco-client.npc.test.ts` | Use the canonical wallet layout in fixtures; verify migrated mnemonic/config still drives NPC identity | +| `README.md` | Document the new location and migration behavior; retain legacy cocod terminology only where discussing compatibility | +| `SKILL.md` | Update wallet storage and environment-variable documentation | + +The fixture `src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz` keeps its name because it records the fixture's provenance. + +--- + +## Target path model + +Define the path model in one dependency-light module (for example `src/daemon/wallet/paths.ts`, or in `src/utils/config.ts` if that does not introduce a cycle): + +```text +ROUTSTRD config root process.env.ROUTSTRD_DIR || ~/.routstrd +canonical wallet directory process.env.ROUTSTRD_WALLET_DIR || /wallet +canonical wallet config /config.json +canonical wallet database /coco.db +canonical wallet lock process.env.ROUTSTRD_WALLET_PID || /wallet.pid + +legacy cocod directory process.env.COCOD_DIR || ~/.cocod +legacy cocod socket process.env.COCOD_SOCKET || /cocod.sock +legacy cocod PID process.env.COCOD_PID || /cocod.pid +``` + +Use `HOME` with `USERPROFILE` fallback, matching the updated Windows-support base. Use functions or injectable path objects where tests need to change environment variables after module import. + +`COCOD_DIR`, `COCOD_SOCKET`, and `COCOD_PID` remain compatibility controls for locating an old external cocod. They must not redirect the new in-process wallet away from `~/.routstrd/wallet`. `ROUTSTRD_WALLET_DIR` is the new explicit data-directory override. + +--- + +## Implementation plan + +### Phase 1 — Centralize and separate paths + +1. Add the canonical wallet and legacy cocod path definitions above. +2. Remove the local `.cocod` path construction from `coco-client.ts` and `cli.ts`. +3. Change `CreateCocoClientOptions` so tests/tooling can independently override: + - wallet data directory; + - wallet lock path; + - legacy cocod socket and PID paths. +4. Keep `cocod-client.ts` pointed at the legacy socket. Do not make it import a resolver that prefers the new wallet directory. + +### Phase 2 — Add a safe automatic migration primitive + +Add an idempotent `migrateLegacyWallet()` helper with injectable paths/filesystem operations for tests. + +Preconditions and behavior: + +1. If the canonical wallet already contains both `config.json` and `coco.db`, return `already-current` and do not touch legacy data. +2. If neither canonical nor legacy wallet data exists, return `fresh`; initialization will create the canonical directory. +3. If only the legacy wallet exists: + - first verify that legacy cocod is not running using the existing PID and socket guard; + - create a private staging directory under `~/.routstrd` with mode `0700`; + - copy the complete `config.json` and `coco.db` into staging without parsing or rewriting them; + - apply `0600` to both files; + - validate that the staged files exist and have the expected byte sizes; + - atomically rename the staged directory to `wallet`; + - only after the canonical directory is committed, remove the legacy `config.json` and `coco.db`; + - never copy `cocod.sock` or `cocod.pid`. +4. If canonical storage is partial, legacy storage is partial, both contain wallet data, or files conflict, stop with an actionable error. Never merge databases and never generate a new mnemonic over an ambiguous state. +5. Clean up an uncommitted staging directory after failure. A committed canonical wallet remains authoritative if cleanup of the old files fails; report that cleanup warning clearly. + +The migration should preserve unknown config fields, including `defaultMintUrl`, and preserve the database byte-for-byte. This also preserves the NPC identity because that identity is derived from the mnemonic. + +A staging copy plus atomic directory rename is preferred over two independent file renames: a crash must not expose a half-created canonical wallet. Copying also permits a clear rollback before commit and supports a legacy directory located on another filesystem through `COCOD_DIR`. + +### Phase 3 — Run migration from every wallet-opening path + +1. **CLI onboarding/init:** run migration before `initializeWallet()`. Only initialize a new mnemonic when migration reports `fresh`. +2. **Daemon direct startup:** run migration before `createCocoClient()` opens `coco.db`. This covers users who invoke the daemon without rerunning onboarding. +3. **Start/restart/update/service paths:** retain `stopLegacyCocod()` before migration/startup where those paths already stop legacy cocod. Direct daemon startup should refuse with the existing actionable error rather than silently running two wallet engines. +4. Print a concise success message showing old and new directories, but never print config contents or the mnemonic during migration. + +A separate `routstrd wallet migrate` command is optional as a manual recovery/preview entry point, but it must call the same migration primitive. It is not a substitute for automatic migration. + +### Phase 4 — Separate process locking + +Refactor the current `claimLegacyCocodPidFile()` behavior into explicit responsibilities: + +1. Claim `wallet.pid` for the lifetime of the in-process wallet to prevent two routstrd wallet instances from opening `coco.db`. +2. Continue claiming the legacy `cocod.pid` while routstrd is active, after verifying no real cocod owns it, to prevent an old cocod from starting. +3. If either claim fails, release any claim already acquired before returning the error. +4. On `CocodClient.dispose()`, startup failure, and daemon shutdown, release only PID files still owned by the current process. +5. Keep `stopLegacyCocod()` and `assertLegacyCocodNotRunning()` operating only on legacy cocod paths. +6. Update both PID-release waits in `src/cli.ts` (`restartDaemonsAfterUpdate` and `restart`) to wait for the canonical `wallet.pid` rather than `.cocod/cocod.pid`. + +There is no canonical wallet socket. + +### Phase 5 — Update initialization and client defaults + +1. Make `initializeWallet()` default to the canonical wallet directory. +2. Preserve directory mode `0700` and config mode `0600` on both migrated and fresh wallets. +3. In `createCocoClient()`, derive `config.json` and `coco.db` from the canonical wallet directory, but take legacy guard paths separately. +4. Update the wallet-access comment near `unlock()` to reference `~/.routstrd/wallet/config.json`. +5. Leave `CocodClient`, `resolveCocodExecutable()`, and `cocodPath` compatibility behavior unchanged. + +### Phase 6 — Tests + +Add or update tests for: + +- fresh initialization creates `~/.routstrd/wallet`, not `.cocod`; +- a legacy config and database migrate byte-for-byte; +- `defaultMintUrl` and unknown config fields survive migration; +- the NPC-derived identity is unchanged after migration; +- migration never copies socket or PID files; +- migration refuses while a real legacy cocod is running; +- stale legacy socket/PID handling remains safe; +- an existing complete canonical wallet wins without modifying it; +- partial canonical, partial legacy, and conflicting dual-wallet states fail without generating a mnemonic; +- staging cleanup and retry after an interrupted migration; +- custom `ROUTSTRD_DIR`, `ROUTSTRD_WALLET_DIR`, and legacy `COCOD_DIR` paths; +- Windows-compatible path construction; +- acquiring the second process lock rolls back the first on failure; +- `dispose()` releases both owned locks and does not unlink another process's lock; +- both CLI restart paths wait for `wallet.pid`. + +Keep `cocod-0.0.24-wallet.db.gz` unchanged and continue using it to prove the migrated database opens successfully with the current in-process wallet. + +### Phase 7 — Documentation + +Update `README.md` and `SKILL.md` to state: + +- wallet data is stored in `~/.routstrd/wallet`; +- existing `~/.cocod` data is migrated automatically on first startup; +- users must back up the mnemonic before migration; +- `ROUTSTRD_WALLET_DIR` overrides the canonical wallet directory; +- `COCOD_DIR`, `COCOD_SOCKET`, `COCOD_PID`, and `cocodPath` refer only to legacy external-cocod compatibility. + +Do not replace every use of the word `cocod`: references to the external daemon, compatibility client, legacy guard, package, and fixture are still accurate. + +--- + +## Acceptance criteria + +- A fresh install creates wallet data only under `~/.routstrd/wallet`. +- Starting from a valid `.cocod` wallet results in the same config and database under the canonical directory without changing balances, mnemonic-derived NPC identity, or default mint. +- No startup path silently creates a new mnemonic when recoverable legacy data exists. +- A running legacy cocod blocks migration and database opening. +- The new wallet directory contains no copied Unix socket and uses `wallet.pid` only as an in-process lock. +- Legacy cocod probing and exclusion continue to use `.cocod/cocod.sock` and `.cocod/cocod.pid`. +- The implementation works with `USERPROFILE`/Windows path construction and custom directory overrides. +- All wallet, CLI, typecheck, and build tests pass on the rebased `coco-integration` branch. + +--- + +## Suggested commit sequence + +1. `refactor(wallet): separate routstrd wallet paths from legacy cocod paths` +2. `feat(wallet): atomically migrate legacy cocod wallet data` +3. `fix(wallet): use independent routstrd and legacy cocod process locks` +4. `test(wallet): cover migration, conflicts, NPC identity, and lock rollback` +5. `docs: document routstrd wallet storage and legacy migration` diff --git a/README.md b/README.md index f944210..f776e15 100644 --- a/README.md +++ b/README.md @@ -161,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`: diff --git a/SKILL.md b/SKILL.md index 0ac5272..1ac182a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -170,7 +170,9 @@ Refresh routstr21 models from Nostr and re-run integrations for all registered c | Variable | Default | Description | |----------|---------|-------------| | `ROUTSTRD_DIR` | `~/.routstrd` | Config directory | -| `COCOD_DIR` | `~/.cocod` | Wallet config directory | +| `ROUTSTRD_WALLET_DIR` | `~/.routstrd/wallet` | In-process Cashu wallet data directory | +| `ROUTSTRD_WALLET_PID` | `/wallet.pid` | In-process wallet lock path | +| `COCOD_DIR` | `~/.cocod` | Legacy external cocod compatibility directory | ### `routstrd mode` diff --git a/src/cli.test.ts b/src/cli.test.ts index a617dfb..0886893 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -24,7 +24,7 @@ afterEach(() => { describe("initializeWallet", () => { test("creates the wallet directory and config with restrictive permissions", () => { - const walletDir = join(makeTempDir(), ".cocod"); + const walletDir = join(makeTempDir(), "wallet"); initializeWallet(walletDir); @@ -39,7 +39,7 @@ describe("initializeWallet", () => { }); test("repairs permissions without replacing an existing wallet", () => { - const walletDir = join(makeTempDir(), ".cocod"); + const walletDir = join(makeTempDir(), "wallet"); const walletConfig = join(walletDir, "config.json"); const existingConfig = JSON.stringify({ mnemonic: "existing seed" }); diff --git a/src/cli.ts b/src/cli.ts index cd38571..5fab864 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,6 +17,7 @@ import { } from "./utils/clients"; import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs"; import { execSync } from "child_process"; +import { dirname, join } from "path"; import { CONFIG_DIR, DB_PATH, @@ -27,7 +28,18 @@ import { } from "./utils/config"; import { logger } from "./utils/logger"; import { setupIntegration, runIntegrationsForClients } from "./integrations"; -import { stopLegacyCocod } from "./daemon/wallet/coco-client"; +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"; @@ -85,12 +97,8 @@ async function printLightningInvoice(invoice: string): Promise { console.log(`${qr}\nInvoice:\n${invoice}`); } -export function initializeWallet( - walletDir = - process.env.COCOD_DIR || - `${process.env.HOME || process.env.USERPROFILE || ""}/.cocod`, -): void { - const walletConfig = `${walletDir}/config.json`; +export function initializeWallet(walletDir = defaultWalletDir()): void { + const walletConfig = join(walletDir, "config.json"); // The wallet directory and config contain the plaintext seed phrase. Correct // permissions on existing installations as well as newly created ones. @@ -144,8 +152,8 @@ async function restartDaemonsAfterUpdate(): Promise { await callDaemon("/stop", { method: "POST" }); - // Wait for HTTP health check to fail AND pidfile to be released. - const pidFilePath = `${process.env.HOME || process.env.USERPROFILE || ""}/.cocod/cocod.pid`; + // 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)); const healthDown = !(await isDaemonRunning()); @@ -232,6 +240,24 @@ async function initDaemon(): Promise { } console.log(`Database will be stored at: ${DB_PATH}`); + const migration = await migrateLegacyWallet({ + assertLegacyStopped: () => + assertLegacyCocodNotRunning({ + socketPath: legacyCocodSocketPath(), + pidFilePath: legacyCocodPidPath(), + }), + acquireLegacyLock: () => { + mkdirSync(dirname(legacyCocodPidPath()), { + recursive: true, + mode: 0o700, + }); + return claimLegacyCocodPidFile({ pidFilePath: legacyCocodPidPath() }); + }, + }); + if (migration.status === "migrated") { + console.log(`Migrated wallet from ${migration.from} to ${migration.to}.`); + for (const warning of migration.cleanupWarnings) console.warn(warning); + } initializeWallet(); await startDaemon({ port: String(config.port || 8008), host: config.host || undefined }); @@ -1833,8 +1859,8 @@ program console.log("Stopping daemon..."); await callDaemon("/stop", { method: "POST" }); - // Wait for HTTP health check to fail AND pidfile to be released. - const pidFilePath = `${process.env.HOME || process.env.USERPROFILE || ""}/.cocod/cocod.pid`; + // 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)); const healthDown = !(await isDaemonRunning()); diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 6dc587a..0e3bb4b 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -54,7 +54,18 @@ import { createDaemonRequestHandler } from "./http"; import { FileRequestResponseLogSink } from "./request-response-log-sink"; import { refreshModelsAndIntegrations } from "../integrations"; import { RoutstrClient } from "@routstr/sdk"; -import { createCocoClient } from "./wallet/coco-client"; +import { mkdirSync } from "fs"; +import { dirname } from "path"; +import { + assertLegacyCocodNotRunning, + claimLegacyCocodPidFile, + createCocoClient, +} 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 @@ -115,6 +126,25 @@ async function main(): Promise { const { ensureProvidersBootstrapped, getRoutstr21Models, getModelProviders, refreshProvidersAndModels } = createModelService(modelManager, providerManager, store); + const migration = await migrateLegacyWallet({ + assertLegacyStopped: () => + assertLegacyCocodNotRunning({ + socketPath: legacyCocodSocketPath(), + pidFilePath: legacyCocodPidPath(), + }), + acquireLegacyLock: () => { + mkdirSync(dirname(legacyCocodPidPath()), { + recursive: true, + mode: 0o700, + }); + return claimLegacyCocodPidFile({ pidFilePath: legacyCocodPidPath() }); + }, + }); + 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 ──────────────────────────────── diff --git a/src/daemon/wallet/coco-client.npc.test.ts b/src/daemon/wallet/coco-client.npc.test.ts index d348473..b1ae0fc 100644 --- a/src/daemon/wallet/coco-client.npc.test.ts +++ b/src/daemon/wallet/coco-client.npc.test.ts @@ -49,7 +49,7 @@ const tempDirs: string[] = []; function makeWalletDir(): string { const dir = mkdtempSync(join(tmpdir(), "routstrd-coco-npc-test-")); tempDirs.push(dir); - const walletDir = join(dir, ".cocod"); + const walletDir = join(dir, "wallet"); mkdirSync(walletDir, { recursive: true }); writeFileSync( join(walletDir, "config.json"), @@ -58,6 +58,14 @@ function makeWalletDir(): string { 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 }; @@ -74,7 +82,7 @@ afterEach(() => { describe("in-process coco client NPC integration", () => { it("registers the plugin and exposes the wallet's NPC lightning address", async () => { - const client = await createCocoClient({ configDir: makeWalletDir() }); + const client = await createCocoClient(testClientOptions()); try { const info = await client.getNpcAddress(); expect(info).toEqual({ @@ -89,7 +97,7 @@ describe("in-process coco client NPC integration", () => { 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({ configDir: makeWalletDir() }); + const client = await createCocoClient(testClientOptions()); try { const info = await client.getNpcAddress(); expect(info.address).toStartWith("npub1"); @@ -106,7 +114,7 @@ describe("in-process coco client NPC integration", () => { success: false, pr: { amount: 21, mints: ["https://mint.example.com"] }, }; - const client = await createCocoClient({ configDir: makeWalletDir() }); + const client = await createCocoClient(testClientOptions()); try { const result = await client.setNpcUsername("bob"); expect(result).toEqual({ @@ -122,7 +130,7 @@ describe("in-process coco client NPC integration", () => { }); it("confirms the claim fee payment when requested", async () => { - const client = await createCocoClient({ configDir: makeWalletDir() }); + const client = await createCocoClient(testClientOptions()); try { const result = await client.setNpcUsername("bob", true); expect(result).toEqual({ success: true }); @@ -135,7 +143,7 @@ describe("in-process coco client NPC integration", () => { }); it("triggers a manual quote sync", async () => { - const client = await createCocoClient({ configDir: makeWalletDir() }); + const client = await createCocoClient(testClientOptions()); try { await client.syncNpc(); expect(npcState.syncCalls).toBe(1); @@ -146,7 +154,7 @@ describe("in-process coco client NPC integration", () => { it("rejects NPC calls with a clear error when the plugin is disabled", async () => { const client = await createCocoClient({ - configDir: makeWalletDir(), + ...testClientOptions(), enableNpc: false, }); try { diff --git a/src/daemon/wallet/coco-client.test.ts b/src/daemon/wallet/coco-client.test.ts index 8ce421e..b9dd10a 100644 --- a/src/daemon/wallet/coco-client.test.ts +++ b/src/daemon/wallet/coco-client.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, mock } from "bun:test"; import { gunzipSync } from "bun"; import { + existsSync, mkdtempSync, mkdirSync, readFileSync, @@ -43,7 +44,7 @@ function socketOnly(path: string): boolean { describe("default mint functionality", () => { it("automatically adds default mint when no mints exist", async () => { - const walletDir = join(makeTempDir(), ".cocod"); + const walletDir = join(makeTempDir(), "wallet"); mkdirSync(walletDir, { recursive: true }); writeFileSync( join(walletDir, "config.json"), @@ -55,8 +56,19 @@ describe("default mint functionality", () => { }), ); - const client = await createCocoClient({ configDir: walletDir }); + 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"); @@ -65,10 +77,12 @@ describe("default mint functionality", () => { } 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(), ".cocod"); + const walletDir = join(makeTempDir(), "wallet"); const configuredDefault = "https://mint.cubabitcoin.org"; mkdirSync(walletDir, { recursive: true }); @@ -84,7 +98,11 @@ describe("default mint functionality", () => { }), ); - const client = await createCocoClient({ configDir: walletDir }); + 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(); @@ -99,7 +117,7 @@ describe("default mint functionality", () => { }); it("allows setting default mint to an already trusted mint", async () => { - const walletDir = join(makeTempDir(), ".cocod"); + const walletDir = join(makeTempDir(), "wallet"); mkdirSync(walletDir, { recursive: true }); writeFileSync( join(walletDir, "config.json"), @@ -111,7 +129,11 @@ describe("default mint functionality", () => { }), ); - const client = await createCocoClient({ configDir: walletDir }); + 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(); @@ -133,7 +155,7 @@ describe("default mint functionality", () => { describe("legacy cocod wallet migration", () => { it("opens an existing unencrypted config and preserves database balances", async () => { - const walletDir = join(makeTempDir(), ".cocod"); + const walletDir = join(makeTempDir(), "wallet"); const mintUrl = "https://mint.example.com"; mkdirSync(walletDir, { recursive: true }); @@ -164,7 +186,9 @@ describe("legacy cocod wallet migration", () => { // 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({ - configDir: walletDir, + walletDir, + legacySocketPath: join(walletDir, "legacy-cocod.sock"), + legacyPidPath: join(walletDir, "legacy-cocod.pid"), enableNpc: false, }); try { @@ -177,7 +201,9 @@ describe("legacy cocod wallet migration", () => { // Prove the migrated schema remains reopenable and the pre-existing // proofs survive a complete in-process wallet restart. const reopenedClient = await createCocoClient({ - configDir: walletDir, + walletDir, + legacySocketPath: join(walletDir, "legacy-cocod.sock"), + legacyPidPath: join(walletDir, "legacy-cocod.pid"), enableNpc: false, }); try { diff --git a/src/daemon/wallet/coco-client.ts b/src/daemon/wallet/coco-client.ts index 2b351a3..3a90cb0 100644 --- a/src/daemon/wallet/coco-client.ts +++ b/src/daemon/wallet/coco-client.ts @@ -16,13 +16,14 @@ import { finalizeEvent, nip19, type EventTemplate } from "nostr-tools"; import { closeSync, existsSync, + mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "fs"; -import { join } from "path"; +import { dirname, join } from "path"; import { mnemonicToSeedSync } from "@scure/bip39"; import type { CocodClient, @@ -31,20 +32,15 @@ import type { NpcUsernameResult, } from "./cocod-client"; import { logger } from "../../utils/logger"; +import { + legacyCocodPidPath, + legacyCocodSocketPath, + walletDir as defaultWalletDir, + walletPidPath as defaultWalletPidPath, +} from "./paths"; const NPC_DEFAULT_BASE_URL = "https://npubx.cash"; -const CONFIG_DIR = - process.env.COCOD_DIR || - `${process.env.HOME || process.env.USERPROFILE || ""}/.cocod`; - -const CONFIG_FILE = join(CONFIG_DIR, "config.json"); -const DB_PATH = join(CONFIG_DIR, "coco.db"); -const LEGACY_COCOD_SOCKET = - process.env.COCOD_SOCKET || join(CONFIG_DIR, "cocod.sock"); -const LEGACY_COCOD_PID_FILE = - process.env.COCOD_PID || join(CONFIG_DIR, "cocod.pid"); - const STALE_SOCKET_ERROR_CODES = new Set([ "ECONNREFUSED", "ENOENT", @@ -167,7 +163,7 @@ function createCocoLogger(bindings: Record = {}): CocoLogger { }; } -function loadConfig(configFile: string = CONFIG_FILE): CocodConfig { +function loadConfig(configFile: string): CocodConfig { if (!existsSync(configFile)) { throw new Error( `Config file not found at ${configFile}. Run 'routstrd onboard' first.`, @@ -182,10 +178,7 @@ function loadConfig(configFile: string = CONFIG_FILE): CocodConfig { return config; } -function saveConfig( - config: CocodConfig, - configFile: string = CONFIG_FILE, -): void { +function saveConfig(config: CocodConfig, configFile: string): void { const temporaryFile = `${configFile}.${process.pid}.tmp`; try { writeFileSync(temporaryFile, JSON.stringify(config, null, 2), { @@ -239,8 +232,8 @@ function hasErrorCode(error: unknown, codes: Set): boolean { export async function assertLegacyCocodNotRunning( options: LegacyCocodGuardOptions = {}, ): Promise { - const socketPath = options.socketPath || LEGACY_COCOD_SOCKET; - const pidFilePath = options.pidFilePath || LEGACY_COCOD_PID_FILE; + 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; @@ -325,8 +318,8 @@ export async function assertLegacyCocodNotRunning( export async function stopLegacyCocod( options: LegacyCocodStopOptions = {}, ): Promise { - const socketPath = options.socketPath || LEGACY_COCOD_SOCKET; - const pidFilePath = options.pidFilePath || LEGACY_COCOD_PID_FILE; + 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")); @@ -416,7 +409,14 @@ export async function stopLegacyCocod( export function claimLegacyCocodPidFile( options: LegacyCocodPidClaimOptions = {}, ): () => void { - const pidFilePath = options.pidFilePath || LEGACY_COCOD_PID_FILE; + 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)); @@ -507,10 +507,15 @@ export function claimLegacyCocodPidFile( } export interface CreateCocoClientOptions { - /** Override the wallet directory, primarily for migration tests and tooling. */ + /** Override the canonical wallet data directory. */ + walletDir?: string; + /** Deprecated alias retained for existing callers during migration. */ configDir?: string; - socketPath?: string; - pidFilePath?: 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 */ @@ -520,20 +525,36 @@ export interface CreateCocoClientOptions { export async function createCocoClient( options: CreateCocoClientOptions = {}, ): Promise { - const configDir = options.configDir || CONFIG_DIR; + const configDir = options.walletDir || options.configDir || defaultWalletDir(); const configFile = join(configDir, "config.json"); const dbPath = join(configDir, "coco.db"); - const socketPath = - options.socketPath || - (options.configDir ? join(configDir, "cocod.sock") : LEGACY_COCOD_SOCKET); - const pidFilePath = - options.pidFilePath || - (options.configDir ? join(configDir, "cocod.pid") : LEGACY_COCOD_PID_FILE); + 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, pidFilePath }); - const releaseLegacyPidClaim = claimLegacyCocodPidFile({ pidFilePath }); + await assertLegacyCocodNotRunning({ + socketPath: legacySocket, + pidFilePath: legacyPidFile, + }); + // The canonical wallet directory is created by initialization/migration. + // Keep a legacy PID claim as an exclusion fence for old cocod binaries. + mkdirSync(dirname(legacyPidFile), { recursive: true, mode: 0o700 }); + const releaseWalletPidClaim = claimPidFile({ pidFilePath: walletPidFile }); + let releaseLegacyPidClaim: () => void; + try { + releaseLegacyPidClaim = claimLegacyCocodPidFile({ + pidFilePath: legacyPidFile, + }); + } catch (error) { + releaseWalletPidClaim(); + throw error; + } let database: Database | undefined; let coco: Awaited> | undefined; @@ -616,6 +637,7 @@ export async function createCocoClient( } catch (error) { database?.close(); releaseLegacyPidClaim(); + releaseWalletPidClaim(); throw error; } @@ -653,7 +675,7 @@ export async function createCocoClient( async unlock(_passphrase: string): Promise { // coco-core does not support passphrase locking. - // Wallet access is controlled via the mnemonic in ~/.cocod/config.json. + // Wallet access is controlled by ~/.routstrd/wallet/config.json. return "wallet does not require unlocking"; }, @@ -756,6 +778,7 @@ export async function createCocoClient( database.close(); } finally { releaseLegacyPidClaim(); + releaseWalletPidClaim(); } } }, diff --git a/src/daemon/wallet/migration.test.ts b/src/daemon/wallet/migration.test.ts new file mode 100644 index 0000000..e61892a --- /dev/null +++ b/src/daemon/wallet/migration.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + 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("copies wallet data byte-for-byte 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 }, + }); + const database = new Uint8Array([0, 1, 2, 3, 255]); + writeFileSync(join(legacyDir, "config.json"), config); + writeFileSync(join(legacyDir, "coco.db"), database); + writeFileSync(join(legacyDir, "cocod.pid"), "123"); + writeFileSync(join(legacyDir, "cocod.sock"), "not-a-real-socket"); + const assertStopped = mock(async () => {}); + const releaseLock = mock(() => {}); + const acquireLock = mock(() => releaseLock); + + const result = await migrateLegacyWallet({ + walletDir, + legacyDir, + assertLegacyStopped: assertStopped, + acquireLegacyLock: acquireLock, + }); + + 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); + expect(readFileSync(join(walletDir, "coco.db"))).toEqual(Buffer.from(database)); + 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); + }); + + 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 when both canonical and legacy configs exist", async () => { + const base = root(); + const legacyDir = join(base, ".cocod"); + const walletDir = join(base, ".routstrd", "wallet"); + mkdirSync(legacyDir, { recursive: true }); + mkdirSync(walletDir, { recursive: true }); + writeFileSync(join(legacyDir, "config.json"), "legacy"); + writeFileSync(join(walletDir, "config.json"), "current"); + + await expect(migrateLegacyWallet({ walletDir, legacyDir })).rejects.toThrow( + "both", + ); + expect(readFileSync(join(walletDir, "config.json"), "utf8")).toBe("current"); + expect(readFileSync(join(legacyDir, "config.json"), "utf8")).toBe("legacy"); + }); +}); diff --git a/src/daemon/wallet/migration.ts b/src/daemon/wallet/migration.ts new file mode 100644 index 0000000..ab55632 --- /dev/null +++ b/src/daemon/wallet/migration.ts @@ -0,0 +1,136 @@ +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + unlinkSync, +} from "fs"; +import { dirname, join } from "path"; +import { legacyCocodDir, walletDir } from "./paths"; + +export type WalletMigrationResult = + | { status: "fresh" } + | { status: "already-current" } + | { status: "migrated"; from: string; to: string; cleanupWarnings: string[] }; + +export interface WalletMigrationOptions { + walletDir?: string; + legacyDir?: string; + /** Called after state validation and before legacy files are copied. */ + assertLegacyStopped?: () => void | Promise; + /** Claims the legacy cocod exclusion lock for the duration of the copy. */ + acquireLegacyLock?: () => (() => void) | Promise<() => void>; +} + +type WalletState = "absent" | "database-only" | "initialized"; + +function state(configPath: string, dbPath: string): WalletState { + const hasConfig = existsSync(configPath); + const hasDb = existsSync(dbPath); + if (hasConfig) return "initialized"; + if (hasDb) return "database-only"; + return "absent"; +} + +function filesEqual(left: string, right: string): boolean { + if (!existsSync(left) || !existsSync(right)) return false; + if (statSync(left).size !== statSync(right).size) return false; + return readFileSync(left).equals(readFileSync(right)); +} + +/** + * Copy a legacy cocod wallet into the canonical routstrd wallet directory. + * The staging directory is renamed atomically, so the canonical path is never + * exposed with only one of its two required files. + */ +export async function migrateLegacyWallet( + options: WalletMigrationOptions = {}, +): Promise { + const targetDir = options.walletDir || walletDir(); + const sourceDir = options.legacyDir || legacyCocodDir(); + const targetConfig = join(targetDir, "config.json"); + const targetDb = join(targetDir, "coco.db"); + const sourceConfig = join(sourceDir, "config.json"); + const sourceDb = join(sourceDir, "coco.db"); + const 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 (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) { + copyFileSync(sourceDb, stagedDb); + chmodSync(stagedDb, 0o600); + } + + if ( + statSync(stagedConfig).size !== statSync(sourceConfig).size || + (hasSourceDb && statSync(stagedDb).size !== statSync(sourceDb).size) + ) { + throw new Error("Cannot migrate wallet: staged files failed size validation."); + } + + renameSync(stagingDir, targetDir); + } catch (error) { + rmSync(stagingDir, { recursive: true, force: true }); + throw error; + } finally { + releaseLegacyLock?.(); + } + + const cleanupWarnings: string[] = []; + for (const path of [sourceConfig, sourceDb]) { + if (!existsSync(path)) continue; + try { + unlinkSync(path); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + cleanupWarnings.push(`Could not remove legacy wallet file ${path}: ${message}`); + } + } + + return { status: "migrated", from: sourceDir, to: targetDir, cleanupWarnings }; +} diff --git a/src/daemon/wallet/paths.ts b/src/daemon/wallet/paths.ts new file mode 100644 index 0000000..bdbd3e7 --- /dev/null +++ b/src/daemon/wallet/paths.ts @@ -0,0 +1,33 @@ +import { join } from "path"; + +function homeDir(): string { + return process.env.HOME || process.env.USERPROFILE || ""; +} + +/** Root directory for routstrd state. Evaluated lazily so env overrides work in tests. */ +export function routstrdConfigDir(): string { + return process.env.ROUTSTRD_DIR || join(homeDir(), ".routstrd"); +} + +/** Canonical directory for the in-process Cashu wallet. */ +export function walletDir(): string { + return process.env.ROUTSTRD_WALLET_DIR || join(routstrdConfigDir(), "wallet"); +} + +/** Lock owned by an in-process routstrd wallet instance. */ +export function walletPidPath(): string { + return process.env.ROUTSTRD_WALLET_PID || join(walletDir(), "wallet.pid"); +} + +/** Legacy external cocod directory. This is not the routstrd wallet directory. */ +export function legacyCocodDir(): string { + return process.env.COCOD_DIR || join(homeDir(), ".cocod"); +} + +export function legacyCocodSocketPath(): string { + return process.env.COCOD_SOCKET || join(legacyCocodDir(), "cocod.sock"); +} + +export function legacyCocodPidPath(): string { + return process.env.COCOD_PID || join(legacyCocodDir(), "cocod.pid"); +} diff --git a/src/start-daemon.ts b/src/start-daemon.ts index 9e45702..a9c58a5 100644 --- a/src/start-daemon.ts +++ b/src/start-daemon.ts @@ -9,7 +9,6 @@ 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`; From 769e8206ede365281b22300f0330270306fe1e44 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:15:33 +0000 Subject: [PATCH 2/3] fix(wallet): stop legacy cocod before direct daemon startup and ignore migration lock PID routstrd start already stopped the real legacy cocod daemon, but wallet migration claimed ~/.cocod/cocod.pid using routstrd's own PID. The post-claim recheck in assertLegacyCocodNotRunning then saw that live PID and falsely reported it as a running legacy cocod, so every start attempt failed with a different 'Legacy cocod daemon is still running with PID ' error. - assertLegacyCocodNotRunning now accepts ignorePid so the migration's own lock owner is not mistaken for cocod. - The direct daemon entry point (src/daemon/index.ts) calls stopLegacyCocod before migration, so startup is safe even when launched directly or by an older/global CLI, instead of relying solely on the parent command. - initDaemon() stops legacy cocod before migrating as well. - Added a regression test for the self-owned migration lock PID. --- bun.lock | 1 + src/cli.ts | 13 ++++++++++++- src/daemon/index.ts | 19 ++++++++++++++++++- src/daemon/wallet/coco-client.test.ts | 13 +++++++++++++ src/daemon/wallet/coco-client.ts | 7 ++++++- 5 files changed, 50 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index d8caf08..52ef082 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "routstrd", diff --git a/src/cli.ts b/src/cli.ts index 5fab864..a362ef5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -240,18 +240,29 @@ async function initDaemon(): Promise { } console.log(`Database will be stored at: ${DB_PATH}`); + await stopLegacyCocod(); + + let migrationLockOwner: number | undefined; const migration = await migrateLegacyWallet({ assertLegacyStopped: () => assertLegacyCocodNotRunning({ socketPath: legacyCocodSocketPath(), pidFilePath: legacyCocodPidPath(), + ignorePid: migrationLockOwner, }), acquireLegacyLock: () => { mkdirSync(dirname(legacyCocodPidPath()), { recursive: true, mode: 0o700, }); - return claimLegacyCocodPidFile({ pidFilePath: legacyCocodPidPath() }); + const release = claimLegacyCocodPidFile({ + pidFilePath: legacyCocodPidPath(), + }); + migrationLockOwner = process.pid; + return () => { + migrationLockOwner = undefined; + release(); + }; }, }); if (migration.status === "migrated") { diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 0e3bb4b..6475874 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -60,6 +60,7 @@ import { assertLegacyCocodNotRunning, claimLegacyCocodPidFile, createCocoClient, + stopLegacyCocod, } from "./wallet/coco-client"; import { migrateLegacyWallet } from "./wallet/migration"; import { @@ -126,18 +127,34 @@ async function main(): Promise { const { ensureProvidersBootstrapped, getRoutstr21Models, getModelProviders, refreshProvidersAndModels } = createModelService(modelManager, providerManager, store); + // 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, }); - return claimLegacyCocodPidFile({ pidFilePath: legacyCocodPidPath() }); + const release = claimLegacyCocodPidFile({ + pidFilePath: legacyCocodPidPath(), + }); + migrationLockOwner = process.pid; + return () => { + migrationLockOwner = undefined; + release(); + }; }, }); if (migration.status === "migrated") { diff --git a/src/daemon/wallet/coco-client.test.ts b/src/daemon/wallet/coco-client.test.ts index b9dd10a..0749c42 100644 --- a/src/daemon/wallet/coco-client.test.ts +++ b/src/daemon/wallet/coco-client.test.ts @@ -315,6 +315,19 @@ describe("assertLegacyCocodNotRunning", () => { ).resolves.toBeUndefined(); }); + it("ignores the caller's own migration lock PID", async () => { + await expect( + assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: (path) => path === PID_FILE_PATH, + readFile: () => "4242\n", + isProcessRunning: () => true, + ignorePid: 4242, + }), + ).resolves.toBeUndefined(); + }); + it("fails closed when the socket cannot be probed safely", async () => { const fetchImpl = mock(async () => { throw Object.assign(new Error("permission denied"), { code: "EACCES" }); diff --git a/src/daemon/wallet/coco-client.ts b/src/daemon/wallet/coco-client.ts index 3a90cb0..8aa2cfa 100644 --- a/src/daemon/wallet/coco-client.ts +++ b/src/daemon/wallet/coco-client.ts @@ -60,6 +60,8 @@ export interface LegacyCocodGuardOptions { 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; } @@ -243,7 +245,10 @@ export async function assertLegacyCocodNotRunning( try { const pid = Number.parseInt(readFile(pidFilePath).trim(), 10); - return Number.isInteger(pid) && pid > 0 && isProcessRunning(pid) + return Number.isInteger(pid) && + pid > 0 && + pid !== options.ignorePid && + isProcessRunning(pid) ? pid : null; } catch { From 89ab33763eec5d5331c662347bed27a195042e29 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:41:49 +0000 Subject: [PATCH 3/3] fix(wallet): snapshot SQLite WAL during cocod-to-routstrd migration Raw-copying coco.db silently omitted uncheckpointed WAL data, losing proofs and derivation counters. Replace the byte copy with VACUUM INTO, verify quick_check and semantic equality (proofs, counters, operations, mints) before committing, refuse orphaned -wal/-shm sidecars, and archive the complete legacy wallet instead of deleting it immediately. Add a real WAL-mode integration test proving uncheckpointed proofs and counters survive migration. --- src/daemon/wallet/migration.test.ts | 50 +++++++++++++-- src/daemon/wallet/migration.ts | 97 +++++++++++++++++++++++++---- 2 files changed, 130 insertions(+), 17 deletions(-) diff --git a/src/daemon/wallet/migration.test.ts b/src/daemon/wallet/migration.test.ts index e61892a..b053bcd 100644 --- a/src/daemon/wallet/migration.test.ts +++ b/src/daemon/wallet/migration.test.ts @@ -1,9 +1,11 @@ import { afterEach, describe, expect, it, mock } from "bun:test"; +import { Database } from "bun:sqlite"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, + readdirSync, rmSync, statSync, writeFileSync, @@ -23,7 +25,7 @@ afterEach(() => { }); describe("migrateLegacyWallet", () => { - it("copies wallet data byte-for-byte and leaves process files behind", async () => { + 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"); @@ -34,28 +36,51 @@ describe("migrateLegacyWallet", () => { defaultMintUrl: "https://mint.example", unknown: { preserved: true }, }); - const database = new Uint8Array([0, 1, 2, 3, 255]); writeFileSync(join(legacyDir, "config.json"), config); - writeFileSync(join(legacyDir, "coco.db"), database); 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); - expect(readFileSync(join(walletDir, "coco.db"))).toEqual(Buffer.from(database)); + 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); @@ -64,6 +89,10 @@ describe("migrateLegacyWallet", () => { 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 () => { @@ -89,6 +118,17 @@ describe("migrateLegacyWallet", () => { ); }); + 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"); diff --git a/src/daemon/wallet/migration.ts b/src/daemon/wallet/migration.ts index ab55632..e4e5bc2 100644 --- a/src/daemon/wallet/migration.ts +++ b/src/daemon/wallet/migration.ts @@ -7,9 +7,9 @@ import { renameSync, rmSync, statSync, - unlinkSync, } from "fs"; -import { dirname, join } from "path"; +import { Database } from "bun:sqlite"; +import { basename, dirname, join } from "path"; import { legacyCocodDir, walletDir } from "./paths"; export type WalletMigrationResult = @@ -42,6 +42,68 @@ function filesEqual(left: string, right: string): boolean { return readFileSync(left).equals(readFileSync(right)); } +function sqlString(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +type WalletSummary = Record; +const SUMMARY_QUERIES: Record = { + proofs: + "SELECT mintUrl, state, COUNT(*) count, COALESCE(SUM(amount), 0) amount FROM coco_cashu_proofs GROUP BY mintUrl, state ORDER BY mintUrl, state", + counters: + "SELECT mintUrl, keysetId, counter FROM coco_cashu_counters ORDER BY mintUrl, keysetId", + mintOperations: + "SELECT state, COUNT(*) count FROM coco_cashu_mint_operations GROUP BY state ORDER BY state", + sendOperations: + "SELECT state, COUNT(*) count FROM coco_cashu_send_operations GROUP BY state ORDER BY state", + meltOperations: + "SELECT state, COUNT(*) count FROM coco_cashu_melt_operations GROUP BY state ORDER BY state", + mints: "SELECT mintUrl, trusted FROM coco_cashu_mints ORDER BY mintUrl", +}; + +function verifyDatabase(database: Database, label: string): WalletSummary { + const checks = database.query("PRAGMA quick_check").values() as unknown[][]; + if (checks.length !== 1 || checks[0]?.[0] !== "ok") { + throw new Error(`${label} failed PRAGMA quick_check: ${JSON.stringify(checks)}`); + } + + const tables = new Set( + (database + .query("SELECT name FROM sqlite_master WHERE type = 'table'") + .values() as string[][]).map(([name]) => name), + ); + const summary: WalletSummary = {}; + for (const [name, query] of Object.entries(SUMMARY_QUERIES)) { + const table = query.match(/FROM\s+(coco_cashu_\w+)/i)?.[1]; + summary[name] = table && tables.has(table) ? database.query(query).all() : []; + } + return summary; +} + +/** Write a standalone SQLite snapshot containing committed WAL frames. */ +function snapshotDatabase(sourcePath: string, destinationPath: string): void { + const source = new Database(sourcePath); + let sourceSummary: WalletSummary; + try { + sourceSummary = verifyDatabase(source, "Legacy wallet database"); + source.exec(`VACUUM INTO ${sqlString(destinationPath)}`); + } finally { + source.close(); + } + + const destination = new Database(destinationPath, { readonly: true }); + try { + const destinationSummary = verifyDatabase(destination, "Staged wallet database"); + if (JSON.stringify(sourceSummary) !== JSON.stringify(destinationSummary)) { + throw new Error( + "Staged wallet database does not contain the same proofs, counters, operations, and mints as the legacy database.", + ); + } + } finally { + destination.close(); + } +} + /** * Copy a legacy cocod wallet into the canonical routstrd wallet directory. * The staging directory is renamed atomically, so the canonical path is never @@ -56,6 +118,8 @@ export async function migrateLegacyWallet( 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); @@ -76,6 +140,12 @@ export async function migrateLegacyWallet( ); } 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}. ` + @@ -102,15 +172,14 @@ export async function migrateLegacyWallet( chmodSync(stagedConfig, 0o600); const hasSourceDb = existsSync(sourceDb); if (hasSourceDb) { - copyFileSync(sourceDb, stagedDb); + // 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 || - (hasSourceDb && statSync(stagedDb).size !== statSync(sourceDb).size) - ) { - throw new Error("Cannot migrate wallet: staged files failed size validation."); + if (statSync(stagedConfig).size !== statSync(sourceConfig).size) { + throw new Error("Cannot migrate wallet: staged config failed size validation."); } renameSync(stagingDir, targetDir); @@ -121,14 +190,18 @@ export async function migrateLegacyWallet( releaseLegacyLock?.(); } + // Preserve the complete legacy SQLite file set for manual recovery instead + // of deleting the only rollback copy immediately after migration. const cleanupWarnings: string[] = []; - for (const path of [sourceConfig, sourceDb]) { - if (!existsSync(path)) continue; + const sourceFiles = [sourceConfig, sourceDb, sourceWal, sourceShm].filter(existsSync); + if (sourceFiles.length > 0) { + const archiveDir = join(sourceDir, `wallet-migrated-${Date.now()}`); try { - unlinkSync(path); + 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 remove legacy wallet file ${path}: ${message}`); + cleanupWarnings.push(`Could not archive legacy wallet files: ${message}`); } }