@cogcoin/client 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +1 -1
  2. package/dist/app-paths.d.ts +2 -0
  3. package/dist/app-paths.js +4 -0
  4. package/dist/art/wallet.txt +9 -9
  5. package/dist/bitcoind/bootstrap/chainstate.d.ts +2 -1
  6. package/dist/bitcoind/bootstrap/chainstate.js +4 -1
  7. package/dist/bitcoind/bootstrap/chunk-manifest.d.ts +14 -0
  8. package/dist/bitcoind/bootstrap/chunk-manifest.js +85 -0
  9. package/dist/bitcoind/bootstrap/chunk-recovery.d.ts +4 -0
  10. package/dist/bitcoind/bootstrap/chunk-recovery.js +122 -0
  11. package/dist/bitcoind/bootstrap/constants.d.ts +3 -1
  12. package/dist/bitcoind/bootstrap/constants.js +3 -1
  13. package/dist/bitcoind/bootstrap/controller.d.ts +10 -2
  14. package/dist/bitcoind/bootstrap/controller.js +56 -12
  15. package/dist/bitcoind/bootstrap/default-snapshot-chunk-manifest.d.ts +2 -0
  16. package/dist/bitcoind/bootstrap/default-snapshot-chunk-manifest.js +2309 -0
  17. package/dist/bitcoind/bootstrap/download.js +177 -83
  18. package/dist/bitcoind/bootstrap/headers.d.ts +16 -2
  19. package/dist/bitcoind/bootstrap/headers.js +124 -14
  20. package/dist/bitcoind/bootstrap/state.d.ts +11 -1
  21. package/dist/bitcoind/bootstrap/state.js +50 -23
  22. package/dist/bitcoind/bootstrap/types.d.ts +12 -1
  23. package/dist/bitcoind/client/factory.js +11 -2
  24. package/dist/bitcoind/client/internal-types.d.ts +1 -0
  25. package/dist/bitcoind/client/managed-client.d.ts +1 -1
  26. package/dist/bitcoind/client/managed-client.js +29 -15
  27. package/dist/bitcoind/client/sync-engine.js +88 -16
  28. package/dist/bitcoind/errors.js +9 -0
  29. package/dist/bitcoind/indexer-daemon.d.ts +7 -0
  30. package/dist/bitcoind/indexer-daemon.js +31 -22
  31. package/dist/bitcoind/processing-start-height.d.ts +7 -0
  32. package/dist/bitcoind/processing-start-height.js +9 -0
  33. package/dist/bitcoind/progress/controller.js +1 -0
  34. package/dist/bitcoind/progress/formatting.js +4 -1
  35. package/dist/bitcoind/retryable-rpc.d.ts +11 -0
  36. package/dist/bitcoind/retryable-rpc.js +30 -0
  37. package/dist/bitcoind/service.d.ts +16 -1
  38. package/dist/bitcoind/service.js +228 -115
  39. package/dist/bitcoind/testing.d.ts +1 -1
  40. package/dist/bitcoind/testing.js +1 -1
  41. package/dist/bitcoind/types.d.ts +10 -0
  42. package/dist/cli/commands/follow.js +9 -0
  43. package/dist/cli/commands/service-runtime.js +150 -134
  44. package/dist/cli/commands/sync.js +9 -0
  45. package/dist/cli/commands/wallet-admin.js +77 -21
  46. package/dist/cli/context.js +4 -2
  47. package/dist/cli/mutation-json.js +2 -0
  48. package/dist/cli/output.js +3 -1
  49. package/dist/cli/parse.d.ts +1 -1
  50. package/dist/cli/parse.js +6 -0
  51. package/dist/cli/preview-json.js +2 -0
  52. package/dist/cli/runner.js +1 -0
  53. package/dist/cli/types.d.ts +6 -3
  54. package/dist/cli/types.js +1 -1
  55. package/dist/cli/wallet-format.js +134 -14
  56. package/dist/wallet/lifecycle.d.ts +6 -0
  57. package/dist/wallet/lifecycle.js +168 -37
  58. package/dist/wallet/read/context.js +10 -4
  59. package/dist/wallet/reset.d.ts +61 -2
  60. package/dist/wallet/reset.js +208 -63
  61. package/dist/wallet/root-resolution.d.ts +20 -0
  62. package/dist/wallet/root-resolution.js +37 -0
  63. package/dist/wallet/runtime.d.ts +3 -0
  64. package/dist/wallet/runtime.js +3 -0
  65. package/dist/wallet/state/crypto.d.ts +3 -0
  66. package/dist/wallet/state/crypto.js +3 -0
  67. package/dist/wallet/state/pending-init.d.ts +24 -0
  68. package/dist/wallet/state/pending-init.js +59 -0
  69. package/dist/wallet/state/provider.d.ts +1 -0
  70. package/dist/wallet/state/provider.js +7 -1
  71. package/dist/wallet/state/storage.d.ts +7 -1
  72. package/dist/wallet/state/storage.js +39 -0
  73. package/dist/wallet/types.d.ts +9 -0
  74. package/package.json +4 -2
