diff --git a/src/daemon/wallet/coco-client.test.ts b/src/daemon/wallet/coco-client.test.ts index 38f9143..49d2667 100644 --- a/src/daemon/wallet/coco-client.test.ts +++ b/src/daemon/wallet/coco-client.test.ts @@ -1,7 +1,12 @@ -import { describe, expect, it, mock } from "bun:test"; +import { afterEach, describe, expect, it, mock } from "bun:test"; +import { gunzipSync } from "bun"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; import { assertLegacyCocodNotRunning, claimLegacyCocodPidFile, + createCocoClient, } from "./coco-client"; type GuardOptions = NonNullable< @@ -11,11 +16,67 @@ type LegacyFetch = NonNullable; const SOCKET_PATH = "/tmp/routstrd-test/cocod.sock"; const PID_FILE_PATH = "/tmp/routstrd-test/cocod.pid"; +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "routstrd-coco-migration-test-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); function socketOnly(path: string): boolean { return path === SOCKET_PATH; } +describe("legacy cocod wallet migration", () => { + it("opens an existing unencrypted config and preserves database balances", async () => { + const walletDir = join(makeTempDir(), ".cocod"); + const mintUrl = "https://mint.example.com"; + mkdirSync(walletDir, { recursive: true }); + writeFileSync( + join(walletDir, "config.json"), + JSON.stringify({ + version: 1, + mnemonic: + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + encrypted: false, + }), + ); + + // This fixture was generated with @routstr/cocod 0.0.24 using its + // coco-cashu-sqlite-bun 1.1.2-rc.50 adapter. Keeping it frozen prevents + // this test from accidentally creating its "legacy" database with the + // same current adapter that createCocoClient uses to read it. + const fixture = readFileSync( + join(import.meta.dir, "fixtures", "cocod-0.0.24-wallet.db.gz"), + ); + writeFileSync(join(walletDir, "coco.db"), gunzipSync(fixture)); + + const client = await createCocoClient({ configDir: walletDir }); + try { + expect(await client.getStatus()).toBe("UNLOCKED"); + expect(await client.getBalances()).toEqual({ [mintUrl]: 10 }); + } finally { + await client.dispose?.(); + } + + // Prove the migrated schema remains reopenable and the pre-existing + // proofs survive a complete in-process wallet restart. + const reopenedClient = await createCocoClient({ configDir: walletDir }); + try { + expect(await reopenedClient.getBalances()).toEqual({ [mintUrl]: 10 }); + } finally { + await reopenedClient.dispose?.(); + } + }); +}); + describe("assertLegacyCocodNotRunning", () => { it("does not probe when the legacy socket and PID file do not exist", async () => { const fetchImpl = mock(async () => new Response("pong")); diff --git a/src/daemon/wallet/coco-client.ts b/src/daemon/wallet/coco-client.ts index cac507f..d16f130 100644 --- a/src/daemon/wallet/coco-client.ts +++ b/src/daemon/wallet/coco-client.ts @@ -64,13 +64,13 @@ interface CocodConfig { encrypted: boolean; } -function loadMnemonic(): string { - if (!existsSync(CONFIG_FILE)) { +function loadMnemonic(configFile: string = CONFIG_FILE): string { + if (!existsSync(configFile)) { throw new Error( - `Config file not found at ${CONFIG_FILE}. Run 'routstrd onboard' first.`, + `Config file not found at ${configFile}. Run 'routstrd onboard' first.`, ); } - const config = JSON.parse(readFileSync(CONFIG_FILE, "utf-8")) as CocodConfig; + const config = JSON.parse(readFileSync(configFile, "utf-8")) as CocodConfig; if (config.encrypted) { throw new Error( "Encrypted wallets are not supported yet. Please use an unencrypted wallet.", @@ -79,11 +79,6 @@ function loadMnemonic(): string { return config.mnemonic; } -async function seedGetter(): Promise { - const mnemonic = loadMnemonic(); - return mnemonicToSeedSync(mnemonic); -} - function defaultIsProcessRunning(pid: number): boolean { try { process.kill(pid, 0); @@ -282,21 +277,41 @@ export function claimLegacyCocodPidFile( }; } -export async function createCocoClient(): Promise { - await assertLegacyCocodNotRunning(); - const releaseLegacyPidClaim = claimLegacyCocodPidFile(); +export interface CreateCocoClientOptions { + /** Override the wallet directory, primarily for migration tests and tooling. */ + configDir?: string; + socketPath?: string; + pidFilePath?: string; +} + +export async function createCocoClient( + options: CreateCocoClientOptions = {}, +): Promise { + const configDir = options.configDir || CONFIG_DIR; + 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); + + await assertLegacyCocodNotRunning({ socketPath, pidFilePath }); + const releaseLegacyPidClaim = claimLegacyCocodPidFile({ pidFilePath }); let database: Database | undefined; let coco: Awaited> | undefined; try { - database = new Database(DB_PATH); + // Read and validate the existing cocod config during startup rather than + // deferring failure until coco-core first needs wallet key material. + const seed = mnemonicToSeedSync(loadMnemonic(configFile)); + database = new Database(dbPath); const repo = new SqliteRepositories({ database }); await repo.init(); coco = await initializeCoco({ repo, - seedGetter, + seedGetter: async () => seed, }); } catch (error) { database?.close(); diff --git a/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz b/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz new file mode 100644 index 0000000..a7fc1aa Binary files /dev/null and b/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz differ