@cogcoin/client 0.5.5 → 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.
- package/README.md +1 -1
- package/dist/bitcoind/bootstrap/chainstate.d.ts +2 -1
- package/dist/bitcoind/bootstrap/chainstate.js +4 -1
- package/dist/bitcoind/bootstrap/controller.d.ts +4 -1
- package/dist/bitcoind/bootstrap/controller.js +42 -5
- package/dist/bitcoind/bootstrap/headers.d.ts +12 -0
- package/dist/bitcoind/bootstrap/headers.js +95 -10
- package/dist/bitcoind/client/factory.js +11 -2
- package/dist/bitcoind/client/managed-client.d.ts +1 -1
- package/dist/bitcoind/client/managed-client.js +2 -2
- package/dist/bitcoind/client/sync-engine.js +48 -13
- package/dist/bitcoind/indexer-daemon.d.ts +7 -0
- package/dist/bitcoind/indexer-daemon.js +31 -22
- package/dist/bitcoind/processing-start-height.d.ts +7 -0
- package/dist/bitcoind/processing-start-height.js +9 -0
- package/dist/bitcoind/progress/controller.js +1 -0
- package/dist/bitcoind/progress/formatting.js +4 -1
- package/dist/bitcoind/retryable-rpc.d.ts +11 -0
- package/dist/bitcoind/retryable-rpc.js +30 -0
- package/dist/bitcoind/service.d.ts +16 -1
- package/dist/bitcoind/service.js +228 -115
- package/dist/bitcoind/testing.d.ts +1 -1
- package/dist/bitcoind/testing.js +1 -1
- package/dist/bitcoind/types.d.ts +1 -0
- package/dist/cli/commands/follow.js +9 -0
- package/dist/cli/commands/service-runtime.js +150 -134
- package/dist/cli/commands/sync.js +9 -0
- package/dist/cli/commands/wallet-admin.js +77 -21
- package/dist/cli/context.js +4 -2
- package/dist/cli/mutation-json.js +2 -0
- package/dist/cli/output.js +2 -0
- package/dist/cli/parse.d.ts +1 -1
- package/dist/cli/parse.js +6 -0
- package/dist/cli/preview-json.js +2 -0
- package/dist/cli/runner.js +1 -0
- package/dist/cli/types.d.ts +6 -3
- package/dist/cli/types.js +1 -1
- package/dist/cli/wallet-format.js +134 -14
- package/dist/wallet/lifecycle.d.ts +6 -0
- package/dist/wallet/lifecycle.js +109 -37
- package/dist/wallet/read/context.js +10 -4
- package/dist/wallet/reset.d.ts +61 -2
- package/dist/wallet/reset.js +208 -63
- package/dist/wallet/root-resolution.d.ts +20 -0
- package/dist/wallet/root-resolution.js +37 -0
- package/dist/wallet/runtime.d.ts +1 -0
- package/dist/wallet/runtime.js +1 -0
- package/dist/wallet/state/crypto.d.ts +3 -0
- package/dist/wallet/state/crypto.js +3 -0
- package/dist/wallet/state/storage.d.ts +7 -1
- package/dist/wallet/state/storage.js +39 -0
- package/dist/wallet/types.d.ts +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# `@cogcoin/client`
|
|
2
2
|
|
|
3
|
-
`@cogcoin/client@0.5.
|
|
3
|
+
`@cogcoin/client@0.5.6` 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,4 +1,5 @@
|
|
|
1
1
|
import type { BitcoinRpcClient } from "../rpc.js";
|
|
2
|
-
import type { SnapshotMetadata } from "../types.js";
|
|
2
|
+
import type { RpcChainState, SnapshotMetadata } from "../types.js";
|
|
3
3
|
import type { BootstrapPersistentState } from "./types.js";
|
|
4
4
|
export declare function isSnapshotAlreadyLoaded(rpc: Pick<BitcoinRpcClient, "getChainStates">, snapshot: SnapshotMetadata, state: BootstrapPersistentState): Promise<boolean>;
|
|
5
|
+
export declare function findLoadedSnapshotChainState(rpc: Pick<BitcoinRpcClient, "getChainStates">, snapshot: SnapshotMetadata, state: BootstrapPersistentState): Promise<RpcChainState | null>;
|
|
@@ -8,6 +8,9 @@ function chainStateMatches(chainState, snapshot, baseHeight, tipHashHex) {
|
|
|
8
8
|
return chainState.blocks === snapshot.height && chainState.validated === false;
|
|
9
9
|
}
|
|
10
10
|
export async function isSnapshotAlreadyLoaded(rpc, snapshot, state) {
|
|
11
|
+
return (await findLoadedSnapshotChainState(rpc, snapshot, state)) !== null;
|
|
12
|
+
}
|
|
13
|
+
export async function findLoadedSnapshotChainState(rpc, snapshot, state) {
|
|
11
14
|
const chainStates = await rpc.getChainStates();
|
|
12
|
-
return chainStates.chainstates.
|
|
15
|
+
return chainStates.chainstates.find((chainState) => chainStateMatches(chainState, snapshot, state.baseHeight, state.tipHashHex)) ?? null;
|
|
13
16
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ClientTip } from "../../types.js";
|
|
2
2
|
import { ManagedProgressController } from "../progress.js";
|
|
3
3
|
import { BitcoinRpcClient } from "../rpc.js";
|
|
4
|
-
import
|
|
4
|
+
import { type ManagedRpcRetryState } from "../retryable-rpc.js";
|
|
5
|
+
import type { BootstrapPhase, SnapshotMetadata, SnapshotChunkManifest } from "../types.js";
|
|
5
6
|
export declare class AssumeUtxoBootstrapController {
|
|
6
7
|
#private;
|
|
7
8
|
constructor(options: {
|
|
@@ -9,12 +10,14 @@ export declare class AssumeUtxoBootstrapController {
|
|
|
9
10
|
dataDir: string;
|
|
10
11
|
progress: ManagedProgressController;
|
|
11
12
|
snapshot?: SnapshotMetadata;
|
|
13
|
+
manifest?: SnapshotChunkManifest;
|
|
12
14
|
fetchImpl?: typeof fetch;
|
|
13
15
|
});
|
|
14
16
|
get quoteStatePath(): string;
|
|
15
17
|
get snapshot(): SnapshotMetadata;
|
|
16
18
|
ensureReady(indexedTip: ClientTip | null, expectedChain: "main" | "regtest", options?: {
|
|
17
19
|
signal?: AbortSignal;
|
|
20
|
+
retryState?: ManagedRpcRetryState;
|
|
18
21
|
}): Promise<void>;
|
|
19
22
|
getStateForTesting(): Promise<{
|
|
20
23
|
metadataVersion: number;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
1
2
|
import { createBootstrapProgressForTesting, ManagedProgressController } from "../progress.js";
|
|
2
3
|
import { BitcoinRpcClient } from "../rpc.js";
|
|
3
4
|
import { DEFAULT_SNAPSHOT_METADATA } from "./constants.js";
|
|
@@ -5,7 +6,8 @@ import { downloadSnapshotFileForTesting } from "./download.js";
|
|
|
5
6
|
import { waitForHeaders } from "./headers.js";
|
|
6
7
|
import { resolveBootstrapPaths } from "./paths.js";
|
|
7
8
|
import { loadBootstrapStateRecord, saveBootstrapState } from "./state.js";
|
|
8
|
-
import { isSnapshotAlreadyLoaded } from "./chainstate.js";
|
|
9
|
+
import { findLoadedSnapshotChainState, isSnapshotAlreadyLoaded } from "./chainstate.js";
|
|
10
|
+
import { describeManagedRpcRetryError, isRetryableManagedRpcError, } from "../retryable-rpc.js";
|
|
9
11
|
async function loadSnapshotIntoNode(rpc, snapshotPath) {
|
|
10
12
|
return rpc.loadTxOutSet(snapshotPath);
|
|
11
13
|
}
|
|
@@ -14,13 +16,17 @@ export class AssumeUtxoBootstrapController {
|
|
|
14
16
|
#paths;
|
|
15
17
|
#progress;
|
|
16
18
|
#snapshot;
|
|
19
|
+
#debugLogPath;
|
|
20
|
+
#manifest;
|
|
17
21
|
#fetchImpl;
|
|
18
22
|
#stateRecordPromise = null;
|
|
19
23
|
constructor(options) {
|
|
20
24
|
this.#rpc = options.rpc;
|
|
21
25
|
this.#progress = options.progress;
|
|
22
26
|
this.#snapshot = options.snapshot ?? DEFAULT_SNAPSHOT_METADATA;
|
|
27
|
+
this.#manifest = options.manifest;
|
|
23
28
|
this.#paths = resolveBootstrapPaths(options.dataDir, this.#snapshot);
|
|
29
|
+
this.#debugLogPath = join(options.dataDir, "debug.log");
|
|
24
30
|
this.#fetchImpl = options.fetchImpl;
|
|
25
31
|
}
|
|
26
32
|
get quoteStatePath() {
|
|
@@ -47,18 +53,23 @@ export class AssumeUtxoBootstrapController {
|
|
|
47
53
|
}
|
|
48
54
|
const { state, snapshotIdentity } = await this.#loadStateRecord();
|
|
49
55
|
if (state.loadTxOutSetComplete && await isSnapshotAlreadyLoaded(this.#rpc, this.#snapshot, state)) {
|
|
56
|
+
if (state.lastError !== null) {
|
|
57
|
+
state.lastError = null;
|
|
58
|
+
await saveBootstrapState(this.#paths, state);
|
|
59
|
+
}
|
|
50
60
|
await this.#progress.setPhase("bitcoin_sync", {
|
|
51
61
|
blocks: state.baseHeight,
|
|
52
62
|
targetHeight: state.baseHeight ?? this.#snapshot.height,
|
|
53
63
|
baseHeight: state.baseHeight,
|
|
54
64
|
tipHashHex: state.tipHashHex,
|
|
55
65
|
message: "Using the previously loaded assumeutxo chainstate.",
|
|
56
|
-
lastError:
|
|
66
|
+
lastError: null,
|
|
57
67
|
});
|
|
58
68
|
return;
|
|
59
69
|
}
|
|
60
70
|
await downloadSnapshotFileForTesting({
|
|
61
71
|
fetchImpl: this.#fetchImpl,
|
|
72
|
+
manifest: this.#manifest,
|
|
62
73
|
metadata: this.#snapshot,
|
|
63
74
|
paths: this.#paths,
|
|
64
75
|
progress: this.#progress,
|
|
@@ -69,22 +80,48 @@ export class AssumeUtxoBootstrapController {
|
|
|
69
80
|
if (!await isSnapshotAlreadyLoaded(this.#rpc, this.#snapshot, state)) {
|
|
70
81
|
await waitForHeaders(this.#rpc, this.#snapshot, this.#progress, {
|
|
71
82
|
signal: options.signal,
|
|
83
|
+
retryState: options.retryState,
|
|
84
|
+
debugLogPath: this.#debugLogPath,
|
|
72
85
|
});
|
|
73
86
|
await this.#progress.setPhase("load_snapshot", {
|
|
74
87
|
downloadedBytes: this.#snapshot.sizeBytes,
|
|
75
88
|
totalBytes: this.#snapshot.sizeBytes,
|
|
76
89
|
percent: 100,
|
|
77
90
|
message: "Loading the UTXO snapshot into bitcoind.",
|
|
91
|
+
lastError: null,
|
|
78
92
|
});
|
|
79
|
-
|
|
93
|
+
let loadResult;
|
|
94
|
+
try {
|
|
95
|
+
loadResult = await loadSnapshotIntoNode(this.#rpc, this.#paths.snapshotPath);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (!isRetryableManagedRpcError(error)) {
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
state.lastError = describeManagedRpcRetryError(error);
|
|
102
|
+
await saveBootstrapState(this.#paths, state);
|
|
103
|
+
const loadedChainState = await findLoadedSnapshotChainState(this.#rpc, this.#snapshot, state);
|
|
104
|
+
if (loadedChainState === null) {
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
loadResult = {
|
|
108
|
+
base_height: loadedChainState.blocks ?? this.#snapshot.height,
|
|
109
|
+
coins_loaded: 0,
|
|
110
|
+
tip_hash: loadedChainState.snapshot_blockhash ?? state.tipHashHex ?? "",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
80
113
|
state.loadTxOutSetComplete = true;
|
|
81
114
|
state.baseHeight = loadResult.base_height;
|
|
82
|
-
state.tipHashHex = loadResult.tip_hash;
|
|
115
|
+
state.tipHashHex = loadResult.tip_hash === "" ? state.tipHashHex : loadResult.tip_hash;
|
|
83
116
|
state.phase = "bitcoin_sync";
|
|
84
117
|
state.lastError = null;
|
|
85
118
|
await saveBootstrapState(this.#paths, state);
|
|
86
119
|
}
|
|
87
120
|
const info = await this.#rpc.getBlockchainInfo();
|
|
121
|
+
if (state.lastError !== null) {
|
|
122
|
+
state.lastError = null;
|
|
123
|
+
await saveBootstrapState(this.#paths, state);
|
|
124
|
+
}
|
|
88
125
|
await this.#progress.setPhase("bitcoin_sync", {
|
|
89
126
|
blocks: info.blocks,
|
|
90
127
|
headers: info.headers,
|
|
@@ -92,7 +129,7 @@ export class AssumeUtxoBootstrapController {
|
|
|
92
129
|
baseHeight: state.baseHeight,
|
|
93
130
|
tipHashHex: state.tipHashHex,
|
|
94
131
|
message: "Bitcoin Core is syncing blocks after assumeutxo bootstrap.",
|
|
95
|
-
lastError:
|
|
132
|
+
lastError: null,
|
|
96
133
|
});
|
|
97
134
|
}
|
|
98
135
|
async getStateForTesting() {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ManagedRpcRetryState } from "../retryable-rpc.js";
|
|
1
2
|
import type { BitcoinRpcClient } from "../rpc.js";
|
|
2
3
|
import type { ManagedProgressController } from "../progress.js";
|
|
3
4
|
import type { SnapshotMetadata } from "../types.js";
|
|
@@ -6,10 +7,21 @@ export declare function waitForHeaders(rpc: Pick<BitcoinRpcClient, "getBlockchai
|
|
|
6
7
|
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
7
8
|
noPeerTimeoutMs?: number;
|
|
8
9
|
signal?: AbortSignal;
|
|
10
|
+
retryState?: ManagedRpcRetryState;
|
|
11
|
+
debugLogPath?: string;
|
|
12
|
+
readDebugLogProgress?: (debugLogPath: string) => Promise<{
|
|
13
|
+
height: number;
|
|
14
|
+
message: string;
|
|
15
|
+
} | null>;
|
|
9
16
|
}): Promise<void>;
|
|
10
17
|
export declare function waitForHeadersForTesting(rpc: Pick<BitcoinRpcClient, "getBlockchainInfo" | "getNetworkInfo">, snapshot: SnapshotMetadata | undefined, progress: Pick<ManagedProgressController, "setPhase">, options?: {
|
|
11
18
|
now?: () => number;
|
|
12
19
|
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
13
20
|
noPeerTimeoutMs?: number;
|
|
14
21
|
signal?: AbortSignal;
|
|
22
|
+
debugLogPath?: string;
|
|
23
|
+
readDebugLogProgress?: (debugLogPath: string) => Promise<{
|
|
24
|
+
height: number;
|
|
25
|
+
message: string;
|
|
26
|
+
} | null>;
|
|
15
27
|
}): Promise<void>;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
1
2
|
import { formatManagedSyncErrorMessage } from "../errors.js";
|
|
3
|
+
import { MANAGED_RPC_RETRY_MESSAGE, consumeManagedRpcRetryDelayMs, createManagedRpcRetryState, describeManagedRpcRetryError, resetManagedRpcRetryState, isRetryableManagedRpcError, } from "../retryable-rpc.js";
|
|
2
4
|
import { DEFAULT_SNAPSHOT_METADATA, HEADER_NO_PEER_TIMEOUT_MS, HEADER_POLL_MS, } from "./constants.js";
|
|
5
|
+
const DEBUG_LOG_TAIL_BYTES = 64 * 1024;
|
|
6
|
+
const HEADER_SYNC_DEBUG_LINE_PATTERN = /Pre-synchronizing blockheaders,\s*height:\s*([\d,]+)\s*\(~(\d+(?:\.\d+)?)%\)/u;
|
|
3
7
|
function createAbortError(signal) {
|
|
4
8
|
const reason = signal?.reason;
|
|
5
9
|
if (reason instanceof Error) {
|
|
@@ -33,7 +37,52 @@ function resolvePeerCount(networkInfo) {
|
|
|
33
37
|
? networkInfo.connections
|
|
34
38
|
: (networkInfo.connections_in ?? 0) + (networkInfo.connections_out ?? 0);
|
|
35
39
|
}
|
|
36
|
-
function
|
|
40
|
+
async function readDebugLogTail(filePath, maxBytes = DEBUG_LOG_TAIL_BYTES) {
|
|
41
|
+
let handle = null;
|
|
42
|
+
try {
|
|
43
|
+
handle = await open(filePath, "r");
|
|
44
|
+
const stats = await handle.stat();
|
|
45
|
+
const bytesToRead = Math.min(maxBytes, Math.max(0, stats.size));
|
|
46
|
+
if (bytesToRead === 0) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
50
|
+
await handle.read(buffer, 0, bytesToRead, stats.size - bytesToRead);
|
|
51
|
+
return buffer.toString("utf8");
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await handle?.close().catch(() => { });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function readHeaderSyncProgressFromDebugLog(debugLogPath) {
|
|
64
|
+
const tail = await readDebugLogTail(debugLogPath);
|
|
65
|
+
if (tail === null) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const lines = tail.split(/\r?\n/u).reverse();
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
const match = HEADER_SYNC_DEBUG_LINE_PATTERN.exec(line);
|
|
71
|
+
if (match === null) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const height = Number(match[1].replaceAll(",", ""));
|
|
75
|
+
if (!Number.isFinite(height) || height < 0) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
height,
|
|
80
|
+
message: `Pre-synchronizing blockheaders, height: ${height.toLocaleString()} (~${match[2]}%)`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
function resolveHeaderWaitMessage(headers, peerCount, networkActive, rpcHeaders, headerSyncMessage) {
|
|
37
86
|
if (!networkActive) {
|
|
38
87
|
return "Bitcoin networking is inactive for the managed node.";
|
|
39
88
|
}
|
|
@@ -43,33 +92,69 @@ function resolveHeaderWaitMessage(headers, peerCount, networkActive) {
|
|
|
43
92
|
if (peerCount === 0) {
|
|
44
93
|
return `Waiting for peers to continue header sync (${headers.toLocaleString()} headers, 0 peers).`;
|
|
45
94
|
}
|
|
46
|
-
|
|
95
|
+
if (rpcHeaders > 0) {
|
|
96
|
+
return "Waiting for Bitcoin headers to reach the snapshot height.";
|
|
97
|
+
}
|
|
98
|
+
return headerSyncMessage ?? "Pre-synchronizing blockheaders.";
|
|
47
99
|
}
|
|
48
100
|
export async function waitForHeaders(rpc, snapshot, progress, options = {}) {
|
|
49
101
|
const now = options.now ?? Date.now;
|
|
50
102
|
const sleepImpl = options.sleep ?? sleep;
|
|
51
103
|
const noPeerTimeoutMs = options.noPeerTimeoutMs ?? HEADER_NO_PEER_TIMEOUT_MS;
|
|
52
104
|
const { signal } = options;
|
|
105
|
+
const retryState = options.retryState ?? createManagedRpcRetryState();
|
|
106
|
+
const readDebugLogProgress = options.readDebugLogProgress ?? readHeaderSyncProgressFromDebugLog;
|
|
53
107
|
let noPeerSince = null;
|
|
108
|
+
let lastBlocks = 0;
|
|
109
|
+
let lastHeaders = 0;
|
|
54
110
|
while (true) {
|
|
55
111
|
throwIfAborted(signal);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
112
|
+
let info;
|
|
113
|
+
let networkInfo;
|
|
114
|
+
try {
|
|
115
|
+
[info, networkInfo] = await Promise.all([
|
|
116
|
+
rpc.getBlockchainInfo(),
|
|
117
|
+
rpc.getNetworkInfo(),
|
|
118
|
+
]);
|
|
119
|
+
resetManagedRpcRetryState(retryState);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (!isRetryableManagedRpcError(error)) {
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
await progress.setPhase("wait_headers_for_snapshot", {
|
|
126
|
+
headers: lastHeaders,
|
|
127
|
+
targetHeight: snapshot.height,
|
|
128
|
+
blocks: lastBlocks,
|
|
129
|
+
percent: (Math.min(lastHeaders, snapshot.height) / snapshot.height) * 100,
|
|
130
|
+
lastError: describeManagedRpcRetryError(error),
|
|
131
|
+
message: MANAGED_RPC_RETRY_MESSAGE,
|
|
132
|
+
});
|
|
133
|
+
await sleepImpl(consumeManagedRpcRetryDelayMs(retryState), signal);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
lastBlocks = info.blocks;
|
|
137
|
+
const debugLogProgress = info.headers === 0 && options.debugLogPath !== undefined
|
|
138
|
+
? await readDebugLogProgress(options.debugLogPath)
|
|
139
|
+
: null;
|
|
140
|
+
const observedHeaders = info.headers > 0
|
|
141
|
+
? info.headers
|
|
142
|
+
: Math.max(info.headers, debugLogProgress?.height ?? 0);
|
|
143
|
+
lastHeaders = observedHeaders;
|
|
60
144
|
const peerCount = resolvePeerCount(networkInfo);
|
|
61
|
-
const message = resolveHeaderWaitMessage(
|
|
145
|
+
const message = resolveHeaderWaitMessage(observedHeaders, peerCount, networkInfo.networkactive, info.headers, debugLogProgress?.message ?? null);
|
|
62
146
|
await progress.setPhase("wait_headers_for_snapshot", {
|
|
63
|
-
headers:
|
|
147
|
+
headers: observedHeaders,
|
|
64
148
|
targetHeight: snapshot.height,
|
|
65
149
|
blocks: info.blocks,
|
|
66
|
-
percent: (Math.min(
|
|
150
|
+
percent: (Math.min(observedHeaders, snapshot.height) / snapshot.height) * 100,
|
|
151
|
+
lastError: null,
|
|
67
152
|
message,
|
|
68
153
|
});
|
|
69
154
|
if (info.headers >= snapshot.height) {
|
|
70
155
|
return;
|
|
71
156
|
}
|
|
72
|
-
if (
|
|
157
|
+
if (observedHeaders === 0 && peerCount === 0) {
|
|
73
158
|
noPeerSince ??= now();
|
|
74
159
|
if (now() - noPeerSince >= noPeerTimeoutMs) {
|
|
75
160
|
throw new Error(formatManagedSyncErrorMessage("bitcoind_no_peers_for_header_sync_check_internet_or_firewall"));
|
|
@@ -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
|
-
|
|
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
|
|
61
|
+
startHeight: resolveCogcoinProcessingStartHeight(genesisParameters),
|
|
53
62
|
});
|
|
54
63
|
}
|
|
55
64
|
export async function openManagedBitcoindClientInternal(options) {
|
|
@@ -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>;
|
|
@@ -22,7 +22,7 @@ export class DefaultManagedBitcoindClient {
|
|
|
22
22
|
#syncPromise = Promise.resolve(createInitialSyncResult());
|
|
23
23
|
#debounceTimer = null;
|
|
24
24
|
#syncAbortControllers = new Set();
|
|
25
|
-
constructor(client, store, node, rpc, progress, bootstrap, indexerDaemon, syncDebounceMs) {
|
|
25
|
+
constructor(client, store, node, rpc, progress, bootstrap, indexerDaemon, startHeight, syncDebounceMs) {
|
|
26
26
|
this.#client = client;
|
|
27
27
|
this.#store = store;
|
|
28
28
|
this.#node = node;
|
|
@@ -30,7 +30,7 @@ export class DefaultManagedBitcoindClient {
|
|
|
30
30
|
this.#progress = progress;
|
|
31
31
|
this.#bootstrap = bootstrap;
|
|
32
32
|
this.#indexerDaemon = indexerDaemon;
|
|
33
|
-
this.#startHeight =
|
|
33
|
+
this.#startHeight = startHeight;
|
|
34
34
|
this.#syncDebounceMs = syncDebounceMs;
|
|
35
35
|
}
|
|
36
36
|
async getTip() {
|
|
@@ -1,5 +1,6 @@
|
|
|
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
6
|
function createAbortError(signal) {
|
|
@@ -44,12 +45,42 @@ async function setBitcoinSyncProgress(dependencies, info) {
|
|
|
44
45
|
headers: info.headers,
|
|
45
46
|
targetHeight: info.headers,
|
|
46
47
|
etaSeconds,
|
|
48
|
+
lastError: null,
|
|
47
49
|
message: dependencies.node.expectedChain === "main"
|
|
48
50
|
? "Bitcoin Core is syncing blocks after assumeutxo bootstrap."
|
|
49
51
|
: "Reading blocks from the managed Bitcoin node.",
|
|
50
52
|
});
|
|
51
53
|
}
|
|
52
|
-
async function
|
|
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) {
|
|
53
84
|
const startHeight = Math.min(tip.height, bestHeight);
|
|
54
85
|
for (let height = startHeight; height >= dependencies.startHeight; height -= 1) {
|
|
55
86
|
const localHashHex = height === tip.height
|
|
@@ -58,14 +89,14 @@ async function findCommonAncestor(dependencies, tip, bestHeight) {
|
|
|
58
89
|
if (localHashHex === null) {
|
|
59
90
|
continue;
|
|
60
91
|
}
|
|
61
|
-
const chainHashHex = await dependencies.rpc.getBlockHash(height);
|
|
92
|
+
const chainHashHex = await runRpc(() => dependencies.rpc.getBlockHash(height));
|
|
62
93
|
if (chainHashHex === localHashHex) {
|
|
63
94
|
return height;
|
|
64
95
|
}
|
|
65
96
|
}
|
|
66
97
|
return dependencies.startHeight - 1;
|
|
67
98
|
}
|
|
68
|
-
async function syncAgainstBestHeight(dependencies, bestHeight) {
|
|
99
|
+
async function syncAgainstBestHeight(dependencies, bestHeight, runRpc) {
|
|
69
100
|
if (bestHeight < dependencies.startHeight) {
|
|
70
101
|
return {
|
|
71
102
|
appliedBlocks: 0,
|
|
@@ -77,7 +108,7 @@ async function syncAgainstBestHeight(dependencies, bestHeight) {
|
|
|
77
108
|
let rewoundBlocks = 0;
|
|
78
109
|
let commonAncestorHeight = null;
|
|
79
110
|
if (startTip !== null) {
|
|
80
|
-
const rewindTarget = await findCommonAncestor(dependencies, startTip, bestHeight);
|
|
111
|
+
const rewindTarget = await findCommonAncestor(dependencies, startTip, bestHeight, runRpc);
|
|
81
112
|
if (rewindTarget < startTip.height) {
|
|
82
113
|
commonAncestorHeight = rewindTarget < dependencies.startHeight ? null : rewindTarget;
|
|
83
114
|
await dependencies.client.rewindToHeight(rewindTarget);
|
|
@@ -93,8 +124,8 @@ async function syncAgainstBestHeight(dependencies, bestHeight) {
|
|
|
93
124
|
await dependencies.progress.setCogcoinSync(nextHeight - 1, bestHeight, estimateEtaSeconds(dependencies.cogcoinRateTracker, nextHeight - 1, bestHeight));
|
|
94
125
|
}
|
|
95
126
|
for (let height = nextHeight; height <= bestHeight; height += 1) {
|
|
96
|
-
const blockHashHex = await dependencies.rpc.getBlockHash(height);
|
|
97
|
-
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));
|
|
98
129
|
const normalizedBlock = normalizeRpcBlock(rpcBlock);
|
|
99
130
|
await dependencies.client.applyBlock(normalizedBlock);
|
|
100
131
|
if (typeof rpcBlock.time === "number") {
|
|
@@ -111,12 +142,15 @@ async function syncAgainstBestHeight(dependencies, bestHeight) {
|
|
|
111
142
|
}
|
|
112
143
|
export async function syncToTip(dependencies) {
|
|
113
144
|
try {
|
|
145
|
+
const retryState = createManagedRpcRetryState();
|
|
146
|
+
const runRpc = (operation) => runWithManagedRpcRetry(dependencies, retryState, operation);
|
|
114
147
|
throwIfAborted(dependencies.abortSignal);
|
|
115
|
-
await dependencies.node.validate();
|
|
148
|
+
await runRpc(() => dependencies.node.validate());
|
|
116
149
|
const indexedTipBeforeBootstrap = await dependencies.client.getTip();
|
|
117
|
-
await dependencies.bootstrap.ensureReady(indexedTipBeforeBootstrap, dependencies.node.expectedChain, {
|
|
150
|
+
await runRpc(() => dependencies.bootstrap.ensureReady(indexedTipBeforeBootstrap, dependencies.node.expectedChain, {
|
|
118
151
|
signal: dependencies.abortSignal,
|
|
119
|
-
|
|
152
|
+
retryState,
|
|
153
|
+
}));
|
|
120
154
|
const startTip = await dependencies.client.getTip();
|
|
121
155
|
const aggregate = {
|
|
122
156
|
appliedBlocks: 0,
|
|
@@ -129,9 +163,9 @@ export async function syncToTip(dependencies) {
|
|
|
129
163
|
};
|
|
130
164
|
while (true) {
|
|
131
165
|
throwIfAborted(dependencies.abortSignal);
|
|
132
|
-
const startInfo = await dependencies.rpc.getBlockchainInfo();
|
|
166
|
+
const startInfo = await runRpc(() => dependencies.rpc.getBlockchainInfo());
|
|
133
167
|
await setBitcoinSyncProgress(dependencies, startInfo);
|
|
134
|
-
const pass = await syncAgainstBestHeight(dependencies, startInfo.blocks);
|
|
168
|
+
const pass = await syncAgainstBestHeight(dependencies, startInfo.blocks, runRpc);
|
|
135
169
|
aggregate.appliedBlocks += pass.appliedBlocks;
|
|
136
170
|
aggregate.rewoundBlocks += pass.rewoundBlocks;
|
|
137
171
|
if (pass.commonAncestorHeight !== null) {
|
|
@@ -140,19 +174,20 @@ export async function syncToTip(dependencies) {
|
|
|
140
174
|
: Math.min(aggregate.commonAncestorHeight, pass.commonAncestorHeight);
|
|
141
175
|
}
|
|
142
176
|
const finalTip = await dependencies.client.getTip();
|
|
143
|
-
const endInfo = await dependencies.rpc.getBlockchainInfo();
|
|
177
|
+
const endInfo = await runRpc(() => dependencies.rpc.getBlockchainInfo());
|
|
144
178
|
const caughtUpCogcoin = endInfo.blocks < dependencies.startHeight || finalTip?.height === endInfo.blocks;
|
|
145
179
|
aggregate.endingHeight = finalTip?.height ?? null;
|
|
146
180
|
aggregate.bestHeight = endInfo.blocks;
|
|
147
181
|
aggregate.bestHashHex = endInfo.bestblockhash;
|
|
148
182
|
if (endInfo.blocks === endInfo.headers && caughtUpCogcoin) {
|
|
149
183
|
if (dependencies.isFollowing()) {
|
|
150
|
-
dependencies.progress.replaceFollowBlockTimes(await dependencies.loadVisibleFollowBlockTimes(finalTip));
|
|
184
|
+
dependencies.progress.replaceFollowBlockTimes(await runRpc(() => dependencies.loadVisibleFollowBlockTimes(finalTip)));
|
|
151
185
|
}
|
|
152
186
|
await dependencies.progress.setPhase(dependencies.isFollowing() ? "follow_tip" : "complete", {
|
|
153
187
|
blocks: endInfo.blocks,
|
|
154
188
|
headers: endInfo.headers,
|
|
155
189
|
targetHeight: endInfo.headers,
|
|
190
|
+
lastError: null,
|
|
156
191
|
message: dependencies.isFollowing()
|
|
157
192
|
? "Following the live Bitcoin tip."
|
|
158
193
|
: "Managed sync fully caught up to the live tip.",
|
|
@@ -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
|
-
|
|
384
|
-
|
|
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
|
+
}
|