@cogcoin/client 0.5.5 → 0.5.6

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 (53) hide show
  1. package/README.md +1 -1
  2. package/dist/bitcoind/bootstrap/chainstate.d.ts +2 -1
  3. package/dist/bitcoind/bootstrap/chainstate.js +4 -1
  4. package/dist/bitcoind/bootstrap/controller.d.ts +4 -1
  5. package/dist/bitcoind/bootstrap/controller.js +42 -5
  6. package/dist/bitcoind/bootstrap/headers.d.ts +12 -0
  7. package/dist/bitcoind/bootstrap/headers.js +95 -10
  8. package/dist/bitcoind/client/factory.js +11 -2
  9. package/dist/bitcoind/client/managed-client.d.ts +1 -1
  10. package/dist/bitcoind/client/managed-client.js +2 -2
  11. package/dist/bitcoind/client/sync-engine.js +48 -13
  12. package/dist/bitcoind/indexer-daemon.d.ts +7 -0
  13. package/dist/bitcoind/indexer-daemon.js +31 -22
  14. package/dist/bitcoind/processing-start-height.d.ts +7 -0
  15. package/dist/bitcoind/processing-start-height.js +9 -0
  16. package/dist/bitcoind/progress/controller.js +1 -0
  17. package/dist/bitcoind/progress/formatting.js +4 -1
  18. package/dist/bitcoind/retryable-rpc.d.ts +11 -0
  19. package/dist/bitcoind/retryable-rpc.js +30 -0
  20. package/dist/bitcoind/service.d.ts +16 -1
  21. package/dist/bitcoind/service.js +228 -115
  22. package/dist/bitcoind/testing.d.ts +1 -1
  23. package/dist/bitcoind/testing.js +1 -1
  24. package/dist/bitcoind/types.d.ts +1 -0
  25. package/dist/cli/commands/follow.js +9 -0
  26. package/dist/cli/commands/service-runtime.js +150 -134
  27. package/dist/cli/commands/sync.js +9 -0
  28. package/dist/cli/commands/wallet-admin.js +77 -21
  29. package/dist/cli/context.js +4 -2
  30. package/dist/cli/mutation-json.js +2 -0
  31. package/dist/cli/output.js +2 -0
  32. package/dist/cli/parse.d.ts +1 -1
  33. package/dist/cli/parse.js +6 -0
  34. package/dist/cli/preview-json.js +2 -0
  35. package/dist/cli/runner.js +1 -0
  36. package/dist/cli/types.d.ts +6 -3
  37. package/dist/cli/types.js +1 -1
  38. package/dist/cli/wallet-format.js +134 -14
  39. package/dist/wallet/lifecycle.d.ts +6 -0
  40. package/dist/wallet/lifecycle.js +109 -37
  41. package/dist/wallet/read/context.js +10 -4
  42. package/dist/wallet/reset.d.ts +61 -2
  43. package/dist/wallet/reset.js +208 -63
  44. package/dist/wallet/root-resolution.d.ts +20 -0
  45. package/dist/wallet/root-resolution.js +37 -0
  46. package/dist/wallet/runtime.d.ts +1 -0
  47. package/dist/wallet/runtime.js +1 -0
  48. package/dist/wallet/state/crypto.d.ts +3 -0
  49. package/dist/wallet/state/crypto.js +3 -0
  50. package/dist/wallet/state/storage.d.ts +7 -1
  51. package/dist/wallet/state/storage.js +39 -0
  52. package/dist/wallet/types.d.ts +1 -0
  53. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # `@cogcoin/client`
2
2
 
