@cogcoin/client 0.5.3 → 0.5.5

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 (70) hide show
  1. package/README.md +11 -3
  2. package/dist/app-paths.d.ts +2 -0
  3. package/dist/app-paths.js +4 -0
  4. package/dist/art/wallet.txt +10 -0
  5. package/dist/bitcoind/bootstrap/chunk-manifest.d.ts +14 -0
  6. package/dist/bitcoind/bootstrap/chunk-manifest.js +85 -0
  7. package/dist/bitcoind/bootstrap/chunk-recovery.d.ts +4 -0
  8. package/dist/bitcoind/bootstrap/chunk-recovery.js +122 -0
  9. package/dist/bitcoind/bootstrap/constants.d.ts +3 -1
  10. package/dist/bitcoind/bootstrap/constants.js +3 -1
  11. package/dist/bitcoind/bootstrap/controller.d.ts +6 -1
  12. package/dist/bitcoind/bootstrap/controller.js +14 -7
  13. package/dist/bitcoind/bootstrap/default-snapshot-chunk-manifest.d.ts +2 -0
  14. package/dist/bitcoind/bootstrap/default-snapshot-chunk-manifest.js +2309 -0
  15. package/dist/bitcoind/bootstrap/download.js +177 -83
  16. package/dist/bitcoind/bootstrap/headers.d.ts +4 -2
  17. package/dist/bitcoind/bootstrap/headers.js +29 -4
  18. package/dist/bitcoind/bootstrap/state.d.ts +11 -1
  19. package/dist/bitcoind/bootstrap/state.js +50 -23
  20. package/dist/bitcoind/bootstrap/types.d.ts +12 -1
  21. package/dist/bitcoind/client/internal-types.d.ts +1 -0
  22. package/dist/bitcoind/client/managed-client.js +27 -13
  23. package/dist/bitcoind/client/sync-engine.js +42 -5
  24. package/dist/bitcoind/errors.js +9 -0
  25. package/dist/bitcoind/indexer-daemon.d.ts +9 -0
  26. package/dist/bitcoind/indexer-daemon.js +51 -14
  27. package/dist/bitcoind/service.d.ts +9 -0
  28. package/dist/bitcoind/service.js +65 -24
  29. package/dist/bitcoind/testing.d.ts +2 -2
  30. package/dist/bitcoind/testing.js +2 -2
  31. package/dist/bitcoind/types.d.ts +9 -0
  32. package/dist/cli/commands/service-runtime.d.ts +2 -0
  33. package/dist/cli/commands/service-runtime.js +432 -0
  34. package/dist/cli/commands/wallet-admin.js +227 -132
  35. package/dist/cli/commands/wallet-mutation.js +597 -580
  36. package/dist/cli/context.js +23 -1
  37. package/dist/cli/mutation-json.d.ts +17 -1
  38. package/dist/cli/mutation-json.js +42 -0
  39. package/dist/cli/output.js +113 -2
  40. package/dist/cli/parse.d.ts +1 -1
  41. package/dist/cli/parse.js +65 -0
  42. package/dist/cli/preview-json.d.ts +19 -1
  43. package/dist/cli/preview-json.js +31 -0
  44. package/dist/cli/prompt.js +40 -12
  45. package/dist/cli/runner.js +12 -0
  46. package/dist/cli/signals.d.ts +1 -0
  47. package/dist/cli/signals.js +44 -0
  48. package/dist/cli/types.d.ts +24 -2
  49. package/dist/cli/types.js +6 -0
  50. package/dist/cli/wallet-format.js +3 -0
  51. package/dist/cli/workflow-hints.d.ts +1 -0
  52. package/dist/cli/workflow-hints.js +3 -0
  53. package/dist/wallet/fs/lock.d.ts +2 -0
  54. package/dist/wallet/fs/lock.js +32 -0
  55. package/dist/wallet/lifecycle.d.ts +19 -1
  56. package/dist/wallet/lifecycle.js +315 -8
  57. package/dist/wallet/material.d.ts +2 -0
  58. package/dist/wallet/material.js +8 -1
  59. package/dist/wallet/mnemonic-art.d.ts +2 -0
  60. package/dist/wallet/mnemonic-art.js +54 -0
  61. package/dist/wallet/reset.d.ts +61 -0
  62. package/dist/wallet/reset.js +781 -0
  63. package/dist/wallet/runtime.d.ts +2 -0
  64. package/dist/wallet/runtime.js +2 -0
  65. package/dist/wallet/state/pending-init.d.ts +24 -0
  66. package/dist/wallet/state/pending-init.js +59 -0
  67. package/dist/wallet/state/provider.d.ts +1 -0
  68. package/dist/wallet/state/provider.js +7 -1
  69. package/dist/wallet/types.d.ts +8 -0
  70. package/package.json +6 -4
