@cogcoin/client 0.5.4 → 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 (74) hide show
  1. package/README.md +1 -1
  2. package/dist/app-paths.d.ts +2 -0
  3. package/dist/app-paths.js +4 -0
  4. package/dist/art/wallet.txt +9 -9
  5. package/dist/bitcoind/bootstrap/chainstate.d.ts +2 -1
  6. package/dist/bitcoind/bootstrap/chainstate.js +4 -1
  7. package/dist/bitcoind/bootstrap/chunk-manifest.d.ts +14 -0
  8. package/dist/bitcoind/bootstrap/chunk-manifest.js +85 -0
  9. package/dist/bitcoind/bootstrap/chunk-recovery.d.ts +4 -0
  10. package/dist/bitcoind/bootstrap/chunk-recovery.js +122 -0
  11. package/dist/bitcoind/bootstrap/constants.d.ts +3 -1
  12. package/dist/bitcoind/bootstrap/constants.js +3 -1
  13. package/dist/bitcoind/bootstrap/controller.d.ts +10 -2
  14. package/dist/bitcoind/bootstrap/controller.js +56 -12
  15. package/dist/bitcoind/bootstrap/default-snapshot-chunk-manifest.d.ts +2 -0
  16. package/dist/bitcoind/bootstrap/default-snapshot-chunk-manifest.js +2309 -0
  17. package/dist/bitcoind/bootstrap/download.js +177 -83
  18. package/dist/bitcoind/bootstrap/headers.d.ts +16 -2
  19. package/dist/bitcoind/bootstrap/headers.js +124 -14
  20. package/dist/bitcoind/bootstrap/state.d.ts +11 -1
  21. package/dist/bitcoind/bootstrap/state.js +50 -23
  22. package/dist/bitcoind/bootstrap/types.d.ts +12 -1
  23. package/dist/bitcoind/client/factory.js +11 -2
  24. package/dist/bitcoind/client/internal-types.d.ts +1 -0
  25. package/dist/bitcoind/client/managed-client.d.ts +1 -1
  26. package/dist/bitcoind/client/managed-client.js +29 -15
  27. package/dist/bitcoind/client/sync-engine.js +88 -16
  28. package/dist/bitcoind/errors.js +9 -0
  29. package/dist/bitcoind/indexer-daemon.d.ts +7 -0
  30. package/dist/bitcoind/indexer-daemon.js +31 -22
  31. package/dist/bitcoind/processing-start-height.d.ts +7 -0
  32. package/dist/bitcoind/processing-start-height.js +9 -0
  33. package/dist/bitcoind/progress/controller.js +1 -0
  34. package/dist/bitcoind/progress/formatting.js +4 -1
  35. package/dist/bitcoind/retryable-rpc.d.ts +11 -0
  36. package/dist/bitcoind/retryable-rpc.js +30 -0
  37. package/dist/bitcoind/service.d.ts +16 -1
  38. package/dist/bitcoind/service.js +228 -115
  39. package/dist/bitcoind/testing.d.ts +1 -1
  40. package/dist/bitcoind/testing.js +1 -1
  41. package/dist/bitcoind/types.d.ts +10 -0
  42. package/dist/cli/commands/follow.js +9 -0
  43. package/dist/cli/commands/service-runtime.js +150 -134
  44. package/dist/cli/commands/sync.js +9 -0
  45. package/dist/cli/commands/wallet-admin.js +77 -21
  46. package/dist/cli/context.js +4 -2
  47. package/dist/cli/mutation-json.js +2 -0
  48. package/dist/cli/output.js +3 -1
  49. package/dist/cli/parse.d.ts +1 -1
  50. package/dist/cli/parse.js +6 -0
  51. package/dist/cli/preview-json.js +2 -0
  52. package/dist/cli/runner.js +1 -0
  53. package/dist/cli/types.d.ts +6 -3
  54. package/dist/cli/types.js +1 -1
  55. package/dist/cli/wallet-format.js +134 -14
  56. package/dist/wallet/lifecycle.d.ts +6 -0
  57. package/dist/wallet/lifecycle.js +168 -37
  58. package/dist/wallet/read/context.js +10 -4
  59. package/dist/wallet/reset.d.ts +61 -2
  60. package/dist/wallet/reset.js +208 -63
  61. package/dist/wallet/root-resolution.d.ts +20 -0
  62. package/dist/wallet/root-resolution.js +37 -0
  63. package/dist/wallet/runtime.d.ts +3 -0
  64. package/dist/wallet/runtime.js +3 -0
  65. package/dist/wallet/state/crypto.d.ts +3 -0
  66. package/dist/wallet/state/crypto.js +3 -0
  67. package/dist/wallet/state/pending-init.d.ts +24 -0
  68. package/dist/wallet/state/pending-init.js +59 -0
  69. package/dist/wallet/state/provider.d.ts +1 -0
  70. package/dist/wallet/state/provider.js +7 -1
  71. package/dist/wallet/state/storage.d.ts +7 -1
  72. package/dist/wallet/state/storage.js +39 -0
  73. package/dist/wallet/types.d.ts +9 -0
  74. package/package.json +4 -2
