@routstr/cocod 0.0.16 → 0.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,6 +66,9 @@ cocod history --watch
66
66
  cocod logs
67
67
  cocod logs --follow
68
68
  cocod logs --path
69
+
70
+ # Debug a stuck init/unlock in another terminal
71
+ cocod logs --follow
69
72
  ```
70
73
 
71
74
  ## NPC (Lightning Address)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@routstr/cocod",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "module": "src/index.ts",
5
5
  "type": "module",
6
6
  "private": false,
package/src/cli-shared.ts CHANGED
@@ -42,6 +42,64 @@ export async function isDaemonRunning(): Promise<boolean> {
42
42
  }
43
43
  }
44
44
 
45
+ const DAEMON_POLL_INTERVAL_MS = 1_000;
46
+ const DAEMON_SLOW_START_WARNING_MS = 30_000;
47
+ const DAEMON_START_TIMEOUT_MS = 60_000;
48
+ const DAEMON_START_LOG_LINES = 40;
49
+
50
+ function sleep(ms: number): Promise<void> {
51
+ return new Promise((resolve) => setTimeout(resolve, ms));
52
+ }
53
+
54
+ async function waitForDaemonReady(startedAt: number, warningShown: { value: boolean }): Promise<void> {
55
+ for (;;) {
56
+ try {
57
+ const result = await callDaemon("/status");
58
+ if (typeof result.output === "string") {
59
+ return;
60
+ }
61
+ } catch {
62
+ // Daemon may not be accepting requests yet
63
+ }
64
+
65
+ const elapsedMs = Date.now() - startedAt;
66
+
67
+ if (!warningShown.value && elapsedMs >= DAEMON_SLOW_START_WARNING_MS) {
68
+ warningShown.value = true;
69
+ console.log("Daemon is taking longer than expected, please wait...");
70
+ }
71
+
72
+ if (elapsedMs >= DAEMON_START_TIMEOUT_MS) {
73
+ throw new Error("Daemon failed to start after 1 minute");
74
+ }
75
+
76
+ await sleep(DAEMON_POLL_INTERVAL_MS);
77
+ }
78
+ }
79
+
80
+ function printProgressStep(message: string): void {
81
+ console.log(`• ${message}`);
82
+ }
83
+
84
+ function maybePrintFriendlyProgress(path: string, body?: object): void {
85
+ if (path === "/init") {
86
+ const mintUrl =
87
+ body && "mintUrl" in body && typeof body.mintUrl === "string"
88
+ ? body.mintUrl
89
+ : "https://mint.minibits.cash/Bitcoin";
90
+
91
+ printProgressStep("Preparing wallet...");
92
+ printProgressStep(`Connecting to mint: ${mintUrl}`);
93
+ printProgressStep("This can take a few seconds on first run.");
94
+ return;
95
+ }
96
+
97
+ if (path === "/unlock") {
98
+ printProgressStep("Unlocking wallet...");
99
+ printProgressStep("Reconnecting wallet services...");
100
+ }
101
+ }
102
+
45
103
  export async function startDaemonProcess(): Promise<void> {
46
104
  const proc = Bun.spawn({
47
105
  cmd: ["bun", "run", `${import.meta.dir}/index.ts`, "daemon"],
@@ -51,14 +109,28 @@ export async function startDaemonProcess(): Promise<void> {
51
109
  });
52
110
  proc.unref();
53
111
 
54
- for (let i = 0; i < 50; i++) {
55
- await new Promise((resolve) => setTimeout(resolve, 100));
112
+ const startedAt = Date.now();
113
+ const warningShown = { value: false };
114
+
115
+ for (;;) {
116
+ await sleep(DAEMON_POLL_INTERVAL_MS);
56
117
  if (await isDaemonRunning()) {
118
+ await waitForDaemonReady(startedAt, warningShown);
57
119
  return;
58
120
  }
59
- }
60
121
 
61
- throw new Error("Daemon failed to start within 5 seconds");
122
+ const elapsedMs = Date.now() - startedAt;
123
+
124
+ if (!warningShown.value && elapsedMs >= DAEMON_SLOW_START_WARNING_MS) {
125
+ warningShown.value = true;
126
+ console.log("Daemon is taking longer than expected, please wait...");
127
+ console.log(`Tip: run 'cocod logs --follow' or 'tail -n ${DAEMON_START_LOG_LINES} ~/.cocod/daemon.log' in another terminal.`);
128
+ }
129
+
130
+ if (elapsedMs >= DAEMON_START_TIMEOUT_MS) {
131
+ throw new Error("Daemon failed to start after 1 minute");
132
+ }
133
+ }
62
134
  }
63
135
 
64
136
  export async function ensureDaemonRunning(): Promise<void> {
@@ -76,6 +148,7 @@ export async function handleDaemonCommand(
76
148
  ): Promise<CommandResponse> {
77
149
  try {
78
150
  await ensureDaemonRunning();
151
+ maybePrintFriendlyProgress(path, options.body);
79
152
  const result = await callDaemon(path, options);
80
153
 
81
154
  if (result.error) {
package/src/daemon.ts CHANGED
@@ -1,21 +1,90 @@
1
1
  import { mnemonicToSeedSync } from "@scure/bip39";
2
- import { unlink } from "node:fs/promises";
3
- import { CONFIG_FILE, SOCKET_PATH, PID_FILE } from "./utils/config.js";
2
+ import { closeSync, openSync, writeFileSync } from "node:fs";
3
+ import { mkdir, unlink } from "node:fs/promises";
4
+ import process from "node:process";
5
+ import { CONFIG_DIR, CONFIG_FILE, SOCKET_PATH, PID_FILE } from "./utils/config.js";
4
6
  import { createDaemonLogger, serializeError } from "./utils/logger.js";
5
7
  import { DaemonStateManager } from "./utils/state.js";
6
8
  import { initializeWallet } from "./utils/wallet.js";
7
9
  import { createRouteHandlers, buildRoutes } from "./routes.js";
8
10
  import type { WalletConfig } from "./utils/config.js";
9
11
 
12
+ async function isProcessAlive(pid: number): Promise<boolean> {
13
+ if (!Number.isInteger(pid) || pid <= 0) {
14
+ return false;
15
+ }
16
+
17
+ try {
18
+ process.kill(pid, 0);
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ async function acquirePidLock(logger: ReturnType<typeof createDaemonLogger>): Promise<void> {
26
+ await mkdir(CONFIG_DIR, { recursive: true });
27
+
28
+ const pidFile = Bun.file(PID_FILE);
29
+ if (await pidFile.exists()) {
30
+ const existingPidText = (await pidFile.text()).trim();
31
+ const existingPid = Number.parseInt(existingPidText, 10);
32
+
33
+ if (await isProcessAlive(existingPid)) {
34
+ logger.warn("daemon.start.skipped", {
35
+ reason: "already_running",
36
+ pid: existingPid,
37
+ pidFile: PID_FILE,
38
+ });
39
+ await logger.flush();
40
+ console.error(`Error: Daemon is already running with PID ${existingPid}`);
41
+ process.exit(1);
42
+ }
43
+
44
+ logger.warn("daemon.pid.stale", {
45
+ pid: existingPidText || null,
46
+ pidFile: PID_FILE,
47
+ });
48
+ try {
49
+ await unlink(PID_FILE);
50
+ } catch {
51
+ // File may already be gone
52
+ }
53
+ }
54
+
55
+ try {
56
+ const fd = openSync(PID_FILE, "wx");
57
+ try {
58
+ writeFileSync(fd, `${process.pid}`);
59
+ } finally {
60
+ closeSync(fd);
61
+ }
62
+ } catch {
63
+ const currentPidText = (await Bun.file(PID_FILE).text()).trim();
64
+ const currentPid = Number.parseInt(currentPidText, 10);
65
+
66
+ logger.warn("daemon.start.skipped", {
67
+ reason: "pid_lock_exists",
68
+ pid: Number.isNaN(currentPid) ? currentPidText : currentPid,
69
+ pidFile: PID_FILE,
70
+ });
71
+ await logger.flush();
72
+ console.error("Error: Daemon is already starting or running");
73
+ process.exit(1);
74
+ }
75
+ }
76
+
10
77
  export async function startDaemon() {
11
78
  const stateManager = new DaemonStateManager();
12
- const logger = createDaemonLogger();
79
+ const logger = createDaemonLogger({ mirrorToConsole: false });
13
80
 
14
81
  logger.info("daemon.start.requested", {
15
82
  pidFile: PID_FILE,
16
83
  socketPath: SOCKET_PATH,
17
84
  });
18
85
 
86
+ await acquirePidLock(logger);
87
+
19
88
  try {
20
89
  const testConn = await Bun.connect({
21
90
  unix: SOCKET_PATH,
@@ -31,6 +100,11 @@ export async function startDaemon() {
31
100
  reason: "already_running",
32
101
  socketPath: SOCKET_PATH,
33
102
  });
103
+ try {
104
+ await unlink(PID_FILE);
105
+ } catch {
106
+ // File might not exist
107
+ }
34
108
  await logger.flush();
35
109
  console.error(`Error: Daemon is already running on ${SOCKET_PATH}`);
36
110
  process.exit(1);
@@ -38,25 +112,11 @@ export async function startDaemon() {
38
112
  // Not running, safe to proceed
39
113
  }
40
114
 
41
- try {
42
- await Bun.write(PID_FILE, "");
43
- await unlink(PID_FILE);
44
- } catch {
45
- // Directory creation failed or file didn't exist
46
- }
47
-
48
115
  try {
49
116
  await unlink(SOCKET_PATH);
50
117
  } catch {
51
118
  // File might not exist
52
119
  }
53
- try {
54
- await unlink(PID_FILE);
55
- } catch {
56
- // File might not exist
57
- }
58
-
59
- await Bun.write(PID_FILE, process.pid.toString());
60
120
 
61
121
  try {
62
122
  const configExists = await Bun.file(CONFIG_FILE).exists();
@@ -87,6 +147,7 @@ export async function startDaemon() {
87
147
  }
88
148
  } else {
89
149
  logger.info("wallet.config_missing");
150
+ logger.info("wallet.uninitialized");
90
151
  }
91
152
  } catch (error) {
92
153
  logger.warn("wallet.config_load_failed", { error: serializeError(error) });
@@ -160,9 +221,6 @@ export async function startDaemon() {
160
221
  });
161
222
 
162
223
  logger.info("daemon.started", { socketPath: SOCKET_PATH });
163
- if (stateManager.isUninitialized()) {
164
- logger.info("wallet.uninitialized");
165
- }
166
224
 
167
225
  process.on("unhandledRejection", (error) => {
168
226
  logger.error("daemon.unhandled_rejection", { error: serializeError(error) });
package/src/routes.ts CHANGED
@@ -36,34 +36,50 @@ export function createRouteHandlers(
36
36
  },
37
37
  "/init": {
38
38
  POST: stateManager.requireUninitialized(async (req: Request) => {
39
+ const initLogger = logger?.child?.({ route: "/init" }) ?? logger;
40
+
39
41
  try {
42
+ initLogger?.info?.("wallet.init.started");
43
+
40
44
  const body = (await req.json()) as {
41
45
  mnemonic?: string;
42
46
  passphrase?: string;
43
47
  mintUrl?: string;
44
48
  };
45
49
 
50
+ initLogger?.info?.("wallet.init.request_parsed", {
51
+ encrypted: Boolean(body.passphrase),
52
+ hasMnemonic: Boolean(body.mnemonic),
53
+ mintUrl: body.mintUrl || "https://mint.minibits.cash/Bitcoin",
54
+ });
55
+
46
56
  let mnemonic: string;
47
57
  if (body.mnemonic) {
58
+ initLogger?.info?.("wallet.init.validating_mnemonic");
48
59
  if (!validateMnemonic(body.mnemonic, wordlist)) {
60
+ initLogger?.warn?.("wallet.init.invalid_mnemonic");
49
61
  return Response.json({ error: "Invalid mnemonic" }, { status: 400 });
50
62
  }
51
63
  mnemonic = body.mnemonic;
52
64
  } else {
65
+ initLogger?.info?.("wallet.init.generating_mnemonic");
53
66
  mnemonic = generateMnemonic(wordlist, 256);
54
67
  }
55
68
 
56
69
  const mintUrl = body.mintUrl || "https://mint.minibits.cash/Bitcoin";
57
70
  const encrypted = !!body.passphrase;
58
71
 
72
+ initLogger?.info?.("wallet.init.resetting_config_file", { configFile: CONFIG_FILE });
59
73
  await Bun.write(CONFIG_FILE, "");
60
74
  await unlink(CONFIG_FILE);
61
75
 
62
76
  let config: WalletConfig;
63
77
 
64
78
  if (encrypted && body.passphrase) {
79
+ initLogger?.info?.("wallet.init.encrypting_mnemonic");
65
80
  const { ciphertext, salt } = await encryptMnemonic(mnemonic, body.passphrase);
66
81
 
82
+ initLogger?.info?.("wallet.init.writing_salt_file", { saltFile: SALT_FILE });
67
83
  await Bun.write(SALT_FILE, salt);
68
84
 
69
85
  config = {
@@ -75,6 +91,7 @@ export function createRouteHandlers(
75
91
  };
76
92
 
77
93
  stateManager.setLocked(ciphertext, mintUrl);
94
+ initLogger?.info?.("wallet.init.completed_locked", { mintUrl, state: "LOCKED" });
78
95
  } else {
79
96
  config = {
80
97
  version: 1,
@@ -84,19 +101,25 @@ export function createRouteHandlers(
84
101
  createdAt: new Date().toISOString(),
85
102
  };
86
103
 
87
- const manager = await initializeWallet(config, undefined, logger);
104
+ initLogger?.info?.("wallet.init.initializing_wallet_manager", { mintUrl });
105
+ const manager = await initializeWallet(config, undefined, initLogger);
106
+ initLogger?.info?.("wallet.init.wallet_manager_ready", { mintUrl });
88
107
  const seed = mnemonicToSeedSync(mnemonic);
89
108
  stateManager.setUnlocked(manager, mintUrl, seed);
109
+ initLogger?.info?.("wallet.init.completed_unlocked", { mintUrl, state: "UNLOCKED" });
90
110
  }
91
111
 
112
+ initLogger?.info?.("wallet.init.writing_config_file", { configFile: CONFIG_FILE });
92
113
  await Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2));
93
114
 
94
115
  const output = encrypted
95
116
  ? `Initialized (locked). Mnemonic: ${mnemonic}\nIMPORTANT: Write down this mnemonic and keep it safe!`
96
117
  : `Initialized. Mnemonic: ${mnemonic}\nIMPORTANT: Write down this mnemonic and keep it safe!`;
97
118
 
119
+ initLogger?.info?.("wallet.init.response_ready", { encrypted, mintUrl });
98
120
  return Response.json({ output });
99
121
  } catch (error) {
122
+ initLogger?.error?.("wallet.init.failed", { error: serializeError(error) });
100
123
  const message = error instanceof Error ? error.message : String(error);
101
124
  return Response.json({ error: `Init failed: ${message}` }, { status: 500 });
102
125
  }
@@ -104,15 +127,21 @@ export function createRouteHandlers(
104
127
  },
105
128
  "/unlock": {
106
129
  POST: stateManager.requireLocked(async (req: Request, state: LockedState) => {
130
+ const unlockLogger = logger?.child?.({ route: "/unlock" }) ?? logger;
131
+
107
132
  try {
133
+ unlockLogger?.info?.("wallet.unlock.started", { mintUrl: state.mintUrl });
108
134
  const body = (await req.json()) as { passphrase: string };
109
135
 
110
136
  if (!body.passphrase) {
137
+ unlockLogger?.warn?.("wallet.unlock.missing_passphrase");
111
138
  return Response.json({ error: "Passphrase required" }, { status: 400 });
112
139
  }
113
140
 
141
+ unlockLogger?.info?.("wallet.unlock.reading_salt_file", { saltFile: SALT_FILE });
114
142
  const salt = await Bun.file(SALT_FILE).text();
115
143
  const { decryptMnemonic } = await import("./utils/crypto.js");
144
+ unlockLogger?.info?.("wallet.unlock.decrypting_mnemonic");
116
145
  const mnemonic = await decryptMnemonic(state.encryptedMnemonic, body.passphrase, salt);
117
146
 
118
147
  const config: WalletConfig = {
@@ -123,13 +152,22 @@ export function createRouteHandlers(
123
152
  createdAt: new Date().toISOString(),
124
153
  };
125
154
 
126
- const manager = await initializeWallet(config, undefined, logger);
155
+ unlockLogger?.info?.("wallet.unlock.initializing_wallet_manager", {
156
+ mintUrl: state.mintUrl,
157
+ });
158
+ const manager = await initializeWallet(config, undefined, unlockLogger);
159
+ unlockLogger?.info?.("wallet.unlock.wallet_manager_ready", { mintUrl: state.mintUrl });
127
160
  const seed = mnemonicToSeedSync(mnemonic);
128
161
 
129
162
  stateManager.setUnlocked(manager, state.mintUrl, seed);
163
+ unlockLogger?.info?.("wallet.unlock.completed", {
164
+ mintUrl: state.mintUrl,
165
+ state: "UNLOCKED",
166
+ });
130
167
 
131
168
  return Response.json({ output: "Unlocked" });
132
169
  } catch (error) {
170
+ unlockLogger?.error?.("wallet.unlock.failed", { error: serializeError(error) });
133
171
  const message = error instanceof Error ? error.message : String(error);
134
172
  return Response.json({ error: `Unlock failed: ${message}` }, { status: 401 });
135
173
  }
@@ -504,11 +542,13 @@ async function runRoute(
504
542
  const durationMs = Math.round(performance.now() - startedAt);
505
543
  const level = response.status >= 500 ? "error" : response.status >= 400 ? "warn" : "info";
506
544
 
507
- requestLogger?.log?.(level, "request.completed", {
508
- durationMs,
509
- state: getState().status,
510
- status: response.status,
511
- });
545
+ if (path !== "/status") {
546
+ requestLogger?.log?.(level, "request.completed", {
547
+ durationMs,
548
+ state: getState().status,
549
+ status: response.status,
550
+ });
551
+ }
512
552
 
513
553
  return response;
514
554
  } catch (error) {
@@ -14,38 +14,54 @@ export async function initializeWallet(
14
14
  passphrase?: string,
15
15
  logger?: Logger,
16
16
  ): Promise<Manager> {
17
+ const walletLogger = logger?.child?.({ component: "wallet-init" }) ?? logger;
18
+ walletLogger?.info?.("wallet.initialize.started", {
19
+ encrypted: config.encrypted,
20
+ mintUrl: config.mintUrl,
21
+ dbFile: DB_FILE,
22
+ });
23
+
17
24
  let mnemonic: string;
18
25
 
19
26
  if (config.encrypted) {
27
+ walletLogger?.info?.("wallet.initialize.decrypting_config_mnemonic", { saltFile: SALT_FILE });
20
28
  if (!passphrase) {
21
29
  throw new Error("Passphrase required for encrypted wallet");
22
30
  }
23
31
  const salt = await Bun.file(SALT_FILE).text();
24
32
  mnemonic = await decryptMnemonic(config.mnemonic, passphrase, salt);
25
33
  } else {
34
+ walletLogger?.info?.("wallet.initialize.using_plaintext_config_mnemonic");
26
35
  mnemonic = config.mnemonic;
27
36
  }
28
37
 
38
+ walletLogger?.info?.("wallet.initialize.derived_mnemonic");
29
39
  const seed = mnemonicToSeedSync(mnemonic);
30
40
 
41
+ walletLogger?.info?.("wallet.initialize.opening_database", { dbFile: DB_FILE });
31
42
  const repo = new SqliteRepositories({ database: new Database(DB_FILE) });
32
- const walletLogger = logger?.child?.({ component: "coco" }) ?? logger;
33
- const cocoLogger = walletLogger ?? new ConsoleLogger("Coco", { level: "info" });
43
+ const cocoLogger = walletLogger?.child?.({ component: "coco" }) ?? new ConsoleLogger("Coco", { level: "info" });
44
+ walletLogger?.info?.("wallet.initialize.preparing_signer");
34
45
  const sk = privateKeyFromSeedWords(mnemonic);
35
46
  const signer = async (t: EventTemplate) => finalizeEvent(t, sk);
47
+ walletLogger?.info?.("wallet.initialize.creating_npc_plugin", { npcUrl: "https://npubx.cash" });
36
48
  const npcPlugin = new NPCPlugin("https://npubx.cash", signer, {
37
49
  useWebsocket: true,
38
50
  logger: cocoLogger,
39
51
  });
52
+ walletLogger?.info?.("wallet.initialize.initializing_coco_core", { mintUrl: config.mintUrl });
40
53
  const coco = await initializeCoco({
41
54
  repo,
42
55
  seedGetter: async () => seed,
43
56
  logger: cocoLogger,
44
57
  });
45
58
 
59
+ walletLogger?.info?.("wallet.initialize.registering_npc_plugin");
46
60
  coco.use(npcPlugin);
47
61
 
62
+ walletLogger?.info?.("wallet.initialize.adding_trusted_mint", { mintUrl: config.mintUrl });
48
63
  await coco.mint.addMint(config.mintUrl, { trusted: true });
64
+ walletLogger?.info?.("wallet.initialize.completed", { mintUrl: config.mintUrl });
49
65
 
50
66
  return coco;
51
67
  }