@@ -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
  }
@@ -3,11 +3,13 @@ import type { ManagedProgressController } from "../progress.js";
3
3
  import type { SnapshotMetadata } from "../types.js";
4
4
  export declare function waitForHeaders(rpc: Pick<BitcoinRpcClient, "getBlockchainInfo" | "getNetworkInfo">, snapshot: SnapshotMetadata, progress: Pick<ManagedProgressController, "setPhase">, options?: {
5
5
  now?: () => number;
6
- sleep?: (ms: number) => Promise<void>;
6
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
7
7
  noPeerTimeoutMs?: number;
8
+ signal?: AbortSignal;
8
9
  }): Promise<void>;
9
10
  export declare function waitForHeadersForTesting(rpc: Pick<BitcoinRpcClient, "getBlockchainInfo" | "getNetworkInfo">, snapshot: SnapshotMetadata | undefined, progress: Pick<ManagedProgressController, "setPhase">, options?: {
10
11
  now?: () => number;
11
- sleep?: (ms: number) => Promise<void>;
12
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
12
13
  noPeerTimeoutMs?: number;
14
+ signal?: AbortSignal;
13
15
  }): Promise<void>;
@@ -1,8 +1,31 @@
1
1
  import { formatManagedSyncErrorMessage } from "../errors.js";
2
2
  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);
3
+ function createAbortError(signal) {
4
+ const reason = signal?.reason;
5
+ if (reason instanceof Error) {
6
+ return reason;
7
+ }
8
+ const error = new Error("managed_sync_aborted");
9
+ error.name = "AbortError";
10
+ return error;
11
+ }
12
+ function throwIfAborted(signal) {
13
+ if (signal?.aborted) {
14
+ throw createAbortError(signal);
15
+ }
16
+ }
17
+ function sleep(ms, signal) {
18
+ return new Promise((resolve, reject) => {
19
+ const timer = setTimeout(() => {
20
+ signal?.removeEventListener("abort", onAbort);
21
+ resolve();
22
+ }, ms);
23
+ const onAbort = () => {
24
+ clearTimeout(timer);
25
+ signal?.removeEventListener("abort", onAbort);
26
+ reject(createAbortError(signal));
27
+ };
28
+ signal?.addEventListener("abort", onAbort, { once: true });
6
29
  });
7
30
  }
