@cogcoin/client 1.2.2 → 1.2.4

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
@@ -1,6 +1,6 @@
1
1
  # `@cogcoin/client`
2
2
 
3
- `@cogcoin/client@1.2.2` is the reference Cogcoin client package for applications that want a local wallet, durable SQLite-backed state, and a managed Bitcoin Core integration around `@cogcoin/indexer`. It publishes the reusable client APIs, the SQLite adapter, the managed `bitcoind` integration, and the first-party `cogcoin` CLI in one package.
3
+ `@cogcoin/client@1.2.4` is the reference Cogcoin client package for applications that want a local wallet, durable SQLite-backed state, and a managed Bitcoin Core integration around `@cogcoin/indexer`. It publishes the reusable client APIs, the SQLite adapter, the managed `bitcoind` integration, and the first-party `cogcoin` CLI in one package.
4
4
 
5
5
  Use Node 22 or newer.
6
6
 
@@ -126,6 +126,10 @@ The published package depends on:
126
126
 
127
127
  `@cogcoin/vectors@1.0.1` is kept as a repository development dependency for conformance tests and is not part of the published runtime dependency surface.
128
128
 
129
+ ## Upgrade Notes For `1.2.4`
130
+
131
+ `@cogcoin/client@1.2.4` treats a live managed Bitcoin Core process returning startup `-28 Loading block index...` as a normal `starting` state. `cogcoin mine` waits instead of repeatedly restarting the process, and `cogcoin repair` prints progress during long checks. If repair restarts a stale managed bitcoind to add raw transaction ZMQ support and Core is still loading, repair exits with a clear note to wait and rerun `cogcoin status` or `cogcoin mine`.
132
+
129
133
  ## Upgrade Notes For `1.2.0`
130
134
 
131
135
  `@cogcoin/client@1.2.0` updates the runtime indexer to `@cogcoin/indexer@1.0.2`. Existing wallet state, mining configuration, Bitcoin Core data, and secrets remain compatible and are not reset.
@@ -7,6 +7,7 @@ import { stopIndexerDaemonServiceWithLockHeld } from "./indexer-daemon.js";
7
7
  import { readManagedBitcoindObservedStatus, listManagedBitcoindStatusCandidates } from "./managed-runtime/bitcoind-status.js";
8
8
  import { attachOrStartManagedBitcoindRuntime, probeManagedBitcoindRuntime } from "./managed-runtime/bitcoind-runtime.js";
9
9
  import { createRpcClient, validateNodeConfigForTesting } from "./node.js";
10
+ import { isManagedRpcWarmupError } from "./retryable-rpc.js";
10
11
  import { resolveManagedServicePaths, UNINITIALIZED_WALLET_ROOT_ID } from "./service-paths.js";
11
12
  import { DEFAULT_MANAGED_BITCOIND_FOLLOW_POLL_INTERVAL_MS, } from "./types.js";
12
13
  import { buildManagedServiceArgsForTesting, LOCAL_HOST, resolveManagedBitcoindRuntimeConfig, SUPPORTED_BITCOIND_VERSION, verifyManagedBitcoindVersion, writeManagedBitcoindRuntimeConfigFile, writeManagedBitcoindRuntimeConfigFileFromStatus, writeBitcoinConfForTesting, } from "./managed-bitcoind-service-config.js";
@@ -192,6 +193,30 @@ export async function attachOrStartManagedBitcoindService(options) {
192
193
  child.unref();
193
194
  }
194
195
  const rpc = createRpcClient(rpcConfig);
