@cogcoin/client 0.5.4 → 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.
- package/README.md +1 -1
- package/dist/app-paths.d.ts +2 -0
- package/dist/app-paths.js +4 -0
- package/dist/art/wallet.txt +9 -9
- package/dist/bitcoind/bootstrap/chunk-manifest.d.ts +14 -0
- package/dist/bitcoind/bootstrap/chunk-manifest.js +85 -0
- package/dist/bitcoind/bootstrap/chunk-recovery.d.ts +4 -0
- package/dist/bitcoind/bootstrap/chunk-recovery.js +122 -0
- package/dist/bitcoind/bootstrap/constants.d.ts +3 -1
- package/dist/bitcoind/bootstrap/constants.js +3 -1
- package/dist/bitcoind/bootstrap/controller.d.ts +6 -1
- package/dist/bitcoind/bootstrap/controller.js +14 -7
- package/dist/bitcoind/bootstrap/default-snapshot-chunk-manifest.d.ts +2 -0
- package/dist/bitcoind/bootstrap/default-snapshot-chunk-manifest.js +2309 -0
- package/dist/bitcoind/bootstrap/download.js +177 -83
- package/dist/bitcoind/bootstrap/headers.d.ts +4 -2
- package/dist/bitcoind/bootstrap/headers.js +29 -4
- package/dist/bitcoind/bootstrap/state.d.ts +11 -1
- package/dist/bitcoind/bootstrap/state.js +50 -23
- package/dist/bitcoind/bootstrap/types.d.ts +12 -1
- package/dist/bitcoind/client/internal-types.d.ts +1 -0
- package/dist/bitcoind/client/managed-client.js +27 -13
- package/dist/bitcoind/client/sync-engine.js +42 -5
- package/dist/bitcoind/errors.js +9 -0
- package/dist/bitcoind/types.d.ts +9 -0
- package/dist/cli/output.js +1 -1
- package/dist/wallet/lifecycle.js +64 -5
- package/dist/wallet/runtime.d.ts +2 -0
- package/dist/wallet/runtime.js +2 -0
- package/dist/wallet/state/pending-init.d.ts +24 -0
- package/dist/wallet/state/pending-init.js +59 -0
- package/dist/wallet/state/provider.d.ts +1 -0
- package/dist/wallet/state/provider.js +7 -1
- package/dist/wallet/types.d.ts +8 -0
- 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 {
|
|
4
|
-
import {
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
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 -
|
|
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
|
|
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 (
|
|
147
|
+
if (existingFull?.size === metadata.sizeBytes) {
|
|
70
148
|
try {
|
|
71
149
|
await validateSnapshotFileForTesting(paths.snapshotPath, metadata);
|
|
72
|
-
state
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
let
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
4
|
-
|
|
5
|
-
|
|
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 {
|
|
3
|
+
import { BOOTSTRAP_STATE_VERSION, DEFAULT_SNAPSHOT_METADATA, } from "./constants.js";
|
|
4
4
|
function createInitialBootstrapState(snapshot) {
|
|
5
5
|
return {
|
|
6
|
-
metadataVersion:
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
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 () =>
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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();
|