@cogcoin/client 0.5.13 → 0.5.14

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # `@cogcoin/client`
2
2
 
3
- `@cogcoin/client@0.5.13` is the store-backed Cogcoin client package for applications that want a local wallet, durable SQLite-backed state, and a managed Bitcoin Core integration around `@cogcoin/indexer`. It publishes the reusable client APIs, the SQLite adapter, the managed `bitcoind` integration, and the first-party `cogcoin` CLI in one package.
3
+ `@cogcoin/client@0.5.14` is the store-backed Cogcoin client package for applications that want a local wallet, durable SQLite-backed state, and a managed Bitcoin Core integration around `@cogcoin/indexer`. It publishes the reusable client APIs, the SQLite adapter, the managed `bitcoind` integration, and the first-party `cogcoin` CLI in one package.
4
4
 
5
5
  Use Node 22 or newer.
6
6
 
@@ -1,5 +1,5 @@
1
1
  import type { ManagedProgressController } from "../progress.js";
2
- import type { GetblockArchiveManifest } from "../types.js";
2
+ import type { GetblockArchiveManifest, GetblockRangeManifest } from "../types.js";
3
3
  interface GetblockArchivePaths {
4
4
  directory: string;
5
5
  artifactPath: string;
@@ -10,8 +10,27 @@ export interface ReadyGetblockArchive {
10
10
  manifest: GetblockArchiveManifest;
11
11
  artifactPath: string;
12
12
  }
13
+ export interface RefreshedGetblockManifest {
14
+ manifest: GetblockRangeManifest | null;
15
+ source: "remote" | "cache" | "none";
16
+ }
17
+ export declare function refreshGetblockManifestCache(options: {
18
+ dataDir: string;
19
+ fetchImpl?: typeof fetch;
20
+ signal?: AbortSignal;
21
+ persist?: boolean;
22
+ }): Promise<RefreshedGetblockManifest>;
23
+ export declare function resolveGetblockArchiveRange(manifest: GetblockRangeManifest, firstBlockHeight: number, lastBlockHeight: number): GetblockArchiveManifest | null;
24
+ export declare function resolveGetblockArchiveRangeForHeight(manifest: GetblockRangeManifest, height: number): GetblockArchiveManifest | null;
13
25
  export declare function resolveGetblockArchivePathsForTesting(dataDir: string, firstBlockHeight?: number, lastBlockHeight?: number): GetblockArchivePaths;
14
26
  export declare function resolveReadyGetblockArchiveForTesting(dataDir: string, manifest: GetblockArchiveManifest): Promise<ReadyGetblockArchive | null>;
27
+ export declare function preparePublishedGetblockArchiveRange(options: {
28
+ dataDir: string;
29
+ progress: Pick<ManagedProgressController, "setPhase">;
30
+ manifest: GetblockArchiveManifest;
31
+ fetchImpl?: typeof fetch;
32
+ signal?: AbortSignal;
33
+ }): Promise<ReadyGetblockArchive>;
15
34
  export declare function prepareGetblockArchiveRange(options: {
16
35
  dataDir: string;
17
36
  progress: Pick<ManagedProgressController, "setPhase">;
@@ -33,7 +52,10 @@ export declare function prepareLatestGetblockArchive(options: {
33
52
  }): Promise<ReadyGetblockArchive | null>;
34
53
  export declare const prepareLatestGetblockArchiveForTesting: typeof prepareLatestGetblockArchive;
35
54
  export declare const prepareGetblockArchiveRangeForTesting: typeof prepareGetblockArchiveRange;
55
+ export declare const preparePublishedGetblockArchiveRangeForTesting: typeof preparePublishedGetblockArchiveRange;
36
56
  export declare const deleteGetblockArchiveRangeForTesting: typeof deleteGetblockArchiveRange;
57
+ export declare const refreshGetblockManifestCacheForTesting: typeof refreshGetblockManifestCache;
58
+ export declare const resolveGetblockArchiveRangeForHeightForTesting: typeof resolveGetblockArchiveRangeForHeight;
37
59
  export declare function waitForGetblockArchiveImportForTesting(rpc: Pick<{
38
60
  getBlockchainInfo(): Promise<{
39
61
  blocks: number;
@@ -6,6 +6,7 @@ const GETBLOCK_ARCHIVE_STATE_VERSION = 1;
6
6
  const GETBLOCK_ARCHIVE_BASE_HEIGHT = 910_000;
7
7
  const GETBLOCK_ARCHIVE_RANGE_SIZE = 500;
8
8
  const GETBLOCK_ARCHIVE_FIRST_HEIGHT = GETBLOCK_ARCHIVE_BASE_HEIGHT + 1;
9
+ const GETBLOCK_ARCHIVE_MANIFEST_FILENAME = "getblock-manifest.json";
9
10
  const GETBLOCK_ARCHIVE_REMOTE_BASE_URL = "https://snapshots.cogcoin.org/";
10
11
  const GETBLOCK_ARCHIVE_REMOTE_MANIFEST_URL = `${GETBLOCK_ARCHIVE_REMOTE_BASE_URL}getblock-manifest.json`;
11
12
  const TRUSTED_FRONTIER_REVERIFY_CHUNKS = 2;
@@ -14,6 +15,9 @@ const IMPORT_POLL_MS = 2_000;
14
15
  function buildRangeFilename(firstBlockHeight, lastBlockHeight) {
15
16
  return `getblock-${firstBlockHeight}-${lastBlockHeight}.dat`;
16
17
  }
18
+ function resolveManifestCachePath(dataDir) {
19
+ return join(dataDir, "bootstrap", "getblock", GETBLOCK_ARCHIVE_MANIFEST_FILENAME);
20
+ }
17
21
  function createInitialState() {
18
22
  return {
19
23
  metadataVersion: GETBLOCK_ARCHIVE_STATE_VERSION,
@@ -117,6 +121,12 @@ async function writeJsonAtomic(path, payload) {
117
121
  await writeFile(tempPath, JSON.stringify(payload, null, 2));
118
122
  await rename(tempPath, path);
119
123
  }
124
+ async function writeTextAtomic(path, payload) {
125
+ await mkdir(dirname(path), { recursive: true });
126
+ const tempPath = `${path}.tmp`;
127
+ await writeFile(tempPath, payload);
128
+ await rename(tempPath, path);
129
+ }
120
130
  async function loadState(paths) {
121
131
  try {
122
132
  const parsed = JSON.parse(await readFile(paths.statePath, "utf8"));
@@ -466,16 +476,74 @@ async function downloadRemoteArchive(paths, manifest, state, progress, fetchImpl
466
476
  }
467
477
  }
468
478
  async function fetchManifestRange(fetchImpl, firstBlockHeight, lastBlockHeight, signal) {
469
- const response = await fetchImpl(GETBLOCK_ARCHIVE_REMOTE_MANIFEST_URL, { signal });
470
- if (response.status === 404) {
479
+ const refreshed = await refreshGetblockManifestCache({
480
+ dataDir: "",
481
+ fetchImpl,
482
+ signal,
483
+ persist: false,
484
+ });
485
+ if (refreshed.manifest === null) {
471
486
  return null;
472
487
  }
488
+ return resolveGetblockArchiveRange(refreshed.manifest, firstBlockHeight, lastBlockHeight);
489
+ }
490
+ async function fetchAggregateManifest(fetchImpl, signal) {
491
+ const response = await fetchImpl(GETBLOCK_ARCHIVE_REMOTE_MANIFEST_URL, { signal });
473
492
  if (!response.ok) {
474
493
  throw new Error(`managed_getblock_archive_manifest_http_${response.status}`);
475
494
  }
476
- const manifest = assertAggregateManifestShape(await response.json());
495
+ const rawText = await response.text();
496
+ return {
497
+ manifest: assertAggregateManifestShape(JSON.parse(rawText)),
498
+ rawText,
499
+ };
500
+ }
501
+ async function loadCachedAggregateManifest(dataDir) {
502
+ try {
503
+ return assertAggregateManifestShape(JSON.parse(await readFile(resolveManifestCachePath(dataDir), "utf8")));
504
+ }
505
+ catch (error) {
506
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
507
+ return null;
508
+ }
509
+ return null;
510
+ }
511
+ }
512
+ async function saveCachedAggregateManifest(dataDir, rawText) {
513
+ await writeTextAtomic(resolveManifestCachePath(dataDir), rawText);
514
+ }
515
+ export async function refreshGetblockManifestCache(options) {
516
+ const fetchImpl = options.fetchImpl ?? fetch;
517
+ try {
518
+ const { manifest, rawText } = await fetchAggregateManifest(fetchImpl, options.signal);
519
+ if (options.persist !== false) {
520
+ await saveCachedAggregateManifest(options.dataDir, rawText).catch(() => undefined);
521
+ }
522
+ return {
523
+ manifest,
524
+ source: "remote",
525
+ };
526
+ }
527
+ catch {
528
+ if (options.persist === false) {
529
+ return {
530
+ manifest: null,
531
+ source: "none",
532
+ };
533
+ }
534
+ const cachedManifest = await loadCachedAggregateManifest(options.dataDir);
535
+ return {
536
+ manifest: cachedManifest,
537
+ source: cachedManifest === null ? "none" : "cache",
538
+ };
539
+ }
540
+ }
541
+ export function resolveGetblockArchiveRange(manifest, firstBlockHeight, lastBlockHeight) {
477
542
  return manifest.ranges.find((range) => range.firstBlockHeight === firstBlockHeight && range.lastBlockHeight === lastBlockHeight) ?? null;
478
543
  }
544
+ export function resolveGetblockArchiveRangeForHeight(manifest, height) {
545
+ return manifest.ranges.find((range) => range.firstBlockHeight <= height && height <= range.lastBlockHeight) ?? null;
546
+ }
479
547
  export function resolveGetblockArchivePathsForTesting(dataDir, firstBlockHeight = GETBLOCK_ARCHIVE_FIRST_HEIGHT, lastBlockHeight = GETBLOCK_ARCHIVE_BASE_HEIGHT + GETBLOCK_ARCHIVE_RANGE_SIZE) {
480
548
  return resolvePaths(dataDir, firstBlockHeight, lastBlockHeight);
481
549
  }
@@ -512,49 +580,68 @@ async function resolveReadyLocalGetblockArchive(paths, manifest, state = null) {
512
580
  export async function resolveReadyGetblockArchiveForTesting(dataDir, manifest) {
513
581
  return resolveReadyLocalGetblockArchive(resolvePaths(dataDir, manifest.firstBlockHeight, manifest.lastBlockHeight), manifest);
514
582
  }
583
+ export async function preparePublishedGetblockArchiveRange(options) {
584
+ const paths = resolvePaths(options.dataDir, options.manifest.firstBlockHeight, options.manifest.lastBlockHeight);
585
+ await mkdir(paths.directory, { recursive: true });
586
+ const state = await loadState(paths);
587
+ const readyLocal = await resolveReadyLocalGetblockArchive(paths, options.manifest, state);
588
+ if (readyLocal !== null) {
589
+ return readyLocal;
590
+ }
591
+ await downloadRemoteArchive(paths, options.manifest, state, options.progress, options.fetchImpl ?? fetch, options.signal);
592
+ const ready = await resolveReadyLocalGetblockArchive(paths, options.manifest, state);
593
+ if (ready === null) {
594
+ throw new Error("managed_getblock_archive_ready_resolution_failed");
595
+ }
596
+ return ready;
597
+ }
515
598
  export async function prepareGetblockArchiveRange(options) {
516
599
  const paths = resolvePaths(options.dataDir, options.firstBlockHeight, options.lastBlockHeight);
517
600
  await mkdir(paths.directory, { recursive: true });
518
601
  const state = await loadState(paths);
519
602
  const fetchImpl = options.fetchImpl ?? fetch;
520
- let remoteManifest;
521
- try {
522
- remoteManifest = await fetchManifestRange(fetchImpl, options.firstBlockHeight, options.lastBlockHeight, options.signal);
603
+ const refreshed = await refreshGetblockManifestCache({
604
+ dataDir: options.dataDir,
605
+ fetchImpl,
606
+ signal: options.signal,
607
+ });
608
+ const publishedRange = refreshed.manifest === null
609
+ ? null
610
+ : resolveGetblockArchiveRange(refreshed.manifest, options.firstBlockHeight, options.lastBlockHeight);
611
+ if (publishedRange !== null) {
612
+ return preparePublishedGetblockArchiveRange({
613
+ dataDir: options.dataDir,
614
+ progress: options.progress,
615
+ manifest: publishedRange,
616
+ fetchImpl,
617
+ signal: options.signal,
618
+ });
523
619
  }
524
- catch {
525
- const readyLocal = {
526
- formatVersion: state.formatVersion,
527
- chain: "main",
528
- baseSnapshotHeight: GETBLOCK_ARCHIVE_BASE_HEIGHT,
529
- firstBlockHeight: state.firstBlockHeight ?? options.firstBlockHeight,
530
- lastBlockHeight: state.lastBlockHeight ?? options.lastBlockHeight,
531
- artifactFilename: buildRangeFilename(state.firstBlockHeight ?? options.firstBlockHeight, state.lastBlockHeight ?? options.lastBlockHeight),
532
- artifactSizeBytes: state.artifactSizeBytes,
533
- artifactSha256: state.artifactSha256 ?? "",
534
- chunkSizeBytes: state.chunkSizeBytes,
535
- chunkSha256s: [],
536
- };
537
- if (state.validated
538
- && state.firstBlockHeight === options.firstBlockHeight
539
- && state.lastBlockHeight === options.lastBlockHeight
540
- && state.artifactSha256 !== null) {
541
- const resolved = await resolveReadyLocalGetblockArchive(paths, readyLocal, state).catch(() => null);
542
- if (resolved !== null) {
543
- return resolved;
544
- }
620
+ const readyLocal = {
621
+ formatVersion: state.formatVersion,
622
+ chain: "main",
623
+ baseSnapshotHeight: GETBLOCK_ARCHIVE_BASE_HEIGHT,
624
+ firstBlockHeight: state.firstBlockHeight ?? options.firstBlockHeight,
625
+ lastBlockHeight: state.lastBlockHeight ?? options.lastBlockHeight,
626
+ artifactFilename: buildRangeFilename(state.firstBlockHeight ?? options.firstBlockHeight, state.lastBlockHeight ?? options.lastBlockHeight),
627
+ artifactSizeBytes: state.artifactSizeBytes,
628
+ artifactSha256: state.artifactSha256 ?? "",
629
+ chunkSizeBytes: state.chunkSizeBytes,
630
+ chunkSha256s: [],
631
+ };
632
+ if (state.validated
633
+ && state.firstBlockHeight === options.firstBlockHeight
634
+ && state.lastBlockHeight === options.lastBlockHeight
635
+ && state.artifactSha256 !== null) {
636
+ const resolved = await resolveReadyLocalGetblockArchive(paths, readyLocal, state).catch(() => null);
637
+ if (resolved !== null) {
638
+ return resolved;
545
639
  }
546
- throw new Error("managed_getblock_archive_manifest_refresh_failed");
547
- }
548
- if (remoteManifest === null) {
549
- return null;
550
640
  }
551
- const readyLocal = await resolveReadyLocalGetblockArchive(paths, remoteManifest, state);
552
- if (readyLocal !== null
553
- && readyLocal.manifest.artifactSha256 === remoteManifest.artifactSha256) {
554
- return readyLocal;
641
+ if (refreshed.source === "none") {
642
+ throw new Error("managed_getblock_archive_manifest_refresh_failed");
555
643
  }
556
- await downloadRemoteArchive(paths, remoteManifest, state, options.progress, fetchImpl, options.signal);
557
- return resolveReadyLocalGetblockArchive(paths, remoteManifest, state);
644
+ return null;
558
645
  }
559
646
  export async function deleteGetblockArchiveRange(options) {
560
647
  const paths = resolvePaths(options.dataDir, options.firstBlockHeight, options.lastBlockHeight);
@@ -571,7 +658,10 @@ export async function prepareLatestGetblockArchive(options) {
571
658
  }
572
659
  export const prepareLatestGetblockArchiveForTesting = prepareLatestGetblockArchive;
573
660
  export const prepareGetblockArchiveRangeForTesting = prepareGetblockArchiveRange;
661
+ export const preparePublishedGetblockArchiveRangeForTesting = preparePublishedGetblockArchiveRange;
574
662
  export const deleteGetblockArchiveRangeForTesting = deleteGetblockArchiveRange;
663
+ export const refreshGetblockManifestCacheForTesting = refreshGetblockManifestCache;
664
+ export const resolveGetblockArchiveRangeForHeightForTesting = resolveGetblockArchiveRangeForHeight;
575
665
  export async function waitForGetblockArchiveImportForTesting(rpc, progress, targetEndHeight, signal) {
576
666
  await waitForGetblockArchiveImport(rpc, progress, targetEndHeight, signal);
577
667
  }
@@ -1,7 +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 { deleteGetblockArchiveRange, deleteGetblockArchiveRangeForTesting, prepareGetblockArchiveRange, prepareGetblockArchiveRangeForTesting, prepareLatestGetblockArchive, prepareLatestGetblockArchiveForTesting, resolveGetblockArchivePathsForTesting, resolveReadyGetblockArchiveForTesting, waitForGetblockArchiveImport, waitForGetblockArchiveImportForTesting, } from "./bootstrap/getblock-archive.js";
4
+ export { deleteGetblockArchiveRange, deleteGetblockArchiveRangeForTesting, preparePublishedGetblockArchiveRange, preparePublishedGetblockArchiveRangeForTesting, prepareGetblockArchiveRange, prepareGetblockArchiveRangeForTesting, prepareLatestGetblockArchive, prepareLatestGetblockArchiveForTesting, refreshGetblockManifestCache, refreshGetblockManifestCacheForTesting, resolveGetblockArchiveRange, resolveGetblockArchiveRangeForHeight, resolveGetblockArchiveRangeForHeightForTesting, resolveGetblockArchivePathsForTesting, resolveReadyGetblockArchiveForTesting, waitForGetblockArchiveImport, waitForGetblockArchiveImportForTesting, } from "./bootstrap/getblock-archive.js";
5
5
  export { waitForHeadersForTesting } from "./bootstrap/headers.js";
6
6
  export { resolveBootstrapPathsForTesting } from "./bootstrap/paths.js";
7
7
  export { loadBootstrapStateForTesting, saveBootstrapStateForTesting, createBootstrapStateForTesting, } from "./bootstrap/state.js";
@@ -1,7 +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 { deleteGetblockArchiveRange, deleteGetblockArchiveRangeForTesting, prepareGetblockArchiveRange, prepareGetblockArchiveRangeForTesting, prepareLatestGetblockArchive, prepareLatestGetblockArchiveForTesting, resolveGetblockArchivePathsForTesting, resolveReadyGetblockArchiveForTesting, waitForGetblockArchiveImport, waitForGetblockArchiveImportForTesting, } from "./bootstrap/getblock-archive.js";
4
+ export { deleteGetblockArchiveRange, deleteGetblockArchiveRangeForTesting, preparePublishedGetblockArchiveRange, preparePublishedGetblockArchiveRangeForTesting, prepareGetblockArchiveRange, prepareGetblockArchiveRangeForTesting, prepareLatestGetblockArchive, prepareLatestGetblockArchiveForTesting, refreshGetblockManifestCache, refreshGetblockManifestCacheForTesting, resolveGetblockArchiveRange, resolveGetblockArchiveRangeForHeight, resolveGetblockArchiveRangeForHeightForTesting, resolveGetblockArchivePathsForTesting, resolveReadyGetblockArchiveForTesting, waitForGetblockArchiveImport, waitForGetblockArchiveImportForTesting, } from "./bootstrap/getblock-archive.js";
5
5
  export { waitForHeadersForTesting } from "./bootstrap/headers.js";
6
6
  export { resolveBootstrapPathsForTesting } from "./bootstrap/paths.js";
7
7
  export { loadBootstrapStateForTesting, saveBootstrapStateForTesting, createBootstrapStateForTesting, } from "./bootstrap/state.js";
@@ -1,4 +1,4 @@
1
- import { AssumeUtxoBootstrapController, deleteGetblockArchiveRange, prepareGetblockArchiveRange, } from "../bootstrap.js";
1
+ import { AssumeUtxoBootstrapController, deleteGetblockArchiveRange, preparePublishedGetblockArchiveRange, refreshGetblockManifestCache, resolveGetblockArchiveRangeForHeight, } from "../bootstrap.js";
2
2
  import { createRpcClient } from "../node.js";
3
3
  import { attachOrStartManagedBitcoindService, stopManagedBitcoindService, } from "../service.js";
4
4
  import { closeFollowLoopResources, scheduleSync, startFollowingTipLoop } from "./follow-loop.js";
@@ -6,21 +6,6 @@ import { loadVisibleFollowBlockTimes } from "./follow-block-times.js";
6
6
  import { createBlockRateTracker, createInitialSyncResult, } from "./internal-types.js";
7
7
  import { syncToTip as runManagedSync } from "./sync-engine.js";
8
8
  const GETBLOCK_RANGE_BASE_HEIGHT = 910_000;
9
- const GETBLOCK_RANGE_SIZE = 500;
10
- function isBoundaryHeight(height) {
11
- return height >= GETBLOCK_RANGE_BASE_HEIGHT
12
- && (height - GETBLOCK_RANGE_BASE_HEIGHT) % GETBLOCK_RANGE_SIZE === 0;
13
- }
14
- function resolveNextBoundary(height) {
15
- if (height < GETBLOCK_RANGE_BASE_HEIGHT) {
16
- return GETBLOCK_RANGE_BASE_HEIGHT;
17
- }
18
- if (isBoundaryHeight(height)) {
19
- return height;
20
- }
21
- return GETBLOCK_RANGE_BASE_HEIGHT
22
- + Math.ceil((height - GETBLOCK_RANGE_BASE_HEIGHT) / GETBLOCK_RANGE_SIZE) * GETBLOCK_RANGE_SIZE;
23
- }
24
9
  function mergeSyncResults(target, source) {
25
10
  target.appliedBlocks += source.appliedBlocks;
26
11
  target.rewoundBlocks += source.rewoundBlocks;
@@ -218,56 +203,93 @@ export class DefaultManagedBitcoindClient {
218
203
  this.#assertOpen();
219
204
  await this.#progress.playCompletionScene();
220
205
  }
206
+ async #setGetblockStatusMessage(currentHeight, message, targetHeight = currentHeight) {
207
+ const safeTargetHeight = Math.max(currentHeight, targetHeight);
208
+ await this.#progress.setPhase("bitcoin_sync", {
209
+ blocks: currentHeight,
210
+ headers: safeTargetHeight,
211
+ targetHeight: safeTargetHeight,
212
+ etaSeconds: null,
213
+ lastError: null,
214
+ message,
215
+ });
216
+ }
217
+ async #refreshGetblockManifest(currentHeight, abortSignal, mode) {
218
+ await this.#setGetblockStatusMessage(currentHeight, mode === "startup"
219
+ ? "Fetching Getblock manifest."
220
+ : "Refreshing Getblock manifest.");
221
+ const refreshed = await refreshGetblockManifestCache({
222
+ dataDir: this.#dataDir,
223
+ fetchImpl: this.#fetchImpl,
224
+ signal: abortSignal,
225
+ });
226
+ if (refreshed.source === "remote" && refreshed.manifest !== null) {
227
+ await this.#setGetblockStatusMessage(currentHeight, `Getblock manifest ready through height ${refreshed.manifest.publishedThroughHeight.toLocaleString()}.`, refreshed.manifest.publishedThroughHeight);
228
+ return refreshed.manifest;
229
+ }
230
+ if (refreshed.source === "cache" && refreshed.manifest !== null) {
231
+ await this.#setGetblockStatusMessage(currentHeight, `Warning: Getblock manifest fetch failed; using cached manifest through height ${refreshed.manifest.publishedThroughHeight.toLocaleString()}.`, refreshed.manifest.publishedThroughHeight);
232
+ return refreshed.manifest;
233
+ }
234
+ await this.#setGetblockStatusMessage(currentHeight, "Warning: Getblock manifest fetch failed and no cached manifest is available; continuing with ordinary Bitcoin sync.");
235
+ return null;
236
+ }
221
237
  async #syncWithStagedRanges(abortSignal) {
222
238
  const aggregate = createInitialSyncResult();
223
- let stagedModeEnabled = true;
224
239
  await this.#ensureBootstrapReady(abortSignal);
225
- while (stagedModeEnabled) {
226
- const info = await this.#rpc.getBlockchainInfo();
227
- if (info.blocks < GETBLOCK_RANGE_BASE_HEIGHT) {
228
- break;
240
+ let info = await this.#rpc.getBlockchainInfo();
241
+ if (info.blocks < GETBLOCK_RANGE_BASE_HEIGHT) {
242
+ mergeSyncResults(aggregate, await this.#runManagedSyncPass(null, abortSignal));
243
+ return aggregate;
244
+ }
245
+ let manifest = await this.#refreshGetblockManifest(info.blocks, abortSignal, "startup");
246
+ while (manifest !== null) {
247
+ const nextMissingHeight = info.blocks + 1;
248
+ if (nextMissingHeight > manifest.publishedThroughHeight) {
249
+ const refreshed = await this.#refreshGetblockManifest(info.blocks, abortSignal, "refresh");
250
+ if (refreshed === null || refreshed.publishedThroughHeight < nextMissingHeight) {
251
+ manifest = refreshed;
252
+ break;
253
+ }
254
+ manifest = refreshed;
229
255
  }
230
- const nextBoundary = resolveNextBoundary(info.blocks);
231
- if (nextBoundary === null) {
256
+ if (manifest === null) {
232
257
  break;
233
258
  }
234
- if (!isBoundaryHeight(info.blocks)) {
235
- mergeSyncResults(aggregate, await this.#runManagedSyncPass(nextBoundary, abortSignal));
236
- continue;
259
+ const selectedRange = resolveGetblockArchiveRangeForHeight(manifest, nextMissingHeight);
260
+ if (selectedRange === null) {
261
+ await this.#setGetblockStatusMessage(info.blocks, `Warning: Getblock manifest has no published range for next missing block ${nextMissingHeight.toLocaleString()}; continuing with ordinary Bitcoin sync.`);
262
+ break;
237
263
  }
238
- const firstBlockHeight = info.blocks + 1;
239
- const lastBlockHeight = info.blocks + GETBLOCK_RANGE_SIZE;
264
+ await this.#setGetblockStatusMessage(info.blocks, `Using Getblock range ${selectedRange.firstBlockHeight.toLocaleString()}-${selectedRange.lastBlockHeight.toLocaleString()} for current Bitcoin height ${info.blocks.toLocaleString()} (next missing block ${nextMissingHeight.toLocaleString()}).`, selectedRange.lastBlockHeight);
240
265
  let readyRange;
241
266
  try {
242
- readyRange = await prepareGetblockArchiveRange({
267
+ readyRange = await preparePublishedGetblockArchiveRange({
243
268
  dataDir: this.#dataDir,
244
269
  progress: this.#progress,
245
- firstBlockHeight,
246
- lastBlockHeight,
270
+ manifest: selectedRange,
247
271
  fetchImpl: this.#fetchImpl,
248
272
  signal: abortSignal,
249
273
  });
250
274
  }
251
275
  catch {
252
- stagedModeEnabled = false;
253
- break;
254
- }
255
- if (readyRange === null) {
256
- stagedModeEnabled = false;
276
+ await this.#setGetblockStatusMessage(info.blocks, "Warning: Getblock range staging failed; continuing with ordinary Bitcoin sync.");
257
277
  break;
258
278
  }
259
279
  const stagedRestartActive = await this.#restartManagedNodeWithRange(readyRange, abortSignal);
260
- mergeSyncResults(aggregate, await this.#runManagedSyncPass(lastBlockHeight, abortSignal));
280
+ mergeSyncResults(aggregate, await this.#runManagedSyncPass(selectedRange.lastBlockHeight, abortSignal));
261
281
  if (stagedRestartActive) {
262
282
  await deleteGetblockArchiveRange({
263
283
  dataDir: this.#dataDir,
264
- firstBlockHeight,
265
- lastBlockHeight,
284
+ firstBlockHeight: selectedRange.firstBlockHeight,
285
+ lastBlockHeight: selectedRange.lastBlockHeight,
266
286
  }).catch(() => undefined);
267
287
  }
268
288
  else {
269
- stagedModeEnabled = false;
289
+ await this.#setGetblockStatusMessage(selectedRange.lastBlockHeight, "Warning: Restarting with the Getblock archive failed; continuing with ordinary Bitcoin sync.");
290
+ break;
270
291
  }
292
+ info = await this.#rpc.getBlockchainInfo();
271
293
  }
272
294
  mergeSyncResults(aggregate, await this.#runManagedSyncPass(null, abortSignal));
273
295
  return aggregate;
@@ -5,7 +5,7 @@ export { attachOrStartIndexerDaemon, readIndexerDaemonStatusForTesting, stopInde
5
5
  export { normalizeRpcBlock } from "./normalize.js";
6
6
  export { BitcoinRpcClient } from "./rpc.js";
7
7
  export { attachOrStartManagedBitcoindService, buildManagedServiceArgsForTesting, readManagedBitcoindServiceStatusForTesting, resolveManagedBitcoindDbcacheMiB, stopManagedBitcoindService, shutdownManagedBitcoindServiceForTesting, writeBitcoinConfForTesting, } from "./service.js";
8
- export { AssumeUtxoBootstrapController, DEFAULT_SNAPSHOT_METADATA, createBootstrapStateForTesting, deleteGetblockArchiveRangeForTesting, downloadSnapshotFileForTesting, loadBootstrapStateForTesting, prepareGetblockArchiveRangeForTesting, prepareLatestGetblockArchiveForTesting, resolveBootstrapPathsForTesting, resolveGetblockArchivePathsForTesting, resolveReadyGetblockArchiveForTesting, saveBootstrapStateForTesting, validateSnapshotFileForTesting, waitForGetblockArchiveImportForTesting, waitForHeadersForTesting, } from "./bootstrap.js";
8
+ export { AssumeUtxoBootstrapController, DEFAULT_SNAPSHOT_METADATA, createBootstrapStateForTesting, deleteGetblockArchiveRangeForTesting, downloadSnapshotFileForTesting, loadBootstrapStateForTesting, preparePublishedGetblockArchiveRangeForTesting, prepareGetblockArchiveRangeForTesting, prepareLatestGetblockArchiveForTesting, refreshGetblockManifestCacheForTesting, resolveBootstrapPathsForTesting, resolveGetblockArchivePathsForTesting, resolveGetblockArchiveRangeForHeightForTesting, resolveReadyGetblockArchiveForTesting, saveBootstrapStateForTesting, validateSnapshotFileForTesting, waitForGetblockArchiveImportForTesting, waitForHeadersForTesting, } from "./bootstrap.js";
9
9
  export { buildBitcoindArgsForTesting, createRpcClient, launchManagedBitcoindNode, resolveDefaultBitcoindDataDirForTesting, validateNodeConfigForTesting, } from "./node.js";
10
10
  export { ManagedProgressController, TtyProgressRenderer, advanceFollowSceneStateForTesting, createFollowSceneStateForTesting, createBootstrapProgressForTesting, formatCompactFollowAgeLabelForTesting, loadBannerArtForTesting, loadScrollArtForTesting, loadTrainCarArtForTesting, loadTrainArtForTesting, loadTrainSmokeArtForTesting, formatProgressLineForTesting, formatQuoteLineForTesting, renderArtFrameForTesting, renderCompletionFrameForTesting, renderFollowFrameForTesting, renderIntroFrameForTesting, resolveCompletionMessageForTesting, resolveIntroMessageForTesting, resolveStatusFieldTextForTesting, setFollowBlockTimeForTesting, setFollowBlockTimesForTesting, syncFollowSceneStateForTesting, } from "./progress.js";
11
11
  export { WritingQuoteRotator, loadWritingQuotesForTesting, shuffleIndicesForTesting, } from "./quotes.js";
@@ -5,7 +5,7 @@ export { attachOrStartIndexerDaemon, readIndexerDaemonStatusForTesting, stopInde
5
5
  export { normalizeRpcBlock } from "./normalize.js";
6
6
  export { BitcoinRpcClient } from "./rpc.js";
7
7
  export { attachOrStartManagedBitcoindService, buildManagedServiceArgsForTesting, readManagedBitcoindServiceStatusForTesting, resolveManagedBitcoindDbcacheMiB, stopManagedBitcoindService, shutdownManagedBitcoindServiceForTesting, writeBitcoinConfForTesting, } from "./service.js";
8
- export { AssumeUtxoBootstrapController, DEFAULT_SNAPSHOT_METADATA, createBootstrapStateForTesting, deleteGetblockArchiveRangeForTesting, downloadSnapshotFileForTesting, loadBootstrapStateForTesting, prepareGetblockArchiveRangeForTesting, prepareLatestGetblockArchiveForTesting, resolveBootstrapPathsForTesting, resolveGetblockArchivePathsForTesting, resolveReadyGetblockArchiveForTesting, saveBootstrapStateForTesting, validateSnapshotFileForTesting, waitForGetblockArchiveImportForTesting, waitForHeadersForTesting, } from "./bootstrap.js";
8
+ export { AssumeUtxoBootstrapController, DEFAULT_SNAPSHOT_METADATA, createBootstrapStateForTesting, deleteGetblockArchiveRangeForTesting, downloadSnapshotFileForTesting, loadBootstrapStateForTesting, preparePublishedGetblockArchiveRangeForTesting, prepareGetblockArchiveRangeForTesting, prepareLatestGetblockArchiveForTesting, refreshGetblockManifestCacheForTesting, resolveBootstrapPathsForTesting, resolveGetblockArchivePathsForTesting, resolveGetblockArchiveRangeForHeightForTesting, resolveReadyGetblockArchiveForTesting, saveBootstrapStateForTesting, validateSnapshotFileForTesting, waitForGetblockArchiveImportForTesting, waitForHeadersForTesting, } from "./bootstrap.js";
9
9
  export { buildBitcoindArgsForTesting, createRpcClient, launchManagedBitcoindNode, resolveDefaultBitcoindDataDirForTesting, validateNodeConfigForTesting, } from "./node.js";
10
10
  export { ManagedProgressController, TtyProgressRenderer, advanceFollowSceneStateForTesting, createFollowSceneStateForTesting, createBootstrapProgressForTesting, formatCompactFollowAgeLabelForTesting, loadBannerArtForTesting, loadScrollArtForTesting, loadTrainCarArtForTesting, loadTrainArtForTesting, loadTrainSmokeArtForTesting, formatProgressLineForTesting, formatQuoteLineForTesting, renderArtFrameForTesting, renderCompletionFrameForTesting, renderFollowFrameForTesting, renderIntroFrameForTesting, resolveCompletionMessageForTesting, resolveIntroMessageForTesting, resolveStatusFieldTextForTesting, setFollowBlockTimeForTesting, setFollowBlockTimesForTesting, syncFollowSceneStateForTesting, } from "./progress.js";
11
11
  export { WritingQuoteRotator, loadWritingQuotesForTesting, shuffleIndicesForTesting, } from "./quotes.js";
@@ -24,7 +24,7 @@ export async function runStatusCommand(parsed, context) {
24
24
  }));
25
25
  return 0;
26
26
  }
27
- writeLine(context.stdout, formatWalletOverviewReport(readContext));
27
+ writeLine(context.stdout, formatWalletOverviewReport(readContext, await context.readPackageVersion()));
28
28
  return 0;
29
29
  }
30
30
  finally {
@@ -1,14 +1,106 @@
1
1
  import { dirname } from "node:path";
2
2
  import { formatManagedSyncErrorMessage } from "../../bitcoind/errors.js";
3
+ import { formatBytes, formatDuration } from "../../bitcoind/progress/formatting.js";
3
4
  import { FileLockBusyError, acquireFileLock } from "../../wallet/fs/lock.js";
4
5
  import { resolveWalletRootIdFromLocalArtifacts } from "../../wallet/root-resolution.js";
5
- import { writeLine } from "../io.js";
6
+ import { usesTtyProgress, writeLine } from "../io.js";
6
7
  import { classifyCliError, formatCliTextError } from "../output.js";
7
8
  import { createStopSignalWatcher, waitForCompletionOrStop } from "../signals.js";
9
+ const SYNC_PROGRESS_LOG_INTERVAL_MS = 5_000;
10
+ function createSyncProgressReporter(options) {
11
+ let lastPhase = null;
12
+ let lastMessage = "";
13
+ let lastDownloadPrintedAt = 0;
14
+ let lastDownloadBytes = null;
15
+ let lastImportPrintedAt = 0;
16
+ let lastImportBlocks = null;
17
+ const infoEnabled = options.progressOutput !== "none";
18
+ function shouldPrintEntryMessage(message, phase) {
19
+ if (message === "Waiting to start managed sync." || message === "Sync complete.") {
20
+ return false;
21
+ }
22
+ if (message.startsWith("Warning:")) {
23
+ return true;
24
+ }
25
+ if (!infoEnabled) {
26
+ return false;
27
+ }
28
+ if (phase === "getblock_archive_download" || phase === "getblock_archive_import") {
29
+ return true;
30
+ }
31
+ return phase === "snapshot_download"
32
+ || phase === "wait_headers_for_snapshot"
33
+ || phase === "load_snapshot"
34
+ || phase === "bitcoin_sync"
35
+ || phase === "cogcoin_sync"
36
+ || message.includes("Getblock manifest")
37
+ || message.startsWith("Fetching Getblock manifest.")
38
+ || message.startsWith("Refreshing Getblock manifest.")
39
+ || message.startsWith("Using Getblock range ");
40
+ }
41
+ function formatDownloadLine(label, event) {
42
+ const current = event.progress.downloadedBytes ?? 0;
43
+ const total = event.progress.totalBytes ?? 0;
44
+ const percent = event.progress.percent ?? (total > 0 ? (current / total) * 100 : 0);
45
+ const speed = event.progress.bytesPerSecond === null ? "--" : `${formatBytes(event.progress.bytesPerSecond)}/s`;
46
+ return `${label}: ${percent.toFixed(2)}% (${formatBytes(current)} / ${formatBytes(total)}, ${speed}, ETA ${formatDuration(event.progress.etaSeconds)})`;
47
+ }
48
+ return (event) => {
49
+ const message = event.progress.message.trim();
50
+ const phaseChanged = event.phase !== lastPhase;
51
+ const messageChanged = message !== lastMessage;
52
+ if ((phaseChanged || messageChanged) && shouldPrintEntryMessage(message, event.phase)) {
53
+ options.write(message);
54
+ }
55
+ if (infoEnabled && event.phase === "getblock_archive_download") {
56
+ const now = Date.now();
57
+ const currentBytes = event.progress.downloadedBytes ?? 0;
58
+ const isComplete = (event.progress.percent ?? 0) >= 100;
59
+ const shouldPrintMilestone = phaseChanged
60
+ || lastDownloadBytes !== currentBytes && (isComplete
61
+ || now - lastDownloadPrintedAt >= SYNC_PROGRESS_LOG_INTERVAL_MS);
62
+ if (shouldPrintMilestone) {
63
+ options.write(formatDownloadLine("Getblock download", event));
64
+ lastDownloadPrintedAt = now;
65
+ lastDownloadBytes = currentBytes;
66
+ }
67
+ }
68
+ else if (infoEnabled && event.phase === "snapshot_download") {
69
+ const now = Date.now();
70
+ const currentBytes = event.progress.downloadedBytes ?? 0;
71
+ const isComplete = (event.progress.percent ?? 0) >= 100;
72
+ const shouldPrintMilestone = phaseChanged
73
+ || lastDownloadBytes !== currentBytes && (isComplete
74
+ || now - lastDownloadPrintedAt >= SYNC_PROGRESS_LOG_INTERVAL_MS);
75
+ if (shouldPrintMilestone) {
76
+ options.write(formatDownloadLine("Snapshot download", event));
77
+ lastDownloadPrintedAt = now;
78
+ lastDownloadBytes = currentBytes;
79
+ }
80
+ }
81
+ else if (infoEnabled && event.phase === "getblock_archive_import") {
82
+ const now = Date.now();
83
+ const currentBlocks = event.progress.blocks ?? 0;
84
+ const targetBlocks = event.progress.targetHeight ?? currentBlocks;
85
+ const isComplete = currentBlocks >= targetBlocks;
86
+ const shouldPrintMilestone = phaseChanged
87
+ || lastImportBlocks !== currentBlocks && (isComplete
88
+ || now - lastImportPrintedAt >= SYNC_PROGRESS_LOG_INTERVAL_MS);
89
+ if (shouldPrintMilestone) {
90
+ options.write(`Getblock import: Bitcoin ${currentBlocks.toLocaleString()} / ${targetBlocks.toLocaleString()}`);
91
+ lastImportPrintedAt = now;
92
+ lastImportBlocks = currentBlocks;
93
+ }
94
+ }
95
+ lastPhase = event.phase;
96
+ lastMessage = message;
97
+ };
98
+ }
8
99
  export async function runSyncCommand(parsed, context) {
9
100
  const dbPath = parsed.dbPath ?? context.resolveDefaultClientDatabasePath();
10
101
  const dataDir = parsed.dataDir ?? context.resolveDefaultBitcoindDataDir();
11
102
  const runtimePaths = context.resolveWalletRuntimePaths();
103
+ const ttyProgressActive = usesTtyProgress(parsed.progressOutput, context.stderr);
12
104
  let controlLock = null;
13
105
  let store = null;
14
106
  let storeOwned = true;
@@ -40,6 +132,12 @@ export async function runSyncCommand(parsed, context) {
40
132
  dataDir,
41
133
  walletRootId: walletRoot.walletRootId,
42
134
  progressOutput: parsed.progressOutput,
135
+ onProgress: ttyProgressActive ? undefined : createSyncProgressReporter({
136
+ progressOutput: parsed.progressOutput,
137
+ write: (line) => {
138
+ writeLine(context.stderr, line);
139
+ },
140
+ }),
43
141
  });
44
142
  storeOwned = false;
45
143
  const stopWatcher = createStopSignalWatcher(context.signalSource, context.stderr, client, context.forceExit, [runtimePaths.walletControlLockPath]);