feat: ensure wallet exists before deriving auth identity in remote flow

routstrd remote now runs the same ensure-wallet sequence as init (stop
legacy cocod, migrate a legacy cocod wallet so its mnemonic wins, then
initialize a fresh wallet) before deriving the NIP-98 identity, so the
operator nsec is mnemonic-derived and recoverable from the wallet backup
regardless of which command runs first. This avoids shadowing a legacy
wallet with a fresh mnemonic, which would have made later migration fail
on divergent wallets. An already-configured nsec is never replaced.

- extract ensureLocalWallet() shared by init and remote
- move initializeWallet into wallet-config.ts, return created status,
  handle concurrent-initializer EEXIST races
- warn (but still derive) when the wallet mnemonic fails BIP-39 checksum
  validation, keeping auth/NPC/wallet identities self-consistent
- relocate initializeWallet tests next to wallet-config, add
  ensure-wallet integration tests (fresh, idempotent, legacy migration,
  divergent-wallets refusal)
This commit is contained in:
redshift
2026-08-17 08:24:33 +01:00
parent fd842817de
commit 66b5f6ac59
10 changed files with 384 additions and 138 deletions
+7
View File
@@ -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 <url>` 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`:
+2 -2
View File
@@ -148,7 +148,7 @@ Manage admin npubs (subcommand required).
### `routstrd remote <url>`
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`) |
-55
View File
@@ -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);
});
});
+31 -76
View File
@@ -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<void> {
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<void> {
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;
+76
View File
@@ -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<EnsureLocalWalletResult> {
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 };
}
+63 -1
View File
@@ -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;
}
+16
View File
@@ -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);
});
});
+121
View File
@@ -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<string, string | undefined>();
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);
});
});
+12
View File
@@ -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");
+56 -4
View File
@@ -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);
});
});