@cogcoin/client 0.5.5 → 0.5.7

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 (91) hide show
  1. package/README.md +11 -2
  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/getblock-archive.d.ts +39 -0
  7. package/dist/bitcoind/bootstrap/getblock-archive.js +548 -0
  8. package/dist/bitcoind/bootstrap/headers.d.ts +12 -0
  9. package/dist/bitcoind/bootstrap/headers.js +95 -10
  10. package/dist/bitcoind/bootstrap.d.ts +1 -0
  11. package/dist/bitcoind/bootstrap.js +1 -0
  12. package/dist/bitcoind/client/factory.js +91 -28
  13. package/dist/bitcoind/client/managed-client.d.ts +1 -1
  14. package/dist/bitcoind/client/managed-client.js +4 -3
  15. package/dist/bitcoind/client/sync-engine.js +55 -13
  16. package/dist/bitcoind/errors.js +18 -0
  17. package/dist/bitcoind/indexer-daemon-main.js +78 -0
  18. package/dist/bitcoind/indexer-daemon.d.ts +10 -1
  19. package/dist/bitcoind/indexer-daemon.js +44 -28
  20. package/dist/bitcoind/node.js +2 -0
  21. package/dist/bitcoind/processing-start-height.d.ts +7 -0
  22. package/dist/bitcoind/processing-start-height.js +9 -0
  23. package/dist/bitcoind/progress/constants.d.ts +1 -0
  24. package/dist/bitcoind/progress/constants.js +1 -0
  25. package/dist/bitcoind/progress/controller.d.ts +22 -0
  26. package/dist/bitcoind/progress/controller.js +49 -23
  27. package/dist/bitcoind/progress/formatting.js +29 -1
  28. package/dist/bitcoind/progress/render-policy.d.ts +35 -0
  29. package/dist/bitcoind/progress/render-policy.js +81 -0
  30. package/dist/bitcoind/retryable-rpc.d.ts +11 -0
  31. package/dist/bitcoind/retryable-rpc.js +30 -0
  32. package/dist/bitcoind/service-paths.js +2 -6
  33. package/dist/bitcoind/service.d.ts +21 -2
  34. package/dist/bitcoind/service.js +274 -122
  35. package/dist/bitcoind/testing.d.ts +2 -2
  36. package/dist/bitcoind/testing.js +2 -2
  37. package/dist/bitcoind/types.d.ts +36 -1
  38. package/dist/cli/commands/follow.js +11 -0
  39. package/dist/cli/commands/getblock-archive-restart.d.ts +5 -0
  40. package/dist/cli/commands/getblock-archive-restart.js +15 -0
  41. package/dist/cli/commands/mining-admin.js +4 -0
  42. package/dist/cli/commands/mining-read.js +8 -5
  43. package/dist/cli/commands/mining-runtime.js +4 -0
  44. package/dist/cli/commands/service-runtime.js +150 -134
  45. package/dist/cli/commands/status.js +2 -0
  46. package/dist/cli/commands/sync.js +11 -0
  47. package/dist/cli/commands/wallet-admin.js +106 -24
  48. package/dist/cli/commands/wallet-mutation.js +57 -4
  49. package/dist/cli/commands/wallet-read.js +2 -0
  50. package/dist/cli/context.js +8 -4
  51. package/dist/cli/mutation-command-groups.d.ts +2 -1
  52. package/dist/cli/mutation-command-groups.js +5 -0
  53. package/dist/cli/mutation-json.d.ts +18 -2
  54. package/dist/cli/mutation-json.js +49 -0
  55. package/dist/cli/mutation-success.d.ts +1 -0
  56. package/dist/cli/mutation-success.js +2 -2
  57. package/dist/cli/output.js +86 -1
  58. package/dist/cli/parse.d.ts +1 -1
  59. package/dist/cli/parse.js +133 -3
  60. package/dist/cli/preview-json.d.ts +10 -1
  61. package/dist/cli/preview-json.js +32 -0
  62. package/dist/cli/prompt.js +1 -1
  63. package/dist/cli/runner.js +4 -0
  64. package/dist/cli/types.d.ts +15 -5
  65. package/dist/cli/types.js +1 -1
  66. package/dist/cli/wallet-format.js +140 -14
  67. package/dist/wallet/lifecycle.d.ts +21 -1
  68. package/dist/wallet/lifecycle.js +252 -116
  69. package/dist/wallet/mining/visualizer.d.ts +11 -6
  70. package/dist/wallet/mining/visualizer.js +32 -15
  71. package/dist/wallet/read/context.js +10 -4
  72. package/dist/wallet/reset.d.ts +61 -2
  73. package/dist/wallet/reset.js +246 -89
  74. package/dist/wallet/root-resolution.d.ts +20 -0
  75. package/dist/wallet/root-resolution.js +37 -0
  76. package/dist/wallet/runtime.d.ts +13 -1
  77. package/dist/wallet/runtime.js +54 -11
  78. package/dist/wallet/state/crypto.d.ts +3 -0
  79. package/dist/wallet/state/crypto.js +3 -0
  80. package/dist/wallet/state/provider.d.ts +1 -0
  81. package/dist/wallet/state/provider.js +119 -3
  82. package/dist/wallet/state/seed-index.d.ts +43 -0
  83. package/dist/wallet/state/seed-index.js +151 -0
  84. package/dist/wallet/state/storage.d.ts +7 -1
  85. package/dist/wallet/state/storage.js +39 -0
  86. package/dist/wallet/tx/anchor.d.ts +22 -0
  87. package/dist/wallet/tx/anchor.js +215 -8
  88. package/dist/wallet/tx/index.d.ts +1 -1
  89. package/dist/wallet/tx/index.js +1 -1
  90. package/dist/wallet/types.d.ts +1 -0
  91. package/package.json +1 -1
