feat(wallet): migrate cocod data to routstrd wallet directory

This commit is contained in:
redshift
2026-08-02 17:32:51 +01:00
parent 86ac688b7b
commit e87860be9f
13 changed files with 721 additions and 67 deletions
+253
View File
@@ -0,0 +1,253 @@
# Migrate wallet storage from `~/.cocod` to `~/.routstrd/wallet`
Task: [`routstrd-migrate-cocod-to-routstrd-files`](https://github.com/nodestrich/routstrd)
Event ID: `2f420644005154c5b106017c4a753854bb2660ba834cde7e6e267532cb5448e9`
Priority: 997 · Status: open
---
## Baseline
This plan is based on `coco-integration` at `86ac688` (after the default-mint, NPC/npubx.cash, and Windows-support changes).
The task branch is currently based on `04f8c22`; rebase it onto the latest `coco-integration` before implementing this plan.
## Goal
Move the in-process Cashu wallet's persistent data from:
```text
~/.cocod/config.json
~/.cocod/coco.db
```
to:
```text
~/.routstrd/wallet/config.json
~/.routstrd/wallet/coco.db
```
The migration must preserve the mnemonic, proofs, default mint, and NPC identity. Existing users must not accidentally get a new wallet, and `cocod` and routstrd must never open the same database concurrently.
Fresh installs must use `~/.routstrd/wallet` immediately. Existing installs must be migrated automatically and safely; merely continuing to run from `~/.cocod` is not sufficient for this task.
---
## Important corrections to the previous plan
### 1. Do not rename or migrate `cocod.sock`
The in-process `coco-core` wallet does not expose a Unix socket. `cocod.sock` belongs to the legacy external `cocod` daemon and is used only to determine whether that daemon is still alive.
There should therefore be no `wallet.sock`. Do not copy a socket inode into the new directory.
### 2. Keep legacy-process paths separate from wallet-data paths
On the current base, `src/daemon/wallet/coco-client.ts` derives all of these from one `CONFIG_DIR`:
- wallet config and database;
- legacy cocod socket;
- legacy cocod PID/process lock.
That coupling must be removed. After migration:
- wallet data comes from `~/.routstrd/wallet`;
- probes and shutdown logic for an external legacy cocod continue to use `~/.cocod/cocod.sock` and `~/.cocod/cocod.pid`;
- routstrd uses `~/.routstrd/wallet/wallet.pid` as the in-process wallet lock.
While routstrd owns the migrated wallet, it should also retain the existing legacy-cocod exclusion mechanism (claiming the legacy `cocod.pid`) so an old cocod cannot be started against a stale or partially migrated legacy wallet. Acquisition and release of both locks must be rollback-safe.
### 3. Do not rename `cocodPath`
`RoutstrdConfig.cocodPath` is an executable path for the external compatibility client, not a wallet storage path. Renaming it to `walletPath` would change its meaning and would not help this migration.
The current daemon creates `createCocoClient()` directly and injects it into `createWalletAdapter()`, so `cocodPath` is effectively bypassed on the normal in-process path. Cleanup or removal of that compatibility setting is a separate task.
### 4. Do not implement fallback as the steady state
A resolver that permanently falls back to `~/.cocod` does not move the data. Legacy detection is needed to initiate migration, but successful startup should resolve to the canonical directory afterward.
### 5. Account for the updated base
The latest `coco-integration` adds:
- `defaultMintUrl` persistence in the wallet's `config.json`;
- the NPC plugin, whose Nostr identity is derived from the same mnemonic;
- `src/daemon/wallet/coco-client.npc.test.ts`;
- Windows support and `USERPROFILE` fallback.
Migration must preserve the complete config JSON rather than reconstructing selected fields. Path construction must use `path.join` rather than hard-coded `/` separators.
---
## Current runtime path references on the latest base
### Wallet data and legacy process coordination
| File | Current responsibility | Required change |
|---|---|---|
| `src/daemon/wallet/coco-client.ts` | Uses one `.cocod` directory for config, DB, socket, and PID; owns the in-process wallet and legacy-cocod guard | Split canonical data paths from legacy process paths; migrate before opening the DB; use `wallet.pid` for the in-process lock |
| `src/daemon/wallet/cocod-client.ts` | External cocod compatibility client; defaults to `.cocod/cocod.sock` | Keep legacy defaults and clarify that they are external-cocod paths |
| `src/cli.ts` | Initializes `.cocod`; two restart paths wait on `.cocod/cocod.pid` | Initialize/migrate the canonical wallet and wait on `wallet.pid` |
| `src/utils/config.ts` | Defines `~/.routstrd`, but no wallet subdirectory | Export canonical and legacy wallet/process path helpers or constants |
| `src/daemon/index.ts` | Calls `createCocoClient()` before building the adapter | Ensure migration occurs before the client opens the DB |
### Tests and documentation
| File | Required update |
|---|---|
| `src/cli.test.ts` | Change fresh-wallet expectations and add migration coverage |
| `src/daemon/wallet/coco-client.test.ts` | Update data/lock paths and add migration/dual-lock tests |
| `src/daemon/wallet/coco-client.npc.test.ts` | Use the canonical wallet layout in fixtures; verify migrated mnemonic/config still drives NPC identity |
| `README.md` | Document the new location and migration behavior; retain legacy cocod terminology only where discussing compatibility |
| `SKILL.md` | Update wallet storage and environment-variable documentation |
The fixture `src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz` keeps its name because it records the fixture's provenance.
---
## Target path model
Define the path model in one dependency-light module (for example `src/daemon/wallet/paths.ts`, or in `src/utils/config.ts` if that does not introduce a cycle):
```text
ROUTSTRD config root process.env.ROUTSTRD_DIR || ~/.routstrd
canonical wallet directory process.env.ROUTSTRD_WALLET_DIR || <config root>/wallet
canonical wallet config <wallet directory>/config.json
canonical wallet database <wallet directory>/coco.db
canonical wallet lock process.env.ROUTSTRD_WALLET_PID || <wallet directory>/wallet.pid
legacy cocod directory process.env.COCOD_DIR || ~/.cocod
legacy cocod socket process.env.COCOD_SOCKET || <legacy directory>/cocod.sock
legacy cocod PID process.env.COCOD_PID || <legacy directory>/cocod.pid
```
Use `HOME` with `USERPROFILE` fallback, matching the updated Windows-support base. Use functions or injectable path objects where tests need to change environment variables after module import.
`COCOD_DIR`, `COCOD_SOCKET`, and `COCOD_PID` remain compatibility controls for locating an old external cocod. They must not redirect the new in-process wallet away from `~/.routstrd/wallet`. `ROUTSTRD_WALLET_DIR` is the new explicit data-directory override.
---
## Implementation plan
### Phase 1 — Centralize and separate paths
1. Add the canonical wallet and legacy cocod path definitions above.
2. Remove the local `.cocod` path construction from `coco-client.ts` and `cli.ts`.
3. Change `CreateCocoClientOptions` so tests/tooling can independently override:
- wallet data directory;
- wallet lock path;
- legacy cocod socket and PID paths.
4. Keep `cocod-client.ts` pointed at the legacy socket. Do not make it import a resolver that prefers the new wallet directory.
### Phase 2 — Add a safe automatic migration primitive
Add an idempotent `migrateLegacyWallet()` helper with injectable paths/filesystem operations for tests.
Preconditions and behavior:
1. If the canonical wallet already contains both `config.json` and `coco.db`, return `already-current` and do not touch legacy data.
2. If neither canonical nor legacy wallet data exists, return `fresh`; initialization will create the canonical directory.
3. If only the legacy wallet exists:
- first verify that legacy cocod is not running using the existing PID and socket guard;
- create a private staging directory under `~/.routstrd` with mode `0700`;
- copy the complete `config.json` and `coco.db` into staging without parsing or rewriting them;
- apply `0600` to both files;
- validate that the staged files exist and have the expected byte sizes;
- atomically rename the staged directory to `wallet`;
- only after the canonical directory is committed, remove the legacy `config.json` and `coco.db`;
- never copy `cocod.sock` or `cocod.pid`.
4. If canonical storage is partial, legacy storage is partial, both contain wallet data, or files conflict, stop with an actionable error. Never merge databases and never generate a new mnemonic over an ambiguous state.
5. Clean up an uncommitted staging directory after failure. A committed canonical wallet remains authoritative if cleanup of the old files fails; report that cleanup warning clearly.
The migration should preserve unknown config fields, including `defaultMintUrl`, and preserve the database byte-for-byte. This also preserves the NPC identity because that identity is derived from the mnemonic.
A staging copy plus atomic directory rename is preferred over two independent file renames: a crash must not expose a half-created canonical wallet. Copying also permits a clear rollback before commit and supports a legacy directory located on another filesystem through `COCOD_DIR`.
### Phase 3 — Run migration from every wallet-opening path
1. **CLI onboarding/init:** run migration before `initializeWallet()`. Only initialize a new mnemonic when migration reports `fresh`.
2. **Daemon direct startup:** run migration before `createCocoClient()` opens `coco.db`. This covers users who invoke the daemon without rerunning onboarding.
3. **Start/restart/update/service paths:** retain `stopLegacyCocod()` before migration/startup where those paths already stop legacy cocod. Direct daemon startup should refuse with the existing actionable error rather than silently running two wallet engines.
4. Print a concise success message showing old and new directories, but never print config contents or the mnemonic during migration.
A separate `routstrd wallet migrate` command is optional as a manual recovery/preview entry point, but it must call the same migration primitive. It is not a substitute for automatic migration.
### Phase 4 — Separate process locking
Refactor the current `claimLegacyCocodPidFile()` behavior into explicit responsibilities:
1. Claim `wallet.pid` for the lifetime of the in-process wallet to prevent two routstrd wallet instances from opening `coco.db`.
2. Continue claiming the legacy `cocod.pid` while routstrd is active, after verifying no real cocod owns it, to prevent an old cocod from starting.
3. If either claim fails, release any claim already acquired before returning the error.
4. On `CocodClient.dispose()`, startup failure, and daemon shutdown, release only PID files still owned by the current process.
5. Keep `stopLegacyCocod()` and `assertLegacyCocodNotRunning()` operating only on legacy cocod paths.
6. Update both PID-release waits in `src/cli.ts` (`restartDaemonsAfterUpdate` and `restart`) to wait for the canonical `wallet.pid` rather than `.cocod/cocod.pid`.
There is no canonical wallet socket.
### Phase 5 — Update initialization and client defaults
1. Make `initializeWallet()` default to the canonical wallet directory.
2. Preserve directory mode `0700` and config mode `0600` on both migrated and fresh wallets.
3. In `createCocoClient()`, derive `config.json` and `coco.db` from the canonical wallet directory, but take legacy guard paths separately.
4. Update the wallet-access comment near `unlock()` to reference `~/.routstrd/wallet/config.json`.
5. Leave `CocodClient`, `resolveCocodExecutable()`, and `cocodPath` compatibility behavior unchanged.
### Phase 6 — Tests
Add or update tests for:
- fresh initialization creates `~/.routstrd/wallet`, not `.cocod`;
- a legacy config and database migrate byte-for-byte;
- `defaultMintUrl` and unknown config fields survive migration;
- the NPC-derived identity is unchanged after migration;
- migration never copies socket or PID files;
- migration refuses while a real legacy cocod is running;
- stale legacy socket/PID handling remains safe;
- an existing complete canonical wallet wins without modifying it;
- partial canonical, partial legacy, and conflicting dual-wallet states fail without generating a mnemonic;
- staging cleanup and retry after an interrupted migration;
- custom `ROUTSTRD_DIR`, `ROUTSTRD_WALLET_DIR`, and legacy `COCOD_DIR` paths;
- Windows-compatible path construction;
- acquiring the second process lock rolls back the first on failure;
- `dispose()` releases both owned locks and does not unlink another process's lock;
- both CLI restart paths wait for `wallet.pid`.
Keep `cocod-0.0.24-wallet.db.gz` unchanged and continue using it to prove the migrated database opens successfully with the current in-process wallet.
### Phase 7 — Documentation
Update `README.md` and `SKILL.md` to state:
- wallet data is stored in `~/.routstrd/wallet`;
- existing `~/.cocod` data is migrated automatically on first startup;
- users must back up the mnemonic before migration;
- `ROUTSTRD_WALLET_DIR` overrides the canonical wallet directory;
- `COCOD_DIR`, `COCOD_SOCKET`, `COCOD_PID`, and `cocodPath` refer only to legacy external-cocod compatibility.
Do not replace every use of the word `cocod`: references to the external daemon, compatibility client, legacy guard, package, and fixture are still accurate.
---
## Acceptance criteria
- A fresh install creates wallet data only under `~/.routstrd/wallet`.
- Starting from a valid `.cocod` wallet results in the same config and database under the canonical directory without changing balances, mnemonic-derived NPC identity, or default mint.
- No startup path silently creates a new mnemonic when recoverable legacy data exists.
- A running legacy cocod blocks migration and database opening.
- The new wallet directory contains no copied Unix socket and uses `wallet.pid` only as an in-process lock.
- Legacy cocod probing and exclusion continue to use `.cocod/cocod.sock` and `.cocod/cocod.pid`.
- The implementation works with `USERPROFILE`/Windows path construction and custom directory overrides.
- All wallet, CLI, typecheck, and build tests pass on the rebased `coco-integration` branch.
---
## Suggested commit sequence
1. `refactor(wallet): separate routstrd wallet paths from legacy cocod paths`
2. `feat(wallet): atomically migrate legacy cocod wallet data`
3. `fix(wallet): use independent routstrd and legacy cocod process locks`
4. `test(wallet): cover migration, conflicts, NPC identity, and lock rollback`
5. `docs: document routstrd wallet storage and legacy migration`
+11
View File
@@ -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
Configuration is stored in `~/.routstrd/config.json`: Configuration is stored in `~/.routstrd/config.json`:
+3 -1
View File
@@ -170,7 +170,9 @@ Refresh routstr21 models from Nostr and re-run integrations for all registered c
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `ROUTSTRD_DIR` | `~/.routstrd` | Config directory | | `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` ### `routstrd mode`
+2 -2
View File
@@ -24,7 +24,7 @@ afterEach(() => {
describe("initializeWallet", () => { describe("initializeWallet", () => {
test("creates the wallet directory and config with restrictive permissions", () => { test("creates the wallet directory and config with restrictive permissions", () => {
const walletDir = join(makeTempDir(), ".cocod"); const walletDir = join(makeTempDir(), "wallet");
initializeWallet(walletDir); initializeWallet(walletDir);
@@ -39,7 +39,7 @@ describe("initializeWallet", () => {
}); });
test("repairs permissions without replacing an existing wallet", () => { 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 walletConfig = join(walletDir, "config.json");
const existingConfig = JSON.stringify({ mnemonic: "existing seed" }); const existingConfig = JSON.stringify({ mnemonic: "existing seed" });
+37 -11
View File
@@ -17,6 +17,7 @@ import {
} from "./utils/clients"; } from "./utils/clients";
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs"; import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
import { execSync } from "child_process"; import { execSync } from "child_process";
import { dirname, join } from "path";
import { import {
CONFIG_DIR, CONFIG_DIR,
DB_PATH, DB_PATH,
@@ -27,7 +28,18 @@ import {
} from "./utils/config"; } from "./utils/config";
import { logger } from "./utils/logger"; import { logger } from "./utils/logger";
import { setupIntegration, runIntegrationsForClients } from "./integrations"; 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 { getClientsList } from "./utils/clients";
import * as QRCode from "qrcode"; import * as QRCode from "qrcode";
import { normalizeNostrPubkey, npubFromPubkey, npubFromSecretKey } from "./utils/nip98"; import { normalizeNostrPubkey, npubFromPubkey, npubFromSecretKey } from "./utils/nip98";
@@ -85,12 +97,8 @@ async function printLightningInvoice(invoice: string): Promise<void> {
console.log(`${qr}\nInvoice:\n${invoice}`); console.log(`${qr}\nInvoice:\n${invoice}`);
} }
export function initializeWallet( export function initializeWallet(walletDir = defaultWalletDir()): void {
walletDir = const walletConfig = join(walletDir, "config.json");
process.env.COCOD_DIR ||
`${process.env.HOME || process.env.USERPROFILE || ""}/.cocod`,
): void {
const walletConfig = `${walletDir}/config.json`;
// The wallet directory and config contain the plaintext seed phrase. Correct // The wallet directory and config contain the plaintext seed phrase. Correct
// permissions on existing installations as well as newly created ones. // permissions on existing installations as well as newly created ones.
@@ -144,8 +152,8 @@ async function restartDaemonsAfterUpdate(): Promise<void> {
await callDaemon("/stop", { method: "POST" }); await callDaemon("/stop", { method: "POST" });
// Wait for HTTP health check to fail AND pidfile to be released. // Wait for HTTP health check to fail AND wallet lock to be released.
const pidFilePath = `${process.env.HOME || process.env.USERPROFILE || ""}/.cocod/cocod.pid`; const pidFilePath = walletPidPath();
for (let i = 0; i < 100; i++) { for (let i = 0; i < 100; i++) {
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
const healthDown = !(await isDaemonRunning()); const healthDown = !(await isDaemonRunning());
@@ -232,6 +240,24 @@ async function initDaemon(): Promise<void> {
} }
console.log(`Database will be stored at: ${DB_PATH}`); 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(); initializeWallet();
await startDaemon({ port: String(config.port || 8008), host: config.host || undefined }); await startDaemon({ port: String(config.port || 8008), host: config.host || undefined });
@@ -1833,8 +1859,8 @@ program
console.log("Stopping daemon..."); console.log("Stopping daemon...");
await callDaemon("/stop", { method: "POST" }); await callDaemon("/stop", { method: "POST" });
// Wait for HTTP health check to fail AND pidfile to be released. // Wait for HTTP health check to fail AND wallet lock to be released.
const pidFilePath = `${process.env.HOME || process.env.USERPROFILE || ""}/.cocod/cocod.pid`; const pidFilePath = walletPidPath();
for (let i = 0; i < 100; i++) { for (let i = 0; i < 100; i++) {
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
const healthDown = !(await isDaemonRunning()); const healthDown = !(await isDaemonRunning());
+31 -1
View File
@@ -54,7 +54,18 @@ import { createDaemonRequestHandler } from "./http";
import { FileRequestResponseLogSink } from "./request-response-log-sink"; import { FileRequestResponseLogSink } from "./request-response-log-sink";
import { refreshModelsAndIntegrations } from "../integrations"; import { refreshModelsAndIntegrations } from "../integrations";
import { RoutstrClient } from "@routstr/sdk"; 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 // Global error handlers — the daemon is spawned detached with stdout/stderr
// redirected to a file, so without these, uncaught async errors would kill // redirected to a file, so without these, uncaught async errors would kill
@@ -115,6 +126,25 @@ async function main(): Promise<void> {
const { ensureProvidersBootstrapped, getRoutstr21Models, getModelProviders, refreshProvidersAndModels } = const { ensureProvidersBootstrapped, getRoutstr21Models, getModelProviders, refreshProvidersAndModels } =
createModelService(modelManager, providerManager, store); 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(); const walletClient = await createCocoClient();
// ── Auto-refill configuration ──────────────────────────────── // ── Auto-refill configuration ────────────────────────────────
+15 -7
View File
@@ -49,7 +49,7 @@ const tempDirs: string[] = [];
function makeWalletDir(): string { function makeWalletDir(): string {
const dir = mkdtempSync(join(tmpdir(), "routstrd-coco-npc-test-")); const dir = mkdtempSync(join(tmpdir(), "routstrd-coco-npc-test-"));
tempDirs.push(dir); tempDirs.push(dir);
const walletDir = join(dir, ".cocod"); const walletDir = join(dir, "wallet");
mkdirSync(walletDir, { recursive: true }); mkdirSync(walletDir, { recursive: true });
writeFileSync( writeFileSync(
join(walletDir, "config.json"), join(walletDir, "config.json"),
@@ -58,6 +58,14 @@ function makeWalletDir(): string {
return walletDir; return walletDir;
} }
function testClientOptions(walletDir = makeWalletDir()) {
return {
walletDir,
legacySocketPath: join(walletDir, "legacy-cocod.sock"),
legacyPidPath: join(walletDir, "legacy-cocod.pid"),
};
}
function resetNpcState(): void { function resetNpcState(): void {
npcState.info = { name: "alice", pubkey: "ab".repeat(32) }; npcState.info = { name: "alice", pubkey: "ab".repeat(32) };
npcState.usernameResult = { success: true }; npcState.usernameResult = { success: true };
@@ -74,7 +82,7 @@ afterEach(() => {
describe("in-process coco client NPC integration", () => { describe("in-process coco client NPC integration", () => {
it("registers the plugin and exposes the wallet's NPC lightning address", async () => { 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 { try {
const info = await client.getNpcAddress(); const info = await client.getNpcAddress();
expect(info).toEqual({ 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 () => { it("falls back to the npub address when no username is set", async () => {
npcState.info = { name: null, pubkey: "ab".repeat(32) }; npcState.info = { name: null, pubkey: "ab".repeat(32) };
const client = await createCocoClient({ configDir: makeWalletDir() }); const client = await createCocoClient(testClientOptions());
try { try {
const info = await client.getNpcAddress(); const info = await client.getNpcAddress();
expect(info.address).toStartWith("npub1"); expect(info.address).toStartWith("npub1");
@@ -106,7 +114,7 @@ describe("in-process coco client NPC integration", () => {
success: false, success: false,
pr: { amount: 21, mints: ["https://mint.example.com"] }, pr: { amount: 21, mints: ["https://mint.example.com"] },
}; };
const client = await createCocoClient({ configDir: makeWalletDir() }); const client = await createCocoClient(testClientOptions());
try { try {
const result = await client.setNpcUsername("bob"); const result = await client.setNpcUsername("bob");
expect(result).toEqual({ expect(result).toEqual({
@@ -122,7 +130,7 @@ describe("in-process coco client NPC integration", () => {
}); });
it("confirms the claim fee payment when requested", async () => { it("confirms the claim fee payment when requested", async () => {
const client = await createCocoClient({ configDir: makeWalletDir() }); const client = await createCocoClient(testClientOptions());
try { try {
const result = await client.setNpcUsername("bob", true); const result = await client.setNpcUsername("bob", true);
expect(result).toEqual({ success: true }); expect(result).toEqual({ success: true });
@@ -135,7 +143,7 @@ describe("in-process coco client NPC integration", () => {
}); });
it("triggers a manual quote sync", async () => { it("triggers a manual quote sync", async () => {
const client = await createCocoClient({ configDir: makeWalletDir() }); const client = await createCocoClient(testClientOptions());
try { try {
await client.syncNpc(); await client.syncNpc();
expect(npcState.syncCalls).toBe(1); 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 () => { it("rejects NPC calls with a clear error when the plugin is disabled", async () => {
const client = await createCocoClient({ const client = await createCocoClient({
configDir: makeWalletDir(), ...testClientOptions(),
enableNpc: false, enableNpc: false,
}); });
try { try {
+35 -9
View File
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, mock } from "bun:test"; import { afterEach, describe, expect, it, mock } from "bun:test";
import { gunzipSync } from "bun"; import { gunzipSync } from "bun";
import { import {
existsSync,
mkdtempSync, mkdtempSync,
mkdirSync, mkdirSync,
readFileSync, readFileSync,
@@ -43,7 +44,7 @@ function socketOnly(path: string): boolean {
describe("default mint functionality", () => { describe("default mint functionality", () => {
it("automatically adds default mint when no mints exist", async () => { it("automatically adds default mint when no mints exist", async () => {
const walletDir = join(makeTempDir(), ".cocod"); const walletDir = join(makeTempDir(), "wallet");
mkdirSync(walletDir, { recursive: true }); mkdirSync(walletDir, { recursive: true });
writeFileSync( writeFileSync(
join(walletDir, "config.json"), 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 { 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(); const mints = await client.listMints();
expect(mints).toContain("https://mint.cubabitcoin.org"); expect(mints).toContain("https://mint.cubabitcoin.org");
@@ -65,10 +77,12 @@ describe("default mint functionality", () => {
} finally { } finally {
await client.dispose?.(); 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 () => { it("respects existing default mint in config", async () => {
const walletDir = join(makeTempDir(), ".cocod"); const walletDir = join(makeTempDir(), "wallet");
const configuredDefault = "https://mint.cubabitcoin.org"; const configuredDefault = "https://mint.cubabitcoin.org";
mkdirSync(walletDir, { recursive: true }); 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 { try {
// Should respect the configured default mint // Should respect the configured default mint
const defaultMint = await client.getDefaultMint(); const defaultMint = await client.getDefaultMint();
@@ -99,7 +117,7 @@ describe("default mint functionality", () => {
}); });
it("allows setting default mint to an already trusted mint", async () => { 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 }); mkdirSync(walletDir, { recursive: true });
writeFileSync( writeFileSync(
join(walletDir, "config.json"), 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 { try {
// The initial default should be the auto-added Cuba mint // The initial default should be the auto-added Cuba mint
const initialDefault = await client.getDefaultMint(); const initialDefault = await client.getDefaultMint();
@@ -133,7 +155,7 @@ describe("default mint functionality", () => {
describe("legacy cocod wallet migration", () => { describe("legacy cocod wallet migration", () => {
it("opens an existing unencrypted config and preserves database balances", async () => { 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"; const mintUrl = "https://mint.example.com";
mkdirSync(walletDir, { recursive: true }); mkdirSync(walletDir, { recursive: true });
@@ -164,7 +186,9 @@ describe("legacy cocod wallet migration", () => {
// NPC is disabled here: this test verifies database migration, and the // NPC is disabled here: this test verifies database migration, and the
// real plugin would otherwise open a websocket to npubx.cash. // real plugin would otherwise open a websocket to npubx.cash.
const client = await createCocoClient({ const client = await createCocoClient({
configDir: walletDir, walletDir,
legacySocketPath: join(walletDir, "legacy-cocod.sock"),
legacyPidPath: join(walletDir, "legacy-cocod.pid"),
enableNpc: false, enableNpc: false,
}); });
try { try {
@@ -177,7 +201,9 @@ describe("legacy cocod wallet migration", () => {
// Prove the migrated schema remains reopenable and the pre-existing // Prove the migrated schema remains reopenable and the pre-existing
// proofs survive a complete in-process wallet restart. // proofs survive a complete in-process wallet restart.
const reopenedClient = await createCocoClient({ const reopenedClient = await createCocoClient({
configDir: walletDir, walletDir,
legacySocketPath: join(walletDir, "legacy-cocod.sock"),
legacyPidPath: join(walletDir, "legacy-cocod.pid"),
enableNpc: false, enableNpc: false,
}); });
try { try {
+58 -35
View File
@@ -16,13 +16,14 @@ import { finalizeEvent, nip19, type EventTemplate } from "nostr-tools";
import { import {
closeSync, closeSync,
existsSync, existsSync,
mkdirSync,
openSync, openSync,
readFileSync, readFileSync,
renameSync, renameSync,
unlinkSync, unlinkSync,
writeFileSync, writeFileSync,
} from "fs"; } from "fs";
import { join } from "path"; import { dirname, join } from "path";
import { mnemonicToSeedSync } from "@scure/bip39"; import { mnemonicToSeedSync } from "@scure/bip39";
import type { import type {
CocodClient, CocodClient,
@@ -31,20 +32,15 @@ import type {
NpcUsernameResult, NpcUsernameResult,
} from "./cocod-client"; } from "./cocod-client";
import { logger } from "../../utils/logger"; 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 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([ const STALE_SOCKET_ERROR_CODES = new Set([
"ECONNREFUSED", "ECONNREFUSED",
"ENOENT", "ENOENT",
@@ -167,7 +163,7 @@ function createCocoLogger(bindings: Record<string, unknown> = {}): CocoLogger {
}; };
} }
function loadConfig(configFile: string = CONFIG_FILE): CocodConfig { function loadConfig(configFile: string): CocodConfig {
if (!existsSync(configFile)) { if (!existsSync(configFile)) {
throw new Error( throw new Error(
`Config file not found at ${configFile}. Run 'routstrd onboard' first.`, `Config file not found at ${configFile}. Run 'routstrd onboard' first.`,
@@ -182,10 +178,7 @@ function loadConfig(configFile: string = CONFIG_FILE): CocodConfig {
return config; return config;
} }
function saveConfig( function saveConfig(config: CocodConfig, configFile: string): void {
config: CocodConfig,
configFile: string = CONFIG_FILE,
): void {
const temporaryFile = `${configFile}.${process.pid}.tmp`; const temporaryFile = `${configFile}.${process.pid}.tmp`;
try { try {
writeFileSync(temporaryFile, JSON.stringify(config, null, 2), { writeFileSync(temporaryFile, JSON.stringify(config, null, 2), {
@@ -239,8 +232,8 @@ function hasErrorCode(error: unknown, codes: Set<string>): boolean {
export async function assertLegacyCocodNotRunning( export async function assertLegacyCocodNotRunning(
options: LegacyCocodGuardOptions = {}, options: LegacyCocodGuardOptions = {},
): Promise<void> { ): Promise<void> {
const socketPath = options.socketPath || LEGACY_COCOD_SOCKET; const socketPath = options.socketPath || legacyCocodSocketPath();
const pidFilePath = options.pidFilePath || LEGACY_COCOD_PID_FILE; const pidFilePath = options.pidFilePath || legacyCocodPidPath();
const pathExists = options.pathExists || existsSync; const pathExists = options.pathExists || existsSync;
const readFile = options.readFile || ((path) => readFileSync(path, "utf-8")); const readFile = options.readFile || ((path) => readFileSync(path, "utf-8"));
const isProcessRunning = options.isProcessRunning || defaultIsProcessRunning; const isProcessRunning = options.isProcessRunning || defaultIsProcessRunning;
@@ -325,8 +318,8 @@ export async function assertLegacyCocodNotRunning(
export async function stopLegacyCocod( export async function stopLegacyCocod(
options: LegacyCocodStopOptions = {}, options: LegacyCocodStopOptions = {},
): Promise<void> { ): Promise<void> {
const socketPath = options.socketPath || LEGACY_COCOD_SOCKET; const socketPath = options.socketPath || legacyCocodSocketPath();
const pidFilePath = options.pidFilePath || LEGACY_COCOD_PID_FILE; const pidFilePath = options.pidFilePath || legacyCocodPidPath();
const pathExists = options.pathExists || existsSync; const pathExists = options.pathExists || existsSync;
const readFile = const readFile =
options.readFile || ((path: string) => readFileSync(path, "utf-8")); options.readFile || ((path: string) => readFileSync(path, "utf-8"));
@@ -416,7 +409,14 @@ export async function stopLegacyCocod(
export function claimLegacyCocodPidFile( export function claimLegacyCocodPidFile(
options: LegacyCocodPidClaimOptions = {}, options: LegacyCocodPidClaimOptions = {},
): () => void { ): () => 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 pid = options.pid ?? process.pid;
const openExclusive = const openExclusive =
options.openExclusive || ((path: string) => openSync(path, "wx", 0o600)); options.openExclusive || ((path: string) => openSync(path, "wx", 0o600));
@@ -507,10 +507,15 @@ export function claimLegacyCocodPidFile(
} }
export interface CreateCocoClientOptions { 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; configDir?: string;
socketPath?: string; /** Override the in-process wallet lock path. */
pidFilePath?: string; walletPidPath?: string;
/** Override legacy external-cocod coordination paths. */
legacySocketPath?: string;
legacyPidPath?: string;
/** Set to false to skip NPC (npubx.cash) plugin registration. Default: true. */ /** Set to false to skip NPC (npubx.cash) plugin registration. Default: true. */
enableNpc?: boolean; enableNpc?: boolean;
/** NPC server base URL. Default: https://npubx.cash */ /** NPC server base URL. Default: https://npubx.cash */
@@ -520,20 +525,36 @@ export interface CreateCocoClientOptions {
export async function createCocoClient( export async function createCocoClient(
options: CreateCocoClientOptions = {}, options: CreateCocoClientOptions = {},
): Promise<CocodClient> { ): Promise<CocodClient> {
const configDir = options.configDir || CONFIG_DIR; const configDir = options.walletDir || options.configDir || defaultWalletDir();
const configFile = join(configDir, "config.json"); const configFile = join(configDir, "config.json");
const dbPath = join(configDir, "coco.db"); const dbPath = join(configDir, "coco.db");
const socketPath = const walletPidFile =
options.socketPath || options.walletPidPath ||
(options.configDir ? join(configDir, "cocod.sock") : LEGACY_COCOD_SOCKET); (options.walletDir || options.configDir
const pidFilePath = ? join(configDir, "wallet.pid")
options.pidFilePath || : defaultWalletPidPath());
(options.configDir ? join(configDir, "cocod.pid") : LEGACY_COCOD_PID_FILE); const legacySocket = options.legacySocketPath || legacyCocodSocketPath();
const legacyPidFile = options.legacyPidPath || legacyCocodPidPath();
const npcBaseUrl = options.npcBaseUrl || NPC_DEFAULT_BASE_URL; const npcBaseUrl = options.npcBaseUrl || NPC_DEFAULT_BASE_URL;
const npcAddressDomain = new URL(npcBaseUrl).host; const npcAddressDomain = new URL(npcBaseUrl).host;
await assertLegacyCocodNotRunning({ socketPath, pidFilePath }); await assertLegacyCocodNotRunning({
const releaseLegacyPidClaim = claimLegacyCocodPidFile({ pidFilePath }); 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 database: Database | undefined;
let coco: Awaited<ReturnType<typeof initializeCoco>> | undefined; let coco: Awaited<ReturnType<typeof initializeCoco>> | undefined;
@@ -616,6 +637,7 @@ export async function createCocoClient(
} catch (error) { } catch (error) {
database?.close(); database?.close();
releaseLegacyPidClaim(); releaseLegacyPidClaim();
releaseWalletPidClaim();
throw error; throw error;
} }
@@ -653,7 +675,7 @@ export async function createCocoClient(
async unlock(_passphrase: string): Promise<string> { async unlock(_passphrase: string): Promise<string> {
// coco-core does not support passphrase locking. // 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"; return "wallet does not require unlocking";
}, },
@@ -756,6 +778,7 @@ export async function createCocoClient(
database.close(); database.close();
} finally { } finally {
releaseLegacyPidClaim(); releaseLegacyPidClaim();
releaseWalletPidClaim();
} }
} }
}, },
+107
View File
@@ -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");
});
});
+136
View File
@@ -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<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));
}
/**
* 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 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 };
}
+33
View File
@@ -0,0 +1,33 @@
import { join } from "path";
function homeDir(): string {
return process.env.HOME || process.env.USERPROFILE || "";
}
/** Root directory for routstrd state. Evaluated lazily so env overrides work in tests. */
export function routstrdConfigDir(): string {
return process.env.ROUTSTRD_DIR || join(homeDir(), ".routstrd");
}
/** Canonical directory for the in-process Cashu wallet. */
export function walletDir(): string {
return process.env.ROUTSTRD_WALLET_DIR || join(routstrdConfigDir(), "wallet");
}
/** Lock owned by an in-process routstrd wallet instance. */
export function walletPidPath(): string {
return process.env.ROUTSTRD_WALLET_PID || join(walletDir(), "wallet.pid");
}
/** Legacy external cocod directory. This is not the routstrd wallet directory. */
export function legacyCocodDir(): string {
return process.env.COCOD_DIR || join(homeDir(), ".cocod");
}
export function legacyCocodSocketPath(): string {
return process.env.COCOD_SOCKET || join(legacyCocodDir(), "cocod.sock");
}
export function legacyCocodPidPath(): string {
return process.env.COCOD_PID || join(legacyCocodDir(), "cocod.pid");
}
-1
View File
@@ -9,7 +9,6 @@ import { logger } from "./utils/logger";
import { CONFIG_DIR, LOGS_DIR } from "./utils/config"; import { CONFIG_DIR, LOGS_DIR } from "./utils/config";
import { withCrossProcessLock } from "./utils/process-lock"; import { withCrossProcessLock } from "./utils/process-lock";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { existsSync } from "fs";
const DAEMON_STARTUP_LOCK_PATH = `${CONFIG_DIR}/routstrd-startup.lock`; const DAEMON_STARTUP_LOCK_PATH = `${CONFIG_DIR}/routstrd-startup.lock`;
const DEBUG_LOG_PATH = `${CONFIG_DIR}/debug.log`; const DEBUG_LOG_PATH = `${CONFIG_DIR}/debug.log`;