@cogcoin/client 1.2.1 → 1.2.3

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.1` 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.3` 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
 
@@ -66,7 +66,9 @@ export async function probeManagedBitcoindStatusCandidate(status, options, runti
66
66
  const rpc = createRpcClient(status.rpc);
67
67
  try {
68
68
  await waitForManagedBitcoindRpcReady(rpc, status.rpc.cookieFile, status.chain, options.startupTimeoutMs ?? DEFAULT_MANAGED_BITCOIND_STARTUP_TIMEOUT_MS);
69
- await validateNodeConfigForTesting(rpc, status.chain, status.zmq.endpoint);
69
+ await validateNodeConfigForTesting(rpc, status.chain, status.zmq.endpoint, {
70
+ requireRawTxZmq: true,
71
+ });
70
72
  return {
71
73
  compatibility: "compatible",
72
74
  status,
@@ -96,7 +98,9 @@ export async function refreshManagedBitcoindStatus(status, paths, options) {
96
98
  const targetWalletRootId = options.walletRootId ?? status.walletRootId;
97
99
  try {
98
100
  await waitForManagedBitcoindRpcReady(rpc, status.rpc.cookieFile, status.chain, options.startupTimeoutMs ?? DEFAULT_MANAGED_BITCOIND_STARTUP_TIMEOUT_MS);
99
- await validateNodeConfigForTesting(rpc, status.chain, status.zmq.endpoint);
101
+ await validateNodeConfigForTesting(rpc, status.chain, status.zmq.endpoint, {
102
+ requireRawTxZmq: true,
103
+ });
100
104
  const walletReplica = await loadManagedWalletReplicaIfPresent(rpc, targetWalletRootId, status.dataDir);
101
105
  const nextStatus = {
102
106
  ...status,
@@ -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, mapBitcoindCompatibilityToRepairIssue, mapBitcoindRepairHealth, waitForProcessExit, } from "./repair-runtime.js";
7
+ import { clearManagedBitcoindArtifactsForDataDir, isManagedBitcoindRpcUnavailableError, mapBitcoindCompatibilityToRepairIssue, mapBitcoindRepairHealth, waitForProcessExit, } from "./repair-runtime.js";
8
8
  export async function repairManagedBitcoindStage(options) {
9
9
  let state = options.state;
10
10
  let repairStateNeedsPersist = options.repairStateNeedsPersist;
@@ -40,7 +40,7 @@ export async function repairManagedBitcoindStage(options) {
40
40
  if (initialBitcoindProbe.compatibility !== "rawtx-zmq-missing") {
41
41
  throw new Error("managed_bitcoind_process_id_unavailable");
42
42
  }
43
- await clearManagedBitcoindArtifacts(options.servicePaths);
43
+ await clearManagedBitcoindArtifactsForDataDir(options.servicePaths, options.context.dataDir);
44
44
  bitcoindServiceAction = "restarted-missing-rawtx-zmq";
45
45
  }
46
46
  else {
@@ -53,7 +53,7 @@ export async function repairManagedBitcoindStage(options) {
53
53
  }
54
54
  }
55
55
  await waitForProcessExit(processId, 15_000, "managed_bitcoind_stop_timeout");
56
- await clearManagedBitcoindArtifacts(options.servicePaths);
56
+ await clearManagedBitcoindArtifactsForDataDir(options.servicePaths, options.context.dataDir);
57
57
  bitcoindServiceAction = initialBitcoindProbe.compatibility === "rawtx-zmq-missing"
58
58
  ? "restarted-missing-rawtx-zmq"
59
59
  : "stopped-incompatible-service";
@@ -68,7 +68,7 @@ export async function repairManagedBitcoindStage(options) {
68
68
  options.servicePaths.bitcoindWalletStatusPath,
69
69
  ].map(pathExists));
70
70
  if (hasStaleArtifacts.some(Boolean)) {
71
- await clearManagedBitcoindArtifacts(options.servicePaths);
71
+ await clearManagedBitcoindArtifactsForDataDir(options.servicePaths, options.context.dataDir);
72
72
  bitcoindServiceAction = "cleared-stale-artifacts";
73
73
  }
74
74
  }
@@ -79,74 +79,105 @@ export async function repairManagedBitcoindStage(options) {
79
79
  finally {
80
80
  await bitcoindLock.release();
81
81
  }
82
- const bitcoindHandle = await options.context.attachService({
83
- dataDir: options.context.dataDir,
84
- chain: "main",
85
- startHeight: 0,
86
- walletRootId: state.walletRootId,
87
- });
88
- try {
89
- const rpc = options.context.rpcFactory(bitcoindHandle.rpc);
90
- const normalizedDescriptorState = await normalizeWalletDescriptorState(state, rpc);
91
- if (normalizedDescriptorState.changed) {
92
- state = normalizedDescriptorState.state;
93
- repairStateNeedsPersist = true;
94
- }
95
- const reconciledCoinControl = await persistWalletCoinControlStateIfNeeded({
96
- state,
97
- access: {
98
- provider: options.context.provider,
99
- secretReference: createWalletSecretReference(state.walletRootId),
100
- },
101
- paths: options.context.paths,
102
- nowUnixMs: options.context.nowUnixMs,
103
- replacePrimary: options.recoveredFromBackup && !repairStateNeedsPersist,
104
- rpc,
105
- });
106
- state = reconciledCoinControl.state;
107
- if (reconciledCoinControl.changed) {
108
- repairStateNeedsPersist = false;
109
- }
110
- let replica = await verifyManagedCoreWalletReplica(state, options.context.dataDir, {
111
- nodeHandle: bitcoindHandle,
112
- attachService: options.context.attachService,
113
- rpcFactory: options.context.rpcFactory,
114
- });
115
- if (replica.proofStatus !== "ready") {
116
- state = await recreateManagedCoreWalletReplica(state, options.context.provider, options.context.paths, options.context.dataDir, options.context.nowUnixMs, {
117
- attachService: options.context.attachService,
118
- rpcFactory: options.context.rpcFactory,
82
+ for (let attachAttempt = 0; attachAttempt < 2; attachAttempt += 1) {
83
+ let bitcoindHandle = null;
84
+ let handleClosed = false;
85
+ try {
86
+ bitcoindHandle = await options.context.attachService({
87
+ dataDir: options.context.dataDir,
88
+ chain: "main",
89
+ startHeight: 0,
90
+ walletRootId: state.walletRootId,
119
91
  });
120
- recreatedManagedCoreWallet = true;
121
- managedCoreReplicaAction = "recreated";
122
- repairStateNeedsPersist = false;
123
- replica = await verifyManagedCoreWalletReplica(state, options.context.dataDir, {
92
+ const rpc = options.context.rpcFactory(bitcoindHandle.rpc);
93
+ const normalizedDescriptorState = await normalizeWalletDescriptorState(state, rpc);
94
+ if (normalizedDescriptorState.changed) {
95
+ state = normalizedDescriptorState.state;
96
+ repairStateNeedsPersist = true;
97
+ }
98
+ const reconciledCoinControl = await persistWalletCoinControlStateIfNeeded({
99
+ state,
100
+ access: {
101
+ provider: options.context.provider,
102
+ secretReference: createWalletSecretReference(state.walletRootId),
103
+ },
104
+ paths: options.context.paths,
105
+ nowUnixMs: options.context.nowUnixMs,
106
+ replacePrimary: options.recoveredFromBackup && !repairStateNeedsPersist,
107
+ rpc,
108
+ });
109
+ state = reconciledCoinControl.state;
110
+ if (reconciledCoinControl.changed) {
111
+ repairStateNeedsPersist = false;
112
+ }
113
+ let replica = await verifyManagedCoreWalletReplica(state, options.context.dataDir, {
124
114
  nodeHandle: bitcoindHandle,
125
115
  attachService: options.context.attachService,
126
116
  rpcFactory: options.context.rpcFactory,
127
117
  });
118
+ if (replica.proofStatus !== "ready") {
119
+ state = await recreateManagedCoreWalletReplica(state, options.context.provider, options.context.paths, options.context.dataDir, options.context.nowUnixMs, {
120
+ attachService: options.context.attachService,
121
+ rpcFactory: options.context.rpcFactory,
122
+ });
123
+ recreatedManagedCoreWallet = true;
124
+ managedCoreReplicaAction = "recreated";
125
+ repairStateNeedsPersist = false;
126
+ replica = await verifyManagedCoreWalletReplica(state, options.context.dataDir, {
127
+ nodeHandle: bitcoindHandle,
128
+ attachService: options.context.attachService,
129
+ rpcFactory: options.context.rpcFactory,
130
+ });
131
+ }
132
+ const finalBitcoindStatus = await bitcoindHandle.refreshServiceStatus?.() ?? null;
133
+ const chainInfo = await rpc.getBlockchainInfo();
134
+ bitcoindPostRepairHealth = mapBitcoindRepairHealth({
135
+ serviceState: finalBitcoindStatus?.state ?? null,
136
+ catchingUp: chainInfo.blocks < chainInfo.headers,
137
+ replica,
138
+ });
139
+ if (bitcoindServiceAction === "none" && initialBitcoindProbe.compatibility === "unreachable") {
140
+ bitcoindServiceAction = "restarted-compatible-service";
141
+ }
142
+ return {
143
+ state,
144
+ repairStateNeedsPersist,
145
+ recreatedManagedCoreWallet,
146
+ bitcoindServiceAction,
147
+ bitcoindCompatibilityIssue,
148
+ managedCoreReplicaAction,
149
+ bitcoindPostRepairHealth,
150
+ };
128
151
  }
129
- const finalBitcoindStatus = await bitcoindHandle.refreshServiceStatus?.() ?? null;
130
- const chainInfo = await rpc.getBlockchainInfo();
131
- bitcoindPostRepairHealth = mapBitcoindRepairHealth({
132
- serviceState: finalBitcoindStatus?.state ?? null,
133
- catchingUp: chainInfo.blocks < chainInfo.headers,
134
- replica,
135
- });
136
- if (bitcoindServiceAction === "none" && initialBitcoindProbe.compatibility === "unreachable") {
137
- bitcoindServiceAction = "restarted-compatible-service";
152
+ catch (error) {
153
+ if (bitcoindHandle !== null) {
154
+ await bitcoindHandle.stop?.().catch(() => undefined);
155
+ handleClosed = true;
156
+ }
157
+ if (attachAttempt === 0 && isManagedBitcoindRpcUnavailableError(error)) {
158
+ const retryLock = await acquireFileLock(options.servicePaths.bitcoindLockPath, {
159
+ purpose: "managed-bitcoind-repair-stale-rpc",
160
+ walletRootId: state.walletRootId,
161
+ dataDir: options.context.dataDir,
162
+ });
163
+ try {
164
+ await clearManagedBitcoindArtifactsForDataDir(options.servicePaths, options.context.dataDir);
165
+ }
166
+ finally {
167
+ await retryLock.release();
168
+ }
169
+ if (bitcoindServiceAction === "none") {
170
+ bitcoindServiceAction = "restarted-compatible-service";
171
+ }
172
+ continue;
173
+ }
174
+ throw error;
175
+ }
176
+ finally {
177
+ if (!handleClosed) {
178
+ await bitcoindHandle?.stop?.().catch(() => undefined);
179
+ }
138
180
  }
139
181
  }
140
- finally {
141
- await bitcoindHandle.stop?.().catch(() => undefined);
142
- }
143
- return {
144
- state,
145
- repairStateNeedsPersist,
146
- recreatedManagedCoreWallet,
147
- bitcoindServiceAction,
148
- bitcoindCompatibilityIssue,
149
- managedCoreReplicaAction,
150
- bitcoindPostRepairHealth,
151
- };
182
+ throw new Error("managed_bitcoind_repair_retry_exhausted");
152
183
  }
@@ -29,8 +29,10 @@ export declare function verifyIndexerPostRepairHealth(options: {
29
29
  daemonInstanceId: string;
30
30
  }>;
31
31
  export declare function isProcessAlive(pid: number | null): Promise<boolean>;
32
+ export declare function isManagedBitcoindRpcUnavailableError(error: unknown): boolean;
32
33
  export declare function waitForProcessExit(pid: number, timeoutMs?: number, errorCode?: string): Promise<void>;
33
34
  export declare function clearIndexerDaemonArtifacts(servicePaths: ReturnType<typeof resolveManagedServicePaths>): Promise<void>;
34
35
  export declare function clearManagedBitcoindArtifacts(servicePaths: ReturnType<typeof resolveManagedServicePaths>): Promise<void>;
36
+ export declare function clearManagedBitcoindArtifactsForDataDir(servicePaths: ReturnType<typeof resolveManagedServicePaths>, dataDir: string): Promise<void>;
35
37
  export declare function stopRecordedManagedProcess(pid: number | null, errorCode: string): Promise<void>;
36
38
  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";
@@ -153,6 +153,22 @@ export async function isProcessAlive(pid) {
153
153
  return true;
154
154
  }
155
155
  }
156
+ export function isManagedBitcoindRpcUnavailableError(error) {
157
+ if (error instanceof Error && "code" in error) {
158
+ const code = error.code;
159
+ if (code === "ENOENT" || code === "ECONNREFUSED" || code === "ECONNRESET" || code === "EPIPE") {
160
+ return true;
161
+ }
162
+ }
163
+ if (!(error instanceof Error)) {
164
+ return false;
165
+ }
166
+ return error.message === "bitcoind_cookie_timeout"
167
+ || error.message.includes("cookie file is unavailable")
168
+ || error.message.includes("ECONNREFUSED")
169
+ || error.message.includes("ECONNRESET")
170
+ || error.message.includes("socket hang up");
171
+ }
156
172
  export async function waitForProcessExit(pid, timeoutMs = 15_000, errorCode = "indexer_daemon_stop_timeout") {
157
173
  const deadline = Date.now() + timeoutMs;
158
174
  while (Date.now() < deadline) {
@@ -168,11 +184,62 @@ export async function clearIndexerDaemonArtifacts(servicePaths) {
168
184
  await rm(servicePaths.indexerDaemonSocketPath, { force: true }).catch(() => undefined);
169
185
  }
170
186
  export async function clearManagedBitcoindArtifacts(servicePaths) {
171
- await rm(servicePaths.bitcoindStatusPath, { force: true }).catch(() => undefined);
172
- await rm(servicePaths.bitcoindPidPath, { force: true }).catch(() => undefined);
173
- await rm(servicePaths.bitcoindReadyPath, { force: true }).catch(() => undefined);
174
- await rm(servicePaths.bitcoindRuntimeConfigPath, { force: true }).catch(() => undefined);
175
- await rm(servicePaths.bitcoindWalletStatusPath, { force: true }).catch(() => undefined);
187
+ await clearManagedBitcoindArtifactRoot(servicePaths.walletRuntimeRoot);
188
+ }
189
+ async function readManagedBitcoindStatusAtRoot(serviceRoot) {
190
+ try {
191
+ return JSON.parse(await readFile(join(serviceRoot, "bitcoind-status.json"), "utf8"));
192
+ }
193
+ catch {
194
+ return null;
195
+ }
196
+ }
197
+ async function clearManagedBitcoindArtifactRoot(serviceRoot) {
198
+ await rm(join(serviceRoot, "bitcoind-status.json"), { force: true }).catch(() => undefined);
199
+ await rm(join(serviceRoot, "bitcoind.pid"), { force: true }).catch(() => undefined);
200
+ await rm(join(serviceRoot, "bitcoind.ready"), { force: true }).catch(() => undefined);
201
+ await rm(join(serviceRoot, "bitcoind-config.json"), { force: true }).catch(() => undefined);
202
+ await rm(join(serviceRoot, "bitcoind-wallet.json"), { force: true }).catch(() => undefined);
203
+ }
204
+ async function stopManagedBitcoindPid(pid) {
205
+ if (pid === null || !await isProcessAlive(pid)) {
206
+ return;
207
+ }
208
+ try {
209
+ process.kill(pid, "SIGTERM");
210
+ }
211
+ catch (error) {
212
+ if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) {
213
+ throw error;
214
+ }
215
+ }
216
+ await waitForProcessExit(pid, 15_000, "managed_bitcoind_stop_timeout");
217
+ }
218
+ export async function clearManagedBitcoindArtifactsForDataDir(servicePaths, dataDir) {
219
+ const serviceRoots = new Set([servicePaths.walletRuntimeRoot]);
220
+ const runtimeEntries = await readdir(servicePaths.runtimeRoot, { withFileTypes: true }).catch((error) => {
221
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
222
+ return [];
223
+ }
224
+ throw error;
225
+ });
226
+ for (const entry of runtimeEntries) {
227
+ if (!entry.isDirectory()) {
228
+ continue;
229
+ }
230
+ const serviceRoot = join(servicePaths.runtimeRoot, entry.name);
231
+ const status = await readManagedBitcoindStatusAtRoot(serviceRoot);
232
+ if (status?.dataDir === dataDir) {
233
+ serviceRoots.add(serviceRoot);
234
+ }
235
+ }
236
+ for (const serviceRoot of serviceRoots) {
237
+ const status = await readManagedBitcoindStatusAtRoot(serviceRoot);
238
+ if (status?.dataDir === dataDir) {
239
+ await stopManagedBitcoindPid(status.processId);
240
+ }
241
+ await clearManagedBitcoindArtifactRoot(serviceRoot);
242
+ }
176
243
  }
177
244
  export async function stopRecordedManagedProcess(pid, errorCode) {
178
245
  if (pid === null || !await isProcessAlive(pid)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cogcoin/client",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
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",