@@ -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"));
@@ -1,6 +1,7 @@
1
1
  export { DEFAULT_SNAPSHOT_METADATA } from "./bootstrap/constants.js";
2
2
  export { AssumeUtxoBootstrapController } from "./bootstrap/controller.js";
3
3
  export { downloadSnapshotFileForTesting } from "./bootstrap/download.js";
4
+ export { prepareLatestGetblockArchive, prepareLatestGetblockArchiveForTesting, resolveGetblockArchivePathsForTesting, resolveReadyGetblockArchiveForTesting, waitForGetblockArchiveImport, waitForGetblockArchiveImportForTesting, } from "./bootstrap/getblock-archive.js";
4
5
  export { waitForHeadersForTesting } from "./bootstrap/headers.js";
5
6
  export { resolveBootstrapPathsForTesting } from "./bootstrap/paths.js";
6
7
  export { loadBootstrapStateForTesting, saveBootstrapStateForTesting, createBootstrapStateForTesting, } from "./bootstrap/state.js";
@@ -1,6 +1,7 @@
1
1
  export { DEFAULT_SNAPSHOT_METADATA } from "./bootstrap/constants.js";
2
2
  export { AssumeUtxoBootstrapController } from "./bootstrap/controller.js";
3
3
  export { downloadSnapshotFileForTesting } from "./bootstrap/download.js";
4
+ export { prepareLatestGetblockArchive, prepareLatestGetblockArchiveForTesting, resolveGetblockArchivePathsForTesting, resolveReadyGetblockArchiveForTesting, waitForGetblockArchiveImport, waitForGetblockArchiveImportForTesting, } from "./bootstrap/getblock-archive.js";
4
5
  export { waitForHeadersForTesting } from "./bootstrap/headers.js";
5
6
  export { resolveBootstrapPathsForTesting } from "./bootstrap/paths.js";
6
7
  export { loadBootstrapStateForTesting, saveBootstrapStateForTesting, createBootstrapStateForTesting, } from "./bootstrap/state.js";
@@ -1,47 +1,110 @@
1
1
  import { loadBundledGenesisParameters } from "@cogcoin/indexer";
2
2
  import { resolveDefaultBitcoindDataDirForTesting } from "../../app-paths.js";