@@ -1,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
  }
@@ -4,12 +4,18 @@ import { openClient } from "../../client.js";
4
4
  import { AssumeUtxoBootstrapController, DEFAULT_SNAPSHOT_METADATA, resolveBootstrapPathsForTesting } from "../bootstrap.js";
5
5
  import { attachOrStartIndexerDaemon } from "../indexer-daemon.js";
6
6
  import { createRpcClient } from "../node.js";
7
+ import { assertCogcoinProcessingStartHeight, resolveCogcoinProcessingStartHeight, } from "../processing-start-height.js";
7
8
  import { ManagedProgressController } from "../progress.js";
8
9
  import { attachOrStartManagedBitcoindService } from "../service.js";
9
10
  import { DefaultManagedBitcoindClient } from "./managed-client.js";
10
11
  const DEFAULT_SYNC_DEBOUNCE_MS = 250;
11
12
  async function createManagedBitcoindClient(options) {
12
13
  const genesisParameters = options.genesisParameters ?? await loadBundledGenesisParameters();
14
+ assertCogcoinProcessingStartHeight({
15
+ chain: options.chain,
16
+ startHeight: options.startHeight,
17
+ genesisParameters,
18
+ });
13
19
  const dataDir = options.dataDir ?? resolveDefaultBitcoindDataDirForTesting();
14
20
  const node = await attachOrStartManagedBitcoindService({
15
21
  ...options,
@@ -41,7 +47,10 @@ async function createManagedBitcoindClient(options) {
41
47
  startupTimeoutMs: options.startupTimeoutMs,
42
48
  })
43
49
  : null;
44
- return new DefaultManagedBitcoindClient(client, options.store, node, rpc, progress, bootstrap, indexerDaemon, options.syncDebounceMs ?? DEFAULT_SYNC_DEBOUNCE_MS);
50
+ // The persistent service may already exist from a non-processing attach path
51
+ // that used startHeight 0. Cogcoin replay still begins at the requested
52
+ // processing boundary for this managed client.
53
+ return new DefaultManagedBitcoindClient(client, options.store, node, rpc, progress, bootstrap, indexerDaemon, options.startHeight, options.syncDebounceMs ?? DEFAULT_SYNC_DEBOUNCE_MS);
45
54
  }
46
55
  export async function openManagedBitcoindClient(options) {
47
56
  const genesisParameters = options.genesisParameters ?? await loadBundledGenesisParameters();
@@ -49,7 +58,7 @@ export async function openManagedBitcoindClient(options) {
49
58
  ...options,
50
59
  genesisParameters,
51
60
  chain: "main",
52
- startHeight: genesisParameters.genesisBlock,
61
+ startHeight: resolveCogcoinProcessingStartHeight(genesisParameters),
53
62
  });
54
63
  }
55
64
  export async function openManagedBitcoindClientInternal(options) {
@@ -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
  }
@@ -7,7 +7,7 @@ import type { BitcoinRpcClient } from "../rpc.js";
7
7
  import type { ManagedBitcoindClient, ManagedBitcoindNodeHandle, ManagedBitcoindStatus, SyncResult } from "../types.js";
