@cogcoin/client 0.5.11 → 0.5.13

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.
Files changed (46) hide show
  1. package/README.md +3 -1
  2. package/dist/app-paths.d.ts +1 -0
  3. package/dist/app-paths.js +3 -0
  4. package/dist/bitcoind/bootstrap/controller.d.ts +3 -0
  5. package/dist/bitcoind/bootstrap/controller.js +7 -5
  6. package/dist/bitcoind/client/factory.d.ts +8 -0
  7. package/dist/bitcoind/client/factory.js +43 -6
  8. package/dist/bitcoind/client/managed-client.js +19 -10
  9. package/dist/bitcoind/client/sync-engine.js +35 -4
  10. package/dist/bitcoind/progress/formatting.js +1 -1
  11. package/dist/bitcoind/testing.d.ts +1 -0
  12. package/dist/bitcoind/testing.js +1 -0
  13. package/dist/cli/commands/follow.js +47 -14
  14. package/dist/cli/commands/sync.js +48 -15
  15. package/dist/cli/context.js +5 -1
  16. package/dist/cli/output.js +11 -0
  17. package/dist/cli/runner.js +2 -0
  18. package/dist/cli/signals.d.ts +1 -1
  19. package/dist/cli/signals.js +17 -4
  20. package/dist/cli/types.d.ts +4 -0
  21. package/dist/cli/update-notifier.d.ts +2 -0
  22. package/dist/cli/update-notifier.js +276 -0
  23. package/dist/client/default-client.d.ts +1 -1
  24. package/dist/client/default-client.js +7 -1
  25. package/dist/client/factory.js +6 -1
  26. package/dist/sqlite/store.js +3 -0
  27. package/dist/types.d.ts +2 -0
  28. package/dist/wallet/archive.js +10 -8
  29. package/dist/wallet/coin-control.d.ts +41 -0
  30. package/dist/wallet/coin-control.js +365 -0
  31. package/dist/wallet/lifecycle.js +39 -2
  32. package/dist/wallet/mining/runner.js +46 -44
  33. package/dist/wallet/read/context.js +15 -6
  34. package/dist/wallet/reset.js +2 -0
  35. package/dist/wallet/state/storage.js +5 -4
  36. package/dist/wallet/tx/anchor.js +36 -51
  37. package/dist/wallet/tx/cog.js +19 -12
  38. package/dist/wallet/tx/common.d.ts +41 -10
  39. package/dist/wallet/tx/common.js +112 -5
  40. package/dist/wallet/tx/domain-admin.js +13 -8
  41. package/dist/wallet/tx/domain-market.js +19 -12
  42. package/dist/wallet/tx/field.js +21 -18
  43. package/dist/wallet/tx/register.js +17 -12
  44. package/dist/wallet/tx/reputation.js +13 -8
  45. package/dist/wallet/types.d.ts +4 -0
  46. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # `@cogcoin/client`
2
2
 