3
3
  import { openClient } from "../../client.js";
4
- import { AssumeUtxoBootstrapController, DEFAULT_SNAPSHOT_METADATA, resolveBootstrapPathsForTesting } from "../bootstrap.js";
4
+ import { AssumeUtxoBootstrapController, DEFAULT_SNAPSHOT_METADATA, prepareLatestGetblockArchiveForTesting, 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
- import { attachOrStartManagedBitcoindService } from "../service.js";
9
+ import { attachOrStartManagedBitcoindService, probeManagedBitcoindService, stopManagedBitcoindService, } 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();
13
- const dataDir = options.dataDir ?? resolveDefaultBitcoindDataDirForTesting();
14
- const node = await attachOrStartManagedBitcoindService({
15
- ...options,
16
- dataDir,
14
+ assertCogcoinProcessingStartHeight({
15
+ chain: options.chain,
16
+ startHeight: options.startHeight,
17
+ genesisParameters,
17
18
  });
18
- const rpc = createRpcClient(node.rpc);
19
+ const dataDir = options.dataDir ?? resolveDefaultBitcoindDataDirForTesting();
19
20
  const progress = new ManagedProgressController({
20
21
  onProgress: options.onProgress,
21
22
  progressOutput: options.progressOutput,
22
- quoteStatePath: resolveBootstrapPathsForTesting(node.dataDir, DEFAULT_SNAPSHOT_METADATA).quoteStatePath,
23
+ quoteStatePath: resolveBootstrapPathsForTesting(dataDir, DEFAULT_SNAPSHOT_METADATA).quoteStatePath,
23
24
  snapshot: DEFAULT_SNAPSHOT_METADATA,
24
25
  });
25
- const bootstrap = new AssumeUtxoBootstrapController({
26
- rpc,
27
- dataDir: node.dataDir,
28
- progress,
29
- snapshot: DEFAULT_SNAPSHOT_METADATA,
30
- });
31
- const client = await openClient({
32
- store: options.store,
33
- genesisParameters,
34
- snapshotInterval: options.snapshotInterval,
35
- });
36
- const indexerDaemon = options.databasePath
37
- ? await attachOrStartIndexerDaemon({
26
+ let progressStarted = false;
27
+ try {
28
+ await progress.start();
29
+ progressStarted = true;
30
+ let getblockArchive = options.chain === "main"
31
+ ? await prepareLatestGetblockArchiveForTesting({
32
+ dataDir,
33
+ progress,
34
+ fetchImpl: options.fetchImpl,
35
+ })
36
+ : null;
37
+ if (options.chain === "main" && getblockArchive !== null) {
38
+ const existingProbe = await probeManagedBitcoindService({
39
+ ...options,
40
+ dataDir,
41
+ });
42
+ if (existingProbe.compatibility === "compatible" && existingProbe.status !== null) {
43
+ const currentArchiveEndHeight = existingProbe.status.getblockArchiveEndHeight ?? null;
44
+ const currentArchiveSha256 = existingProbe.status.getblockArchiveSha256 ?? null;
45
+ const nextArchiveEndHeight = getblockArchive.manifest.endHeight;
46
+ const nextArchiveSha256 = getblockArchive.manifest.artifactSha256;
47
+ const needsRestart = currentArchiveEndHeight !== nextArchiveEndHeight
48
+ || currentArchiveSha256 !== nextArchiveSha256;
49
+ if (needsRestart) {
50
+ const restartApproved = options.confirmGetblockArchiveRestart === undefined
51
+ ? false
52
+ : await options.confirmGetblockArchiveRestart({
53
+ currentArchiveEndHeight,
54
+ nextArchiveEndHeight,
55
+ });
56
+ if (restartApproved) {
57
+ await stopManagedBitcoindService({
58
+ dataDir,
59
+ walletRootId: options.walletRootId,
60
+ shutdownTimeoutMs: options.shutdownTimeoutMs,
61
+ });
62
+ }
63
+ else {
64
+ getblockArchive = null;
65
+ }
66
+ }
67
+ }
68
+ }
69
+ const node = await attachOrStartManagedBitcoindService({
70
+ ...options,
38
71
  dataDir,
39
- databasePath: options.databasePath,
40
- walletRootId: options.walletRootId,
41
- startupTimeoutMs: options.startupTimeoutMs,
42
- })
43
- : null;
44
- return new DefaultManagedBitcoindClient(client, options.store, node, rpc, progress, bootstrap, indexerDaemon, options.syncDebounceMs ?? DEFAULT_SYNC_DEBOUNCE_MS);
72
+ getblockArchivePath: getblockArchive?.artifactPath ?? null,
73
+ getblockArchiveEndHeight: getblockArchive?.manifest.endHeight ?? null,
74
+ getblockArchiveSha256: getblockArchive?.manifest.artifactSha256 ?? null,
75
+ });
76
+ const rpc = createRpcClient(node.rpc);
77
+ const bootstrap = new AssumeUtxoBootstrapController({
78
+ rpc,
79
+ dataDir: node.dataDir,
80
+ progress,
81
+ snapshot: DEFAULT_SNAPSHOT_METADATA,
82
+ });
83
+ const client = await openClient({
84
+ store: options.store,
85
+ genesisParameters,
86
+ snapshotInterval: options.snapshotInterval,
87
+ });
88
+ const indexerDaemon = options.databasePath
89
+ ? await attachOrStartIndexerDaemon({
90
+ dataDir,
91
+ databasePath: options.databasePath,
92
+ walletRootId: options.walletRootId,
93
+ startupTimeoutMs: options.startupTimeoutMs,
94
+ })
95
+ : null;
96
+ await indexerDaemon?.pauseBackgroundFollow();
97
+ // The persistent service may already exist from a non-processing attach path
98
+ // that used startHeight 0. Cogcoin replay still begins at the requested
99
+ // processing boundary for this managed client.
100
+ return new DefaultManagedBitcoindClient(client, options.store, node, rpc, progress, bootstrap, indexerDaemon, options.startHeight, options.syncDebounceMs ?? DEFAULT_SYNC_DEBOUNCE_MS);
101
+ }
102
+ catch (error) {
103
+ if (progressStarted) {
104
+ await progress.close().catch(() => undefined);
105
+ }
106
+ throw error;
107
+ }
45
108
  }
46
109
  export async function openManagedBitcoindClient(options) {
47
110
  const genesisParameters = options.genesisParameters ?? await loadBundledGenesisParameters();
@@ -49,7 +112,7 @@ export async function openManagedBitcoindClient(options) {
49
112
  ...options,
50
113
  genesisParameters,
51
114
  chain: "main",
52
- startHeight: genesisParameters.genesisBlock,
115
+ startHeight: resolveCogcoinProcessingStartHeight(genesisParameters),
53
116
  });
54
117
  }
55
118
  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() {
@@ -176,8 +176,9 @@ export class DefaultManagedBitcoindClient {
176
176
  await this.#syncPromise.catch(() => undefined);
177
177
  await this.#progress.close();
178
178
  await this.#node.stop();
179
- await this.#indexerDaemon?.close();
180
179
  await this.#client.close();
180
+ await this.#indexerDaemon?.resumeBackgroundFollow().catch(() => undefined);
181
+ await this.#indexerDaemon?.close();
181
182
  }
182
183
  async playSyncCompletionScene() {
183
184
  this.#assertOpen();
@@ -1,5 +1,7 @@
1
+ import { waitForGetblockArchiveImport } from "../bootstrap.js";
1
2
  import { formatManagedSyncErrorMessage } from "../errors.js";
2
3
  import { normalizeRpcBlock } from "../normalize.js";
4
+ import { MANAGED_RPC_RETRY_MESSAGE, consumeManagedRpcRetryDelayMs, createManagedRpcRetryState, describeManagedRpcRetryError, isRetryableManagedRpcError, resetManagedRpcRetryState, } from "../retryable-rpc.js";
3
5
  import { estimateEtaSeconds } from "./rate-tracker.js";
4
6
  const DEFAULT_SYNC_CATCH_UP_POLL_MS = 2_000;
5
7
  function createAbortError(signal) {
@@ -44,12 +46,42 @@ async function setBitcoinSyncProgress(dependencies, info) {
44
46
  headers: info.headers,
45
47
  targetHeight: info.headers,
46
48
  etaSeconds,
49
+ lastError: null,
47
50
  message: dependencies.node.expectedChain === "main"
48
51
  ? "Bitcoin Core is syncing blocks after assumeutxo bootstrap."
49
52
  : "Reading blocks from the managed Bitcoin node.",
50
53
  });
51
54
  }
52
- async function findCommonAncestor(dependencies, tip, bestHeight) {
55
+ async function setRetryingProgress(dependencies, error) {
56
+ const status = dependencies.progress.getStatusSnapshot();
57
+ const { phase: _phase, updatedAt: _updatedAt, ...progress } = status.bootstrapProgress;
58
+ await dependencies.progress.setPhase(status.bootstrapPhase, {
59
+ ...progress,
60
+ lastError: describeManagedRpcRetryError(error),
61
+ message: MANAGED_RPC_RETRY_MESSAGE,
62
+ });
63
+ }
64
+ async function runWithManagedRpcRetry(dependencies, retryState, operation) {
65
+ while (true) {
66
+ throwIfAborted(dependencies.abortSignal);
67
+ try {
68
+ const result = await operation();
69
+ resetManagedRpcRetryState(retryState);
70
+ return result;
71
+ }
72
+ catch (error) {
73
+ if (isAbortError(error, dependencies.abortSignal)) {
74
+ throw createAbortError(dependencies.abortSignal);
75
+ }
76
+ if (!isRetryableManagedRpcError(error)) {
77
+ throw error;
78
+ }
79
+ await setRetryingProgress(dependencies, error);
80
+ await sleep(consumeManagedRpcRetryDelayMs(retryState), dependencies.abortSignal);
81
+ }
82
+ }
83
+ }
84
+ async function findCommonAncestor(dependencies, tip, bestHeight, runRpc) {
53
85
  const startHeight = Math.min(tip.height, bestHeight);
54
86
  for (let height = startHeight; height >= dependencies.startHeight; height -= 1) {
55
87
  const localHashHex = height === tip.height
@@ -58,14 +90,14 @@ async function findCommonAncestor(dependencies, tip, bestHeight) {
58
90
  if (localHashHex === null) {
59
91
  continue;
60
92
  }
61
- const chainHashHex = await dependencies.rpc.getBlockHash(height);
93
+ const chainHashHex = await runRpc(() => dependencies.rpc.getBlockHash(height));
62
94
  if (chainHashHex === localHashHex) {
63
95
  return height;
64
96
  }
65
97
  }
66
98
  return dependencies.startHeight - 1;
67
99
  }
68
- async function syncAgainstBestHeight(dependencies, bestHeight) {
100
+ async function syncAgainstBestHeight(dependencies, bestHeight, runRpc) {
69
101
  if (bestHeight < dependencies.startHeight) {
70
102
  return {
71
103
  appliedBlocks: 0,
@@ -77,7 +109,7 @@ async function syncAgainstBestHeight(dependencies, bestHeight) {
77
109
  let rewoundBlocks = 0;
78
110
  let commonAncestorHeight = null;
79
111
  if (startTip !== null) {
80
- const rewindTarget = await findCommonAncestor(dependencies, startTip, bestHeight);
112
+ const rewindTarget = await findCommonAncestor(dependencies, startTip, bestHeight, runRpc);
81
113
  if (rewindTarget < startTip.height) {
82
114
  commonAncestorHeight = rewindTarget < dependencies.startHeight ? null : rewindTarget;
83
115
  await dependencies.client.rewindToHeight(rewindTarget);
@@ -93,8 +125,8 @@ async function syncAgainstBestHeight(dependencies, bestHeight) {
93
125
  await dependencies.progress.setCogcoinSync(nextHeight - 1, bestHeight, estimateEtaSeconds(dependencies.cogcoinRateTracker, nextHeight - 1, bestHeight));
94
126
  }
95
127
  for (let height = nextHeight; height <= bestHeight; height += 1) {
96
- const blockHashHex = await dependencies.rpc.getBlockHash(height);
97
- const rpcBlock = await dependencies.rpc.getBlock(blockHashHex);
128
+ const blockHashHex = await runRpc(() => dependencies.rpc.getBlockHash(height));
129
+ const rpcBlock = await runRpc(() => dependencies.rpc.getBlock(blockHashHex));
98
130
  const normalizedBlock = normalizeRpcBlock(rpcBlock);
99
131
  await dependencies.client.applyBlock(normalizedBlock);
100
132
  if (typeof rpcBlock.time === "number") {
@@ -111,12 +143,21 @@ async function syncAgainstBestHeight(dependencies, bestHeight) {
111
143
  }
112
144
  export async function syncToTip(dependencies) {
113
145
  try {
146
+ const retryState = createManagedRpcRetryState();
147
+ const runRpc = (operation) => runWithManagedRpcRetry(dependencies, retryState, operation);
114
148
  throwIfAborted(dependencies.abortSignal);
115
- await dependencies.node.validate();
149
+ await runRpc(() => dependencies.node.validate());
116
150
  const indexedTipBeforeBootstrap = await dependencies.client.getTip();
117
- await dependencies.bootstrap.ensureReady(indexedTipBeforeBootstrap, dependencies.node.expectedChain, {
151
+ await runRpc(() => dependencies.bootstrap.ensureReady(indexedTipBeforeBootstrap, dependencies.node.expectedChain, {
118
152
  signal: dependencies.abortSignal,
119
- });
153
+ retryState,
154
+ }));
155
+ if (dependencies.node.expectedChain === "main"
156
+ && dependencies.node.getblockArchiveEndHeight !== null) {
157
+ await waitForGetblockArchiveImport({
158
+ getBlockchainInfo: () => runRpc(() => dependencies.rpc.getBlockchainInfo()),
159
+ }, dependencies.progress, dependencies.node.getblockArchiveEndHeight, dependencies.abortSignal);
160
+ }
120
161
  const startTip = await dependencies.client.getTip();
121
162
  const aggregate = {
122
163
  appliedBlocks: 0,
@@ -129,9 +170,9 @@ export async function syncToTip(dependencies) {
129
170
  };
130
171
  while (true) {
131
172
  throwIfAborted(dependencies.abortSignal);
132
- const startInfo = await dependencies.rpc.getBlockchainInfo();
173
+ const startInfo = await runRpc(() => dependencies.rpc.getBlockchainInfo());
133
174
  await setBitcoinSyncProgress(dependencies, startInfo);
134
- const pass = await syncAgainstBestHeight(dependencies, startInfo.blocks);
175
+ const pass = await syncAgainstBestHeight(dependencies, startInfo.blocks, runRpc);
135
176
  aggregate.appliedBlocks += pass.appliedBlocks;
136
177
  aggregate.rewoundBlocks += pass.rewoundBlocks;
137
178
  if (pass.commonAncestorHeight !== null) {
@@ -140,19 +181,20 @@ export async function syncToTip(dependencies) {
140
181
  : Math.min(aggregate.commonAncestorHeight, pass.commonAncestorHeight);
141
182
  }
142
183
  const finalTip = await dependencies.client.getTip();
143
- const endInfo = await dependencies.rpc.getBlockchainInfo();
184
+ const endInfo = await runRpc(() => dependencies.rpc.getBlockchainInfo());
144
185
  const caughtUpCogcoin = endInfo.blocks < dependencies.startHeight || finalTip?.height === endInfo.blocks;
145
186
  aggregate.endingHeight = finalTip?.height ?? null;
146
187
  aggregate.bestHeight = endInfo.blocks;
147
188
  aggregate.bestHashHex = endInfo.bestblockhash;
148
189
  if (endInfo.blocks === endInfo.headers && caughtUpCogcoin) {
149
190
  if (dependencies.isFollowing()) {
150
- dependencies.progress.replaceFollowBlockTimes(await dependencies.loadVisibleFollowBlockTimes(finalTip));
191
+ dependencies.progress.replaceFollowBlockTimes(await runRpc(() => dependencies.loadVisibleFollowBlockTimes(finalTip)));
151
192
  }
152
193
  await dependencies.progress.setPhase(dependencies.isFollowing() ? "follow_tip" : "complete", {
153
194
  blocks: endInfo.blocks,
154
195
  headers: endInfo.headers,
155
196
  targetHeight: endInfo.headers,
197
+ lastError: null,
156
198
  message: dependencies.isFollowing()
157
199
  ? "Following the live Bitcoin tip."
158
200
  : "Managed sync fully caught up to the live tip.",
@@ -5,6 +5,24 @@ function appendNextStep(message, nextStep) {
5
5
  return `${message} Next: ${nextStep}`;
6
6
  }
7
7
  export function formatManagedSyncErrorMessage(message) {
8
+ if (message.startsWith("managed_getblock_archive_manifest_http_")) {
9
+ return appendNextStep(`Getblock archive manifest request failed (${message.replace("managed_getblock_archive_manifest_http_", "HTTP ")}).`, "Wait a moment, confirm the snapshot host is reachable, then rerun sync.");
10
+ }
11
+ if (message.startsWith("managed_getblock_archive_http_")) {
12
+ return appendNextStep(`Getblock archive request failed (${message.replace("managed_getblock_archive_http_", "HTTP ")}).`, "Wait a moment, confirm the snapshot host is reachable, then rerun sync.");
13
+ }
14
+ if (message === "managed_getblock_archive_response_body_missing") {
15
+ return appendNextStep("Getblock archive server returned an empty response body.", "Wait a moment, confirm the snapshot host is reachable, then rerun sync.");
16
+ }
17
+ if (message === "managed_getblock_archive_resume_requires_partial_content") {
18
+ return appendNextStep("Getblock archive server ignored the resume request for a partial download.", "Wait a moment and rerun sync. If this keeps happening, confirm the snapshot host supports HTTP range requests.");
19
+ }
20
+ if (message.startsWith("managed_getblock_archive_chunk_sha256_mismatch_")) {
21
+ return appendNextStep("A downloaded getblock archive chunk was corrupted and was rolled back to the last verified checkpoint.", "Wait a moment and rerun sync. If this keeps happening, check local disk health and the stability of the archive download.");
22
+ }
23
+ if (message === "managed_getblock_archive_sha256_mismatch" || message === "managed_getblock_archive_truncated") {
24
+ return appendNextStep("The downloaded getblock archive did not match the published manifest.", "Rerun sync so the archive can be downloaded again. If this keeps happening, check local disk health and the snapshot host.");
25
+ }
8
26
  if (message === "bitcoind_no_peers_for_header_sync_check_internet_or_firewall") {
9
27
  return appendNextStep("No Bitcoin peers were available for header sync.", "Check your internet access and firewall rules for outbound Bitcoin connections, then rerun sync.");
10
28
  }
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import net from "node:net";
3
3
  import { access, constants, mkdir, readFile, rm } from "node:fs/promises";
4
4
  import { serializeIndexerState } from "@cogcoin/indexer";
5
+ import { openManagedBitcoindClientInternal } from "./client.js";
5
6
  import { openClient } from "../client.js";
6
7
  import { openSqliteStore } from "../sqlite/index.js";
7
8
  import { writeRuntimeStatusFile } from "../wallet/fs/status-file.js";
@@ -29,6 +30,9 @@ async function readJsonFile(filePath) {
29
30
  throw error;
30
31
  }
31
32
  }
33
+ async function readManagedBitcoindStatus(paths) {
34
+ return readJsonFile(paths.bitcoindStatusPath);
35
+ }
32
36
  async function readPackageVersionFromDisk() {
33
37
  try {
34
38
  const raw = await readFile(new URL("../../package.json", import.meta.url), "utf8");
@@ -168,6 +172,9 @@ async function main() {
168
172
  let lastAppliedAtUnixMs = null;
169
173
  let lastError = null;
170
174
  let hasSuccessfulCoreTipRefresh = false;
175
+ let backgroundStore = null;
176
+ let backgroundClient = null;
177
+ let backgroundResumePromise = null;
171
178
  await mkdir(paths.indexerServiceRoot, { recursive: true });
172
179
  await rm(paths.indexerDaemonSocketPath, { force: true }).catch(() => undefined);
173
180
  const observeAppliedTip = (appliedTip, now) => {
@@ -264,6 +271,58 @@ async function main() {
264
271
  lastError = leaseState.lastError;
265
272
  return writeStatus();
266
273
  };
274
+ const pauseBackgroundFollow = async () => {
275
+ const pendingResume = backgroundResumePromise;
276
+ backgroundResumePromise = null;
277
+ await pendingResume?.catch(() => undefined);
278
+ const client = backgroundClient;
279
+ const store = backgroundStore;
280
+ backgroundClient = null;
281
+ backgroundStore = null;
282
+ await client?.close().catch(() => undefined);
283
+ await store?.close().catch(() => undefined);
284
+ };
285
+ const resumeBackgroundFollow = async () => {
286
+ if (backgroundClient !== null) {
287
+ return;
288
+ }
289
+ if (backgroundResumePromise !== null) {
290
+ return backgroundResumePromise;
291
+ }
292
+ backgroundResumePromise = (async () => {
293
+ const bitcoindStatus = await readManagedBitcoindStatus(paths);
294
+ const store = await openSqliteStore({ filename: databasePath });
295
+ try {
296
+ const client = await openManagedBitcoindClientInternal({
297
+ store,
298
+ dataDir,
299
+ chain: bitcoindStatus?.chain ?? "main",
300
+ startHeight: bitcoindStatus?.startHeight ?? 0,
301
+ walletRootId,
302
+ progressOutput: "none",
303
+ });
304
+ try {
305
+ await client.startFollowingTip();
306
+ backgroundStore = store;
307
+ backgroundClient = client;
308
+ }
309
+ catch (error) {
310
+ await client.close().catch(() => undefined);
311
+ throw error;
312
+ }
313
+ }
314
+ catch (error) {
315
+ await store.close().catch(() => undefined);
316
+ throw error;
317
+ }
318
+ })();
319
+ try {
320
+ await backgroundResumePromise;
321
+ }
322
+ finally {
323
+ backgroundResumePromise = null;
324
+ }
325
+ };
267
326
  const heartbeat = setInterval(() => {
268
327
  void refreshStatus().catch(() => undefined);
269
328
  const now = Date.now();
@@ -427,6 +486,24 @@ async function main() {
427
486
  });
428
487
  return;
429
488
  }
489
+ if (request.method === "PauseBackgroundFollow") {
490
+ await pauseBackgroundFollow();
491
+ writeResponse({
492
+ id: request.id,
493
+ ok: true,
494
+ result: null,
495
+ });
496
+ return;
497
+ }
498
+ if (request.method === "ResumeBackgroundFollow") {
499
+ await resumeBackgroundFollow();
500
+ writeResponse({
501
+ id: request.id,
502
+ ok: true,
503
+ result: null,
504
+ });
505
+ return;
506
+ }
430
507
  throw new Error(`indexer_daemon_unknown_method_${request.method}`);
431
508
  }
432
509
  catch (error) {
@@ -443,6 +520,7 @@ async function main() {
443
520
  });
444
521
  const shutdown = async () => {
445
522
  clearInterval(heartbeat);
523
+ await pauseBackgroundFollow().catch(() => undefined);
446
524
  state = "stopping";
447
525
  heartbeatAtUnixMs = Date.now();
448
526
  updatedAtUnixMs = heartbeatAtUnixMs;