3
- `@cogcoin/client@0.5.5` 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.6` 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
 
@@ -1,4 +1,5 @@
1
1
  import type { BitcoinRpcClient } from "../rpc.js";
2
- import type { SnapshotMetadata } from "../types.js";
2
+ import type { RpcChainState, SnapshotMetadata } from "../types.js";
3
3
  import type { BootstrapPersistentState } from "./types.js";
4
4
  export declare function isSnapshotAlreadyLoaded(rpc: Pick<BitcoinRpcClient, "getChainStates">, snapshot: SnapshotMetadata, state: BootstrapPersistentState): Promise<boolean>;
5
+ export declare function findLoadedSnapshotChainState(rpc: Pick<BitcoinRpcClient, "getChainStates">, snapshot: SnapshotMetadata, state: BootstrapPersistentState): Promise<RpcChainState | null>;
@@ -8,6 +8,9 @@ function chainStateMatches(chainState, snapshot, baseHeight, tipHashHex) {
8
8
  return chainState.blocks === snapshot.height && chainState.validated === false;
9
9
  }
10
10
  export async function isSnapshotAlreadyLoaded(rpc, snapshot, state) {
11
+ return (await findLoadedSnapshotChainState(rpc, snapshot, state)) !== null;
12
+ }
13
+ export async function findLoadedSnapshotChainState(rpc, snapshot, state) {
11
14
  const chainStates = await rpc.getChainStates();
12
- return chainStates.chainstates.some((chainState) => chainStateMatches(chainState, snapshot, state.baseHeight, state.tipHashHex));
15
+ return chainStates.chainstates.find((chainState) => chainStateMatches(chainState, snapshot, state.baseHeight, state.tipHashHex)) ?? null;
13
16
  }
@@ -1,7 +1,8 @@
1
1
  import type { ClientTip } from "../../types.js";
2
2
  import { ManagedProgressController } from "../progress.js";
3
3
  import { BitcoinRpcClient } from "../rpc.js";
4
- import type { BootstrapPhase, SnapshotMetadata } from "../types.js";
4
+ import { type ManagedRpcRetryState } from "../retryable-rpc.js";
5
+ import type { BootstrapPhase, SnapshotMetadata, SnapshotChunkManifest } from "../types.js";
5
6
  export declare class AssumeUtxoBootstrapController {
6
7
  #private;
7
8
  constructor(options: {
@@ -9,12 +10,14 @@ export declare class AssumeUtxoBootstrapController {
9
10
  dataDir: string;
10
11
  progress: ManagedProgressController;
11
12
  snapshot?: SnapshotMetadata;
13
+ manifest?: SnapshotChunkManifest;
12
14
  fetchImpl?: typeof fetch;
13
15
  });
14
16
  get quoteStatePath(): string;
15
17
  get snapshot(): SnapshotMetadata;
16
18
  ensureReady(indexedTip: ClientTip | null, expectedChain: "main" | "regtest", options?: {
17
19
  signal?: AbortSignal;
20
+ retryState?: ManagedRpcRetryState;
18
21
  }): Promise<void>;
19
22
  getStateForTesting(): Promise<{
20
23
  metadataVersion: number;
@@ -1,3 +1,4 @@
1
+ import { join } from "node:path";
1
2
  import { createBootstrapProgressForTesting, ManagedProgressController } from "../progress.js";
2
3
  import { BitcoinRpcClient } from "../rpc.js";
3
4
  import { DEFAULT_SNAPSHOT_METADATA } from "./constants.js";
@@ -5,7 +6,8 @@ import { downloadSnapshotFileForTesting } from "./download.js";
5
6
  import { waitForHeaders } from "./headers.js";
6
7
  import { resolveBootstrapPaths } from "./paths.js";
7
8
  import { loadBootstrapStateRecord, saveBootstrapState } from "./state.js";
8
- import { isSnapshotAlreadyLoaded } from "./chainstate.js";
9
+ import { findLoadedSnapshotChainState, isSnapshotAlreadyLoaded } from "./chainstate.js";
10
+ import { describeManagedRpcRetryError, isRetryableManagedRpcError, } from "../retryable-rpc.js";
9
11
  async function loadSnapshotIntoNode(rpc, snapshotPath) {
10
12
  return rpc.loadTxOutSet(snapshotPath);
11
13
  }
@@ -14,13 +16,17 @@ export class AssumeUtxoBootstrapController {
14
16
  #paths;
15
17
  #progress;
16
18
  #snapshot;
19
+ #debugLogPath;
20
+ #manifest;
17
21
  #fetchImpl;
18
22
  #stateRecordPromise = null;
19
23
  constructor(options) {
20
24
  this.#rpc = options.rpc;
21
25
  this.#progress = options.progress;
22
26
  this.#snapshot = options.snapshot ?? DEFAULT_SNAPSHOT_METADATA;
27
+ this.#manifest = options.manifest;
23
28
  this.#paths = resolveBootstrapPaths(options.dataDir, this.#snapshot);
29
+ this.#debugLogPath = join(options.dataDir, "debug.log");
24
30
  this.#fetchImpl = options.fetchImpl;
25
31
  }
26
32
  get quoteStatePath() {
@@ -47,18 +53,23 @@ export class AssumeUtxoBootstrapController {
47
53
  }
48
54
  const { state, snapshotIdentity } = await this.#loadStateRecord();
49
55
  if (state.loadTxOutSetComplete && await isSnapshotAlreadyLoaded(this.#rpc, this.#snapshot, state)) {
56
+ if (state.lastError !== null) {
57
+ state.lastError = null;
58
+ await saveBootstrapState(this.#paths, state);
59
+ }
50
60
  await this.#progress.setPhase("bitcoin_sync", {
51
61
  blocks: state.baseHeight,
52
62
  targetHeight: state.baseHeight ?? this.#snapshot.height,
53
63
  baseHeight: state.baseHeight,
54
64
  tipHashHex: state.tipHashHex,
55
65
  message: "Using the previously loaded assumeutxo chainstate.",
56
- lastError: state.lastError,
66
+ lastError: null,
57
67
  });
58
68
  return;
59
69
  }
60
70
  await downloadSnapshotFileForTesting({
61
71
  fetchImpl: this.#fetchImpl,
72
+ manifest: this.#manifest,
62
73
  metadata: this.#snapshot,
63
74
  paths: this.#paths,
64
75
  progress: this.#progress,
@@ -69,22 +80,48 @@ export class AssumeUtxoBootstrapController {
69
80
  if (!await isSnapshotAlreadyLoaded(this.#rpc, this.#snapshot, state)) {
70
81
  await waitForHeaders(this.#rpc, this.#snapshot, this.#progress, {
71
82
  signal: options.signal,
83
+ retryState: options.retryState,
84
+ debugLogPath: this.#debugLogPath,
72
85
  });
73
86
  await this.#progress.setPhase("load_snapshot", {
74
87
  downloadedBytes: this.#snapshot.sizeBytes,
75
88
  totalBytes: this.#snapshot.sizeBytes,
76
89
  percent: 100,
77
90
  message: "Loading the UTXO snapshot into bitcoind.",
91
+ lastError: null,
78
92
  });
79
- const loadResult = await loadSnapshotIntoNode(this.#rpc, this.#paths.snapshotPath);
93
+ let loadResult;
94
+ try {
95
+ loadResult = await loadSnapshotIntoNode(this.#rpc, this.#paths.snapshotPath);
96
+ }
97
+ catch (error) {
98
+ if (!isRetryableManagedRpcError(error)) {
99
+ throw error;
100
+ }
101
+ state.lastError = describeManagedRpcRetryError(error);
102
+ await saveBootstrapState(this.#paths, state);
103
+ const loadedChainState = await findLoadedSnapshotChainState(this.#rpc, this.#snapshot, state);
104
+ if (loadedChainState === null) {
105
+ throw error;
106
+ }
107
+ loadResult = {
108
+ base_height: loadedChainState.blocks ?? this.#snapshot.height,
109
+ coins_loaded: 0,
110
+ tip_hash: loadedChainState.snapshot_blockhash ?? state.tipHashHex ?? "",
111
+ };
112
+ }
80
113
  state.loadTxOutSetComplete = true;
81
114
  state.baseHeight = loadResult.base_height;
82
- state.tipHashHex = loadResult.tip_hash;
115
+ state.tipHashHex = loadResult.tip_hash === "" ? state.tipHashHex : loadResult.tip_hash;
83
116
  state.phase = "bitcoin_sync";
84
117
  state.lastError = null;
85
118
  await saveBootstrapState(this.#paths, state);
86
119
  }
87
120
  const info = await this.#rpc.getBlockchainInfo();
121
+ if (state.lastError !== null) {
122
+ state.lastError = null;
123
+ await saveBootstrapState(this.#paths, state);
124
+ }
88
125
  await this.#progress.setPhase("bitcoin_sync", {
89
126
  blocks: info.blocks,
90
127
  headers: info.headers,
@@ -92,7 +129,7 @@ export class AssumeUtxoBootstrapController {
92
129
  baseHeight: state.baseHeight,
93
130
  tipHashHex: state.tipHashHex,
94
131
  message: "Bitcoin Core is syncing blocks after assumeutxo bootstrap.",
95
- lastError: state.lastError,
132
+ lastError: null,
96
133
  });
97
134
  }
98
135
  async getStateForTesting() {
@@ -1,3 +1,4 @@
1
+ import { type ManagedRpcRetryState } from "../retryable-rpc.js";
1
2
  import type { BitcoinRpcClient } from "../rpc.js";
2
3
  import type { ManagedProgressController } from "../progress.js";
3
4
  import type { SnapshotMetadata } from "../types.js";
@@ -6,10 +7,21 @@ export declare function waitForHeaders(rpc: Pick<BitcoinRpcClient, "getBlockchai
6
7
  sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
7
8
  noPeerTimeoutMs?: number;
8
9
  signal?: AbortSignal;
10
+ retryState?: ManagedRpcRetryState;
11
+ debugLogPath?: string;
12
+ readDebugLogProgress?: (debugLogPath: string) => Promise<{
13
+ height: number;
14
+ message: string;
15
+ } | null>;
9
16
  }): Promise<void>;
10
17
  export declare function waitForHeadersForTesting(rpc: Pick<BitcoinRpcClient, "getBlockchainInfo" | "getNetworkInfo">, snapshot: SnapshotMetadata | undefined, progress: Pick<ManagedProgressController, "setPhase">, options?: {
11
18
  now?: () => number;
12
19
  sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
13
20
  noPeerTimeoutMs?: number;
14
21
  signal?: AbortSignal;
22
+ debugLogPath?: string;
23
+ readDebugLogProgress?: (debugLogPath: string) => Promise<{
24
+ height: number;
25
+ message: string;
26
+ } | null>;
15
27
  }): Promise<void>;
@@ -1,5 +1,9 @@
1
+ import { open } from "node:fs/promises";
1
2
  import { formatManagedSyncErrorMessage } from "../errors.js";
3
+ import { MANAGED_RPC_RETRY_MESSAGE, consumeManagedRpcRetryDelayMs, createManagedRpcRetryState, describeManagedRpcRetryError, resetManagedRpcRetryState, isRetryableManagedRpcError, } from "../retryable-rpc.js";
2
4
  import { DEFAULT_SNAPSHOT_METADATA, HEADER_NO_PEER_TIMEOUT_MS, HEADER_POLL_MS, } from "./constants.js";
5
+ const DEBUG_LOG_TAIL_BYTES = 64 * 1024;
6
+ const HEADER_SYNC_DEBUG_LINE_PATTERN = /Pre-synchronizing blockheaders,\s*height:\s*([\d,]+)\s*\(~(\d+(?:\.\d+)?)%\)/u;
3
7
  function createAbortError(signal) {
4
8
  const reason = signal?.reason;
5
9
  if (reason instanceof Error) {
@@ -33,7 +37,52 @@ function resolvePeerCount(networkInfo) {
33
37
  ? networkInfo.connections
34
38
  : (networkInfo.connections_in ?? 0) + (networkInfo.connections_out ?? 0);
35
39
  }
36
- function resolveHeaderWaitMessage(headers, peerCount, networkActive) {
40
+ async function readDebugLogTail(filePath, maxBytes = DEBUG_LOG_TAIL_BYTES) {
41
+ let handle = null;
42
+ try {
43
+ handle = await open(filePath, "r");
44
+ const stats = await handle.stat();
45
+ const bytesToRead = Math.min(maxBytes, Math.max(0, stats.size));
46
+ if (bytesToRead === 0) {
47
+ return null;
48
+ }
49
+ const buffer = Buffer.alloc(bytesToRead);
50
+ await handle.read(buffer, 0, bytesToRead, stats.size - bytesToRead);
51
+ return buffer.toString("utf8");
52
+ }
53
+ catch (error) {
54
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
55
+ return null;
56
+ }
57
+ return null;
58
+ }
59
+ finally {
60
+ await handle?.close().catch(() => { });
61
+ }
62
+ }
63
+ async function readHeaderSyncProgressFromDebugLog(debugLogPath) {
64
+ const tail = await readDebugLogTail(debugLogPath);
65
+ if (tail === null) {
66
+ return null;
67
+ }
68
+ const lines = tail.split(/\r?\n/u).reverse();
69
+ for (const line of lines) {
70
+ const match = HEADER_SYNC_DEBUG_LINE_PATTERN.exec(line);
71
+ if (match === null) {
72
+ continue;
73
+ }
74
+ const height = Number(match[1].replaceAll(",", ""));
75
+ if (!Number.isFinite(height) || height < 0) {
76
+ return null;
77
+ }
78
+ return {
79
+ height,
80
+ message: `Pre-synchronizing blockheaders, height: ${height.toLocaleString()} (~${match[2]}%)`,
81
+ };
82
+ }
83
+ return null;
84
+ }
85
+ function resolveHeaderWaitMessage(headers, peerCount, networkActive, rpcHeaders, headerSyncMessage) {
37
86
  if (!networkActive) {
38
87
  return "Bitcoin networking is inactive for the managed node.";
39
88
  }
@@ -43,33 +92,69 @@ function resolveHeaderWaitMessage(headers, peerCount, networkActive) {
43
92
  if (peerCount === 0) {
44
93
  return `Waiting for peers to continue header sync (${headers.toLocaleString()} headers, 0 peers).`;
45
94
  }
46
- return "Waiting for Bitcoin headers to reach the snapshot height.";
95
+ if (rpcHeaders > 0) {
96
+ return "Waiting for Bitcoin headers to reach the snapshot height.";
97
+ }
98
+ return headerSyncMessage ?? "Pre-synchronizing blockheaders.";
47
99
  }
48
100
  export async function waitForHeaders(rpc, snapshot, progress, options = {}) {
49
101
  const now = options.now ?? Date.now;
50
102
  const sleepImpl = options.sleep ?? sleep;
51
103
  const noPeerTimeoutMs = options.noPeerTimeoutMs ?? HEADER_NO_PEER_TIMEOUT_MS;
52
104
  const { signal } = options;
105
+ const retryState = options.retryState ?? createManagedRpcRetryState();
106
+ const readDebugLogProgress = options.readDebugLogProgress ?? readHeaderSyncProgressFromDebugLog;
53
107
  let noPeerSince = null;
108
+ let lastBlocks = 0;
109
+ let lastHeaders = 0;
54
110
  while (true) {
55
111
  throwIfAborted(signal);
56
- const [info, networkInfo] = await Promise.all([
57
- rpc.getBlockchainInfo(),
58
- rpc.getNetworkInfo(),
59
- ]);
112
+ let info;
113
+ let networkInfo;
114
+ try {
115
+ [info, networkInfo] = await Promise.all([
116
+ rpc.getBlockchainInfo(),
117
+ rpc.getNetworkInfo(),
118
+ ]);
119
+ resetManagedRpcRetryState(retryState);
120
+ }
121
+ catch (error) {
122
+ if (!isRetryableManagedRpcError(error)) {
123
+ throw error;
124
+ }
125
+ await progress.setPhase("wait_headers_for_snapshot", {
126
+ headers: lastHeaders,
127
+ targetHeight: snapshot.height,
128
+ blocks: lastBlocks,
129
+ percent: (Math.min(lastHeaders, snapshot.height) / snapshot.height) * 100,
130
+ lastError: describeManagedRpcRetryError(error),
131
+ message: MANAGED_RPC_RETRY_MESSAGE,
132
+ });
133
+ await sleepImpl(consumeManagedRpcRetryDelayMs(retryState), signal);
134
+ continue;
135
+ }
136
+ lastBlocks = info.blocks;
137
+ const debugLogProgress = info.headers === 0 && options.debugLogPath !== undefined
138
+ ? await readDebugLogProgress(options.debugLogPath)
139
+ : null;
140
+ const observedHeaders = info.headers > 0
141
+ ? info.headers
142
+ : Math.max(info.headers, debugLogProgress?.height ?? 0);
143
+ lastHeaders = observedHeaders;
60
144
  const peerCount = resolvePeerCount(networkInfo);
61
- const message = resolveHeaderWaitMessage(info.headers, peerCount, networkInfo.networkactive);
145
+ const message = resolveHeaderWaitMessage(observedHeaders, peerCount, networkInfo.networkactive, info.headers, debugLogProgress?.message ?? null);
62
146
  await progress.setPhase("wait_headers_for_snapshot", {
63
- headers: info.headers,
147
+ headers: observedHeaders,
64
148
  targetHeight: snapshot.height,
65
149
  blocks: info.blocks,
66
- percent: (Math.min(info.headers, snapshot.height) / snapshot.height) * 100,
150
+ percent: (Math.min(observedHeaders, snapshot.height) / snapshot.height) * 100,
151
+ lastError: null,
67
152
  message,
68
153
  });
69
154
  if (info.headers >= snapshot.height) {
70
155
  return;
71
156
  }
72
- if (info.headers === 0 && peerCount === 0) {
157
+ if (observedHeaders === 0 && peerCount === 0) {
73
158
  noPeerSince ??= now();
74
159
  if (now() - noPeerSince >= noPeerTimeoutMs) {
75
160
  throw new Error(formatManagedSyncErrorMessage("bitcoind_no_peers_for_header_sync_check_internet_or_firewall"));
@@ -4,12 +4,18 @@ import { openClient } from "../../client.js";
4
4
  import { AssumeUtxoBootstrapController, DEFAULT_SNAPSHOT_METADATA, resolveBootstrapPathsForTesting } from "../bootstrap.js";
5
5
  import { attachOrStartIndexerDaemon } from "../indexer-daemon.js";
6
6
  import { createRpcClient } from "../node.js";
7
+ import { assertCogcoinProcessingStartHeight, resolveCogcoinProcessingStartHeight, } from "../processing-start-height.js";
7
8
  import { ManagedProgressController } from "../progress.js";
8
9
  import { attachOrStartManagedBitcoindService } from "../service.js";
9
10
  import { DefaultManagedBitcoindClient } from "./managed-client.js";
10
11
  const DEFAULT_SYNC_DEBOUNCE_MS = 250;
11
12
  async function createManagedBitcoindClient(options) {
12
13
  const genesisParameters = options.genesisParameters ?? await loadBundledGenesisParameters();
14
+ assertCogcoinProcessingStartHeight({
15
+ chain: options.chain,
16
+ startHeight: options.startHeight,
17
+ genesisParameters,
18
+ });
13
19
  const dataDir = options.dataDir ?? resolveDefaultBitcoindDataDirForTesting();
14
20
  const node = await attachOrStartManagedBitcoindService({
15
21
  ...options,
@@ -41,7 +47,10 @@ async function createManagedBitcoindClient(options) {
41
47
  startupTimeoutMs: options.startupTimeoutMs,
42
48
  })
43
49
  : null;
44
- return new DefaultManagedBitcoindClient(client, options.store, node, rpc, progress, bootstrap, indexerDaemon, options.syncDebounceMs ?? DEFAULT_SYNC_DEBOUNCE_MS);
50
+ // The persistent service may already exist from a non-processing attach path
51
+ // that used startHeight 0. Cogcoin replay still begins at the requested
52
+ // processing boundary for this managed client.
53
+ return new DefaultManagedBitcoindClient(client, options.store, node, rpc, progress, bootstrap, indexerDaemon, options.startHeight, options.syncDebounceMs ?? DEFAULT_SYNC_DEBOUNCE_MS);
45
54
  }
46
55
  export async function openManagedBitcoindClient(options) {
47
56
  const genesisParameters = options.genesisParameters ?? await loadBundledGenesisParameters();
@@ -49,7 +58,7 @@ export async function openManagedBitcoindClient(options) {
49
58
  ...options,
50
59
  genesisParameters,
51
60
  chain: "main",
52
- startHeight: genesisParameters.genesisBlock,
61
+ startHeight: resolveCogcoinProcessingStartHeight(genesisParameters),
53
62
  });
54
63
  }
55
64
  export async function openManagedBitcoindClientInternal(options) {
@@ -7,7 +7,7 @@ import type { BitcoinRpcClient } from "../rpc.js";
7
7
  import type { ManagedBitcoindClient, ManagedBitcoindNodeHandle, ManagedBitcoindStatus, SyncResult } from "../types.js";
8
8
  export declare class DefaultManagedBitcoindClient implements ManagedBitcoindClient {
9
9
  #private;
10
- constructor(client: Client, store: ClientStoreAdapter, node: ManagedBitcoindNodeHandle, rpc: BitcoinRpcClient, progress: ManagedProgressController, bootstrap: AssumeUtxoBootstrapController, indexerDaemon: IndexerDaemonClient | null, syncDebounceMs: number);
10
+ constructor(client: Client, store: ClientStoreAdapter, node: ManagedBitcoindNodeHandle, rpc: BitcoinRpcClient, progress: ManagedProgressController, bootstrap: AssumeUtxoBootstrapController, indexerDaemon: IndexerDaemonClient | null, startHeight: number, syncDebounceMs: number);
11
11
  getTip(): Promise<import("../../types.js").ClientTip | null>;
12
12
  getState(): Promise<import("@cogcoin/indexer/types").IndexerState>;
13
13
  applyBlock(block: BitcoinBlock): Promise<import("../../types.js").ApplyBlockResult>;
@@ -22,7 +22,7 @@ export class DefaultManagedBitcoindClient {
22
22
  #syncPromise = Promise.resolve(createInitialSyncResult());
23
23
  #debounceTimer = null;
24
24
  #syncAbortControllers = new Set();
25
- constructor(client, store, node, rpc, progress, bootstrap, indexerDaemon, syncDebounceMs) {
25
+ constructor(client, store, node, rpc, progress, bootstrap, indexerDaemon, startHeight, syncDebounceMs) {
26
26
  this.#client = client;
27
27
  this.#store = store;
28
28
  this.#node = node;
@@ -30,7 +30,7 @@ export class DefaultManagedBitcoindClient {
30
30
  this.#progress = progress;
31
31
  this.#bootstrap = bootstrap;
32
32
  this.#indexerDaemon = indexerDaemon;
33
- this.#startHeight = node.startHeight;
33
+ this.#startHeight = startHeight;
34
34
  this.#syncDebounceMs = syncDebounceMs;
35
35
  }
36
36
  async getTip() {
@@ -1,5 +1,6 @@
1
1
  import { formatManagedSyncErrorMessage } from "../errors.js";
2
2
  import { normalizeRpcBlock } from "../normalize.js";
3
+ import { MANAGED_RPC_RETRY_MESSAGE, consumeManagedRpcRetryDelayMs, createManagedRpcRetryState, describeManagedRpcRetryError, isRetryableManagedRpcError, resetManagedRpcRetryState, } from "../retryable-rpc.js";
3
4
  import { estimateEtaSeconds } from "./rate-tracker.js";
4
5
  const DEFAULT_SYNC_CATCH_UP_POLL_MS = 2_000;
5
6
  function createAbortError(signal) {
@@ -44,12 +45,42 @@ async function setBitcoinSyncProgress(dependencies, info) {
44
45
  headers: info.headers,
45
46
  targetHeight: info.headers,
46
47
  etaSeconds,
48
+ lastError: null,
47
49
  message: dependencies.node.expectedChain === "main"
48
50
  ? "Bitcoin Core is syncing blocks after assumeutxo bootstrap."
49
51
  : "Reading blocks from the managed Bitcoin node.",
50
52
  });
51
53
  }
52
- async function findCommonAncestor(dependencies, tip, bestHeight) {
54
+ async function setRetryingProgress(dependencies, error) {
55
+ const status = dependencies.progress.getStatusSnapshot();
56
+ const { phase: _phase, updatedAt: _updatedAt, ...progress } = status.bootstrapProgress;
57
+ await dependencies.progress.setPhase(status.bootstrapPhase, {
58
+ ...progress,
59
+ lastError: describeManagedRpcRetryError(error),
60
+ message: MANAGED_RPC_RETRY_MESSAGE,
61
+ });
62
+ }
63
+ async function runWithManagedRpcRetry(dependencies, retryState, operation) {
64
+ while (true) {
65
+ throwIfAborted(dependencies.abortSignal);
66
+ try {
67
+ const result = await operation();
68
+ resetManagedRpcRetryState(retryState);
69
+ return result;
70
+ }
71
+ catch (error) {
72
+ if (isAbortError(error, dependencies.abortSignal)) {
73
+ throw createAbortError(dependencies.abortSignal);
74
+ }
75
+ if (!isRetryableManagedRpcError(error)) {
76
+ throw error;
77
+ }
78
+ await setRetryingProgress(dependencies, error);
79
+ await sleep(consumeManagedRpcRetryDelayMs(retryState), dependencies.abortSignal);
80
+ }
81
+ }
82
+ }
83
+ async function findCommonAncestor(dependencies, tip, bestHeight, runRpc) {
53
84
  const startHeight = Math.min(tip.height, bestHeight);
54
85
  for (let height = startHeight; height >= dependencies.startHeight; height -= 1) {
55
86
  const localHashHex = height === tip.height
@@ -58,14 +89,14 @@ async function findCommonAncestor(dependencies, tip, bestHeight) {
58
89
  if (localHashHex === null) {
59
90
  continue;
60
91
  }
61
- const chainHashHex = await dependencies.rpc.getBlockHash(height);
92
+ const chainHashHex = await runRpc(() => dependencies.rpc.getBlockHash(height));
62
93
  if (chainHashHex === localHashHex) {
63
94
  return height;
64
95
  }
65
96
  }
66
97
  return dependencies.startHeight - 1;
67
98
  }
68
- async function syncAgainstBestHeight(dependencies, bestHeight) {
99
+ async function syncAgainstBestHeight(dependencies, bestHeight, runRpc) {
69
100
  if (bestHeight < dependencies.startHeight) {
70
101
  return {
71
102
  appliedBlocks: 0,
@@ -77,7 +108,7 @@ async function syncAgainstBestHeight(dependencies, bestHeight) {
77
108
  let rewoundBlocks = 0;
78
109
  let commonAncestorHeight = null;
79
110
  if (startTip !== null) {
80
- const rewindTarget = await findCommonAncestor(dependencies, startTip, bestHeight);
111
+ const rewindTarget = await findCommonAncestor(dependencies, startTip, bestHeight, runRpc);
81
112
  if (rewindTarget < startTip.height) {
82
113
  commonAncestorHeight = rewindTarget < dependencies.startHeight ? null : rewindTarget;
83
114
  await dependencies.client.rewindToHeight(rewindTarget);
@@ -93,8 +124,8 @@ async function syncAgainstBestHeight(dependencies, bestHeight) {
93
124
  await dependencies.progress.setCogcoinSync(nextHeight - 1, bestHeight, estimateEtaSeconds(dependencies.cogcoinRateTracker, nextHeight - 1, bestHeight));
94
125
  }
95
126
  for (let height = nextHeight; height <= bestHeight; height += 1) {
96
- const blockHashHex = await dependencies.rpc.getBlockHash(height);
97
- const rpcBlock = await dependencies.rpc.getBlock(blockHashHex);
127
+ const blockHashHex = await runRpc(() => dependencies.rpc.getBlockHash(height));
128
+ const rpcBlock = await runRpc(() => dependencies.rpc.getBlock(blockHashHex));
98
129
  const normalizedBlock = normalizeRpcBlock(rpcBlock);
99
130
  await dependencies.client.applyBlock(normalizedBlock);
100
131
  if (typeof rpcBlock.time === "number") {
@@ -111,12 +142,15 @@ async function syncAgainstBestHeight(dependencies, bestHeight) {
111
142
  }
112
143
  export async function syncToTip(dependencies) {
113
144
  try {
145
+ const retryState = createManagedRpcRetryState();
146
+ const runRpc = (operation) => runWithManagedRpcRetry(dependencies, retryState, operation);
114
147
  throwIfAborted(dependencies.abortSignal);
115
- await dependencies.node.validate();
148
+ await runRpc(() => dependencies.node.validate());
116
149
  const indexedTipBeforeBootstrap = await dependencies.client.getTip();
117
- await dependencies.bootstrap.ensureReady(indexedTipBeforeBootstrap, dependencies.node.expectedChain, {
150
+ await runRpc(() => dependencies.bootstrap.ensureReady(indexedTipBeforeBootstrap, dependencies.node.expectedChain, {
118
151
  signal: dependencies.abortSignal,
119
- });
152
+ retryState,
153
+ }));
120
154
  const startTip = await dependencies.client.getTip();
121
155
  const aggregate = {
122
156
  appliedBlocks: 0,
@@ -129,9 +163,9 @@ export async function syncToTip(dependencies) {
129
163
  };
130
164
  while (true) {
131
165
  throwIfAborted(dependencies.abortSignal);
132
- const startInfo = await dependencies.rpc.getBlockchainInfo();
166
+ const startInfo = await runRpc(() => dependencies.rpc.getBlockchainInfo());
133
167
  await setBitcoinSyncProgress(dependencies, startInfo);
134
- const pass = await syncAgainstBestHeight(dependencies, startInfo.blocks);
168
+ const pass = await syncAgainstBestHeight(dependencies, startInfo.blocks, runRpc);
135
169
  aggregate.appliedBlocks += pass.appliedBlocks;
136
170
  aggregate.rewoundBlocks += pass.rewoundBlocks;
137
171
  if (pass.commonAncestorHeight !== null) {
@@ -140,19 +174,20 @@ export async function syncToTip(dependencies) {
140
174
  : Math.min(aggregate.commonAncestorHeight, pass.commonAncestorHeight);
141
175
  }
142
176
  const finalTip = await dependencies.client.getTip();
143
- const endInfo = await dependencies.rpc.getBlockchainInfo();
177
+ const endInfo = await runRpc(() => dependencies.rpc.getBlockchainInfo());
144
178
  const caughtUpCogcoin = endInfo.blocks < dependencies.startHeight || finalTip?.height === endInfo.blocks;
145
179
  aggregate.endingHeight = finalTip?.height ?? null;
146
180
  aggregate.bestHeight = endInfo.blocks;
147
181
  aggregate.bestHashHex = endInfo.bestblockhash;
148
182
  if (endInfo.blocks === endInfo.headers && caughtUpCogcoin) {
149
183
  if (dependencies.isFollowing()) {
150
- dependencies.progress.replaceFollowBlockTimes(await dependencies.loadVisibleFollowBlockTimes(finalTip));
184
+ dependencies.progress.replaceFollowBlockTimes(await runRpc(() => dependencies.loadVisibleFollowBlockTimes(finalTip)));
151
185
  }
152
186
  await dependencies.progress.setPhase(dependencies.isFollowing() ? "follow_tip" : "complete", {
153
187
  blocks: endInfo.blocks,
154
188
  headers: endInfo.headers,
155
189
  targetHeight: endInfo.headers,
190
+ lastError: null,
156
191
  message: dependencies.isFollowing()
157
192
  ? "Following the live Bitcoin tip."
158
193
  : "Managed sync fully caught up to the live tip.",
@@ -1,4 +1,5 @@
1
1
  import { type ManagedIndexerDaemonObservedStatus, type ManagedIndexerDaemonStatus } from "./types.js";
2
+ import { resolveManagedServicePaths } from "./service-paths.js";
2
3
  interface DaemonRequest {
3
4
  id: string;
4
5
  method: "GetStatus" | "OpenSnapshot" | "ReadSnapshot" | "CloseSnapshot";
@@ -81,6 +82,12 @@ export interface CoherentIndexerSnapshotLease {
81
82
  payload: IndexerSnapshotPayload;
82
83
  status: ManagedIndexerDaemonStatus;
83
84
  }
85
+ export declare function stopIndexerDaemonServiceWithLockHeld(options: {
86
+ dataDir: string;
87
+ walletRootId?: string;
88
+ shutdownTimeoutMs?: number;
89
+ paths?: ReturnType<typeof resolveManagedServicePaths>;
90
+ }): Promise<IndexerDaemonStopResult>;
84
91
  export declare function probeIndexerDaemon(options: {
85
92
  dataDir: string;
86
93
  walletRootId?: string;
@@ -53,6 +53,33 @@ async function clearIndexerDaemonRuntimeArtifacts(paths) {
53
53
  await rm(paths.indexerDaemonStatusPath, { force: true }).catch(() => undefined);
54
54
  await rm(paths.indexerDaemonSocketPath, { force: true }).catch(() => undefined);
55
55
  }
56
+ export async function stopIndexerDaemonServiceWithLockHeld(options) {
57
+ const walletRootId = options.walletRootId ?? UNINITIALIZED_WALLET_ROOT_ID;
58
+ const paths = options.paths ?? resolveManagedServicePaths(options.dataDir, walletRootId);
59
+ const status = await readJsonFile(paths.indexerDaemonStatusPath);
60
+ const processId = status?.processId ?? null;
61
+ if (status === null || processId === null || !await isProcessAlive(processId)) {
62
+ await clearIndexerDaemonRuntimeArtifacts(paths);
63
+ return {
64
+ status: "not-running",
65
+ walletRootId,
66
+ };
67
+ }
68
+ try {
69
+ process.kill(processId, "SIGTERM");
70
+ }
71
+ catch (error) {
72
+ if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) {
73
+ throw error;
74
+ }
75
+ }
76
+ await waitForProcessExit(processId, options.shutdownTimeoutMs ?? 5_000, "indexer_daemon_stop_timeout");
77
+ await clearIndexerDaemonRuntimeArtifacts(paths);
78
+ return {
79
+ status: "stopped",
80
+ walletRootId,
81
+ };
82
+ }
56
83
  function createIndexerDaemonClient(socketPath) {
57
84
  async function sendRequest(request) {
58
85
  return new Promise((resolve, reject) => {
@@ -380,29 +407,11 @@ export async function stopIndexerDaemonService(options) {
380
407
  dataDir: options.dataDir,
381
408
  });
382
409
  try {
383
- const status = await readJsonFile(paths.indexerDaemonStatusPath);
384
- const processId = status?.processId ?? null;
385
- if (status === null || processId === null || !await isProcessAlive(processId)) {
386
- await clearIndexerDaemonRuntimeArtifacts(paths);
387
- return {
388
- status: "not-running",
389
- walletRootId,
390
- };
391
- }
392
- try {
393
- process.kill(processId, "SIGTERM");
394
- }
395
- catch (error) {
396
- if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) {
397
- throw error;
398
- }
399
- }
400
- await waitForProcessExit(processId, options.shutdownTimeoutMs ?? 5_000, "indexer_daemon_stop_timeout");
401
- await clearIndexerDaemonRuntimeArtifacts(paths);
402
- return {
403
- status: "stopped",
410
+ return await stopIndexerDaemonServiceWithLockHeld({
411
+ ...options,
404
412
  walletRootId,
405
- };
413
+ paths,
414
+ });
406
415
  }
407
416
  finally {
408
417
  await lock.release();
@@ -0,0 +1,7 @@
1
+ import type { GenesisParameters } from "@cogcoin/indexer/types";
2
+ export declare function resolveCogcoinProcessingStartHeight(genesisParameters: GenesisParameters): number;
3
+ export declare function assertCogcoinProcessingStartHeight(options: {
4
+ chain: "main" | "regtest";
5
+ startHeight: number;
6
+ genesisParameters: GenesisParameters;
7
+ }): void;
@@ -0,0 +1,9 @@
1
+ export function resolveCogcoinProcessingStartHeight(genesisParameters) {
2
+ return genesisParameters.genesisBlock;
3
+ }
4
+ export function assertCogcoinProcessingStartHeight(options) {
5
+ const processingStartHeight = resolveCogcoinProcessingStartHeight(options.genesisParameters);
6
+ if (options.chain === "main" && options.startHeight < processingStartHeight) {
7
+ throw new Error("cogcoin_processing_start_height_before_genesis");
8
+ }
9
+ }