8
8
  export declare class DefaultManagedBitcoindClient implements ManagedBitcoindClient {
9
9
  #private;
10
- constructor(client: Client, store: ClientStoreAdapter, node: ManagedBitcoindNodeHandle, rpc: BitcoinRpcClient, progress: ManagedProgressController, bootstrap: AssumeUtxoBootstrapController, indexerDaemon: IndexerDaemonClient | null, syncDebounceMs: number);
10
+ constructor(client: Client, store: ClientStoreAdapter, node: ManagedBitcoindNodeHandle, rpc: BitcoinRpcClient, progress: ManagedProgressController, bootstrap: AssumeUtxoBootstrapController, indexerDaemon: IndexerDaemonClient | null, startHeight: number, syncDebounceMs: number);
11
11
  getTip(): Promise<import("../../types.js").ClientTip | null>;
12
12
  getState(): Promise<import("@cogcoin/indexer/types").IndexerState>;
13
13
  applyBlock(block: BitcoinBlock): Promise<import("../../types.js").ApplyBlockResult>;
@@ -21,7 +21,8 @@ export class DefaultManagedBitcoindClient {
21
21
  #cogcoinRateTracker = createBlockRateTracker();
22
22
  #syncPromise = Promise.resolve(createInitialSyncResult());
23
23
  #debounceTimer = null;
24
- constructor(client, store, node, rpc, progress, bootstrap, indexerDaemon, syncDebounceMs) {
24
+ #syncAbortControllers = new Set();
25
+ constructor(client, store, node, rpc, progress, bootstrap, indexerDaemon, startHeight, syncDebounceMs) {
25
26
  this.#client = client;
26
27
  this.#store = store;
27
28
  this.#node = node;
@@ -29,7 +30,7 @@ export class DefaultManagedBitcoindClient {
29
30
  this.#progress = progress;
30
31
  this.#bootstrap = bootstrap;
31
32
  this.#indexerDaemon = indexerDaemon;
32
- this.#startHeight = node.startHeight;
33
+ this.#startHeight = startHeight;
33
34
  this.#syncDebounceMs = syncDebounceMs;
34
35
  }
35
36
  async getTip() {
@@ -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();
@@ -1,10 +1,41 @@
1
1
  import { formatManagedSyncErrorMessage } from "../errors.js";
2
2
  import { normalizeRpcBlock } from "../normalize.js";
3
+ import { MANAGED_RPC_RETRY_MESSAGE, consumeManagedRpcRetryDelayMs, createManagedRpcRetryState, describeManagedRpcRetryError, isRetryableManagedRpcError, resetManagedRpcRetryState, } from "../retryable-rpc.js";
3
4
  import { estimateEtaSeconds } from "./rate-tracker.js";
4
5
  const DEFAULT_SYNC_CATCH_UP_POLL_MS = 2_000;
5
- function sleep(ms) {
6
- return new Promise((resolve) => {
7
- setTimeout(resolve, ms);
6
+ function createAbortError(signal) {
7
+ const reason = signal?.reason;
8
+ if (reason instanceof Error) {
9
+ return reason;
10
+ }
11
+ const error = new Error("managed_sync_aborted");
12
+ error.name = "AbortError";
13
+ return error;
14
+ }
15
+ function isAbortError(error, signal) {
16
+ if (signal?.aborted) {
17
+ return true;
18
+ }
19
+ return error instanceof Error
20
+ && (error.name === "AbortError" || error.message === "managed_sync_aborted");
21
+ }
22
+ function throwIfAborted(signal) {
23
+ if (signal?.aborted) {
24
+ throw createAbortError(signal);
25
+ }
26
+ }
27
+ function sleep(ms, signal) {
28
+ return new Promise((resolve, reject) => {
29
+ const timer = setTimeout(() => {
30
+ signal?.removeEventListener("abort", onAbort);
31
+ resolve();
32
+ }, ms);
33
+ const onAbort = () => {
34
+ clearTimeout(timer);
35
+ signal?.removeEventListener("abort", onAbort);
36
+ reject(createAbortError(signal));
37
+ };
38
+ signal?.addEventListener("abort", onAbort, { once: true });
8
39
  });
9
40
  }
10
41
  async function setBitcoinSyncProgress(dependencies, info) {
@@ -14,12 +45,42 @@ async function setBitcoinSyncProgress(dependencies, info) {
14
45
  headers: info.headers,
15
46
  targetHeight: info.headers,
16
47
  etaSeconds,
48
+ lastError: null,
17
49
  message: dependencies.node.expectedChain === "main"
18
50
  ? "Bitcoin Core is syncing blocks after assumeutxo bootstrap."
19
51
  : "Reading blocks from the managed Bitcoin node.",
20
52
  });
21
53
  }
22
- async function findCommonAncestor(dependencies, tip, bestHeight) {
54
+ async function setRetryingProgress(dependencies, error) {
55
+ const status = dependencies.progress.getStatusSnapshot();
56
+ const { phase: _phase, updatedAt: _updatedAt, ...progress } = status.bootstrapProgress;
57
+ await dependencies.progress.setPhase(status.bootstrapPhase, {
58
+ ...progress,
59
+ lastError: describeManagedRpcRetryError(error),
60
+ message: MANAGED_RPC_RETRY_MESSAGE,
61
+ });
62
+ }
63
+ async function runWithManagedRpcRetry(dependencies, retryState, operation) {
64
+ while (true) {
65
+ throwIfAborted(dependencies.abortSignal);
66
+ try {
67
+ const result = await operation();
68
+ resetManagedRpcRetryState(retryState);
69
+ return result;
70
+ }
71
+ catch (error) {
72
+ if (isAbortError(error, dependencies.abortSignal)) {
73
+ throw createAbortError(dependencies.abortSignal);
74
+ }
75
+ if (!isRetryableManagedRpcError(error)) {
76
+ throw error;
77
+ }
78
+ await setRetryingProgress(dependencies, error);
79
+ await sleep(consumeManagedRpcRetryDelayMs(retryState), dependencies.abortSignal);
80
+ }
81
+ }
82
+ }
83
+ async function findCommonAncestor(dependencies, tip, bestHeight, runRpc) {
23
84
  const startHeight = Math.min(tip.height, bestHeight);
24
85
  for (let height = startHeight; height >= dependencies.startHeight; height -= 1) {
25
86
  const localHashHex = height === tip.height
@@ -28,14 +89,14 @@ async function findCommonAncestor(dependencies, tip, bestHeight) {
28
89
  if (localHashHex === null) {
29
90
  continue;
30
91
  }
31
- const chainHashHex = await dependencies.rpc.getBlockHash(height);
92
+ const chainHashHex = await runRpc(() => dependencies.rpc.getBlockHash(height));
32
93
  if (chainHashHex === localHashHex) {
33
94
  return height;
34
95
  }
35
96
  }
36
97
  return dependencies.startHeight - 1;
37
98
  }
38
- async function syncAgainstBestHeight(dependencies, bestHeight) {
99
+ async function syncAgainstBestHeight(dependencies, bestHeight, runRpc) {
39
100
  if (bestHeight < dependencies.startHeight) {
40
101
  return {
41
102
  appliedBlocks: 0,
@@ -47,7 +108,7 @@ async function syncAgainstBestHeight(dependencies, bestHeight) {
47
108
  let rewoundBlocks = 0;
48
109
  let commonAncestorHeight = null;
49
110
  if (startTip !== null) {
50
- const rewindTarget = await findCommonAncestor(dependencies, startTip, bestHeight);
111
+ const rewindTarget = await findCommonAncestor(dependencies, startTip, bestHeight, runRpc);
51
112
  if (rewindTarget < startTip.height) {
52
113
  commonAncestorHeight = rewindTarget < dependencies.startHeight ? null : rewindTarget;
53
114
  await dependencies.client.rewindToHeight(rewindTarget);
@@ -63,8 +124,8 @@ async function syncAgainstBestHeight(dependencies, bestHeight) {
63
124
  await dependencies.progress.setCogcoinSync(nextHeight - 1, bestHeight, estimateEtaSeconds(dependencies.cogcoinRateTracker, nextHeight - 1, bestHeight));
64
125
  }
65
126
  for (let height = nextHeight; height <= bestHeight; height += 1) {
66
- const blockHashHex = await dependencies.rpc.getBlockHash(height);
67
- const rpcBlock = await dependencies.rpc.getBlock(blockHashHex);
127
+ const blockHashHex = await runRpc(() => dependencies.rpc.getBlockHash(height));
128
+ const rpcBlock = await runRpc(() => dependencies.rpc.getBlock(blockHashHex));
68
129
  const normalizedBlock = normalizeRpcBlock(rpcBlock);
69
130
  await dependencies.client.applyBlock(normalizedBlock);
70
131
  if (typeof rpcBlock.time === "number") {
@@ -81,9 +142,15 @@ async function syncAgainstBestHeight(dependencies, bestHeight) {
81
142
  }
82
143
  export async function syncToTip(dependencies) {
83
144
  try {
84
- await dependencies.node.validate();
145
+ const retryState = createManagedRpcRetryState();
146
+ const runRpc = (operation) => runWithManagedRpcRetry(dependencies, retryState, operation);
147
+ throwIfAborted(dependencies.abortSignal);
148
+ await runRpc(() => dependencies.node.validate());
85
149
  const indexedTipBeforeBootstrap = await dependencies.client.getTip();
86
- await dependencies.bootstrap.ensureReady(indexedTipBeforeBootstrap, dependencies.node.expectedChain);
150
+ await runRpc(() => dependencies.bootstrap.ensureReady(indexedTipBeforeBootstrap, dependencies.node.expectedChain, {
151
+ signal: dependencies.abortSignal,
152
+ retryState,
153
+ }));
87
154
  const startTip = await dependencies.client.getTip();
88
155
  const aggregate = {
89
156
  appliedBlocks: 0,
@@ -95,9 +162,10 @@ export async function syncToTip(dependencies) {
95
162
  bestHashHex: "",
96
163
  };
97
164
  while (true) {
98
- const startInfo = await dependencies.rpc.getBlockchainInfo();
165
+ throwIfAborted(dependencies.abortSignal);
166
+ const startInfo = await runRpc(() => dependencies.rpc.getBlockchainInfo());
99
167
  await setBitcoinSyncProgress(dependencies, startInfo);
100
- const pass = await syncAgainstBestHeight(dependencies, startInfo.blocks);
168
+ const pass = await syncAgainstBestHeight(dependencies, startInfo.blocks, runRpc);
101
169
  aggregate.appliedBlocks += pass.appliedBlocks;
102
170
  aggregate.rewoundBlocks += pass.rewoundBlocks;
103
171
  if (pass.commonAncestorHeight !== null) {
@@ -106,19 +174,20 @@ export async function syncToTip(dependencies) {
106
174
  : Math.min(aggregate.commonAncestorHeight, pass.commonAncestorHeight);
107
175
  }
108
176
  const finalTip = await dependencies.client.getTip();
109
- const endInfo = await dependencies.rpc.getBlockchainInfo();
177
+ const endInfo = await runRpc(() => dependencies.rpc.getBlockchainInfo());
110
178
  const caughtUpCogcoin = endInfo.blocks < dependencies.startHeight || finalTip?.height === endInfo.blocks;
111
179
  aggregate.endingHeight = finalTip?.height ?? null;
112
180
  aggregate.bestHeight = endInfo.blocks;
113
181
  aggregate.bestHashHex = endInfo.bestblockhash;
114
182
  if (endInfo.blocks === endInfo.headers && caughtUpCogcoin) {
115
183
  if (dependencies.isFollowing()) {
116
- dependencies.progress.replaceFollowBlockTimes(await dependencies.loadVisibleFollowBlockTimes(finalTip));
184
+ dependencies.progress.replaceFollowBlockTimes(await runRpc(() => dependencies.loadVisibleFollowBlockTimes(finalTip)));
117
185
  }
118
186
  await dependencies.progress.setPhase(dependencies.isFollowing() ? "follow_tip" : "complete", {
119
187
  blocks: endInfo.blocks,
120
188
  headers: endInfo.headers,
121
189
  targetHeight: endInfo.headers,
190
+ lastError: null,
122
191
  message: dependencies.isFollowing()
123
192
  ? "Following the live Bitcoin tip."
124
193
  : "Managed sync fully caught up to the live tip.",
@@ -129,10 +198,13 @@ export async function syncToTip(dependencies) {
129
198
  if (endInfo.blocks >= dependencies.startHeight && finalTip?.height !== endInfo.blocks) {
130
199
  continue;
131
200
  }
132
- await sleep(DEFAULT_SYNC_CATCH_UP_POLL_MS);
201
+ await sleep(DEFAULT_SYNC_CATCH_UP_POLL_MS, dependencies.abortSignal);
133
202
  }
134
203
  }
135
204
  catch (error) {
205
+ if (isAbortError(error, dependencies.abortSignal)) {
206
+ throw createAbortError(dependencies.abortSignal);
207
+ }
136
208
  const message = formatManagedSyncErrorMessage(error instanceof Error ? error.message : String(error));
137
209
  await dependencies.progress.setPhase("error", {
138
210
  lastError: message,
@@ -18,6 +18,15 @@ export function formatManagedSyncErrorMessage(message) {
18
18
  if (message === "snapshot_response_body_missing") {
19
19
  return appendNextStep("Snapshot server returned an empty response body.", "Wait a moment, confirm the snapshot host is reachable, then rerun sync.");
20
20
  }
21
+ if (message === "snapshot_resume_requires_partial_content") {
22
+ return appendNextStep("Snapshot server ignored the resume request for a partial download.", "Wait a moment and rerun sync. If this keeps happening, confirm the snapshot host supports HTTP range requests.");
23
+ }
24
+ if (message.startsWith("snapshot_chunk_sha256_mismatch_")) {
25
+ return appendNextStep("A downloaded snapshot chunk was corrupted and was rolled back to the last verified checkpoint.", "Wait a moment and rerun sync. If this keeps happening, check local disk health and the stability of the snapshot connection.");
26
+ }
27
+ if (message.startsWith("snapshot_download_incomplete_")) {
28
+ return appendNextStep("Snapshot download ended before the expected file size was reached.", "Wait a moment and rerun sync. The downloader will resume from the last verified checkpoint.");
29
+ }
21
30
  if (message === "bitcoind_cookie_timeout") {
22
31
  return appendNextStep("The managed Bitcoin node did not finish starting in time.", "Check the node logs and local permissions for the Bitcoin data directory, then rerun sync.");
23
32
  }
@@ -1,4 +1,5 @@
1
1
  import { type ManagedIndexerDaemonObservedStatus, type ManagedIndexerDaemonStatus } from "./types.js";
2
+ import { resolveManagedServicePaths } from "./service-paths.js";
2
3
  interface DaemonRequest {
3
4
  id: string;
4
5
  method: "GetStatus" | "OpenSnapshot" | "ReadSnapshot" | "CloseSnapshot";
@@ -81,6 +82,12 @@ export interface CoherentIndexerSnapshotLease {
81
82
  payload: IndexerSnapshotPayload;
82
83
  status: ManagedIndexerDaemonStatus;
83
84
  }
85
+ export declare function stopIndexerDaemonServiceWithLockHeld(options: {
86
+ dataDir: string;
87
+ walletRootId?: string;
88
+ shutdownTimeoutMs?: number;
89
+ paths?: ReturnType<typeof resolveManagedServicePaths>;
90
+ }): Promise<IndexerDaemonStopResult>;
84
91
  export declare function probeIndexerDaemon(options: {
85
92
  dataDir: string;
86
93
  walletRootId?: string;
@@ -53,6 +53,33 @@ async function clearIndexerDaemonRuntimeArtifacts(paths) {
53
53
  await rm(paths.indexerDaemonStatusPath, { force: true }).catch(() => undefined);
54
54
  await rm(paths.indexerDaemonSocketPath, { force: true }).catch(() => undefined);
55
55
  }
56
+ export async function stopIndexerDaemonServiceWithLockHeld(options) {
57
+ const walletRootId = options.walletRootId ?? UNINITIALIZED_WALLET_ROOT_ID;
58
+ const paths = options.paths ?? resolveManagedServicePaths(options.dataDir, walletRootId);
59
+ const status = await readJsonFile(paths.indexerDaemonStatusPath);
60
+ const processId = status?.processId ?? null;
61
+ if (status === null || processId === null || !await isProcessAlive(processId)) {
62
+ await clearIndexerDaemonRuntimeArtifacts(paths);
63
+ return {
64
+ status: "not-running",
65
+ walletRootId,
66
+ };
67
+ }
68
+ try {
69
+ process.kill(processId, "SIGTERM");
70
+ }
71
+ catch (error) {
72
+ if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) {
73
+ throw error;
74
+ }
75
+ }
76
+ await waitForProcessExit(processId, options.shutdownTimeoutMs ?? 5_000, "indexer_daemon_stop_timeout");
77
+ await clearIndexerDaemonRuntimeArtifacts(paths);
78
+ return {
79
+ status: "stopped",
80
+ walletRootId,
81
+ };
82
+ }
56
83
  function createIndexerDaemonClient(socketPath) {
57
84
  async function sendRequest(request) {
58
85
  return new Promise((resolve, reject) => {
@@ -380,29 +407,11 @@ export async function stopIndexerDaemonService(options) {
380
407
  dataDir: options.dataDir,
381
408
  });
382
409
  try {
383
- const status = await readJsonFile(paths.indexerDaemonStatusPath);
384
- const processId = status?.processId ?? null;
385
- if (status === null || processId === null || !await isProcessAlive(processId)) {
386
- await clearIndexerDaemonRuntimeArtifacts(paths);
387
- return {
388
- status: "not-running",
389
- walletRootId,
390
- };
391
- }
392
- try {
393
- process.kill(processId, "SIGTERM");
394
- }
395
- catch (error) {
396
- if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) {
397
- throw error;
398
- }
399
- }
400
- await waitForProcessExit(processId, options.shutdownTimeoutMs ?? 5_000, "indexer_daemon_stop_timeout");
401
- await clearIndexerDaemonRuntimeArtifacts(paths);
402
- return {
403
- status: "stopped",
410
+ return await stopIndexerDaemonServiceWithLockHeld({
411
+ ...options,
404
412
  walletRootId,
405
- };
413
+ paths,
414
+ });
406
415
  }
407
416
  finally {
408
417
  await lock.release();
@@ -0,0 +1,7 @@
1
+ import type { GenesisParameters } from "@cogcoin/indexer/types";
2
+ export declare function resolveCogcoinProcessingStartHeight(genesisParameters: GenesisParameters): number;
3
+ export declare function assertCogcoinProcessingStartHeight(options: {
4
+ chain: "main" | "regtest";
5
+ startHeight: number;
6
+ genesisParameters: GenesisParameters;
7
+ }): void;
@@ -0,0 +1,9 @@
1
+ export function resolveCogcoinProcessingStartHeight(genesisParameters) {
2
+ return genesisParameters.genesisBlock;
3
+ }
4
+ export function assertCogcoinProcessingStartHeight(options) {
5
+ const processingStartHeight = resolveCogcoinProcessingStartHeight(options.genesisParameters);
6
+ if (options.chain === "main" && options.startHeight < processingStartHeight) {
7
+ throw new Error("cogcoin_processing_start_height_before_genesis");
8
+ }
9
+ }
@@ -117,6 +117,7 @@ export class ManagedProgressController {
117
117
  phase: "cogcoin_sync",
118
118
  message: createDefaultMessage("cogcoin_sync"),
119
119
  etaSeconds,
120
+ lastError: null,
120
121
  updatedAt: Date.now(),
121
122
  };
122
123
  if (this.#followVisualMode) {
@@ -4,7 +4,7 @@ export function createDefaultMessage(phase) {
4
4
  case "snapshot_download":
5
5
  return "Downloading UTXO snapshot.";
6
6
  case "wait_headers_for_snapshot":
7
- return "Waiting for Bitcoin headers to reach the snapshot height.";
7
+ return "Pre-synchronizing blockheaders.";
8
8
  case "load_snapshot":
9
9
  return "Loading the UTXO snapshot into bitcoind.";
10
10
  case "bitcoin_sync":
@@ -125,6 +125,9 @@ export function resolveStatusFieldText(progress, snapshotHeight, now = 0) {
125
125
  case "snapshot_download":
126
126
  return `Downloading snapshot to ${snapshotHeight}${animateStatusEllipsis(now)}`;
127
127
  case "wait_headers_for_snapshot":
128
+ return progress.message === "Waiting for Bitcoin headers to reach the snapshot height."
129
+ ? `Waiting for Bitcoin headers to reach the snapshot height${animateStatusEllipsis(now)}`
130
+ : `Pre-synchronizing blockheaders${animateStatusEllipsis(now)}`;
128
131
  case "load_snapshot":
129
132
  case "bitcoin_sync":
130
133
  return `Syncing Bitcoin Blocks${animateStatusEllipsis(now)}`;
@@ -0,0 +1,11 @@
1
+ export declare const MANAGED_RPC_RETRY_BASE_MS = 1000;
2
+ export declare const MANAGED_RPC_RETRY_MAX_MS = 15000;
3
+ export declare const MANAGED_RPC_RETRY_MESSAGE = "Managed Bitcoin RPC temporarily unavailable; retrying until canceled.";
4
+ export interface ManagedRpcRetryState {
5
+ nextDelayMs: number;
6
+ }
7
+ export declare function createManagedRpcRetryState(): ManagedRpcRetryState;
8
+ export declare function resetManagedRpcRetryState(state: ManagedRpcRetryState): void;
9
+ export declare function consumeManagedRpcRetryDelayMs(state: ManagedRpcRetryState): number;
10
+ export declare function isRetryableManagedRpcError(error: unknown): boolean;
11
+ export declare function describeManagedRpcRetryError(error: unknown): string;