3
- `@cogcoin/client@0.5.11` is the store-backed 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@0.5.13` is the store-backed 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
 
@@ -81,6 +81,8 @@ The installed `cogcoin` command covers the first-party local wallet and node wor
81
81
  - mining and hook commands such as `mine`, `mine start`, `mine stop`, `mine status`, `mine log`, `mine setup`, and `hooks status`
82
82
 
83
83
  The CLI also supports stable `--output json` and `--output preview-json` envelopes on the commands that advertise machine-readable output.
84
+ Interactive text invocations periodically check the npm registry for newer `@cogcoin/client` releases and print `npm install -g @cogcoin/client` when a newer version is available.
85
+ Set `COGCOIN_DISABLE_UPDATE_CHECK=1` to disable the CLI update notice entirely.
84
86
  Ordinary `sync`, `follow`, and wallet-aware read/status flows detach from the managed Bitcoin and indexer services on exit instead of stopping them.
85
87
  Use the explicit `bitcoin ...` and `indexer ...` commands when you want direct service inspection or start/stop control.
86
88
  For provider-backed local wallets, normal reads, mutations, export, and mining setup flows auto-materialize a local unlock session when the wallet is not explicitly locked.
@@ -39,3 +39,4 @@ export declare function resolveCogcoinAppRootForTesting(resolution?: CogcoinPath
39
39
  export declare function resolveDefaultBitcoindDataDirForTesting(resolution?: CogcoinPathResolution): string;
40
40
  export declare function resolveDefaultClientDatabasePathForTesting(resolution?: CogcoinPathResolution): string;
41
41
  export declare function resolveDefaultClientDatabaseDirectoryForTesting(resolution?: CogcoinPathResolution): string;
42
+ export declare function resolveDefaultUpdateCheckStatePathForTesting(resolution?: CogcoinPathResolution): string;
package/dist/app-paths.js CHANGED
@@ -125,3 +125,6 @@ export function resolveDefaultClientDatabaseDirectoryForTesting(resolution = {})
125
125
  const dbPath = resolveDefaultClientDatabasePathForTesting(resolution);
126
126
  return platform === "win32" ? win32Path.dirname(dbPath) : dirname(dbPath);
127
127
  }
128
+ export function resolveDefaultUpdateCheckStatePathForTesting(resolution = {}) {
129
+ return joinForPlatform(resolution.platform ?? process.platform, resolveCogcoinPathsForTesting(resolution).stateRoot, "update-check.json");
130
+ }
@@ -3,6 +3,7 @@ import { ManagedProgressController } from "../progress.js";
3
3
  import { BitcoinRpcClient } from "../rpc.js";
4
4
  import { type ManagedRpcRetryState } from "../retryable-rpc.js";
5
5
  import type { BootstrapPhase, SnapshotMetadata, SnapshotChunkManifest } from "../types.js";
6
+ type ResumeDisplayMode = "sync" | "follow";
6
7
  export declare class AssumeUtxoBootstrapController {
7
8
  #private;
8
9
  constructor(options: {
@@ -18,6 +19,7 @@ export declare class AssumeUtxoBootstrapController {
18
19
  ensureReady(indexedTip: ClientTip | null, expectedChain: "main" | "regtest", options?: {
19
20
  signal?: AbortSignal;
20
21
  retryState?: ManagedRpcRetryState;
22
+ resumeDisplayMode?: ResumeDisplayMode;
21
23
  }): Promise<void>;
22
24
  getStateForTesting(): Promise<{
23
25
  metadataVersion: number;
@@ -35,3 +37,4 @@ export declare class AssumeUtxoBootstrapController {
35
37
  updatedAt: number;
36
38
  }>;
37
39
  }
40
+ export {};
@@ -44,11 +44,13 @@ export class AssumeUtxoBootstrapController {
44
44
  return;
45
45
  }
46
46
  if (indexedTip !== null) {
47
- await this.#progress.setPhase("follow_tip", {
48
- blocks: indexedTip.height,
49
- targetHeight: indexedTip.height,
50
- message: "Resuming from the persisted Cogcoin indexed tip.",
51
- });
47
+ if ((options.resumeDisplayMode ?? "sync") === "follow") {
48
+ await this.#progress.setPhase("follow_tip", {
49
+ blocks: indexedTip.height,
50
+ targetHeight: indexedTip.height,
51
+ message: "Resuming from the persisted Cogcoin indexed tip.",
52
+ });
53
+ }
52
54
  return;
53
55
  }
54
56
  const { state, snapshotIdentity } = await this.#loadStateRecord();
@@ -1,3 +1,11 @@
1
+ import { stopIndexerDaemonService, type IndexerDaemonClient } from "../indexer-daemon.js";
1
2
  import type { InternalManagedBitcoindOptions, ManagedBitcoindClient } from "../types.js";
3
+ export declare function pauseIndexerDaemonForForegroundClientForTesting(options: {
4
+ daemon: IndexerDaemonClient;
5
+ dataDir: string;
6
+ walletRootId: string;
7
+ shutdownTimeoutMs?: number;
8
+ stopDaemon?: typeof stopIndexerDaemonService;
9
+ }): Promise<IndexerDaemonClient | null>;
2
10
  export declare function openManagedBitcoindClient(options: Omit<InternalManagedBitcoindOptions, "chain" | "startHeight">): Promise<ManagedBitcoindClient>;
3
11
  export declare function openManagedBitcoindClientInternal(options: InternalManagedBitcoindOptions): Promise<ManagedBitcoindClient>;
@@ -2,13 +2,46 @@ import { loadBundledGenesisParameters } from "@cogcoin/indexer";
2
2
  import { resolveDefaultBitcoindDataDirForTesting } from "../../app-paths.js";
3
3
  import { openClient } from "../../client.js";
4
4
  import { AssumeUtxoBootstrapController, DEFAULT_SNAPSHOT_METADATA, resolveBootstrapPathsForTesting, } from "../bootstrap.js";
5
- import { attachOrStartIndexerDaemon } from "../indexer-daemon.js";
5
+ import { attachOrStartIndexerDaemon, stopIndexerDaemonService, } from "../indexer-daemon.js";
6
6
  import { createRpcClient } from "../node.js";
7
7
  import { assertCogcoinProcessingStartHeight, resolveCogcoinProcessingStartHeight, } from "../processing-start-height.js";
8
8
  import { ManagedProgressController } from "../progress.js";
9
9
  import { attachOrStartManagedBitcoindService, } from "../service.js";
10
10
  import { DefaultManagedBitcoindClient } from "./managed-client.js";
11
11
  const DEFAULT_SYNC_DEBOUNCE_MS = 250;
12
+ function isRecoverableIndexerDaemonPauseError(error) {
13
+ if (!(error instanceof Error)) {
14
+ return false;
15
+ }
16
+ if (error.message === "indexer_daemon_request_timeout"
17
+ || error.message === "indexer_daemon_connection_closed"
18
+ || error.message === "indexer_daemon_protocol_error") {
19
+ return true;
20
+ }
21
+ if ("code" in error) {
22
+ const code = error.code;
23
+ return code === "ENOENT" || code === "ECONNREFUSED" || code === "ECONNRESET";
24
+ }
25
+ return false;
26
+ }
27
+ export async function pauseIndexerDaemonForForegroundClientForTesting(options) {
28
+ try {
29
+ await options.daemon.pauseBackgroundFollow();
30
+ return options.daemon;
31
+ }
32
+ catch (error) {
33
+ await options.daemon.close().catch(() => undefined);
34
+ if (!isRecoverableIndexerDaemonPauseError(error)) {
35
+ throw error;
36
+ }
37
+ await (options.stopDaemon ?? stopIndexerDaemonService)({
38
+ dataDir: options.dataDir,
39
+ walletRootId: options.walletRootId,
40
+ shutdownTimeoutMs: options.shutdownTimeoutMs,
41
+ });
42
+ return null;
43
+ }
44
+ }
12
45
  async function createManagedBitcoindClient(options) {
13
46
  const genesisParameters = options.genesisParameters ?? await loadBundledGenesisParameters();
14
47
  assertCogcoinProcessingStartHeight({
@@ -51,14 +84,18 @@ async function createManagedBitcoindClient(options) {
51
84
  snapshotInterval: options.snapshotInterval,
52
85
  });
53
86
  const indexerDaemon = options.databasePath
54
- ? await attachOrStartIndexerDaemon({
87
+ ? await pauseIndexerDaemonForForegroundClientForTesting({
88
+ daemon: await attachOrStartIndexerDaemon({
89
+ dataDir,
90
+ databasePath: options.databasePath,
91
+ walletRootId: options.walletRootId,
92
+ startupTimeoutMs: options.startupTimeoutMs,
93
+ }),
55
94
  dataDir,
56
- databasePath: options.databasePath,
57
- walletRootId: options.walletRootId,
58
- startupTimeoutMs: options.startupTimeoutMs,
95
+ walletRootId,
96
+ shutdownTimeoutMs: options.shutdownTimeoutMs,
59
97
  })
60
98
  : null;
61
- await indexerDaemon?.pauseBackgroundFollow();
62
99
  // The persistent service may already exist from a non-processing attach path
63
100
  // that used startHeight 0. Cogcoin replay still begins at the requested
64
101
  // processing boundary for this managed client.
@@ -292,7 +292,10 @@ export class DefaultManagedBitcoindClient {
292
292
  async #ensureBootstrapReady(signal) {
293
293
  await this.#node.validate();
294
294
  const indexedTipBeforeBootstrap = await this.#client.getTip();
295
- await this.#bootstrap.ensureReady(indexedTipBeforeBootstrap, this.#node.expectedChain, { signal });
295
+ await this.#bootstrap.ensureReady(indexedTipBeforeBootstrap, this.#node.expectedChain, {
296
+ signal,
297
+ resumeDisplayMode: this.#following ? "follow" : "sync",
298
+ });
296
299
  }
297
300
  async #restartManagedNodeWithRange(readyRange, abortSignal) {
298
301
  if (abortSignal.aborted) {
@@ -372,19 +375,25 @@ export class DefaultManagedBitcoindClient {
372
375
  }
373
376
  }
374
377
  async #resumeIndexerBackgroundFollow() {
375
- if (this.#indexerDaemon === null) {
378
+ if (this.#indexerDaemon === null && this.#reattachIndexerDaemon === null) {
376
379
  return;
377
380
  }
378
- try {
379
- await this.#indexerDaemon.resumeBackgroundFollow();
380
- return;
381
- }
382
- catch (error) {
383
- if (this.#reattachIndexerDaemon === null) {
384
- throw error;
381
+ if (this.#indexerDaemon !== null) {
382
+ try {
383
+ await this.#indexerDaemon.resumeBackgroundFollow();
384
+ return;
385
385
  }
386
+ catch (error) {
387
+ if (this.#reattachIndexerDaemon === null) {
388
+ throw error;
389
+ }
390
+ }
391
+ }
392
+ const reattachIndexerDaemon = this.#reattachIndexerDaemon;
393
+ if (reattachIndexerDaemon === null) {
394
+ return;
386
395
  }
387
- const replacementDaemon = await this.#reattachIndexerDaemon();
396
+ const replacementDaemon = await reattachIndexerDaemon();
388
397
  this.#indexerDaemon = replacementDaemon;
389
398
  await replacementDaemon?.resumeBackgroundFollow();
390
399
  }
@@ -4,6 +4,7 @@ import { normalizeRpcBlock } from "../normalize.js";
4
4
  import { MANAGED_RPC_RETRY_MESSAGE, consumeManagedRpcRetryDelayMs, createManagedRpcRetryState, describeManagedRpcRetryError, isRetryableManagedRpcError, resetManagedRpcRetryState, } from "../retryable-rpc.js";
5
5
  import { estimateEtaSeconds } from "./rate-tracker.js";
6
6
  const DEFAULT_SYNC_CATCH_UP_POLL_MS = 2_000;
7
+ const BITCOIN_SYNC_PHASE_DEBOUNCE_MS = DEFAULT_SYNC_CATCH_UP_POLL_MS * 3;
7
8
  function createAbortError(signal) {
8
9
  const reason = signal?.reason;
9
10
  if (reason instanceof Error) {
@@ -53,6 +54,22 @@ async function setBitcoinSyncProgress(dependencies, info, targetHeightCap) {
53
54
  : "Reading blocks from the managed Bitcoin node.",
54
55
  });
55
56
  }
57
+ function resolveIndexedHeightForReplayWindow(tip, startHeight) {
58
+ return tip?.height ?? (startHeight - 1);
59
+ }
60
+ function hasPendingCogcoinReplay(tip, startHeight, bestHeight) {
61
+ if (bestHeight < startHeight) {
62
+ return false;
63
+ }
64
+ return resolveIndexedHeightForReplayWindow(tip, startHeight) < bestHeight;
65
+ }
66
+ function shouldPreserveCogcoinSyncPhase(dependencies) {
67
+ const status = dependencies.progress.getStatusSnapshot();
68
+ // Keep the TTY on Cogcoin replay through brief idle gaps so it does not
69
+ // bounce back to Bitcoin sync between closely spaced replay passes.
70
+ return status.bootstrapPhase === "cogcoin_sync"
71
+ && Date.now() - status.bootstrapProgress.updatedAt < BITCOIN_SYNC_PHASE_DEBOUNCE_MS;
72
+ }
56
73
  async function setRetryingProgress(dependencies, error) {
57
74
  const status = dependencies.progress.getStatusSnapshot();
58
75
  const { phase: _phase, updatedAt: _updatedAt, ...progress } = status.bootstrapProgress;
@@ -152,6 +169,7 @@ export async function syncToTip(dependencies) {
152
169
  await runRpc(() => dependencies.bootstrap.ensureReady(indexedTipBeforeBootstrap, dependencies.node.expectedChain, {
153
170
  signal: dependencies.abortSignal,
154
171
  retryState,
172
+ resumeDisplayMode: dependencies.isFollowing() ? "follow" : "sync",
155
173
  }));
156
174
  if (dependencies.node.expectedChain === "main"
157
175
  && dependencies.node.getblockArchiveEndHeight !== null) {
@@ -175,7 +193,11 @@ export async function syncToTip(dependencies) {
175
193
  const cappedBestHeight = dependencies.targetHeightCap === null || dependencies.targetHeightCap === undefined
176
194
  ? startInfo.blocks
177
195
  : Math.min(startInfo.blocks, dependencies.targetHeightCap);
178
- await setBitcoinSyncProgress(dependencies, startInfo, dependencies.targetHeightCap ?? null);
196
+ const tipBeforePass = await dependencies.client.getTip();
197
+ if (!hasPendingCogcoinReplay(tipBeforePass, dependencies.startHeight, cappedBestHeight)
198
+ && !shouldPreserveCogcoinSyncPhase(dependencies)) {
199
+ await setBitcoinSyncProgress(dependencies, startInfo, dependencies.targetHeightCap ?? null);
200
+ }
179
201
  const pass = await syncAgainstBestHeight(dependencies, cappedBestHeight, runRpc);
180
202
  aggregate.appliedBlocks += pass.appliedBlocks;
181
203
  aggregate.rewoundBlocks += pass.rewoundBlocks;
@@ -193,8 +215,15 @@ export async function syncToTip(dependencies) {
193
215
  aggregate.endingHeight = finalTip?.height ?? null;
194
216
  aggregate.bestHeight = endBestHeight;
195
217
  aggregate.bestHashHex = endInfo.bestblockhash;
196
- if ((dependencies.targetHeightCap !== null && dependencies.targetHeightCap !== undefined && caughtUpCogcoin)
197
- || (endInfo.blocks === endInfo.headers && caughtUpCogcoin)) {
218
+ const reachedTargetHeightCap = dependencies.targetHeightCap !== null
219
+ && dependencies.targetHeightCap !== undefined
220
+ && endBestHeight >= dependencies.targetHeightCap;
221
+ if (reachedTargetHeightCap && caughtUpCogcoin) {
222
+ return aggregate;
223
+ }
224
+ if (dependencies.targetHeightCap === null
225
+ && endInfo.blocks === endInfo.headers
226
+ && caughtUpCogcoin) {
198
227
  if (dependencies.isFollowing()) {
199
228
  dependencies.progress.replaceFollowBlockTimes(await runRpc(() => dependencies.loadVisibleFollowBlockTimes(finalTip)));
200
229
  }
@@ -209,10 +238,12 @@ export async function syncToTip(dependencies) {
209
238
  });
210
239
  return aggregate;
211
240
  }
212
- await setBitcoinSyncProgress(dependencies, endInfo, dependencies.targetHeightCap ?? null);
213
241
  if (endBestHeight >= dependencies.startHeight && finalTip?.height !== endBestHeight) {
214
242
  continue;
215
243
  }
244
+ if (!shouldPreserveCogcoinSyncPhase(dependencies)) {
245
+ await setBitcoinSyncProgress(dependencies, endInfo, dependencies.targetHeightCap ?? null);
246
+ }
216
247
  await sleep(DEFAULT_SYNC_CATCH_UP_POLL_MS, dependencies.abortSignal);
217
248
  }
218
249
  }
@@ -144,7 +144,7 @@ export function resolveStatusFieldText(progress, snapshotHeight, now = 0) {
144
144
  case "follow_tip":
145
145
  return `Waiting for next block to be mined${animateStatusEllipsis(now)}`;
146
146
  case "complete":
147
- return `Syncing Cogcoin Blocks${animateStatusEllipsis(now)}`;
147
+ return `Sync complete${animateStatusEllipsis(now)}`;
148
148
  case "error":
149
149
  return progress.lastError === null
150
150
  ? progress.message
@@ -1,5 +1,6 @@
1
1
  export { openManagedBitcoindClientInternal } from "./client.js";
2
2
  export { DefaultManagedBitcoindClient } from "./client/managed-client.js";
3
+ export { pauseIndexerDaemonForForegroundClientForTesting } from "./client/factory.js";
3
4
  export { attachOrStartIndexerDaemon, readIndexerDaemonStatusForTesting, stopIndexerDaemonService, shutdownIndexerDaemonForTesting, } from "./indexer-daemon.js";
4
5
  export { normalizeRpcBlock } from "./normalize.js";
5
6
  export { BitcoinRpcClient } from "./rpc.js";
@@ -1,5 +1,6 @@
1
1
  export { openManagedBitcoindClientInternal } from "./client.js";
2
2
  export { DefaultManagedBitcoindClient } from "./client/managed-client.js";
3
+ export { pauseIndexerDaemonForForegroundClientForTesting } from "./client/factory.js";
3
4
  export { attachOrStartIndexerDaemon, readIndexerDaemonStatusForTesting, stopIndexerDaemonService, shutdownIndexerDaemonForTesting, } from "./indexer-daemon.js";
4
5
  export { normalizeRpcBlock } from "./normalize.js";
5
6
  export { BitcoinRpcClient } from "./rpc.js";
@@ -1,22 +1,38 @@
1
1
  import { dirname } from "node:path";
2
+ import { FileLockBusyError, acquireFileLock } from "../../wallet/fs/lock.js";
2
3
  import { resolveWalletRootIdFromLocalArtifacts } from "../../wallet/root-resolution.js";
3
4
  import { usesTtyProgress, writeLine } from "../io.js";
4
- import { classifyCliError } from "../output.js";
5
+ import { classifyCliError, formatCliTextError } from "../output.js";
5
6
  import { createStopSignalWatcher } from "../signals.js";
6
7
  export async function runFollowCommand(parsed, context) {
7
8
  const dbPath = parsed.dbPath ?? context.resolveDefaultClientDatabasePath();
8
9
  const dataDir = parsed.dataDir ?? context.resolveDefaultBitcoindDataDir();
9
- const walletRoot = await resolveWalletRootIdFromLocalArtifacts({
10
- paths: context.resolveWalletRuntimePaths(),
11
- provider: context.walletSecretProvider,
12
- loadRawWalletStateEnvelope: context.loadRawWalletStateEnvelope,
13
- loadUnlockSession: context.loadUnlockSession,
14
- loadWalletExplicitLock: context.loadWalletExplicitLock,
15
- });
16
- await context.ensureDirectory(dirname(dbPath));
17
- const store = await context.openSqliteStore({ filename: dbPath });
10
+ const runtimePaths = context.resolveWalletRuntimePaths();
11
+ let controlLock = null;
12
+ let store = null;
18
13
  let storeOwned = true;
19
14
  try {
15
+ const walletRoot = await resolveWalletRootIdFromLocalArtifacts({
16
+ paths: runtimePaths,
17
+ provider: context.walletSecretProvider,
18
+ loadRawWalletStateEnvelope: context.loadRawWalletStateEnvelope,
19
+ loadUnlockSession: context.loadUnlockSession,
20
+ loadWalletExplicitLock: context.loadWalletExplicitLock,
21
+ });
22
+ try {
23
+ controlLock = await acquireFileLock(runtimePaths.walletControlLockPath, {
24
+ purpose: "managed-follow",
25
+ walletRootId: walletRoot.walletRootId,
26
+ });
27
+ }
28
+ catch (error) {
29
+ if (error instanceof FileLockBusyError) {
30
+ throw new Error("wallet_control_lock_busy");
31
+ }
32
+ throw error;
33
+ }
34
+ await context.ensureDirectory(dirname(dbPath));
35
+ store = await context.openSqliteStore({ filename: dbPath });
20
36
  const client = await context.openManagedBitcoindClient({
21
37
  store,
22
38
  databasePath: dbPath,
@@ -25,7 +41,7 @@ export async function runFollowCommand(parsed, context) {
25
41
  progressOutput: parsed.progressOutput,
26
42
  });
27
43
  storeOwned = false;
28
- const stopWatcher = createStopSignalWatcher(context.signalSource, context.stderr, client, context.forceExit);
44
+ const stopWatcher = createStopSignalWatcher(context.signalSource, context.stderr, client, context.forceExit, [runtimePaths.walletControlLockPath]);
29
45
  try {
30
46
  await client.startFollowingTip();
31
47
  if (!usesTtyProgress(parsed.progressOutput, context.stderr)) {
@@ -43,10 +59,27 @@ export async function runFollowCommand(parsed, context) {
43
59
  }
44
60
  }
45
61
  catch (error) {
46
- writeLine(context.stderr, `follow failed: ${error instanceof Error ? error.message : String(error)}`);
47
- if (storeOwned) {
62
+ const classified = classifyCliError(error);
63
+ if (classified.errorCode === "wallet_control_lock_busy") {
64
+ const formatted = formatCliTextError(error);
65
+ if (formatted !== null) {
66
+ for (const line of formatted) {
67
+ writeLine(context.stderr, line);
68
+ }
69
+ }
70
+ else {
71
+ writeLine(context.stderr, classified.message);
72
+ }
73
+ }
74
+ else {
75
+ writeLine(context.stderr, `follow failed: ${error instanceof Error ? error.message : String(error)}`);
76
+ }
77
+ if (storeOwned && store !== null) {
48
78
  await store.close().catch(() => undefined);
49
79
  }
50
- return classifyCliError(error).exitCode;
80
+ return classified.exitCode;
81
+ }
82
+ finally {
83
+ await controlLock?.release().catch(() => undefined);
51
84
  }
52
85
  }
@@ -1,23 +1,39 @@
1
1
  import { dirname } from "node:path";
2
2
  import { formatManagedSyncErrorMessage } from "../../bitcoind/errors.js";
3
+ import { FileLockBusyError, acquireFileLock } from "../../wallet/fs/lock.js";
3
4
  import { resolveWalletRootIdFromLocalArtifacts } from "../../wallet/root-resolution.js";
4
5
  import { writeLine } from "../io.js";
5
- import { classifyCliError } from "../output.js";
6
+ import { classifyCliError, formatCliTextError } from "../output.js";
6
7
  import { createStopSignalWatcher, waitForCompletionOrStop } from "../signals.js";
7
8
  export async function runSyncCommand(parsed, context) {
8
9
  const dbPath = parsed.dbPath ?? context.resolveDefaultClientDatabasePath();
9
10
  const dataDir = parsed.dataDir ?? context.resolveDefaultBitcoindDataDir();
10
- const walletRoot = await resolveWalletRootIdFromLocalArtifacts({
11
- paths: context.resolveWalletRuntimePaths(),
12
- provider: context.walletSecretProvider,
13
- loadRawWalletStateEnvelope: context.loadRawWalletStateEnvelope,
14
- loadUnlockSession: context.loadUnlockSession,
15
- loadWalletExplicitLock: context.loadWalletExplicitLock,
16
- });
17
- await context.ensureDirectory(dirname(dbPath));
18
- const store = await context.openSqliteStore({ filename: dbPath });
11
+ const runtimePaths = context.resolveWalletRuntimePaths();
12
+ let controlLock = null;
13
+ let store = null;
19
14
  let storeOwned = true;
20
15
  try {
16
+ const walletRoot = await resolveWalletRootIdFromLocalArtifacts({
17
+ paths: runtimePaths,
18
+ provider: context.walletSecretProvider,
19
+ loadRawWalletStateEnvelope: context.loadRawWalletStateEnvelope,
20
+ loadUnlockSession: context.loadUnlockSession,
21
+ loadWalletExplicitLock: context.loadWalletExplicitLock,
22
+ });
23
+ try {
24
+ controlLock = await acquireFileLock(runtimePaths.walletControlLockPath, {
25
+ purpose: "managed-sync",
26
+ walletRootId: walletRoot.walletRootId,
27
+ });
28
+ }
29
+ catch (error) {
30
+ if (error instanceof FileLockBusyError) {
31
+ throw new Error("wallet_control_lock_busy");
32
+ }
33
+ throw error;
34
+ }
35
+ await context.ensureDirectory(dirname(dbPath));
36
+ store = await context.openSqliteStore({ filename: dbPath });
21
37
  const client = await context.openManagedBitcoindClient({
22
38
  store,
23
39
  databasePath: dbPath,
@@ -26,7 +42,7 @@ export async function runSyncCommand(parsed, context) {
26
42
  progressOutput: parsed.progressOutput,
27
43
  });
28
44
  storeOwned = false;
29
- const stopWatcher = createStopSignalWatcher(context.signalSource, context.stderr, client, context.forceExit);
45
+ const stopWatcher = createStopSignalWatcher(context.signalSource, context.stderr, client, context.forceExit, [runtimePaths.walletControlLockPath]);
30
46
  try {
31
47
  const syncOutcome = await waitForCompletionOrStop(client.syncToTip(), stopWatcher);
32
48
  if (syncOutcome.kind === "stopped") {
@@ -51,11 +67,28 @@ export async function runSyncCommand(parsed, context) {
51
67
  }
52
68
  }
53
69
  catch (error) {
54
- const message = formatManagedSyncErrorMessage(error instanceof Error ? error.message : String(error));
55
- writeLine(context.stderr, `sync failed: ${message}`);
56
- if (storeOwned) {
70
+ const classified = classifyCliError(error);
71
+ if (classified.errorCode === "wallet_control_lock_busy") {
72
+ const formatted = formatCliTextError(error);
73
+ if (formatted !== null) {
74
+ for (const line of formatted) {
75
+ writeLine(context.stderr, line);
76
+ }
77
+ }
78
+ else {
79
+ writeLine(context.stderr, classified.message);
80
+ }
81
+ }
82
+ else {
83
+ const message = formatManagedSyncErrorMessage(error instanceof Error ? error.message : String(error));
84
+ writeLine(context.stderr, `sync failed: ${message}`);
85
+ }
86
+ if (storeOwned && store !== null) {
57
87
  await store.close().catch(() => undefined);
58
88
  }
59
- return classifyCliError(error).exitCode;
89
+ return classified.exitCode;
90
+ }
91
+ finally {
92
+ await controlLock?.release().catch(() => undefined);
60
93
  }
61
94
  }
@@ -2,7 +2,7 @@ import { mkdir, readFile } from "node:fs/promises";
2
2
  import { attachOrStartIndexerDaemon, probeIndexerDaemon, readObservedIndexerDaemonStatus, stopIndexerDaemonService, } from "../bitcoind/indexer-daemon.js";
3
3
  import { createRpcClient } from "../bitcoind/node.js";
4
4
  import { attachOrStartManagedBitcoindService, probeManagedBitcoindService, stopManagedBitcoindService, } from "../bitcoind/service.js";
5
- import { resolveDefaultBitcoindDataDirForTesting, resolveDefaultClientDatabasePathForTesting, } from "../app-paths.js";
5
+ import { resolveDefaultBitcoindDataDirForTesting, resolveDefaultClientDatabasePathForTesting, resolveDefaultUpdateCheckStatePathForTesting, } from "../app-paths.js";
6
6
  import { openManagedBitcoindClient } from "../bitcoind/index.js";
7
7
  import { inspectPassiveClientStatus } from "../passive-status.js";
8
8
  import { openSqliteStore } from "../sqlite/index.js";
@@ -26,10 +26,13 @@ export function createDefaultContext(overrides = {}) {
26
26
  stdout: overrides.stdout ?? process.stdout,
27
27
  stderr: overrides.stderr ?? process.stderr,
28
28
  stdin: overrides.stdin ?? process.stdin,
29
+ env: overrides.env ?? process.env,
30
+ now: overrides.now ?? (() => Date.now()),
29
31
  signalSource: overrides.signalSource ?? process,
30
32
  forceExit: overrides.forceExit ?? ((code) => {
31
33
  process.exit(code);
32
34
  }),
35
+ fetchImpl: overrides.fetchImpl ?? fetch,
33
36
  openSqliteStore: overrides.openSqliteStore ?? openSqliteStore,
34
37
  openManagedBitcoindClient: overrides.openManagedBitcoindClient ?? openManagedBitcoindClient,
35
38
  inspectPassiveClientStatus: overrides.inspectPassiveClientStatus ?? inspectPassiveClientStatus,
@@ -96,6 +99,7 @@ export function createDefaultContext(overrides = {}) {
96
99
  loadWalletExplicitLock: overrides.loadWalletExplicitLock ?? loadWalletExplicitLock,
97
100
  resolveDefaultBitcoindDataDir: overrides.resolveDefaultBitcoindDataDir ?? resolveDefaultBitcoindDataDirForTesting,
98
101
  resolveDefaultClientDatabasePath: overrides.resolveDefaultClientDatabasePath ?? resolveDefaultClientDatabasePathForTesting,
102
+ resolveUpdateCheckStatePath: overrides.resolveUpdateCheckStatePath ?? resolveDefaultUpdateCheckStatePathForTesting,
99
103
  resolveWalletRuntimePaths: overrides.resolveWalletRuntimePaths ?? ((seedName) => resolveWalletRuntimePathsForTesting({ seedName })),
100
104
  };
101
105
  }
@@ -187,6 +187,10 @@ export function classifyCliError(error) {
187
187
  return { exitCode: 5, errorCode: message, message };
188
188
  }
189
189
  function isBlockedError(message) {
190
+ if (message === "wallet_control_lock_busy"
191
+ || message.startsWith("file_lock_busy_")) {
192
+ return true;
193
+ }
190
194
  if (message === "wallet_locked"
191
195
  || message === "wallet_uninitialized"
192
196
  || message === "local-state-corrupt"
@@ -239,6 +243,13 @@ export function createCliErrorPresentation(errorCode, fallbackMessage) {
239
243
  next: "Run `cogcoin unlock --for 15m` and retry.",
240
244
  };
241
245
  }
246
+ if (errorCode === "wallet_control_lock_busy") {
247
+ return {
248
+ what: "Another Cogcoin command is already controlling this wallet.",
249
+ why: "Commands that sync, follow, or mutate the local index take an exclusive wallet control lock so they do not write the same sqlite store concurrently.",
250
+ next: "Wait for the other Cogcoin command to finish, or stop it cleanly before retrying.",
251
+ };
252
+ }
242
253
  if (errorCode === "reset_wallet_choice_invalid") {
243
254
  return {
244
255
  what: "Wallet reset choice is invalid.",
@@ -12,6 +12,7 @@ import { runSyncCommand } from "./commands/sync.js";
12
12
  import { runWalletAdminCommand } from "./commands/wallet-admin.js";
13
13
  import { runWalletMutationCommand } from "./commands/wallet-mutation.js";
14
14
  import { runWalletReadCommand } from "./commands/wallet-read.js";
15
+ import { maybeNotifyAboutCliUpdate } from "./update-notifier.js";
15
16
  import { findWalletSeedRecord, loadWalletSeedIndex } from "../wallet/state/seed-index.js";
16
17
  function commandUsesExistingWalletSeed(parsed) {
17
18
  return parsed.seedName !== null
@@ -48,6 +49,7 @@ export async function runCli(argv, contextOverrides = {}) {
48
49
  writeLine(context.stdout, HELP_TEXT.trimEnd());
49
50
  return parsed.help ? 0 : 2;
50
51
  }
52
+ await maybeNotifyAboutCliUpdate(parsed, context);
51
53
  try {
52
54
  if (commandUsesExistingWalletSeed(parsed)) {
53
55
  const mainPaths = context.resolveWalletRuntimePaths("main");
@@ -1,4 +1,4 @@
1
1
  import type { InterruptibleOutcome, ManagedClientLike, SignalSource, StopSignalWatcher, WritableLike } from "./types.js";
2
- export declare function createStopSignalWatcher(signalSource: SignalSource, stderr: WritableLike, client: ManagedClientLike, forceExit: (code: number) => never | void): StopSignalWatcher;
2
+ export declare function createStopSignalWatcher(signalSource: SignalSource, stderr: WritableLike, client: ManagedClientLike, forceExit: (code: number) => never | void, lockPaths?: readonly string[]): StopSignalWatcher;
3
3
  export declare function createOwnedLockCleanupSignalWatcher(signalSource: SignalSource, forceExit: (code: number) => never | void, lockPaths: readonly string[]): StopSignalWatcher;
4
4
  export declare function waitForCompletionOrStop<T>(promise: Promise<T>, stopWatcher: StopSignalWatcher): Promise<InterruptibleOutcome<T>>;
@@ -1,6 +1,6 @@
1
1
  import { writeLine } from "./io.js";
2
2
  import { clearLockIfOwnedByCurrentProcess } from "../wallet/fs/lock.js";
3
- export function createStopSignalWatcher(signalSource, stderr, client, forceExit) {
3
+ export function createStopSignalWatcher(signalSource, stderr, client, forceExit, lockPaths = []) {
4
4
  let closing = false;
5
5
  let resolved = false;
6
6
  let onSignal = () => { };
@@ -17,16 +17,23 @@ export function createStopSignalWatcher(signalSource, stderr, client, forceExit)
17
17
  cleanup();
18
18
  resolve(code);
19
19
  };
20
+ const releaseOwnedLocks = async () => {
21
+ await Promise.allSettled([...new Set(lockPaths)].map(async (lockPath) => {
22
+ await clearLockIfOwnedByCurrentProcess(lockPath);
23
+ }));
24
+ };
20
25
  const onFirstSignal = () => {
21
26
  closing = true;
22
27
  writeLine(stderr, "Detaching from managed Cogcoin client and resuming background indexer follow...");
23
- void client.close().then(() => {
28
+ void client.close().then(async () => {
29
+ await releaseOwnedLocks();
24
30
  if (resolved) {
25
31
  return;
26
32
  }
27
33
  writeLine(stderr, "Detached cleanly; background indexer follow resumed.");
28
34
  settle(0);
29
- }, () => {
35
+ }, async () => {
36
+ await releaseOwnedLocks();
30
37
  if (resolved) {
31
38
  return;
32
39
  }
@@ -40,7 +47,13 @@ export function createStopSignalWatcher(signalSource, stderr, client, forceExit)
40
47
  return;
41
48
  }
42
49
  settle(130);
43
- forceExit(130);
50
+ if (lockPaths.length === 0) {
51
+ forceExit(130);
52
+ return;
53
+ }
54
+ void releaseOwnedLocks().finally(() => {
55
+ forceExit(130);
56
+ });
44
57
  };
45
58
  });
46
59
  signalSource.on("SIGINT", onSignal);
@@ -87,8 +87,11 @@ export interface CliRunnerContext {
87
87
  stdout?: WritableLike;
88
88
  stderr?: WritableLike;
89
89
  stdin?: ReadableLike;
90
+ env?: NodeJS.ProcessEnv;
91
+ now?: () => number;
90
92
  signalSource?: SignalSource;
91
93
  forceExit?: (code: number) => never | void;
94
+ fetchImpl?: typeof fetch;
92
95
  openSqliteStore?: typeof openSqliteStore;
93
96
  openManagedBitcoindClient?: (options: {
94
97
  store: ClientStoreAdapter;
@@ -163,6 +166,7 @@ export interface CliRunnerContext {
163
166
  readPackageVersion?: () => Promise<string>;
164
167
  resolveDefaultBitcoindDataDir?: () => string;
165
168
  resolveDefaultClientDatabasePath?: () => string;
169
+ resolveUpdateCheckStatePath?: () => string;
166
170
  resolveWalletRuntimePaths?: (seedName?: string | null) => WalletRuntimePaths;
167
171
  }
168
172
  export interface StopSignalWatcher {