8
31
  function resolvePeerCount(networkInfo) {
@@ -26,8 +49,10 @@ export async function waitForHeaders(rpc, snapshot, progress, options = {}) {
26
49
  const now = options.now ?? Date.now;
27
50
  const sleepImpl = options.sleep ?? sleep;
28
51
  const noPeerTimeoutMs = options.noPeerTimeoutMs ?? HEADER_NO_PEER_TIMEOUT_MS;
52
+ const { signal } = options;
29
53
  let noPeerSince = null;
30
54
  while (true) {
55
+ throwIfAborted(signal);
31
56
  const [info, networkInfo] = await Promise.all([
32
57
  rpc.getBlockchainInfo(),
33
58
  rpc.getNetworkInfo(),
@@ -53,7 +78,7 @@ export async function waitForHeaders(rpc, snapshot, progress, options = {}) {
53
78
  else {
54
79
  noPeerSince = null;
55
80
  }
56
- await sleepImpl(HEADER_POLL_MS);
81
+ await sleepImpl(HEADER_POLL_MS, signal);
57
82
  }
58
83
  }
59
84
  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;
@@ -1,11 +1,14 @@
1
1
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
- import { DEFAULT_SNAPSHOT_METADATA, SNAPSHOT_METADATA_VERSION, } from "./constants.js";
3
+ import { BOOTSTRAP_STATE_VERSION, DEFAULT_SNAPSHOT_METADATA, } from "./constants.js";
4
4
  function createInitialBootstrapState(snapshot) {
5
5
  return {
6
- metadataVersion: SNAPSHOT_METADATA_VERSION,
6
+ metadataVersion: BOOTSTRAP_STATE_VERSION,
7
7
  snapshot,
8
8
  phase: "snapshot_download",
9
+ integrityVersion: 0,
10
+ chunkSizeBytes: 0,
11
+ verifiedChunkCount: 0,
9
12
  downloadedBytes: 0,
10
13
  validated: false,
11
14
  loadTxOutSetComplete: false,
@@ -21,30 +24,48 @@ async function writeJsonAtomic(path, payload) {
21
24
  await writeFile(tempPath, JSON.stringify(payload, null, 2));
22
25
  await rename(tempPath, path);
23
26
  }
24
- export async function loadBootstrapState(paths, snapshot) {
27
+ function snapshotIdentityMatches(parsed, snapshot) {
28
+ return parsed.snapshot?.sha256 === snapshot.sha256
29
+ && parsed.snapshot?.sizeBytes === snapshot.sizeBytes
30
+ && parsed.snapshot?.height === snapshot.height
31
+ && parsed.snapshot?.filename === snapshot.filename;
32
+ }
33
+ function normalizeLoadedBootstrapState(parsed, snapshot) {
34
+ if (typeof parsed.downloadedBytes !== "number"
35
+ || typeof parsed.validated !== "boolean"
36
+ || typeof parsed.loadTxOutSetComplete !== "boolean") {
37
+ return null;
38
+ }
39
+ return {
40
+ metadataVersion: BOOTSTRAP_STATE_VERSION,
41
+ snapshot,
42
+ phase: parsed.phase ?? "snapshot_download",
43
+ integrityVersion: typeof parsed.integrityVersion === "number" ? parsed.integrityVersion : 0,
44
+ chunkSizeBytes: typeof parsed.chunkSizeBytes === "number" ? parsed.chunkSizeBytes : 0,
45
+ verifiedChunkCount: typeof parsed.verifiedChunkCount === "number" ? parsed.verifiedChunkCount : 0,
46
+ downloadedBytes: parsed.downloadedBytes,
47
+ validated: parsed.validated,
48
+ loadTxOutSetComplete: parsed.loadTxOutSetComplete,
49
+ baseHeight: parsed.baseHeight ?? null,
50
+ tipHashHex: parsed.tipHashHex ?? null,
51
+ lastError: parsed.lastError ?? null,
52
+ updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(),
53
+ };
54
+ }
55
+ export async function loadBootstrapStateRecord(paths, snapshot) {
25
56
  try {
26
57
  const raw = await readFile(paths.statePath, "utf8");
27
58
  const parsed = JSON.parse(raw);
28
- if (parsed.metadataVersion === SNAPSHOT_METADATA_VERSION
29
- && parsed.snapshot?.url === snapshot.url
30
- && parsed.snapshot?.sha256 === snapshot.sha256
31
- && parsed.snapshot?.sizeBytes === snapshot.sizeBytes
32
- && parsed.snapshot?.height === snapshot.height
33
- && parsed.snapshot?.filename === snapshot.filename
34
- && typeof parsed.downloadedBytes === "number"
35
- && typeof parsed.validated === "boolean"
36
- && typeof parsed.loadTxOutSetComplete === "boolean") {
59
+ const snapshotIdentity = parsed.snapshot === undefined
60
+ ? "unknown"
61
+ : snapshotIdentityMatches(parsed, snapshot)
62
+ ? "current"
63
+ : "different";
64
+ const normalized = normalizeLoadedBootstrapState(parsed, snapshot);
65
+ if (normalized !== null) {
37
66
  return {
38
- metadataVersion: SNAPSHOT_METADATA_VERSION,
39
- snapshot,
40
- phase: parsed.phase ?? "snapshot_download",
41
- downloadedBytes: parsed.downloadedBytes,
42
- validated: parsed.validated,
43
- loadTxOutSetComplete: parsed.loadTxOutSetComplete,
44
- baseHeight: parsed.baseHeight ?? null,
45
- tipHashHex: parsed.tipHashHex ?? null,
46
- lastError: parsed.lastError ?? null,
47
- updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(),
67
+ state: normalized,
68
+ snapshotIdentity,
48
69
  };
49
70
  }
50
71
  }
@@ -53,7 +74,13 @@ export async function loadBootstrapState(paths, snapshot) {
53
74
  }
54
75
  const state = createInitialBootstrapState(snapshot);
55
76
  await writeJsonAtomic(paths.statePath, state);
56
- return state;
77
+ return {
78
+ state,
79
+ snapshotIdentity: "unknown",
80
+ };
81
+ }
82
+ export async function loadBootstrapState(paths, snapshot) {
83
+ return (await loadBootstrapStateRecord(paths, snapshot)).state;
57
84
  }
58
85
  export async function saveBootstrapState(paths, state) {
59
86
  state.updatedAt = Date.now();
@@ -1,9 +1,12 @@
1
1
  import type { ManagedProgressController } from "../progress.js";
2
- import type { BootstrapPhase, SnapshotMetadata } from "../types.js";
2
+ import type { BootstrapPhase, SnapshotChunkManifest, SnapshotMetadata } from "../types.js";
3
3
  export interface BootstrapPersistentState {
4
4
  metadataVersion: number;
5
5
  snapshot: SnapshotMetadata;
6
6
  phase: BootstrapPhase;
7
+ integrityVersion: number;
8
+ chunkSizeBytes: number;
9
+ verifiedChunkCount: number;
7
10
  downloadedBytes: number;
8
11
  validated: boolean;
9
12
  loadTxOutSetComplete: boolean;
@@ -12,6 +15,11 @@ export interface BootstrapPersistentState {
12
15
  lastError: string | null;
13
16
  updatedAt: number;
14
17
  }
18
+ export type BootstrapStateSnapshotIdentity = "current" | "different" | "unknown";
19
+ export interface LoadedBootstrapState {
20
+ state: BootstrapPersistentState;
21
+ snapshotIdentity: BootstrapStateSnapshotIdentity;
22
+ }
15
23
  export interface BootstrapPaths {
16
24
  directory: string;
17
25
  snapshotPath: string;
@@ -22,7 +30,10 @@ export interface BootstrapPaths {
22
30
  export interface DownloadSnapshotOptions {
23
31
  fetchImpl?: typeof fetch;
24
32
  metadata: SnapshotMetadata;
33
+ manifest?: SnapshotChunkManifest;
25
34
  paths: BootstrapPaths;
26
35
  progress: Pick<ManagedProgressController, "setPhase">;
27
36
  state: BootstrapPersistentState;
37
+ signal?: AbortSignal;
38
+ snapshotIdentity?: BootstrapStateSnapshotIdentity;
28
39
  }
@@ -23,6 +23,7 @@ export interface SyncEngineDependencies {
23
23
  startHeight: number;
24
24
  bitcoinRateTracker: BlockRateTracker;
25
25
  cogcoinRateTracker: BlockRateTracker;
26
+ abortSignal?: AbortSignal;
26
27
  isFollowing(): boolean;
27
28
  loadVisibleFollowBlockTimes(tip: Awaited<ReturnType<Client["getTip"]>>): Promise<Record<number, number>>;
28
29
  }
@@ -21,6 +21,7 @@ export class DefaultManagedBitcoindClient {
21
21
  #cogcoinRateTracker = createBlockRateTracker();
22
22
  #syncPromise = Promise.resolve(createInitialSyncResult());
23
23
  #debounceTimer = null;
24
+ #syncAbortControllers = new Set();
24
25
  constructor(client, store, node, rpc, progress, bootstrap, indexerDaemon, syncDebounceMs) {
25
26
  this.#client = client;
26
27
  this.#store = store;
@@ -47,19 +48,29 @@ export class DefaultManagedBitcoindClient {
47
48
  async syncToTip() {
48
49
  this.#assertOpen();
49
50
  await this.#progress.start();
50
- const run = async () => runManagedSync({
51
- client: this.#client,
52
- store: this.#store,
53
- node: this.#node,
54
- rpc: this.#rpc,
55
- progress: this.#progress,
56
- bootstrap: this.#bootstrap,
57
- startHeight: this.#startHeight,
58
- bitcoinRateTracker: this.#bitcoinRateTracker,
59
- cogcoinRateTracker: this.#cogcoinRateTracker,
60
- isFollowing: () => this.#following,
61
- loadVisibleFollowBlockTimes: (tip) => this.#loadVisibleFollowBlockTimes(tip),
62
- });
51
+ const run = async () => {
52
+ const abortController = new AbortController();
53
+ this.#syncAbortControllers.add(abortController);
54
+ try {
55
+ return await runManagedSync({
56
+ client: this.#client,
57
+ store: this.#store,
58
+ node: this.#node,
59
+ rpc: this.#rpc,
60
+ progress: this.#progress,
61
+ bootstrap: this.#bootstrap,
62
+ startHeight: this.#startHeight,
63
+ bitcoinRateTracker: this.#bitcoinRateTracker,
64
+ cogcoinRateTracker: this.#cogcoinRateTracker,
65
+ abortSignal: abortController.signal,
66
+ isFollowing: () => this.#following,
67
+ loadVisibleFollowBlockTimes: (tip) => this.#loadVisibleFollowBlockTimes(tip),
68
+ });
69
+ }
70
+ finally {
71
+ this.#syncAbortControllers.delete(abortController);
72
+ }
73
+ };
63
74
  const nextPromise = this.#syncPromise.then(run, run);
64
75
  this.#syncPromise = nextPromise;
65
76
  return nextPromise;
@@ -159,6 +170,9 @@ export class DefaultManagedBitcoindClient {
159
170
  this.#subscriber = null;
160
171
  this.#followLoop = null;
161
172
  this.#pollTimer = null;
173
+ for (const abortController of this.#syncAbortControllers) {
174
+ abortController.abort(new Error("managed_sync_aborted"));
175
+ }
162
176
  await this.#syncPromise.catch(() => undefined);
163
177
  await this.#progress.close();
164
178
  await this.#node.stop();