196
+ const startedAtUnixMs = Date.now();
197
+ const serviceInstanceId = randomBytes(16).toString("hex");
198
+ const startingStatus = createBitcoindServiceStatus({
199
+ binaryVersion,
200
+ serviceInstanceId,
201
+ state: "starting",
202
+ processId: child.pid ?? null,
203
+ walletRootId: startOptions.walletRootId,
204
+ chain: startOptions.chain,
205
+ dataDir: startOptions.dataDir,
206
+ runtimeRoot: paths.walletRuntimeRoot,
207
+ startHeight: startOptions.startHeight,
208
+ rpc: rpcConfig,
209
+ zmq: zmqConfig,
210
+ p2pPort: runtimeConfig.p2pPort,
211
+ getblockArchiveEndHeight: runtimeConfig.getblockArchiveEndHeight ?? null,
212
+ getblockArchiveSha256: runtimeConfig.getblockArchiveSha256 ?? null,
213
+ walletReplica: null,
214
+ startedAtUnixMs,
215
+ heartbeatAtUnixMs: startedAtUnixMs,
216
+ lastError: "Managed bitcoind service is starting.",
217
+ });
218
+ await writeManagedBitcoindRuntimeConfigFile(paths.bitcoindRuntimeConfigPath, runtimeConfig);
219
+ await writeManagedBitcoindStatus(paths, startingStatus);
195
220
  try {
196
221
  await waitForManagedBitcoindRpcReady(rpc, rpcConfig.cookieFile, startOptions.chain, startupTimeoutMs);
197
222
  await validateNodeConfigForTesting(rpc, startOptions.chain, zmqConfig.endpoint, {
@@ -199,6 +224,19 @@ export async function attachOrStartManagedBitcoindService(options) {
199
224
  });
200
225
  }
201
226
  catch (error) {
227
+ const processAlive = child.pid !== undefined && await isManagedBitcoindProcessAlive(child.pid);
228
+ if (startOptions.serviceLifetime !== "ephemeral"
229
+ && processAlive
230
+ && isManagedRpcWarmupError(error)) {
231
+ const nowUnixMs = Date.now();
232
+ await writeManagedBitcoindStatus(paths, {
233
+ ...startingStatus,
234
+ heartbeatAtUnixMs: nowUnixMs,
235
+ updatedAtUnixMs: nowUnixMs,
236
+ lastError: error instanceof Error ? error.message : String(error),
237
+ });
238
+ throw new Error("managed_bitcoind_service_starting");
239
+ }
202
240
  if (child.pid !== undefined) {
203
241
  try {
204
242
  process.kill(child.pid, "SIGTERM");
@@ -215,7 +253,7 @@ export async function attachOrStartManagedBitcoindService(options) {
215
253
  runtimeConfig,
216
254
  status: createBitcoindServiceStatus({
217
255
  binaryVersion,
218
- serviceInstanceId: randomBytes(16).toString("hex"),
256
+ serviceInstanceId,
219
257
  state: "ready",
220
258
  processId: child.pid ?? null,
221
259
  walletRootId: startOptions.walletRootId,
@@ -229,7 +267,7 @@ export async function attachOrStartManagedBitcoindService(options) {
229
267
  getblockArchiveEndHeight: runtimeConfig.getblockArchiveEndHeight ?? null,
230
268
  getblockArchiveSha256: runtimeConfig.getblockArchiveSha256 ?? null,
231
269
  walletReplica,
232
- startedAtUnixMs: nowUnixMs,
270
+ startedAtUnixMs,
233
271
  heartbeatAtUnixMs: nowUnixMs,
234
272
  lastError: walletReplica.message ?? null,
235
273
  }),
@@ -241,6 +279,9 @@ export async function attachOrStartManagedBitcoindService(options) {
241
279
  ({ runtimeConfig, status } = await startManagedProcess(runtimeOptions));
242
280
  }
243
281
  catch (error) {
282
+ if (isManagedRpcWarmupError(error)) {
283
+ throw error;
284
+ }
244
285
  if (runtimeOptions.getblockArchivePath === undefined || runtimeOptions.getblockArchivePath === null) {
245
286
  throw error;
246
287
  }
@@ -8,6 +8,7 @@ import { MANAGED_BITCOIND_SERVICE_API_VERSION, } from "./types.js";
8
8
  import { createMissingManagedWalletReplicaStatus, loadManagedWalletReplicaIfPresent, } from "./managed-bitcoind-service-replica.js";
9
9
  import { DEFAULT_MANAGED_BITCOIND_STARTUP_TIMEOUT_MS, isManagedBitcoindProcessAlive, sleep, } from "./managed-bitcoind-service-process.js";
10
10
  import { waitForManagedBitcoindCookie, } from "./managed-bitcoind-service-config.js";
11
+ import { isManagedRpcWarmupError } from "./retryable-rpc.js";
11
12
  export async function waitForManagedBitcoindRpcReady(rpc, cookieFile, expectedChain, timeoutMs) {
12
13
  await waitForManagedBitcoindCookie(cookieFile, timeoutMs, sleep);
13
14
  const deadline = Date.now() + timeoutMs;
@@ -117,12 +118,14 @@ export async function refreshManagedBitcoindStatus(status, paths, options) {
117
118
  return nextStatus;
118
119
  }
119
120
  catch (error) {
121
+ const processAlive = await isManagedBitcoindProcessAlive(status.processId);
122
+ const stillStarting = processAlive && isManagedRpcWarmupError(error);
120
123
  const nextStatus = {
121
124
  ...status,
122
125
  walletRootId: targetWalletRootId,
123
126
  runtimeRoot: paths.walletRuntimeRoot,
124
- state: "failed",
125
- processId: await isManagedBitcoindProcessAlive(status.processId) ? status.processId : null,
127
+ state: stillStarting ? "starting" : "failed",
128
+ processId: processAlive ? status.processId : null,
126
129
  heartbeatAtUnixMs: nowUnixMs,
127
130
  updatedAtUnixMs: nowUnixMs,
128
131
  lastError: error instanceof Error ? error.message : String(error),
@@ -1,4 +1,5 @@
1
1
  import { join } from "node:path";
2
+ import { isManagedRpcWarmupError } from "../retryable-rpc.js";
2
3
  import { resolveManagedServicePaths } from "../service-paths.js";
3
4
  import { MANAGED_BITCOIND_SERVICE_API_VERSION } from "../types.js";
4
5
  function isRuntimeMismatchError(error) {
@@ -64,6 +65,13 @@ export function mapManagedBitcoindRuntimeProbeFailure(error, status) {
64
65
  error: "managed_bitcoind_runtime_mismatch",
65
66
  };
66
67
  }
68
+ if (isManagedRpcWarmupError(error)) {
69
+ return {
70
+ compatibility: "starting",
71
+ status,
72
+ error: error instanceof Error ? error.message : String(error),
73
+ };
74
+ }
67
75
  if (isUnreachableManagedBitcoindError(error)) {
68
76
  return {
69
77
  compatibility: "unreachable",
@@ -84,6 +92,12 @@ export function resolveManagedBitcoindProbeDecision(probe) {
84
92
  error: null,
85
93
  };
86
94
  }
95
+ if (probe.compatibility === "starting") {
96
+ return {
97
+ action: "wait",
98
+ error: probe.error ?? "managed_bitcoind_service_starting",
99
+ };
100
+ }
87
101
  if (probe.compatibility === "unreachable") {
88
102
  return {
89
103
  action: "start",
@@ -121,6 +135,12 @@ function mapManagedBitcoindStartupError(message) {
121
135
  status: null,
122
136
  message: "The live managed bitcoind service runtime does not match this wallet's expected data directory or chain.",
123
137
  };
138
+ case "managed_bitcoind_service_starting":
139
+ return {
140
+ health: "starting",
141
+ status: null,
142
+ message: "Managed bitcoind service is still starting.",
143
+ };
124
144
  case "managed_bitcoind_protocol_error":
125
145
  return {
126
146
  health: "unavailable",
@@ -128,6 +148,13 @@ function mapManagedBitcoindStartupError(message) {
128
148
  message: "The managed bitcoind runtime artifacts are invalid or incomplete.",
129
149
  };
130
150
  default:
151
+ if (/^bitcoind_rpc_[^_]+_-28(?:_|$)/.test(message)) {
152
+ return {
153
+ health: "starting",
154
+ status: null,
155
+ message,
156
+ };
157
+ }
131
158
  return {
132
159
  health: "unavailable",
133
160
  status: null,
@@ -40,6 +40,9 @@ export async function attachOrStartManagedBitcoindRuntime(options, dependencies)
40
40
  }
41
41
  throw new Error("managed_bitcoind_protocol_error");
42
42
  }
43
+ if (existingDecision.action === "wait") {
44
+ return waitForManagedBitcoindRuntime(options, dependencies);
45
+ }
43
46
  if (existingDecision.action === "reject") {
44
47
  throw new Error(existingDecision.error ?? "managed_bitcoind_protocol_error");
45
48
  }
@@ -56,6 +59,9 @@ export async function attachOrStartManagedBitcoindRuntime(options, dependencies)
56
59
  }
57
60
  throw new Error("managed_bitcoind_protocol_error");
58
61
  }
62
+ if (liveDecision.action === "wait") {
63
+ return waitForManagedBitcoindRuntime(options, dependencies);
64
+ }
59
65
  if (liveDecision.action === "reject") {
60
66
  throw new Error(liveDecision.error ?? "managed_bitcoind_protocol_error");
61
67
  }
@@ -2,7 +2,7 @@ import type { ClientTip } from "../../types.js";
2
2
  import type { ManagedServicePaths } from "../service-paths.js";
3
3
  import type { ManagedBitcoindObservedStatus, ManagedIndexerDaemonObservedStatus, ManagedIndexerTruthSource } from "../types.js";
4
4
  import type { WalletBitcoindStatus, WalletIndexerStatus, WalletNodeStatus, WalletServiceHealth, WalletSnapshotView } from "../../wallet/read/types.js";
5
- export type ManagedBitcoindServiceCompatibility = "compatible" | "service-version-mismatch" | "wallet-root-mismatch" | "runtime-mismatch" | "rawtx-zmq-missing" | "unreachable" | "protocol-error";
5
+ export type ManagedBitcoindServiceCompatibility = "compatible" | "starting" | "service-version-mismatch" | "wallet-root-mismatch" | "runtime-mismatch" | "rawtx-zmq-missing" | "unreachable" | "protocol-error";
6
6
  export interface ManagedBitcoindServiceProbeResult {
7
7
  compatibility: ManagedBitcoindServiceCompatibility;
8
8
  status: ManagedBitcoindObservedStatus | null;
@@ -20,7 +20,7 @@ export interface ManagedIndexerDaemonProbeResult<TClient> {
20
20
  error: string | null;
21
21
  }
22
22
  export interface ManagedBitcoindProbeDecision {
23
- action: "attach" | "start" | "reject";
23
+ action: "attach" | "wait" | "start" | "reject";
24
24
  error: string | null;
25
25
  }
26
26
  export interface IndexerDaemonProbeDecision {
@@ -8,4 +8,5 @@ export declare function createManagedRpcRetryState(): ManagedRpcRetryState;
8
8
  export declare function resetManagedRpcRetryState(state: ManagedRpcRetryState): void;
9
9
  export declare function consumeManagedRpcRetryDelayMs(state: ManagedRpcRetryState): number;
10
10
  export declare function isRetryableManagedRpcError(error: unknown): boolean;
11
+ export declare function isManagedRpcWarmupError(error: unknown): boolean;
11
12
  export declare function describeManagedRpcRetryError(error: unknown): string;
@@ -15,15 +15,32 @@ export function consumeManagedRpcRetryDelayMs(state) {
15
15
  return delayMs;
16
16
  }
17
17
  export function isRetryableManagedRpcError(error) {
18
+ if (isManagedRpcWarmupError(error)) {
19
+ return true;
20
+ }
21
+ const message = error instanceof Error ? error.message : String(error);
22
+ if (message.startsWith("The managed Bitcoin RPC request to ")) {
23
+ return message.includes(" failed");
24
+ }
25
+ return false;
26
+ }
27
+ export function isManagedRpcWarmupError(error) {
18
28
  const message = error instanceof Error ? error.message : String(error);
19
- if (message === "bitcoind_rpc_timeout") {
29
+ if (message === "managed_bitcoind_service_starting"
30
+ || message === "bitcoind_rpc_timeout"
31
+ || message === "bitcoind_cookie_timeout") {
20
32
  return true;
21
33
  }
22
34
  if (/^bitcoind_rpc_[^_]+_-28(?:_|$)/.test(message)) {
23
35
  return true;
24
36
  }
25
37
  if (message.startsWith("The managed Bitcoin RPC request to ")) {
26
- return message.includes(" failed");
38
+ return message.includes(" failed")
39
+ && (message.includes("ECONNREFUSED")
40
+ || message.includes("ECONNRESET")
41
+ || message.includes("socket hang up")
42
+ || message.includes("timeout")
43
+ || message.includes("aborted"));
27
44
  }
28
45
  return message.startsWith("The managed Bitcoin RPC cookie file is unavailable at ")
29
46
  || message.startsWith("The managed Bitcoin RPC cookie file could not be read at ");
@@ -38,7 +38,10 @@ function getResetNextSteps(result) {
38
38
  ? ["Run `cogcoin init` to create or restore a wallet."]
39
39
  : ["Run `cogcoin sync` to bootstrap assumeutxo and the managed Bitcoin/indexer state."];
40
40
  }
41
- function getRepairNextSteps() {
41
+ function getRepairNextSteps(result) {
42
+ if (result.bitcoindPostRepairHealth === "starting") {
43
+ return ["Wait for Bitcoin Core to finish loading, then run `cogcoin status` or rerun `cogcoin mine`."];
44
+ }
42
45
  return ["Run `cogcoin status` to review the repaired local state."];
43
46
  }
44
47
  function formatResetBitcoinDataDirStatus(result) {
@@ -116,7 +119,7 @@ function buildRepairWarningEntries(result) {
116
119
  return entries;
117
120
  }
118
121
  function formatRepairResultText(result) {
119
- const nextStep = getRepairNextSteps()[0] ?? null;
122
+ const nextStep = getRepairNextSteps(result)[0] ?? null;
120
123
  const warningEntries = buildRepairWarningEntries(result);
121
124
  const sections = [
122
125
  formatAdminSection("Wallet", [
@@ -244,6 +247,9 @@ export async function runWalletAdminCommand(parsed, context) {
244
247
  provider: repairProvider,
245
248
  assumeYes: parsed.assumeYes,
246
249
  paths: runtimePaths,
250
+ progress: async (event) => {
251
+ writeLine(context.stdout, event.message);
252
+ },
247
253
  });
248
254
  writeLine(context.stdout, formatRepairResultText(result));
249
255
  return 0;
@@ -21,6 +21,13 @@ export const serviceErrorRules = [
21
21
  next: "Rerun the command in an interactive terminal.",
22
22
  };
23
23
  }
24
+ if (errorCode === "managed_bitcoind_service_starting" || /^bitcoind_rpc_[^_]+_-28(?:_|$)/.test(errorCode)) {
25
+ return {
26
+ what: "Bitcoin Core is still loading.",
27
+ why: "The managed Bitcoin RPC service is alive, but Bitcoin Core is still warming up and returned its normal startup -28 response.",
28
+ next: "Wait for the node to finish loading, then rerun the command. Use `cogcoin status` to check readiness.",
29
+ };
30
+ }
24
31
  if (errorCode.includes("tip_mismatch") || errorCode.includes("stale") || errorCode.includes("catching_up") || errorCode.includes("starting")) {
25
32
  return {
26
33
  what: "Trusted service state is not ready.",
@@ -47,6 +47,7 @@ export function resolveWalletRepairContext(options) {
47
47
  attachIndexerDaemon: options.attachIndexerDaemon ?? attachOrStartIndexerDaemon,
48
48
  probeIndexerDaemon: options.probeIndexerDaemon ?? probeIndexerDaemon,
49
49
  requestMiningPreemption: options.requestMiningPreemption,
50
+ progress: options.progress ?? (() => undefined),
50
51
  };
51
52
  }
52
53
  export async function acquireWalletControlLock(paths, purpose) {
@@ -4,7 +4,7 @@ import { persistWalletCoinControlStateIfNeeded } from "../coin-control.js";
4
4
  import { createWalletSecretReference } from "../state/provider.js";
5
5
  import { recreateManagedCoreWalletReplica, verifyManagedCoreWalletReplica } from "./managed-core.js";
6
6
  import { pathExists } from "./context.js";
7
- import { clearManagedBitcoindArtifacts, isManagedBitcoindRpcUnavailableError, mapBitcoindCompatibilityToRepairIssue, mapBitcoindRepairHealth, waitForProcessExit, } from "./repair-runtime.js";
7
+ import { clearManagedBitcoindArtifactsForDataDir, isManagedBitcoindRpcUnavailableError, isManagedBitcoindStartupWarmupError, mapBitcoindCompatibilityToRepairIssue, mapBitcoindRepairHealth, reportRepairProgress, waitForProcessExit, } from "./repair-runtime.js";
8
8
  export async function repairManagedBitcoindStage(options) {
9
9
  let state = options.state;
10
10
  let repairStateNeedsPersist = options.repairStateNeedsPersist;
@@ -24,6 +24,7 @@ export async function repairManagedBitcoindStage(options) {
24
24
  dataDir: options.context.dataDir,
25
25
  });
26
26
  try {
27
+ await reportRepairProgress(options.context, "bitcoind-check", "Checking managed bitcoind...");
27
28
  initialBitcoindProbe = await options.context.probeBitcoindService({
28
29
  dataDir: options.context.dataDir,
29
30
  chain: "main",
@@ -31,6 +32,18 @@ export async function repairManagedBitcoindStage(options) {
31
32
  walletRootId: state.walletRootId,
32
33
  });
33
34
  bitcoindCompatibilityIssue = mapBitcoindCompatibilityToRepairIssue(initialBitcoindProbe.compatibility);
35
+ if (initialBitcoindProbe.compatibility === "starting") {
36
+ await reportRepairProgress(options.context, "bitcoind-starting", "Bitcoin Core is loading the block index; leaving managed bitcoind running.");
37
+ return {
38
+ state,
39
+ repairStateNeedsPersist,
40
+ recreatedManagedCoreWallet,
41
+ bitcoindServiceAction,
42
+ bitcoindCompatibilityIssue,
43
+ managedCoreReplicaAction,
44
+ bitcoindPostRepairHealth: "starting",
45
+ };
46
+ }
34
47
  if (initialBitcoindProbe.compatibility === "service-version-mismatch"
35
48
  || initialBitcoindProbe.compatibility === "wallet-root-mismatch"
36
49
  || initialBitcoindProbe.compatibility === "runtime-mismatch"
@@ -40,10 +53,14 @@ export async function repairManagedBitcoindStage(options) {
40
53
  if (initialBitcoindProbe.compatibility !== "rawtx-zmq-missing") {
41
54
  throw new Error("managed_bitcoind_process_id_unavailable");
42
55
  }
43
- await clearManagedBitcoindArtifacts(options.servicePaths);
56
+ await reportRepairProgress(options.context, "bitcoind-clear-stale-rawtx", "Clearing stale managed bitcoind runtime missing rawtx ZMQ...");
57
+ await clearManagedBitcoindArtifactsForDataDir(options.servicePaths, options.context.dataDir);
44
58
  bitcoindServiceAction = "restarted-missing-rawtx-zmq";
45
59
  }
46
60
  else {
61
+ await reportRepairProgress(options.context, "bitcoind-stop-incompatible", initialBitcoindProbe.compatibility === "rawtx-zmq-missing"
62
+ ? "Stopping stale managed bitcoind missing rawtx ZMQ..."
63
+ : "Stopping incompatible managed bitcoind service...");
47
64
  try {
48
65
  process.kill(processId, "SIGTERM");
49
66
  }
@@ -53,7 +70,8 @@ export async function repairManagedBitcoindStage(options) {
53
70
  }
54
71
  }
55
72
  await waitForProcessExit(processId, 15_000, "managed_bitcoind_stop_timeout");
56
- await clearManagedBitcoindArtifacts(options.servicePaths);
73
+ await reportRepairProgress(options.context, "bitcoind-clear-artifacts", "Clearing managed bitcoind runtime artifacts...");
74
+ await clearManagedBitcoindArtifactsForDataDir(options.servicePaths, options.context.dataDir);
57
75
  bitcoindServiceAction = initialBitcoindProbe.compatibility === "rawtx-zmq-missing"
58
76
  ? "restarted-missing-rawtx-zmq"
59
77
  : "stopped-incompatible-service";
@@ -68,7 +86,8 @@ export async function repairManagedBitcoindStage(options) {
68
86
  options.servicePaths.bitcoindWalletStatusPath,
69
87
  ].map(pathExists));
70
88
  if (hasStaleArtifacts.some(Boolean)) {
71
- await clearManagedBitcoindArtifacts(options.servicePaths);
89
+ await reportRepairProgress(options.context, "bitcoind-clear-stale", "Clearing stale managed bitcoind artifacts...");
90
+ await clearManagedBitcoindArtifactsForDataDir(options.servicePaths, options.context.dataDir);
72
91
  bitcoindServiceAction = "cleared-stale-artifacts";
73
92
  }
74
93
  }
@@ -83,12 +102,17 @@ export async function repairManagedBitcoindStage(options) {
83
102
  let bitcoindHandle = null;
84
103
  let handleClosed = false;
85
104
  try {
105
+ await reportRepairProgress(options.context, attachAttempt === 0 ? "bitcoind-start" : "bitcoind-retry-start", bitcoindServiceAction === "none"
106
+ ? "Attaching to managed bitcoind..."
107
+ : "Starting managed bitcoind with current ZMQ config...");
108
+ await reportRepairProgress(options.context, "bitcoind-wait-rpc", "Waiting for Bitcoin Core RPC readiness...");
86
109
  bitcoindHandle = await options.context.attachService({
87
110
  dataDir: options.context.dataDir,
88
111
  chain: "main",
89
112
  startHeight: 0,
90
113
  walletRootId: state.walletRootId,
91
114
  });
115
+ await reportRepairProgress(options.context, "bitcoind-normalize-wallet", "Checking managed Bitcoin wallet state...");
92
116
  const rpc = options.context.rpcFactory(bitcoindHandle.rpc);
93
117
  const normalizedDescriptorState = await normalizeWalletDescriptorState(state, rpc);
94
118
  if (normalizedDescriptorState.changed) {
@@ -116,6 +140,7 @@ export async function repairManagedBitcoindStage(options) {
116
140
  rpcFactory: options.context.rpcFactory,
117
141
  });
118
142
  if (replica.proofStatus !== "ready") {
143
+ await reportRepairProgress(options.context, "bitcoind-recreate-replica", "Recreating managed Core wallet replica...");
119
144
  state = await recreateManagedCoreWalletReplica(state, options.context.provider, options.context.paths, options.context.dataDir, options.context.nowUnixMs, {
120
145
  attachService: options.context.attachService,
121
146
  rpcFactory: options.context.rpcFactory,
@@ -129,6 +154,7 @@ export async function repairManagedBitcoindStage(options) {
129
154
  rpcFactory: options.context.rpcFactory,
130
155
  });
131
156
  }
157
+ await reportRepairProgress(options.context, "bitcoind-final-health", "Checking managed bitcoind post-repair health...");
132
158
  const finalBitcoindStatus = await bitcoindHandle.refreshServiceStatus?.() ?? null;
133
159
  const chainInfo = await rpc.getBlockchainInfo();
134
160
  bitcoindPostRepairHealth = mapBitcoindRepairHealth({
@@ -150,6 +176,18 @@ export async function repairManagedBitcoindStage(options) {
150
176
  };
151
177
  }
152
178
  catch (error) {
179
+ if (isManagedBitcoindStartupWarmupError(error)) {
180
+ await reportRepairProgress(options.context, "bitcoind-starting", "Bitcoin Core is loading the block index; leaving managed bitcoind running.");
181
+ return {
182
+ state,
183
+ repairStateNeedsPersist,
184
+ recreatedManagedCoreWallet,
185
+ bitcoindServiceAction,
186
+ bitcoindCompatibilityIssue,
187
+ managedCoreReplicaAction,
188
+ bitcoindPostRepairHealth: "starting",
189
+ };
190
+ }
153
191
  if (bitcoindHandle !== null) {
154
192
  await bitcoindHandle.stop?.().catch(() => undefined);
155
193
  handleClosed = true;
@@ -161,7 +199,8 @@ export async function repairManagedBitcoindStage(options) {
161
199
  dataDir: options.context.dataDir,
162
200
  });
163
201
  try {
164
- await clearManagedBitcoindArtifacts(options.servicePaths);
202
+ await reportRepairProgress(options.context, "bitcoind-clear-stale-rpc", "Clearing stale managed bitcoind RPC artifacts...");
203
+ await clearManagedBitcoindArtifactsForDataDir(options.servicePaths, options.context.dataDir);
165
204
  }
166
205
  finally {
167
206
  await retryLock.release();
@@ -2,7 +2,7 @@ import { attachOrStartIndexerDaemon, probeIndexerDaemon } from "../../bitcoind/i
2
2
  import { probeManagedBitcoindService } from "../../bitcoind/service.js";
3
3
  import { resolveManagedServicePaths } from "../../bitcoind/service-paths.js";
4
4
  import type { ManagedBitcoindServiceStatus } from "../../bitcoind/types.js";
5
- import type { WalletRepairResult } from "./types.js";
5
+ import type { WalletRepairContext, WalletRepairResult } from "./types.js";
6
6
  export declare function ensureIndexerDatabaseHealthy(options: {
7
7
  databasePath: string;
8
8
  dataDir: string;
@@ -18,6 +18,8 @@ export declare function mapBitcoindRepairHealth(options: {
18
18
  proofStatus?: "missing" | "mismatch" | "ready" | "not-proven";
19
19
  } | null;
20
20
  }): WalletRepairResult["bitcoindPostRepairHealth"];
21
+ export declare function reportRepairProgress(context: Pick<WalletRepairContext, "progress">, code: string, message: string): Promise<void>;
22
+ export declare function isManagedBitcoindStartupWarmupError(error: unknown): boolean;
21
23
  export declare function verifyIndexerPostRepairHealth(options: {
22
24
  daemon: Awaited<ReturnType<typeof attachOrStartIndexerDaemon>>;
23
25
  probeIndexerDaemon: typeof probeIndexerDaemon;
@@ -33,5 +35,6 @@ export declare function isManagedBitcoindRpcUnavailableError(error: unknown): bo
33
35
  export declare function waitForProcessExit(pid: number, timeoutMs?: number, errorCode?: string): Promise<void>;
34
36
  export declare function clearIndexerDaemonArtifacts(servicePaths: ReturnType<typeof resolveManagedServicePaths>): Promise<void>;
35
37
  export declare function clearManagedBitcoindArtifacts(servicePaths: ReturnType<typeof resolveManagedServicePaths>): Promise<void>;
38
+ export declare function clearManagedBitcoindArtifactsForDataDir(servicePaths: ReturnType<typeof resolveManagedServicePaths>, dataDir: string): Promise<void>;
36
39
  export declare function stopRecordedManagedProcess(pid: number | null, errorCode: string): Promise<void>;
37
40
  export declare function clearOrphanedRepairLocks(lockPaths: readonly string[]): Promise<void>;
@@ -1,5 +1,5 @@
1
- import { access, constants, mkdir, readFile, rm } from "node:fs/promises";
2
- import { dirname } from "node:path";
1
+ import { access, constants, mkdir, readdir, readFile, rm } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
3
  import { attachOrStartIndexerDaemon, probeIndexerDaemon, readSnapshotWithRetry, } from "../../bitcoind/indexer-daemon.js";
4
4
  import { probeManagedBitcoindService } from "../../bitcoind/service.js";
5
5
  import { resolveManagedServicePaths } from "../../bitcoind/service-paths.js";
@@ -93,6 +93,18 @@ export function mapBitcoindRepairHealth(options) {
93
93
  }
94
94
  return "ready";
95
95
  }
96
+ export async function reportRepairProgress(context, code, message) {
97
+ await context.progress({ code, message });
98
+ }
99
+ export function isManagedBitcoindStartupWarmupError(error) {
100
+ const message = error instanceof Error ? error.message : String(error);
101
+ return message === "managed_bitcoind_service_starting"
102
+ || message === "bitcoind_rpc_timeout"
103
+ || message === "bitcoind_cookie_timeout"
104
+ || /^bitcoind_rpc_[^_]+_-28(?:_|$)/.test(message)
105
+ || message.startsWith("The managed Bitcoin RPC cookie file is unavailable at ")
106
+ || message.startsWith("The managed Bitcoin RPC cookie file could not be read at ");
107
+ }
96
108
  function mapLeaseStateToRepairHealth(state) {
97
109
  switch (state) {
98
110
  case "synced":
@@ -184,11 +196,62 @@ export async function clearIndexerDaemonArtifacts(servicePaths) {
184
196
  await rm(servicePaths.indexerDaemonSocketPath, { force: true }).catch(() => undefined);
185
197
  }
186
198
  export async function clearManagedBitcoindArtifacts(servicePaths) {
187
- await rm(servicePaths.bitcoindStatusPath, { force: true }).catch(() => undefined);
188
- await rm(servicePaths.bitcoindPidPath, { force: true }).catch(() => undefined);
189
- await rm(servicePaths.bitcoindReadyPath, { force: true }).catch(() => undefined);
190
- await rm(servicePaths.bitcoindRuntimeConfigPath, { force: true }).catch(() => undefined);
191
- await rm(servicePaths.bitcoindWalletStatusPath, { force: true }).catch(() => undefined);
199
+ await clearManagedBitcoindArtifactRoot(servicePaths.walletRuntimeRoot);
200
+ }
201
+ async function readManagedBitcoindStatusAtRoot(serviceRoot) {
202
+ try {
203
+ return JSON.parse(await readFile(join(serviceRoot, "bitcoind-status.json"), "utf8"));
204
+ }
205
+ catch {
206
+ return null;
207
+ }
208
+ }
209
+ async function clearManagedBitcoindArtifactRoot(serviceRoot) {
210
+ await rm(join(serviceRoot, "bitcoind-status.json"), { force: true }).catch(() => undefined);
211
+ await rm(join(serviceRoot, "bitcoind.pid"), { force: true }).catch(() => undefined);
212
+ await rm(join(serviceRoot, "bitcoind.ready"), { force: true }).catch(() => undefined);
213
+ await rm(join(serviceRoot, "bitcoind-config.json"), { force: true }).catch(() => undefined);
214
+ await rm(join(serviceRoot, "bitcoind-wallet.json"), { force: true }).catch(() => undefined);
215
+ }
216
+ async function stopManagedBitcoindPid(pid) {
217
+ if (pid === null || !await isProcessAlive(pid)) {
218
+ return;
219
+ }
220
+ try {
221
+ process.kill(pid, "SIGTERM");
222
+ }
223
+ catch (error) {
224
+ if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) {
225
+ throw error;
226
+ }
227
+ }
228
+ await waitForProcessExit(pid, 15_000, "managed_bitcoind_stop_timeout");
229
+ }
230
+ export async function clearManagedBitcoindArtifactsForDataDir(servicePaths, dataDir) {
231
+ const serviceRoots = new Set([servicePaths.walletRuntimeRoot]);
232
+ const runtimeEntries = await readdir(servicePaths.runtimeRoot, { withFileTypes: true }).catch((error) => {
233
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
234
+ return [];
235
+ }
236
+ throw error;
237
+ });
238
+ for (const entry of runtimeEntries) {
239
+ if (!entry.isDirectory()) {
240
+ continue;
241
+ }
242
+ const serviceRoot = join(servicePaths.runtimeRoot, entry.name);
243
+ const status = await readManagedBitcoindStatusAtRoot(serviceRoot);
244
+ if (status?.dataDir === dataDir) {
245
+ serviceRoots.add(serviceRoot);
246
+ }
247
+ }
248
+ for (const serviceRoot of serviceRoots) {
249
+ const status = await readManagedBitcoindStatusAtRoot(serviceRoot);
250
+ if (status?.dataDir === dataDir) {
251
+ await stopManagedBitcoindPid(status.processId);
252
+ }
253
+ await clearManagedBitcoindArtifactRoot(serviceRoot);
254
+ }
192
255
  }
193
256
  export async function stopRecordedManagedProcess(pid, errorCode) {
194
257
  if (pid === null || !await isProcessAlive(pid)) {
@@ -6,7 +6,7 @@ import { acquireWalletControlLock, resolveWalletRepairContext, } from "./context
6
6
  import { repairManagedBitcoindStage } from "./repair-bitcoind.js";
7
7
  import { repairManagedIndexerStage } from "./repair-indexer.js";
8
8
  import { applyRepairStoppedMiningState, cleanupMiningForRepair, persistRepairState, resumeBackgroundMiningAfterRepair, } from "./repair-mining.js";
9
- import { clearOrphanedRepairLocks, ensureIndexerDatabaseHealthy, } from "./repair-runtime.js";
9
+ import { clearOrphanedRepairLocks, ensureIndexerDatabaseHealthy, reportRepairProgress, } from "./repair-runtime.js";
10
10
  export async function repairWallet(options) {
11
11
  const context = resolveWalletRepairContext(options);
12
12
  await clearOrphanedRepairLocks([
@@ -17,6 +17,7 @@ export async function repairWallet(options) {
17
17
  try {
18
18
  let loaded;
19
19
  try {
20
+ await reportRepairProgress(context, "wallet-check", "Checking wallet state...");
20
21
  loaded = await loadWalletState({
21
22
  primaryPath: context.paths.walletStatePath,
22
23
  backupPath: context.paths.walletStateBackupPath,
@@ -31,10 +32,12 @@ export async function repairWallet(options) {
31
32
  let repairedState = loaded.state;
32
33
  let repairStateNeedsPersist = false;
33
34
  const servicePaths = resolveManagedServicePaths(context.dataDir, repairedState.walletRootId);
35
+ await reportRepairProgress(context, "lock-cleanup", "Checking repair locks...");
34
36
  await clearOrphanedRepairLocks([
35
37
  servicePaths.bitcoindLockPath,
36
38
  servicePaths.indexerDaemonLockPath,
37
39
  ]);
40
+ await reportRepairProgress(context, "mining-cleanup", "Checking mining runtime...");
38
41
  const preRepairMiningRuntime = await loadMiningRuntimeStatus(context.paths.miningStatusPath).catch(() => null);
39
42
  const miningCleanup = await cleanupMiningForRepair({
40
43
  paths: context.paths,
@@ -48,6 +51,7 @@ export async function repairWallet(options) {
48
51
  repairStateNeedsPersist = true;
49
52
  }
50
53
  if (!context.assumeYes) {
54
+ await reportRepairProgress(context, "indexer-database-check", "Checking local indexer database...");
51
55
  await ensureIndexerDatabaseHealthy({
52
56
  databasePath: context.databasePath,
53
57
  dataDir: context.dataDir,
@@ -64,7 +68,12 @@ export async function repairWallet(options) {
64
68
  });
65
69
  repairedState = bitcoindStage.state;
66
70
  repairStateNeedsPersist = bitcoindStage.repairStateNeedsPersist;
71
+ const repairNotes = [];
72
+ if (bitcoindStage.bitcoindPostRepairHealth === "starting") {
73
+ repairNotes.push("Managed bitcoind was restarted and is still loading the block index; rerun mining after it reaches ready.");
74
+ }
67
75
  if (recoveredFromBackup) {
76
+ await reportRepairProgress(context, "wallet-state-persist", "Persisting repaired wallet state...");
68
77
  repairedState = await persistRepairState({
69
78
  state: repairedState,
70
79
  provider: context.provider,
@@ -75,6 +84,7 @@ export async function repairWallet(options) {
75
84
  repairStateNeedsPersist = false;
76
85
  }
77
86
  else if (repairStateNeedsPersist) {
87
+ await reportRepairProgress(context, "wallet-state-persist", "Persisting repaired wallet state...");
78
88
  repairedState = await persistRepairState({
79
89
  state: repairedState,
80
90
  provider: context.provider,
@@ -83,11 +93,25 @@ export async function repairWallet(options) {
83
93
  });
84
94
  repairStateNeedsPersist = false;
85
95
  }
86
- const indexerStage = await repairManagedIndexerStage({
87
- context,
88
- servicePaths,
89
- state: repairedState,
90
- });
96
+ const indexerStage = bitcoindStage.bitcoindPostRepairHealth === "starting"
97
+ ? {
98
+ resetIndexerDatabase: false,
99
+ indexerDaemonAction: "none",
100
+ indexerCompatibilityIssue: "none",
101
+ indexerPostRepairHealth: "starting",
102
+ }
103
+ : await (async () => {
104
+ await reportRepairProgress(context, "indexer-check", "Checking indexer...");
105
+ return repairManagedIndexerStage({
106
+ context,
107
+ servicePaths,
108
+ state: repairedState,
109
+ });
110
+ })();
111
+ if (indexerStage.resetIndexerDatabase) {
112
+ repairNotes.push("Indexer artifacts were reset and may still be catching up.");
113
+ }
114
+ await reportRepairProgress(context, "mining-resume", "Checking whether mining should resume...");
91
115
  const miningResume = await resumeBackgroundMiningAfterRepair({
92
116
  miningPreRepairRunMode,
93
117
  provider: context.provider,
@@ -113,9 +137,7 @@ export async function repairWallet(options) {
113
137
  miningResumeAction: miningResume.miningResumeAction,
114
138
  miningPostRepairRunMode: miningResume.miningPostRepairRunMode,
115
139
  miningResumeError: miningResume.miningResumeError,
116
- note: indexerStage.resetIndexerDatabase
117
- ? "Indexer artifacts were reset and may still be catching up."
118
- : null,
140
+ note: repairNotes.length > 0 ? repairNotes.join(" ") : null,
119
141
  };
120
142
  }
121
143
  finally {
@@ -49,6 +49,11 @@ export interface WalletRepairResult {
49
49
  miningResumeError: string | null;
50
50
  note: string | null;
51
51
  }
52
+ export interface WalletRepairProgressEvent {
53
+ code: string;
54
+ message: string;
55
+ }
56
+ export type WalletRepairProgressReporter = (event: WalletRepairProgressEvent) => void | Promise<void>;
52
57
  export interface WalletLifecycleRpcClient {
53
58
  getDescriptorInfo(descriptor: string): Promise<{
54
59
  descriptor: string;
@@ -126,6 +131,7 @@ export interface WalletRepairDependencies extends WalletManagedCoreDependencies
126
131
  attachIndexerDaemon?: typeof attachOrStartIndexerDaemon;
127
132
  probeIndexerDaemon?: typeof probeIndexerDaemon;
128
133
  requestMiningPreemption?: typeof requestMiningGenerationPreemption;
134
+ progress?: WalletRepairProgressReporter;
129
135
  }
130
136
  export interface WalletRepairContext extends WalletManagedCoreContext {
131
137
  dataDir: string;
@@ -135,6 +141,7 @@ export interface WalletRepairContext extends WalletManagedCoreContext {
135
141
  attachIndexerDaemon: NonNullable<WalletRepairDependencies["attachIndexerDaemon"]>;
136
142
  probeIndexerDaemon: NonNullable<WalletRepairDependencies["probeIndexerDaemon"]>;
137
143
  requestMiningPreemption?: WalletRepairDependencies["requestMiningPreemption"];
144
+ progress: WalletRepairProgressReporter;
138
145
  }
139
146
  export interface WalletBitcoindRepairStageResult {
140
147
  state: WalletStateV1;
@@ -159,6 +159,10 @@ export async function handleRecoverableMiningBitcoindFailure(options) {
159
159
  rememberMiningBitcoindRecoveryIdentity(options.loopState, probe.status);
160
160
  options.loopState.bitcoinRecoveryFirstUnreachableAtUnixMs = null;
161
161
  }
162
+ else if (probe.compatibility === "starting") {
163
+ rememberMiningBitcoindRecoveryIdentity(options.loopState, probe.status);
164
+ options.loopState.bitcoinRecoveryFirstUnreachableAtUnixMs = null;
165
+ }
162
166
  else if (probe.compatibility === "unreachable") {
163
167
  const identityChanged = rememberMiningBitcoindRecoveryIdentity(options.loopState, probe.status);
164
168
  const livePid = isMiningBitcoindRecoveryPidAlive(probe.status?.processId ?? null);
@@ -54,6 +54,15 @@ async function attachNodeStatus(options, dependencies) {
54
54
  startupTimeoutMs: options.startupTimeoutMs,
55
55
  });
56
56
  const decision = resolveManagedBitcoindProbeDecision(probe);
57
+ if (decision.action === "wait") {
58
+ return {
59
+ handle: null,
60
+ rpc: null,
61
+ status: null,
62
+ observedStatus: probe.status,
63
+ error: null,
64
+ };
65
+ }
57
66
  if (decision.action === "reject") {
58
67
  return {
59
68
  handle: null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cogcoin/client",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "description": "Store-backed Cogcoin client with wallet flows, SQLite persistence, and managed Bitcoin Core integration.",
5
5
  "license": "MIT",
6
6
  "type": "module",