@@ -1,7 +1,10 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { mkdir, open, rename, rm } from "node:fs/promises";
2
3
  import { formatManagedSyncErrorMessage } from "../errors.js";
3
- import { DEFAULT_SNAPSHOT_METADATA, DOWNLOAD_RETRY_BASE_MS, DOWNLOAD_RETRY_MAX_MS, } from "./constants.js";
4
- import { resetSnapshotFiles, statOrNull, validateSnapshotFileForTesting } from "./snapshot-file.js";
4
+ import { DOWNLOAD_RETRY_BASE_MS, DOWNLOAD_RETRY_MAX_MS, } from "./constants.js";
5
+ import { applyVerifiedFrontierState, reconcileSnapshotDownloadArtifacts, } from "./chunk-recovery.js";
6
+ import { resolveBundledSnapshotChunkManifest, resolveSnapshotChunkCount, resolveSnapshotChunkSize, resolveVerifiedChunkBytes, } from "./chunk-manifest.js";
7
+ import { statOrNull, validateSnapshotFileForTesting } from "./snapshot-file.js";
5
8
  import { saveBootstrapState } from "./state.js";
6
9
  function describeSnapshotDownloadError(error, url) {
7
10
  if (!(error instanceof Error)) {
@@ -27,14 +30,48 @@ function describeSnapshotDownloadError(error, url) {
27
30
  ? `snapshot download failed from ${source}: ${causeMessage}`
28
31
  : `snapshot download failed from ${source}: fetch failed`;
29
32
  }
30
- function sleep(ms) {
31
- return new Promise((resolve) => {
32
- setTimeout(resolve, ms);
33
+ function createAbortError(signal) {
34
+ const reason = signal?.reason;
35
+ if (reason instanceof Error) {
36
+ return reason;
37
+ }
38
+ const error = new Error("managed_sync_aborted");
39
+ error.name = "AbortError";
40
+ return error;
41
+ }
42
+ function isAbortError(error, signal) {
43
+ if (signal?.aborted) {
44
+ return true;
45
+ }
46
+ return error instanceof Error
47
+ && (error.name === "AbortError" || error.message === "managed_sync_aborted");
48
+ }
49
+ function throwIfAborted(signal) {
50
+ if (signal?.aborted) {
51
+ throw createAbortError(signal);
52
+ }
53
+ }
54
+ function sleep(ms, signal) {
55
+ if (ms <= 0) {
56
+ throwIfAborted(signal);
57
+ return Promise.resolve();
58
+ }
59
+ return new Promise((resolve, reject) => {
60
+ const timer = setTimeout(() => {
61
+ signal?.removeEventListener("abort", onAbort);
62
+ resolve();
63
+ }, ms);
64
+ const onAbort = () => {
65
+ clearTimeout(timer);
66
+ signal?.removeEventListener("abort", onAbort);
67
+ reject(createAbortError(signal));
68
+ };
69
+ signal?.addEventListener("abort", onAbort, { once: true });
33
70
  });
34
71
  }
35
- function updateDownloadProgress(progress, state, downloadedBytes, resumed, startedAt) {
72
+ function updateDownloadProgress(progress, state, attemptStartBytes, downloadedBytes, resumed, startedAt) {
36
73
  const elapsedSeconds = Math.max(0.001, (Date.now() - startedAt) / 1000);
37
- const bytesPerSecond = Math.max(0, (downloadedBytes - state.downloadedBytes) / elapsedSeconds);
74
+ const bytesPerSecond = Math.max(0, (downloadedBytes - attemptStartBytes) / elapsedSeconds);
38
75
  const remaining = Math.max(0, state.snapshot.sizeBytes - downloadedBytes);
39
76
  return progress.setPhase("snapshot_download", {
40
77
  message: "Downloading UTXO snapshot.",
@@ -48,76 +85,112 @@ function updateDownloadProgress(progress, state, downloadedBytes, resumed, start
48
85
  lastError: state.lastError,
49
86
  });
50
87
  }
88
+ async function persistVerifiedChunk(state, manifest, verifiedChunkCount, options) {
89
+ applyVerifiedFrontierState(state, manifest, verifiedChunkCount);
90
+ state.validated = false;
91
+ state.lastError = null;
92
+ await saveBootstrapState(options.paths, state);
93
+ }
94
+ async function finalizeSnapshotDownload(metadata, manifest, state, options, resumed) {
95
+ await validateSnapshotFileForTesting(options.paths.partialSnapshotPath, metadata);
96
+ await rename(options.paths.partialSnapshotPath, options.paths.snapshotPath);
97
+ applyVerifiedFrontierState(state, manifest, resolveSnapshotChunkCount(manifest));
98
+ state.validated = true;
99
+ state.lastError = null;
100
+ await saveBootstrapState(options.paths, state);
101
+ await options.progress.setPhase("snapshot_download", {
102
+ resumed,
103
+ downloadedBytes: metadata.sizeBytes,
104
+ totalBytes: metadata.sizeBytes,
105
+ percent: 100,
106
+ bytesPerSecond: null,
107
+ etaSeconds: 0,
108
+ lastError: null,
109
+ });
110
+ }
111
+ async function finalizeExistingSnapshot(metadata, manifest, state, options) {
112
+ applyVerifiedFrontierState(state, manifest, resolveSnapshotChunkCount(manifest));
113
+ state.validated = true;
114
+ state.lastError = null;
115
+ await saveBootstrapState(options.paths, state);
116
+ await rm(options.paths.partialSnapshotPath, { force: true });
117
+ await options.progress.setPhase("snapshot_download", {
118
+ resumed: false,
119
+ downloadedBytes: metadata.sizeBytes,
120
+ totalBytes: metadata.sizeBytes,
121
+ percent: 100,
122
+ bytesPerSecond: null,
123
+ etaSeconds: 0,
124
+ lastError: null,
125
+ });
126
+ }
127
+ async function truncateToVerifiedFrontier(path, verifiedBytes) {
128
+ const file = await open(path, "a+");
129
+ try {
130
+ await file.truncate(verifiedBytes);
131
+ await file.sync();
132
+ }
133
+ finally {
134
+ await file.close();
135
+ }
136
+ }
51
137
  export async function downloadSnapshotFileForTesting(options) {
52
- const { metadata, paths, progress, fetchImpl = fetch } = options;
138
+ const { metadata, paths, progress, fetchImpl = fetch, signal, snapshotIdentity = "current", } = options;
139
+ const manifest = options.manifest ?? resolveBundledSnapshotChunkManifest(metadata);
53
140
  const state = options.state;
54
141
  await mkdir(paths.directory, { recursive: true });
55
- const existingPart = await statOrNull(paths.partialSnapshotPath);
56
142
  const existingFull = await statOrNull(paths.snapshotPath);
57
143
  if (state.validated && existingFull?.size === metadata.sizeBytes) {
58
- await progress.setPhase("snapshot_download", {
59
- resumed: false,
60
- downloadedBytes: metadata.sizeBytes,
61
- totalBytes: metadata.sizeBytes,
62
- percent: 100,
63
- bytesPerSecond: null,
64
- etaSeconds: 0,
65
- lastError: null,
66
- });
144
+ await finalizeExistingSnapshot(metadata, manifest, state, { paths, progress });
67
145
  return;
68
146
  }
69
- if (!state.validated && existingFull?.size === metadata.sizeBytes) {
147
+ if (existingFull?.size === metadata.sizeBytes) {
70
148
  try {
71
149
  await validateSnapshotFileForTesting(paths.snapshotPath, metadata);
72
- state.validated = true;
73
- state.downloadedBytes = metadata.sizeBytes;
74
- state.lastError = null;
75
- await saveBootstrapState(paths, state);
76
- await progress.setPhase("snapshot_download", {
77
- resumed: false,
78
- downloadedBytes: metadata.sizeBytes,
79
- totalBytes: metadata.sizeBytes,
80
- percent: 100,
81
- bytesPerSecond: null,
82
- etaSeconds: 0,
83
- lastError: null,
84
- });
150
+ await finalizeExistingSnapshot(metadata, manifest, state, { paths, progress });
85
151
  return;
86
152
  }
87
153
  catch {
88
- await resetSnapshotFiles(paths);
89
- state.downloadedBytes = 0;
90
154
  state.validated = false;
91
- await saveBootstrapState(paths, state);
92
155
  }
93
156
  }
157
+ else if (state.validated) {
158
+ state.validated = false;
159
+ }
160
+ await reconcileSnapshotDownloadArtifacts(paths, state, manifest, snapshotIdentity);
161
+ await saveBootstrapState(paths, state);
162
+ throwIfAborted(signal);
163
+ if (state.downloadedBytes >= metadata.sizeBytes) {
164
+ await finalizeSnapshotDownload(metadata, manifest, state, { paths, progress }, state.downloadedBytes > 0);
165
+ return;
166
+ }
94
167
  let retryDelayMs = DOWNLOAD_RETRY_BASE_MS;
95
168
  while (true) {
96
- let startOffset = existingPart?.size ?? (await statOrNull(paths.partialSnapshotPath))?.size ?? 0;
97
- if (startOffset > metadata.sizeBytes) {
98
- await rm(paths.partialSnapshotPath, { force: true });
99
- startOffset = 0;
100
- }
169
+ const startOffset = state.downloadedBytes;
101
170
  const resumed = startOffset > 0;
102
171
  try {
172
+ throwIfAborted(signal);
103
173
  const headers = resumed ? { Range: `bytes=${startOffset}-` } : undefined;
104
- const response = await fetchImpl(metadata.url, { headers });
105
- if (!(response.status === 200 || response.status === 206)) {
174
+ const response = await fetchImpl(metadata.url, { headers, signal });
175
+ if (resumed && response.status !== 206) {
176
+ throw new Error(response.status === 200
177
+ ? "snapshot_resume_requires_partial_content"
178
+ : `snapshot_http_${response.status}`);
179
+ }
180
+ if (!resumed && response.status !== 200) {
106
181
  throw new Error(`snapshot_http_${response.status}`);
107
182
  }
108
183
  if (response.body === null) {
109
184
  throw new Error("snapshot_response_body_missing");
110
185
  }
111
- let writeFrom = startOffset;
112
- if (resumed && response.status === 200) {
113
- await rm(paths.partialSnapshotPath, { force: true });
114
- writeFrom = 0;
115
- }
116
- const file = await open(paths.partialSnapshotPath, writeFrom === 0 ? "w" : "a");
186
+ const file = await open(paths.partialSnapshotPath, resumed ? "a" : "w");
117
187
  const reader = response.body.getReader();
118
188
  const startedAt = Date.now();
119
- let downloadedBytes = writeFrom;
120
- let lastPersistAt = 0;
189
+ const attemptStartBytes = startOffset;
190
+ let downloadedBytes = startOffset;
191
+ let currentChunkIndex = state.verifiedChunkCount;
192
+ let currentChunkBytes = 0;
193
+ let currentChunkHash = createHash("sha256");
121
194
  await progress.setPhase("snapshot_download", {
122
195
  resumed,
123
196
  downloadedBytes,
@@ -129,56 +202,77 @@ export async function downloadSnapshotFileForTesting(options) {
129
202
  });
130
203
  try {
131
204
  while (true) {
205
+ throwIfAborted(signal);
132
206
  const { done, value } = await reader.read();
133
207
  if (done) {
134
208
  break;
135
209
  }
136
- if (value === undefined) {
210
+ if (value === undefined || value.byteLength === 0) {
137
211
  continue;
138
212
  }
139
- await file.write(value);
140
- downloadedBytes += value.byteLength;
141
- const now = Date.now();
142
- await updateDownloadProgress(progress, state, downloadedBytes, resumed, startedAt);
143
- if (now - lastPersistAt >= 1_000) {
144
- state.downloadedBytes = downloadedBytes;
145
- state.lastError = null;
146
- await saveBootstrapState(paths, state);
147
- lastPersistAt = now;
213
+ let offset = 0;
214
+ while (offset < value.byteLength) {
215
+ if (currentChunkIndex >= manifest.chunkSha256s.length) {
216
+ throw new Error(`snapshot_size_mismatch_${downloadedBytes + (value.byteLength - offset)}`);
217
+ }
218
+ const chunkSizeBytes = resolveSnapshotChunkSize(manifest, currentChunkIndex);
219
+ const remainingChunkBytes = chunkSizeBytes - currentChunkBytes;
220
+ const writeLength = Math.min(remainingChunkBytes, value.byteLength - offset);
221
+ const segment = value.subarray(offset, offset + writeLength);
222
+ await file.write(segment);
223
+ currentChunkHash.update(segment);
224
+ currentChunkBytes += segment.byteLength;
225
+ downloadedBytes += segment.byteLength;
226
+ offset += writeLength;
227
+ if (currentChunkBytes === chunkSizeBytes) {
228
+ const actualSha256 = currentChunkHash.digest("hex");
229
+ const expectedSha256 = manifest.chunkSha256s[currentChunkIndex];
230
+ if (actualSha256 !== expectedSha256) {
231
+ const verifiedBytes = resolveVerifiedChunkBytes(manifest, currentChunkIndex);
232
+ await file.truncate(verifiedBytes);
233
+ await file.sync();
234
+ throw new Error(`snapshot_chunk_sha256_mismatch_${currentChunkIndex}`);
235
+ }
236
+ currentChunkIndex += 1;
237
+ currentChunkBytes = 0;
238
+ currentChunkHash = createHash("sha256");
239
+ await file.sync();
240
+ await persistVerifiedChunk(state, manifest, currentChunkIndex, { paths });
241
+ }
148
242
  }
243
+ await updateDownloadProgress(progress, state, attemptStartBytes, downloadedBytes, resumed, startedAt);
244
+ }
245
+ if (downloadedBytes !== metadata.sizeBytes) {
246
+ throw new Error(`snapshot_download_incomplete_${downloadedBytes}`);
149
247
  }
150
248
  }
249
+ catch (error) {
250
+ if (!isAbortError(error, signal)) {
251
+ await file.truncate(state.downloadedBytes);
252
+ await file.sync();
253
+ }
254
+ throw error;
255
+ }
151
256
  finally {
152
257
  await file.close();
153
258
  }
154
- state.downloadedBytes = downloadedBytes;
155
- await saveBootstrapState(paths, state);
156
- await validateSnapshotFileForTesting(paths.partialSnapshotPath, metadata);
157
- await rename(paths.partialSnapshotPath, paths.snapshotPath);
158
- state.validated = true;
159
- state.downloadedBytes = metadata.sizeBytes;
160
- state.lastError = null;
161
- await saveBootstrapState(paths, state);
162
- await progress.setPhase("snapshot_download", {
163
- resumed,
164
- downloadedBytes: metadata.sizeBytes,
165
- totalBytes: metadata.sizeBytes,
166
- percent: 100,
167
- bytesPerSecond: null,
168
- etaSeconds: 0,
169
- lastError: null,
170
- });
259
+ await finalizeSnapshotDownload(metadata, manifest, state, { paths, progress }, resumed);
171
260
  return;
172
261
  }
173
262
  catch (error) {
174
- const message = formatManagedSyncErrorMessage(describeSnapshotDownloadError(error, metadata.url));
175
- if (message.startsWith("snapshot_sha256_mismatch_")
176
- || message.startsWith("snapshot_size_mismatch_")) {
177
- await resetSnapshotFiles(paths);
178
- state.downloadedBytes = 0;
263
+ if (isAbortError(error, signal)) {
264
+ const partialInfo = await statOrNull(paths.partialSnapshotPath);
265
+ if (partialInfo !== null) {
266
+ await truncateToVerifiedFrontier(paths.partialSnapshotPath, state.downloadedBytes);
267
+ }
268
+ state.lastError = null;
179
269
  state.validated = false;
270
+ await saveBootstrapState(paths, state);
271
+ throw createAbortError(signal);
180
272
  }
273
+ const message = formatManagedSyncErrorMessage(describeSnapshotDownloadError(error, metadata.url));
181
274
  state.lastError = message;
275
+ state.validated = false;
182
276
  await saveBootstrapState(paths, state);
183
277
  await progress.setPhase("snapshot_download", {
184
278
  resumed,
@@ -189,7 +283,7 @@ export async function downloadSnapshotFileForTesting(options) {
189
283
  etaSeconds: null,
190
284
  lastError: message,
191
285
  });
192
- await sleep(retryDelayMs);
286
+ await sleep(retryDelayMs, signal);
193
287
  retryDelayMs = Math.min(retryDelayMs * 2, DOWNLOAD_RETRY_MAX_MS);
194
288
  }
195
289
  }
@@ -1,13 +1,27 @@
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";
4
5
  export declare function waitForHeaders(rpc: Pick<BitcoinRpcClient, "getBlockchainInfo" | "getNetworkInfo">, snapshot: SnapshotMetadata, progress: Pick<ManagedProgressController, "setPhase">, options?: {
5
6
  now?: () => number;
6
- sleep?: (ms: number) => Promise<void>;
7
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
7
8
  noPeerTimeoutMs?: number;
9
+ signal?: AbortSignal;
10
+ retryState?: ManagedRpcRetryState;
11
+ debugLogPath?: string;
12
+ readDebugLogProgress?: (debugLogPath: string) => Promise<{
13
+ height: number;
14
+ message: string;
15
+ } | null>;
8
16
  }): Promise<void>;
9
17
  export declare function waitForHeadersForTesting(rpc: Pick<BitcoinRpcClient, "getBlockchainInfo" | "getNetworkInfo">, snapshot: SnapshotMetadata | undefined, progress: Pick<ManagedProgressController, "setPhase">, options?: {
10
18
  now?: () => number;
11
- sleep?: (ms: number) => Promise<void>;
19
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
12
20
  noPeerTimeoutMs?: number;
21
+ signal?: AbortSignal;
22
+ debugLogPath?: string;
23
+ readDebugLogProgress?: (debugLogPath: string) => Promise<{
24
+ height: number;
25
+ message: string;
26
+ } | null>;
13
27
  }): Promise<void>;
@@ -1,8 +1,35 @@
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";
3
- function sleep(ms) {
4
- return new Promise((resolve) => {
5
- setTimeout(resolve, ms);
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;
7
+ function createAbortError(signal) {
8
+ const reason = signal?.reason;
9
+ if (reason instanceof Error) {
10
+ return reason;
11
+ }
12
+ const error = new Error("managed_sync_aborted");
13
+ error.name = "AbortError";
14
+ return error;
15
+ }
16
+ function throwIfAborted(signal) {
17
+ if (signal?.aborted) {
18
+ throw createAbortError(signal);
19
+ }
20
+ }
21
+ function sleep(ms, signal) {
22
+ return new Promise((resolve, reject) => {
23
+ const timer = setTimeout(() => {
24
+ signal?.removeEventListener("abort", onAbort);
25
+ resolve();
26
+ }, ms);
27
+ const onAbort = () => {
28
+ clearTimeout(timer);
29
+ signal?.removeEventListener("abort", onAbort);
30
+ reject(createAbortError(signal));
31
+ };
32
+ signal?.addEventListener("abort", onAbort, { once: true });
6
33
  });
7
34
  }
8
35
  function resolvePeerCount(networkInfo) {
@@ -10,7 +37,52 @@ function resolvePeerCount(networkInfo) {
10
37
  ? networkInfo.connections
11
38
  : (networkInfo.connections_in ?? 0) + (networkInfo.connections_out ?? 0);
12
39
  }
13
- 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) {
14
86
  if (!networkActive) {
15
87
  return "Bitcoin networking is inactive for the managed node.";
16
88
  }
@@ -20,31 +92,69 @@ function resolveHeaderWaitMessage(headers, peerCount, networkActive) {
20
92
  if (peerCount === 0) {
21
93
  return `Waiting for peers to continue header sync (${headers.toLocaleString()} headers, 0 peers).`;
22
94
  }
23
- 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.";
24
99
  }
25
100
  export async function waitForHeaders(rpc, snapshot, progress, options = {}) {
26
101
  const now = options.now ?? Date.now;
27
102
  const sleepImpl = options.sleep ?? sleep;
28
103
  const noPeerTimeoutMs = options.noPeerTimeoutMs ?? HEADER_NO_PEER_TIMEOUT_MS;
104
+ const { signal } = options;
105
+ const retryState = options.retryState ?? createManagedRpcRetryState();
106
+ const readDebugLogProgress = options.readDebugLogProgress ?? readHeaderSyncProgressFromDebugLog;
29
107
  let noPeerSince = null;
108
+ let lastBlocks = 0;
109
+ let lastHeaders = 0;
30
110
  while (true) {
31
- const [info, networkInfo] = await Promise.all([
32
- rpc.getBlockchainInfo(),
33
- rpc.getNetworkInfo(),
34
- ]);
111
+ throwIfAborted(signal);
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;
35
144
  const peerCount = resolvePeerCount(networkInfo);
36
- const message = resolveHeaderWaitMessage(info.headers, peerCount, networkInfo.networkactive);
145
+ const message = resolveHeaderWaitMessage(observedHeaders, peerCount, networkInfo.networkactive, info.headers, debugLogProgress?.message ?? null);
37
146
  await progress.setPhase("wait_headers_for_snapshot", {
38
- headers: info.headers,
147
+ headers: observedHeaders,
39
148
  targetHeight: snapshot.height,
40
149
  blocks: info.blocks,
41
- percent: (Math.min(info.headers, snapshot.height) / snapshot.height) * 100,
150
+ percent: (Math.min(observedHeaders, snapshot.height) / snapshot.height) * 100,
151
+ lastError: null,
42
152
  message,
43
153
  });
44
154
  if (info.headers >= snapshot.height) {
45
155
  return;
46
156
  }
47
- if (info.headers === 0 && peerCount === 0) {
157
+ if (observedHeaders === 0 && peerCount === 0) {
48
158
  noPeerSince ??= now();
49
159
  if (now() - noPeerSince >= noPeerTimeoutMs) {
50
160
  throw new Error(formatManagedSyncErrorMessage("bitcoind_no_peers_for_header_sync_check_internet_or_firewall"));
@@ -53,7 +163,7 @@ export async function waitForHeaders(rpc, snapshot, progress, options = {}) {
53
163
  else {
54
164
  noPeerSince = null;
55
165
  }
56
- await sleepImpl(HEADER_POLL_MS);
166
+ await sleepImpl(HEADER_POLL_MS, signal);
57
167
  }
58
168
  }
59
169
  export async function waitForHeadersForTesting(rpc, snapshot = DEFAULT_SNAPSHOT_METADATA, progress, options) {
@@ -1,11 +1,15 @@
1
- import type { BootstrapPaths, BootstrapPersistentState } from "./types.js";
1
+ import type { BootstrapPaths, BootstrapPersistentState, LoadedBootstrapState } from "./types.js";
2
2
  import type { BootstrapPhase, SnapshotMetadata } from "../types.js";
3
+ export declare function loadBootstrapStateRecord(paths: BootstrapPaths, snapshot: SnapshotMetadata): Promise<LoadedBootstrapState>;
3
4
  export declare function loadBootstrapState(paths: BootstrapPaths, snapshot: SnapshotMetadata): Promise<BootstrapPersistentState>;
4
5
  export declare function saveBootstrapState(paths: BootstrapPaths, state: BootstrapPersistentState): Promise<void>;
5
6
  export declare function createBootstrapStateForTesting(snapshot?: SnapshotMetadata): {
6
7
  metadataVersion: number;
7
8
  snapshot: SnapshotMetadata;
8
9
  phase: BootstrapPhase;
10
+ integrityVersion: number;
11
+ chunkSizeBytes: number;
12
+ verifiedChunkCount: number;
9
13
  downloadedBytes: number;
10
14
  validated: boolean;
11
15
  loadTxOutSetComplete: boolean;
@@ -18,6 +22,9 @@ export declare function loadBootstrapStateForTesting(paths: BootstrapPaths, snap
18
22
  metadataVersion: number;
19
23
  snapshot: SnapshotMetadata;
20
24
  phase: BootstrapPhase;
25
+ integrityVersion: number;
26
+ chunkSizeBytes: number;
27
+ verifiedChunkCount: number;
21
28
  downloadedBytes: number;
22
29
  validated: boolean;
23
30
  loadTxOutSetComplete: boolean;
@@ -30,6 +37,9 @@ export declare function saveBootstrapStateForTesting(paths: BootstrapPaths, stat
30
37
  metadataVersion: number;
31
38
  snapshot: SnapshotMetadata;
32
39
  phase: BootstrapPhase;
40
+ integrityVersion: number;
41
+ chunkSizeBytes: number;
42
+ verifiedChunkCount: number;
33
43
  downloadedBytes: number;
34
44
  validated: boolean;
35
45
  loadTxOutSetComplete: boolean;