diff --git a/README.md b/README.md index 52e6e92..c1faecd 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,13 @@ derived from this mnemonic (NIP-06, account index 0), so it matches the NPC (npubx.cash) npub. Back up the mnemonic to recover both the wallet and the auth identity. +Both `routstrd init` and `routstrd remote ` create the wallet (migrating a +legacy cocod wallet first when one exists) whenever no wallet and no nsec are +configured yet, so the identity is mnemonic-derived regardless of which command +runs first. An already-configured nsec is never replaced — installations that +generated a random nsec before this behavior existed keep their existing +identity. + ## Configuration Configuration is stored in `~/.routstrd/config.json`: diff --git a/SKILL.md b/SKILL.md index 012d87c..fa5beb6 100644 --- a/SKILL.md +++ b/SKILL.md @@ -148,7 +148,7 @@ Manage admin npubs (subcommand required). ### `routstrd remote ` -Configure a remote daemon URL. Creates a Nostr identity (nsec/npub) for NIP-98 authentication automatically — derived from the local wallet mnemonic (NIP-06 account 0) when a wallet exists, otherwise generated randomly. +Configure a remote daemon URL. Creates a Nostr identity (nsec/npub) for NIP-98 authentication automatically — derived from the local wallet mnemonic (NIP-06 account 0). A local wallet is created on the spot when none exists (migrating a legacy cocod wallet first, so its mnemonic wins); a random identity is generated only when the wallet mnemonic is unavailable (e.g. an encrypted wallet). ```sh routstrd remote https://your-remote-daemon.com @@ -163,7 +163,7 @@ Refresh routstr21 models from Nostr and re-run integrations for all registered c | `port` | number | 8008 | Daemon HTTP port | | `provider` | string\|null | null | Default provider URL | | `daemonUrl` | string\|null | null | Remote daemon URL | -| `nsec` | string\|null | null | Nostr secret key for NIP-98 auth (derived from wallet mnemonic via NIP-06 account 0 on first init) | +| `nsec` | string\|null | null | Nostr secret key for NIP-98 auth (derived from the wallet mnemonic via NIP-06 account 0 whenever no nsec is configured yet — on init or when configuring a remote) | | `cocodPath` | string\|null | null | Custom path to cocod executable | | `mode` | string | `"apikeys"` | Client mode (`apikeys` or `xcashu`) | diff --git a/src/cli.test.ts b/src/cli.test.ts deleted file mode 100644 index 0886893..0000000 --- a/src/cli.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; -import { initializeWallet } from "./cli"; - -const tempDirs: string[] = []; - -function makeTempDir(): string { - const dir = mkdtempSync(join(tmpdir(), "routstrd-wallet-test-")); - tempDirs.push(dir); - return dir; -} - -function permissions(path: string): number { - return statSync(path).mode & 0o777; -} - -afterEach(() => { - for (const dir of tempDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } -}); - -describe("initializeWallet", () => { - test("creates the wallet directory and config with restrictive permissions", () => { - const walletDir = join(makeTempDir(), "wallet"); - - initializeWallet(walletDir); - - const walletConfig = join(walletDir, "config.json"); - expect(permissions(walletDir)).toBe(0o700); - expect(permissions(walletConfig)).toBe(0o600); - - const config = JSON.parse(readFileSync(walletConfig, "utf8")); - expect(config.encrypted).toBe(false); - expect(typeof config.mnemonic).toBe("string"); - expect(config.mnemonic.length).toBeGreaterThan(0); - }); - - test("repairs permissions without replacing an existing wallet", () => { - const walletDir = join(makeTempDir(), "wallet"); - const walletConfig = join(walletDir, "config.json"); - const existingConfig = JSON.stringify({ mnemonic: "existing seed" }); - - mkdirSync(walletDir, { mode: 0o755 }); - writeFileSync(walletConfig, existingConfig, { mode: 0o644 }); - - initializeWallet(walletDir); - - expect(readFileSync(walletConfig, "utf8")).toBe(existingConfig); - expect(permissions(walletDir)).toBe(0o700); - expect(permissions(walletConfig)).toBe(0o600); - }); -}); diff --git a/src/cli.ts b/src/cli.ts index c0bf6df..51c4d84 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,9 +15,8 @@ import { deleteClientAction, addClientAction, } from "./utils/clients"; -import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs"; +import { existsSync, mkdirSync, readFileSync, statSync } from "fs"; import { execSync } from "child_process"; -import { dirname, join } from "path"; import { CONFIG_DIR, DB_PATH, @@ -28,24 +27,15 @@ import { } from "./utils/config"; import { COCO_LOGS_DIR, logger } from "./utils/logger"; import { setupIntegration, runIntegrationsForClients } from "./integrations"; -import { - assertLegacyCocodNotRunning, - claimLegacyCocodPidFile, - stopLegacyCocod, -} from "./daemon/wallet/coco-client"; -import { migrateLegacyWallet } from "./daemon/wallet/migration"; +import { stopLegacyCocod } from "./daemon/wallet/coco-client"; import { readWalletMnemonic } from "./daemon/wallet/wallet-config"; -import { - legacyCocodPidPath, - legacyCocodSocketPath, - walletDir as defaultWalletDir, - walletPidPath, -} from "./daemon/wallet/paths"; +import { ensureLocalWallet } from "./daemon/wallet/ensure-wallet"; +import { walletPidPath } from "./daemon/wallet/paths"; import { getClientsList } from "./utils/clients"; import * as QRCode from "qrcode"; import { normalizeNostrPubkey, npubFromPubkey, npubFromSecretKey, nsecFromMnemonic } from "./utils/nip98"; import { generateSecretKey, nip19 } from "nostr-tools"; -import { generateMnemonic } from "@scure/bip39"; +import { validateMnemonic } from "@scure/bip39"; import { wordlist } from "@scure/bip39/wordlists/english.js"; import packageJson from "../package.json" with { type: "json" }; import { @@ -98,35 +88,6 @@ async function printLightningInvoice(invoice: string): Promise { console.log(`${qr}\nInvoice:\n${invoice}`); } -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. - mkdirSync(walletDir, { recursive: true, mode: 0o700 }); - chmodSync(walletDir, 0o700); - - if (existsSync(walletConfig)) { - chmodSync(walletConfig, 0o600); - console.log("Wallet already initialized."); - return; - } - - const mnemonic = generateMnemonic(wordlist); - const config = { - version: 1, - mnemonic, - encrypted: false, - createdAt: new Date().toISOString(), - }; - writeFileSync(walletConfig, JSON.stringify(config, null, 2), { - mode: 0o600, - flag: "wx", - }); - console.log("Initialized. Mnemonic:", mnemonic); - console.log("IMPORTANT: Write down this mnemonic and keep it safe!"); -} - /** * Restart the routstrd daemon after an update so the new binary takes * effect immediately. Failures are collected and reported but never @@ -215,12 +176,24 @@ function deriveNostrIdentity(): { } { const mnemonic = readWalletMnemonic(); if (mnemonic) { + // The wallet and NPC plugin derive their keys from this same raw string + // without checksum validation, so deriving anyway keeps all identities + // consistent even when the mnemonic is corrupted. Warn loudly instead of + // silently falling back to a divergent random identity. + if (!validateMnemonic(mnemonic, wordlist)) { + console.warn( + "Warning: the wallet mnemonic fails BIP-39 checksum validation and may be corrupted. " + + "Deriving the authentication identity from it anyway so it stays consistent with the wallet/NPC identity — " + + "verify your wallet backup.", + ); + } const { nsec, npub } = nsecFromMnemonic(mnemonic); return { nsec, npub, fromMnemonic: true }; } - // Fallback for paths where no wallet exists yet (e.g. configuring a remote - // daemon before a local wallet has been created). + // Fallback for wallets whose mnemonic cannot be read (encrypted or malformed + // wallet config). Callers ensure a wallet exists before deriving, so a + // missing wallet should no longer reach this path. const secretKey = generateSecretKey(); return { nsec: nip19.nsecEncode(secretKey), @@ -251,36 +224,7 @@ async function initDaemon(): Promise { const config = await loadConfig(); 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, - }); - const release = claimLegacyCocodPidFile({ - pidFilePath: legacyCocodPidPath(), - }); - migrationLockOwner = process.pid; - return () => { - migrationLockOwner = undefined; - release(); - }; - }, - }); - 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 ensureLocalWallet(); if (!config.nsec) { const { nsec, npub, fromMnemonic } = deriveNostrIdentity(); @@ -512,6 +456,17 @@ program let identityDerivedFromMnemonic = false; if (!config.nsec) { + // The auth identity is derived from the wallet mnemonic, so make sure a + // wallet exists first — migrating a legacy cocod wallet when present so + // its mnemonic wins over a fresh one. This makes the derived nsec + // recoverable from the wallet backup even for users who never run local + // wallet commands. + const { created } = await ensureLocalWallet(); + if (created) { + console.log( + "A local wallet was created so the Nostr identity can be derived from its mnemonic.", + ); + } const { nsec, npub, fromMnemonic } = deriveNostrIdentity(); updates.nsec = nsec; generatedNpub = npub; diff --git a/src/daemon/wallet/ensure-wallet.ts b/src/daemon/wallet/ensure-wallet.ts new file mode 100644 index 0000000..38a4f0c --- /dev/null +++ b/src/daemon/wallet/ensure-wallet.ts @@ -0,0 +1,76 @@ +import { mkdirSync } from "fs"; +import { dirname } from "path"; +import { + assertLegacyCocodNotRunning, + claimLegacyCocodPidFile, + stopLegacyCocod, +} from "./coco-client"; +import { migrateLegacyWallet } from "./migration"; +import { legacyCocodPidPath, legacyCocodSocketPath } from "./paths"; +import { initializeWallet } from "./wallet-config"; + +export interface EnsureLocalWalletResult { + /** True when a legacy cocod wallet was migrated into the canonical directory. */ + migrated: boolean; + /** True when this call created a fresh wallet with a new mnemonic. */ + created: boolean; +} + +export interface EnsureLocalWalletOptions { + log?: (message: string) => void; + warn?: (message: string) => void; +} + +/** + * Guarantee that the canonical routstrd wallet exists, preferring an existing + * mnemonic over generating a new one: + * + * 1. Stop/confine any legacy external cocod process. + * 2. Migrate a legacy cocod wallet when present — its mnemonic always wins + * over a freshly generated one, and a divergent pre-existing canonical + * wallet aborts the migration loudly instead of shadowing funds. + * 3. Otherwise create a fresh wallet with a new BIP-39 mnemonic. + * + * Both `init` and `remote` run this before deriving the operator Nostr + * identity, so the nsec is recoverable from the wallet backup regardless of + * which command ran first. + */ +export async function ensureLocalWallet( + options: EnsureLocalWalletOptions = {}, +): Promise { + const log = options.log ?? console.log; + const warn = options.warn ?? console.warn; + + 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, + }); + const release = claimLegacyCocodPidFile({ + pidFilePath: legacyCocodPidPath(), + }); + migrationLockOwner = process.pid; + return () => { + migrationLockOwner = undefined; + release(); + }; + }, + }); + if (migration.status === "migrated") { + log(`Migrated wallet from ${migration.from} to ${migration.to}.`); + for (const warning of migration.cleanupWarnings) warn(warning); + } + + const created = initializeWallet(undefined, log); + return { migrated: migration.status === "migrated", created }; +} diff --git a/src/daemon/wallet/wallet-config.ts b/src/daemon/wallet/wallet-config.ts index 7cf1035..ec3518b 100644 --- a/src/daemon/wallet/wallet-config.ts +++ b/src/daemon/wallet/wallet-config.ts @@ -1,5 +1,13 @@ -import { existsSync, readFileSync } from "fs"; +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "fs"; import { join } from "path"; +import { generateMnemonic } from "@scure/bip39"; +import { wordlist } from "@scure/bip39/wordlists/english.js"; import { walletDir as defaultWalletDir } from "./paths"; export interface WalletConfigFile { @@ -33,3 +41,57 @@ export function readWalletMnemonic(walletDir = defaultWalletDir()): string | nul ? config.mnemonic.trim() : null; } + +/** + * Create the canonical wallet (a config.json holding a fresh BIP-39 mnemonic) + * when it does not exist yet. An existing wallet is never modified. + * + * Concurrent initializers are safe: the process that loses the probe/write + * race gets EEXIST from the exclusive create and treats it as "already + * initialized", keeping the winner's mnemonic. + * + * Returns true when this call created the wallet. + */ +export function initializeWallet( + walletDir = defaultWalletDir(), + log: (message: string) => void = console.log, +): boolean { + 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. + mkdirSync(walletDir, { recursive: true, mode: 0o700 }); + chmodSync(walletDir, 0o700); + + if (existsSync(walletConfig)) { + chmodSync(walletConfig, 0o600); + log("Wallet already initialized."); + return false; + } + + const mnemonic = generateMnemonic(wordlist); + const config = { + version: 1, + mnemonic, + encrypted: false, + createdAt: new Date().toISOString(), + }; + try { + writeFileSync(walletConfig, JSON.stringify(config, null, 2), { + mode: 0o600, + flag: "wx", + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + // Lost the race with a concurrent initializer; its mnemonic is + // authoritative, so this is not an error. + chmodSync(walletConfig, 0o600); + log("Wallet already initialized."); + return false; + } + throw error; + } + log(`Initialized. Mnemonic: ${mnemonic}`); + log("IMPORTANT: Write down this mnemonic and keep it safe!"); + return true; +} diff --git a/tests/utils/nip98.test.ts b/tests/utils/nip98.test.ts index 4a4e6c9..18e5895 100644 --- a/tests/utils/nip98.test.ts +++ b/tests/utils/nip98.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; import { getPublicKey, nip19 } from "nostr-tools"; +import { validateMnemonic } from "@scure/bip39"; +import { wordlist } from "@scure/bip39/wordlists/english.js"; import { AUTH_NOSTR_ACCOUNT_INDEX, nsecFromMnemonic, @@ -64,4 +66,18 @@ describe("nsecFromMnemonic", () => { expect(decoded.type).toBe("npub"); expect(decoded.data).toBe(getPublicKey(secretKey)); }); + + test("derives deterministically even from a checksum-invalid mnemonic", () => { + // Same words, last word swapped so the BIP-39 checksum fails. The wallet + // and the NPC plugin derive their keys from the raw string without + // checksum validation, so the auth identity must do the same to stay + // consistent with them rather than erroring out. + const corrupted = MNEMONIC.replace(/yellow$/, "zebra"); + expect(validateMnemonic(corrupted, wordlist)).toBe(false); + + const identity = nsecFromMnemonic(corrupted); + expect(identity.secretKey).toHaveLength(32); + expect(nsecFromMnemonic(corrupted)).toEqual(identity); + expect(identity.nsec).not.toBe(ACCOUNT_0.nsec); + }); }); diff --git a/tests/wallet/ensure-wallet.test.ts b/tests/wallet/ensure-wallet.test.ts new file mode 100644 index 0000000..da9df23 --- /dev/null +++ b/tests/wallet/ensure-wallet.test.ts @@ -0,0 +1,121 @@ +import { TEST_ROOT } from "./test-env"; +import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { validateMnemonic } from "@scure/bip39"; +import { wordlist } from "@scure/bip39/wordlists/english.js"; +import { ensureLocalWallet } from "../../src/daemon/wallet/ensure-wallet"; +import { readWalletMnemonic } from "../../src/daemon/wallet/wallet-config"; +import { nsecFromMnemonic } from "../../src/utils/nip98"; + +// BIP-39 test mnemonic (same vector as tests/utils/nip98.test.ts). +const LEGACY_MNEMONIC = + "legal winner thank year wave sausage worth useful legal winner thank yellow"; +const LEGACY_DERIVED_NPUB = + "npub1mx07p7jvpdf4g5lgatea9sgk6mjyfrld947k2nvmwmas94q6sjhssl4jwc"; + +// A different, checksum-valid BIP-39 mnemonic. +const OTHER_MNEMONIC = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"; + +const ENV_KEYS = [ + "ROUTSTRD_WALLET_DIR", + "COCOD_DIR", + "COCOD_SOCKET", + "COCOD_PID", +] as const; + +const silent = { log: () => {}, warn: () => {} }; + +const savedEnv = new Map(); +const tempDirs: string[] = []; + +beforeEach(() => { + const root = mkdtempSync(join(tmpdir(), "routstrd-ensure-wallet-")); + tempDirs.push(root); + for (const key of ENV_KEYS) savedEnv.set(key, process.env[key]); + // paths.ts evaluates env lazily, so per-test overrides take effect at call + // time. COCOD_SOCKET/COCOD_PID default under COCOD_DIR. + process.env.ROUTSTRD_WALLET_DIR = join(root, "wallet"); + process.env.COCOD_DIR = join(root, "cocod"); + delete process.env.COCOD_SOCKET; + delete process.env.COCOD_PID; +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + const value = savedEnv.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + savedEnv.clear(); + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +afterAll(() => { + rmSync(TEST_ROOT, { recursive: true, force: true }); +}); + +function writeLegacyWallet(mnemonic: string): void { + const legacyDir = process.env.COCOD_DIR!; + mkdirSync(legacyDir, { recursive: true }); + writeFileSync( + join(legacyDir, "config.json"), + JSON.stringify({ version: 1, mnemonic, encrypted: false }), + ); +} + +describe("ensureLocalWallet", () => { + test("creates a fresh wallet when neither canonical nor legacy wallets exist", async () => { + const result = await ensureLocalWallet(silent); + expect(result).toEqual({ migrated: false, created: true }); + + const mnemonic = readWalletMnemonic(); + expect(mnemonic).not.toBeNull(); + expect(validateMnemonic(mnemonic!, wordlist)).toBe(true); + }); + + test("is idempotent: a second run keeps the same mnemonic", async () => { + const first = await ensureLocalWallet(silent); + expect(first.created).toBe(true); + const mnemonic = readWalletMnemonic(); + + const second = await ensureLocalWallet(silent); + expect(second).toEqual({ migrated: false, created: false }); + expect(readWalletMnemonic()).toBe(mnemonic); + }); + + test("migrates a legacy cocod wallet and keeps its mnemonic", async () => { + writeLegacyWallet(LEGACY_MNEMONIC); + + const result = await ensureLocalWallet(silent); + expect(result).toEqual({ migrated: true, created: false }); + + // The auth identity derives from the migrated wallet's mnemonic, not a + // freshly generated one. + expect(readWalletMnemonic()).toBe(LEGACY_MNEMONIC); + expect(nsecFromMnemonic(readWalletMnemonic()!).npub).toBe( + LEGACY_DERIVED_NPUB, + ); + }); + + test("refuses to shadow a legacy wallet with a divergent canonical wallet", async () => { + const walletDir = process.env.ROUTSTRD_WALLET_DIR!; + mkdirSync(walletDir, { recursive: true }); + writeFileSync( + join(walletDir, "config.json"), + JSON.stringify({ version: 1, mnemonic: OTHER_MNEMONIC, encrypted: false }), + ); + writeLegacyWallet(LEGACY_MNEMONIC); + + await expect(ensureLocalWallet(silent)).rejects.toThrow( + /Refusing to choose/, + ); + + // The pre-existing canonical wallet is left untouched. + expect(readWalletMnemonic()).toBe(OTHER_MNEMONIC); + expect(existsSync(join(walletDir, "config.json"))).toBe(true); + }); +}); diff --git a/tests/wallet/test-env.ts b/tests/wallet/test-env.ts new file mode 100644 index 0000000..8cdc56b --- /dev/null +++ b/tests/wallet/test-env.ts @@ -0,0 +1,12 @@ +import { mkdtempSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +/** + * Test-wide environment root. Must be imported before any src module that + * captures env vars at load time (src/utils/logger reads ROUTSTRD_DIR when + * the module is first evaluated), so log output lands in a temp directory + * instead of the real ~/.routstrd. + */ +export const TEST_ROOT = mkdtempSync(join(tmpdir(), "routstrd-test-env-")); +process.env.ROUTSTRD_DIR = join(TEST_ROOT, "routstrd"); diff --git a/tests/wallet/wallet-config.test.ts b/tests/wallet/wallet-config.test.ts index d93948b..95707de 100644 --- a/tests/wallet/wallet-config.test.ts +++ b/tests/wallet/wallet-config.test.ts @@ -1,8 +1,13 @@ import { describe, expect, test, afterEach } from "bun:test"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; -import { readWalletMnemonic } from "../../src/daemon/wallet/wallet-config"; +import { validateMnemonic } from "@scure/bip39"; +import { wordlist } from "@scure/bip39/wordlists/english.js"; +import { + initializeWallet, + readWalletMnemonic, +} from "../../src/daemon/wallet/wallet-config"; const MNEMONIC = "legal winner thank year wave sausage worth useful legal winner thank yellow"; @@ -25,8 +30,7 @@ afterEach(() => { } }); -describe("readWalletMnemonic", () => { - test("returns the mnemonic from a valid config", () => { +describe("readWalletMnemonic", () => { test("returns the mnemonic from a valid config", () => { const dir = makeWalletDir(); writeConfig(dir, { version: 1, mnemonic: MNEMONIC, encrypted: false }); @@ -71,3 +75,51 @@ describe("readWalletMnemonic", () => { expect(readWalletMnemonic(dir)).toBe(MNEMONIC); }); }); + +describe("initializeWallet", () => { + const silent = () => {}; + + function permissions(path: string): number { + return statSync(path).mode & 0o777; + } + + test("creates the wallet with a valid BIP-39 mnemonic and restrictive permissions", () => { + const dir = join(makeWalletDir(), "wallet"); + + expect(initializeWallet(dir, silent)).toBe(true); + + expect(permissions(dir)).toBe(0o700); + const configPath = join(dir, "config.json"); + expect(permissions(configPath)).toBe(0o600); + + const config = JSON.parse(readFileSync(configPath, "utf8")); + expect(config.version).toBe(1); + expect(config.encrypted).toBe(false); + expect(validateMnemonic(config.mnemonic, wordlist)).toBe(true); + expect(readWalletMnemonic(dir)).toBe(config.mnemonic); + }); + + test("keeps the existing mnemonic on repeat calls", () => { + const dir = join(makeWalletDir(), "wallet"); + initializeWallet(dir, silent); + const first = readWalletMnemonic(dir); + + expect(initializeWallet(dir, silent)).toBe(false); + expect(readWalletMnemonic(dir)).toBe(first); + }); + + test("repairs permissions without replacing an existing wallet", () => { + const dir = join(makeWalletDir(), "wallet"); + const configPath = join(dir, "config.json"); + const existingConfig = JSON.stringify({ mnemonic: "existing seed" }); + + mkdirSync(dir, { mode: 0o755 }); + writeFileSync(configPath, existingConfig, { mode: 0o644 }); + + expect(initializeWallet(dir, silent)).toBe(false); + + expect(readFileSync(configPath, "utf8")).toBe(existingConfig); + expect(permissions(dir)).toBe(0o700); + expect(permissions(configPath)).toBe(0o600); + }); +});