@cogcoin/client 0.5.9 → 0.5.11

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.
@@ -1,7 +1,39 @@
1
+ import { AssumeUtxoBootstrapController, deleteGetblockArchiveRange, prepareGetblockArchiveRange, } from "../bootstrap.js";
2
+ import { createRpcClient } from "../node.js";
3
+ import { attachOrStartManagedBitcoindService, stopManagedBitcoindService, } from "../service.js";
1
4
  import { closeFollowLoopResources, scheduleSync, startFollowingTipLoop } from "./follow-loop.js";
2
5
  import { loadVisibleFollowBlockTimes } from "./follow-block-times.js";
3
6
  import { createBlockRateTracker, createInitialSyncResult, } from "./internal-types.js";
4
7
  import { syncToTip as runManagedSync } from "./sync-engine.js";
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
+ function mergeSyncResults(target, source) {
25
+ target.appliedBlocks += source.appliedBlocks;
26
+ target.rewoundBlocks += source.rewoundBlocks;
27
+ target.startingHeight = target.startingHeight ?? source.startingHeight;
28
+ target.endingHeight = source.endingHeight;
29
+ target.bestHeight = source.bestHeight;
30
+ target.bestHashHex = source.bestHashHex;
31
+ if (source.commonAncestorHeight !== null) {
32
+ target.commonAncestorHeight = target.commonAncestorHeight === null
33
+ ? source.commonAncestorHeight
34
+ : Math.min(target.commonAncestorHeight, source.commonAncestorHeight);
35
+ }
36
+ }
5
37
  export class DefaultManagedBitcoindClient {
6
38
  #client;
7
39
  #store;
@@ -13,6 +45,11 @@ export class DefaultManagedBitcoindClient {
13
45
  #reattachIndexerDaemon;
14
46
  #startHeight;
15
47
  #syncDebounceMs;
48
+ #dataDir;
49
+ #walletRootId;
50
+ #startupTimeoutMs;
51
+ #shutdownTimeoutMs;
52
+ #fetchImpl;
16
53
  #following = false;
17
54
  #closed = false;
18
55
  #subscriber = null;
@@ -23,7 +60,7 @@ export class DefaultManagedBitcoindClient {
23
60
  #syncPromise = Promise.resolve(createInitialSyncResult());
24
61
  #debounceTimer = null;
25
62
  #syncAbortControllers = new Set();
26
- constructor(client, store, node, rpc, progress, bootstrap, indexerDaemon, reattachIndexerDaemon, startHeight, syncDebounceMs) {
63
+ constructor(client, store, node, rpc, progress, bootstrap, indexerDaemon, reattachIndexerDaemon, startHeight, syncDebounceMs, dataDir, walletRootId, startupTimeoutMs, shutdownTimeoutMs, fetchImpl) {
27
64
  this.#client = client;
28
65
  this.#store = store;
29
66
  this.#node = node;
@@ -34,6 +71,11 @@ export class DefaultManagedBitcoindClient {
34
71
  this.#reattachIndexerDaemon = reattachIndexerDaemon;
35
72
  this.#startHeight = startHeight;
36
73
  this.#syncDebounceMs = syncDebounceMs;
74
+ this.#dataDir = dataDir;
75
+ this.#walletRootId = walletRootId;
76
+ this.#startupTimeoutMs = startupTimeoutMs;
77
+ this.#shutdownTimeoutMs = shutdownTimeoutMs;
78
+ this.#fetchImpl = fetchImpl;
37
79
  }
38
80
  async getTip() {
39
81
  return this.#client.getTip();
@@ -54,20 +96,10 @@ export class DefaultManagedBitcoindClient {
54
96
  const abortController = new AbortController();
55
97
  this.#syncAbortControllers.add(abortController);
56
98
  try {
57
- return await runManagedSync({
58
- client: this.#client,
59
- store: this.#store,
60
- node: this.#node,
61
- rpc: this.#rpc,
62
- progress: this.#progress,
63
- bootstrap: this.#bootstrap,
64
- startHeight: this.#startHeight,
65
- bitcoinRateTracker: this.#bitcoinRateTracker,
66
- cogcoinRateTracker: this.#cogcoinRateTracker,
67
- abortSignal: abortController.signal,
68
- isFollowing: () => this.#following,
69
- loadVisibleFollowBlockTimes: (tip) => this.#loadVisibleFollowBlockTimes(tip),
70
- });
99
+ if (this.#node.expectedChain !== "main") {
100
+ return await this.#runManagedSyncPass(null, abortController.signal);
101
+ }
102
+ return await this.#syncWithStagedRanges(abortController.signal);
71
103
  }
72
104
  finally {
73
105
  this.#syncAbortControllers.delete(abortController);
@@ -186,6 +218,134 @@ export class DefaultManagedBitcoindClient {
186
218
  this.#assertOpen();
187
219
  await this.#progress.playCompletionScene();
188
220
  }
221
+ async #syncWithStagedRanges(abortSignal) {
222
+ const aggregate = createInitialSyncResult();
223
+ let stagedModeEnabled = true;
224
+ await this.#ensureBootstrapReady(abortSignal);
225
+ while (stagedModeEnabled) {
226
+ const info = await this.#rpc.getBlockchainInfo();
227
+ if (info.blocks < GETBLOCK_RANGE_BASE_HEIGHT) {
228
+ break;
229
+ }
230
+ const nextBoundary = resolveNextBoundary(info.blocks);
231
+ if (nextBoundary === null) {
232
+ break;
233
+ }
234
+ if (!isBoundaryHeight(info.blocks)) {
235
+ mergeSyncResults(aggregate, await this.#runManagedSyncPass(nextBoundary, abortSignal));
236
+ continue;
237
+ }
238
+ const firstBlockHeight = info.blocks + 1;
239
+ const lastBlockHeight = info.blocks + GETBLOCK_RANGE_SIZE;
240
+ let readyRange;
241
+ try {
242
+ readyRange = await prepareGetblockArchiveRange({
243
+ dataDir: this.#dataDir,
244
+ progress: this.#progress,
245
+ firstBlockHeight,
246
+ lastBlockHeight,
247
+ fetchImpl: this.#fetchImpl,
248
+ signal: abortSignal,
249
+ });
250
+ }
251
+ catch {
252
+ stagedModeEnabled = false;
253
+ break;
254
+ }
255
+ if (readyRange === null) {
256
+ stagedModeEnabled = false;
257
+ break;
258
+ }
259
+ const stagedRestartActive = await this.#restartManagedNodeWithRange(readyRange, abortSignal);
260
+ mergeSyncResults(aggregate, await this.#runManagedSyncPass(lastBlockHeight, abortSignal));
261
+ if (stagedRestartActive) {
262
+ await deleteGetblockArchiveRange({
263
+ dataDir: this.#dataDir,
264
+ firstBlockHeight,
265
+ lastBlockHeight,
266
+ }).catch(() => undefined);
267
+ }
268
+ else {
269
+ stagedModeEnabled = false;
270
+ }
271
+ }
272
+ mergeSyncResults(aggregate, await this.#runManagedSyncPass(null, abortSignal));
273
+ return aggregate;
274
+ }
275
+ async #runManagedSyncPass(targetHeightCap, abortSignal) {
276
+ return runManagedSync({
277
+ client: this.#client,
278
+ store: this.#store,
279
+ node: this.#node,
280
+ rpc: this.#rpc,
281
+ progress: this.#progress,
282
+ bootstrap: this.#bootstrap,
283
+ startHeight: this.#startHeight,
284
+ targetHeightCap,
285
+ bitcoinRateTracker: this.#bitcoinRateTracker,
286
+ cogcoinRateTracker: this.#cogcoinRateTracker,
287
+ abortSignal,
288
+ isFollowing: () => this.#following,
289
+ loadVisibleFollowBlockTimes: (tip) => this.#loadVisibleFollowBlockTimes(tip),
290
+ });
291
+ }
292
+ async #ensureBootstrapReady(signal) {
293
+ await this.#node.validate();
294
+ const indexedTipBeforeBootstrap = await this.#client.getTip();
295
+ await this.#bootstrap.ensureReady(indexedTipBeforeBootstrap, this.#node.expectedChain, { signal });
296
+ }
297
+ async #restartManagedNodeWithRange(readyRange, abortSignal) {
298
+ if (abortSignal.aborted) {
299
+ throw abortSignal.reason instanceof Error ? abortSignal.reason : new Error("managed_sync_aborted");
300
+ }
301
+ const baseOptions = {
302
+ chain: this.#node.expectedChain,
303
+ startHeight: this.#node.startHeight,
304
+ dataDir: this.#dataDir,
305
+ walletRootId: this.#walletRootId,
306
+ startupTimeoutMs: this.#startupTimeoutMs,
307
+ shutdownTimeoutMs: this.#shutdownTimeoutMs,
308
+ };
309
+ try {
310
+ await stopManagedBitcoindService({
311
+ dataDir: this.#dataDir,
312
+ walletRootId: this.#walletRootId,
313
+ shutdownTimeoutMs: this.#shutdownTimeoutMs,
314
+ });
315
+ const node = await attachOrStartManagedBitcoindService({
316
+ ...baseOptions,
317
+ getblockArchivePath: readyRange.artifactPath,
318
+ getblockArchiveEndHeight: readyRange.manifest.lastBlockHeight,
319
+ getblockArchiveSha256: readyRange.manifest.artifactSha256,
320
+ });
321
+ await this.#replaceManagedBindings(node);
322
+ return true;
323
+ }
324
+ catch {
325
+ const node = await attachOrStartManagedBitcoindService({
326
+ ...baseOptions,
327
+ getblockArchivePath: null,
328
+ getblockArchiveEndHeight: null,
329
+ getblockArchiveSha256: null,
330
+ });
331
+ await this.#replaceManagedBindings(node);
332
+ return false;
333
+ }
334
+ }
335
+ async #replaceManagedBindings(node) {
336
+ this.#node = node;
337
+ this.#rpc = createRpcClient(node.rpc);
338
+ this.#bootstrap = new AssumeUtxoBootstrapController({
339
+ rpc: this.#rpc,
340
+ dataDir: node.dataDir,
341
+ progress: this.#progress,
342
+ snapshot: this.#progress.getStatusSnapshot().snapshot,
343
+ });
344
+ if (this.#subscriber !== null) {
345
+ this.#subscriber.connect(node.zmq.endpoint);
346
+ this.#subscriber.subscribe(node.zmq.topic);
347
+ }
348
+ }
189
349
  #scheduleSync() {
190
350
  scheduleSync({
191
351
  syncDebounceMs: this.#syncDebounceMs,
@@ -39,12 +39,13 @@ function sleep(ms, signal) {
39
39
  signal?.addEventListener("abort", onAbort, { once: true });
40
40
  });
41
41
  }
42
- async function setBitcoinSyncProgress(dependencies, info) {
43
- const etaSeconds = estimateEtaSeconds(dependencies.bitcoinRateTracker, info.blocks, info.headers);
42
+ async function setBitcoinSyncProgress(dependencies, info, targetHeightCap) {
43
+ const targetHeight = targetHeightCap === null ? info.headers : Math.min(info.headers, targetHeightCap);
44
+ const etaSeconds = estimateEtaSeconds(dependencies.bitcoinRateTracker, info.blocks, targetHeight);
44
45
  await dependencies.progress.setPhase("bitcoin_sync", {
45
46
  blocks: info.blocks,
46
47
  headers: info.headers,
47
- targetHeight: info.headers,
48
+ targetHeight,
48
49
  etaSeconds,
49
50
  lastError: null,
50
51
  message: dependencies.node.expectedChain === "main"
@@ -171,8 +172,11 @@ export async function syncToTip(dependencies) {
171
172
  while (true) {
172
173
  throwIfAborted(dependencies.abortSignal);
173
174
  const startInfo = await runRpc(() => dependencies.rpc.getBlockchainInfo());
174
- await setBitcoinSyncProgress(dependencies, startInfo);
175
- const pass = await syncAgainstBestHeight(dependencies, startInfo.blocks, runRpc);
175
+ const cappedBestHeight = dependencies.targetHeightCap === null || dependencies.targetHeightCap === undefined
176
+ ? startInfo.blocks
177
+ : Math.min(startInfo.blocks, dependencies.targetHeightCap);
178
+ await setBitcoinSyncProgress(dependencies, startInfo, dependencies.targetHeightCap ?? null);
179
+ const pass = await syncAgainstBestHeight(dependencies, cappedBestHeight, runRpc);
176
180
  aggregate.appliedBlocks += pass.appliedBlocks;
177
181
  aggregate.rewoundBlocks += pass.rewoundBlocks;
178
182
  if (pass.commonAncestorHeight !== null) {
@@ -182,18 +186,22 @@ export async function syncToTip(dependencies) {
182
186
  }
183
187
  const finalTip = await dependencies.client.getTip();
184
188
  const endInfo = await runRpc(() => dependencies.rpc.getBlockchainInfo());
185
- const caughtUpCogcoin = endInfo.blocks < dependencies.startHeight || finalTip?.height === endInfo.blocks;
189
+ const endBestHeight = dependencies.targetHeightCap === null || dependencies.targetHeightCap === undefined
190
+ ? endInfo.blocks
191
+ : Math.min(endInfo.blocks, dependencies.targetHeightCap);
192
+ const caughtUpCogcoin = endBestHeight < dependencies.startHeight || finalTip?.height === endBestHeight;
186
193
  aggregate.endingHeight = finalTip?.height ?? null;
187
- aggregate.bestHeight = endInfo.blocks;
194
+ aggregate.bestHeight = endBestHeight;
188
195
  aggregate.bestHashHex = endInfo.bestblockhash;
189
- if (endInfo.blocks === endInfo.headers && caughtUpCogcoin) {
196
+ if ((dependencies.targetHeightCap !== null && dependencies.targetHeightCap !== undefined && caughtUpCogcoin)
197
+ || (endInfo.blocks === endInfo.headers && caughtUpCogcoin)) {
190
198
  if (dependencies.isFollowing()) {
191
199
  dependencies.progress.replaceFollowBlockTimes(await runRpc(() => dependencies.loadVisibleFollowBlockTimes(finalTip)));
192
200
  }
193
201
  await dependencies.progress.setPhase(dependencies.isFollowing() ? "follow_tip" : "complete", {
194
202
  blocks: endInfo.blocks,
195
203
  headers: endInfo.headers,
196
- targetHeight: endInfo.headers,
204
+ targetHeight: dependencies.targetHeightCap ?? endInfo.headers,
197
205
  lastError: null,
198
206
  message: dependencies.isFollowing()
199
207
  ? "Following the live Bitcoin tip."
@@ -201,8 +209,8 @@ export async function syncToTip(dependencies) {
201
209
  });
202
210
  return aggregate;
203
211
  }
204
- await setBitcoinSyncProgress(dependencies, endInfo);
205
- if (endInfo.blocks >= dependencies.startHeight && finalTip?.height !== endInfo.blocks) {
212
+ await setBitcoinSyncProgress(dependencies, endInfo, dependencies.targetHeightCap ?? null);
213
+ if (endBestHeight >= dependencies.startHeight && finalTip?.height !== endBestHeight) {
206
214
  continue;
207
215
  }
208
216
  await sleep(DEFAULT_SYNC_CATCH_UP_POLL_MS, dependencies.abortSignal);
@@ -6,22 +6,22 @@ function appendNextStep(message, nextStep) {
6
6
  }
7
7
  export function formatManagedSyncErrorMessage(message) {
8
8
  if (message.startsWith("managed_getblock_archive_manifest_http_")) {
9
- return appendNextStep(`Getblock archive manifest request failed (${message.replace("managed_getblock_archive_manifest_http_", "HTTP ")}).`, "Wait a moment, confirm the snapshot host is reachable, then rerun sync.");
9
+ return appendNextStep(`Getblock range manifest request failed (${message.replace("managed_getblock_archive_manifest_http_", "HTTP ")}).`, "Wait a moment, confirm the snapshot host is reachable, then rerun sync.");
10
10
  }
11
11
  if (message.startsWith("managed_getblock_archive_http_")) {
12
- return appendNextStep(`Getblock archive request failed (${message.replace("managed_getblock_archive_http_", "HTTP ")}).`, "Wait a moment, confirm the snapshot host is reachable, then rerun sync.");
12
+ return appendNextStep(`Getblock range request failed (${message.replace("managed_getblock_archive_http_", "HTTP ")}).`, "Wait a moment, confirm the snapshot host is reachable, then rerun sync.");
13
13
  }
14
14
  if (message === "managed_getblock_archive_response_body_missing") {
15
- return appendNextStep("Getblock archive server returned an empty response body.", "Wait a moment, confirm the snapshot host is reachable, then rerun sync.");
15
+ return appendNextStep("Getblock range server returned an empty response body.", "Wait a moment, confirm the snapshot host is reachable, then rerun sync.");
16
16
  }
17
17
  if (message === "managed_getblock_archive_resume_requires_partial_content") {
18
- return appendNextStep("Getblock archive 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.");
18
+ return appendNextStep("Getblock range 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.");
19
19
  }
20
20
  if (message.startsWith("managed_getblock_archive_chunk_sha256_mismatch_")) {
21
- return appendNextStep("A downloaded getblock archive 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 archive download.");
21
+ return appendNextStep("A downloaded getblock range 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 archive download.");
22
22
  }
23
23
  if (message === "managed_getblock_archive_sha256_mismatch" || message === "managed_getblock_archive_truncated") {
24
- return appendNextStep("The downloaded getblock archive did not match the published manifest.", "Rerun sync so the archive can be downloaded again. If this keeps happening, check local disk health and the snapshot host.");
24
+ return appendNextStep("The downloaded getblock range did not match the published manifest.", "Rerun sync so the archive can be downloaded again. If this keeps happening, check local disk health and the snapshot host.");
25
25
  }
26
26
  if (message === "bitcoind_no_peers_for_header_sync_check_internet_or_firewall") {
27
27
  return appendNextStep("No Bitcoin peers were available for header sync.", "Check your internet access and firewall rules for outbound Bitcoin connections, then rerun sync.");
@@ -2,9 +2,9 @@ import { FIELD_LEFT, FIELD_WIDTH, PREPARING_SYNC_LINE, PROGRESS_TICK_MS, SCROLL_
2
2
  export function createDefaultMessage(phase) {
3
3
  switch (phase) {
4
4
  case "getblock_archive_download":
5
- return "Downloading getblock archive.";
5
+ return "Downloading getblock range.";
6
6
  case "getblock_archive_import":
7
- return "Bitcoin Core is importing getblock archive blocks.";
7
+ return "Bitcoin Core is importing getblock range blocks.";
8
8
  case "snapshot_download":
9
9
  return "Downloading UTXO snapshot.";
10
10
  case "wait_headers_for_snapshot":
@@ -126,9 +126,9 @@ function animateStatusEllipsis(now) {
126
126
  export function resolveStatusFieldText(progress, snapshotHeight, now = 0) {
127
127
  switch (progress.phase) {
128
128
  case "getblock_archive_download":
129
- return `Downloading getblock archive${animateStatusEllipsis(now)}`;
129
+ return `Downloading getblock range${animateStatusEllipsis(now)}`;
130
130
  case "getblock_archive_import":
131
- return `Importing getblock archive${animateStatusEllipsis(now)}`;
131
+ return `Importing getblock range${animateStatusEllipsis(now)}`;
132
132
  case "paused":
133
133
  case "snapshot_download":
134
134
  return `Downloading snapshot to ${snapshotHeight}${animateStatusEllipsis(now)}`;
@@ -4,7 +4,7 @@ export { attachOrStartIndexerDaemon, readIndexerDaemonStatusForTesting, stopInde
4
4
  export { normalizeRpcBlock } from "./normalize.js";
5
5
  export { BitcoinRpcClient } from "./rpc.js";
6
6
  export { attachOrStartManagedBitcoindService, buildManagedServiceArgsForTesting, readManagedBitcoindServiceStatusForTesting, resolveManagedBitcoindDbcacheMiB, stopManagedBitcoindService, shutdownManagedBitcoindServiceForTesting, writeBitcoinConfForTesting, } from "./service.js";
7
- export { AssumeUtxoBootstrapController, DEFAULT_SNAPSHOT_METADATA, createBootstrapStateForTesting, downloadSnapshotFileForTesting, loadBootstrapStateForTesting, prepareLatestGetblockArchiveForTesting, resolveBootstrapPathsForTesting, resolveGetblockArchivePathsForTesting, resolveReadyGetblockArchiveForTesting, saveBootstrapStateForTesting, validateSnapshotFileForTesting, waitForGetblockArchiveImportForTesting, waitForHeadersForTesting, } from "./bootstrap.js";
7
+ export { AssumeUtxoBootstrapController, DEFAULT_SNAPSHOT_METADATA, createBootstrapStateForTesting, deleteGetblockArchiveRangeForTesting, downloadSnapshotFileForTesting, loadBootstrapStateForTesting, prepareGetblockArchiveRangeForTesting, prepareLatestGetblockArchiveForTesting, resolveBootstrapPathsForTesting, resolveGetblockArchivePathsForTesting, resolveReadyGetblockArchiveForTesting, saveBootstrapStateForTesting, validateSnapshotFileForTesting, waitForGetblockArchiveImportForTesting, waitForHeadersForTesting, } from "./bootstrap.js";
8
8
  export { buildBitcoindArgsForTesting, createRpcClient, launchManagedBitcoindNode, resolveDefaultBitcoindDataDirForTesting, validateNodeConfigForTesting, } from "./node.js";
9
9
  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";
10
10
  export { WritingQuoteRotator, loadWritingQuotesForTesting, shuffleIndicesForTesting, } from "./quotes.js";
@@ -4,7 +4,7 @@ export { attachOrStartIndexerDaemon, readIndexerDaemonStatusForTesting, stopInde
4
4
  export { normalizeRpcBlock } from "./normalize.js";
5
5
  export { BitcoinRpcClient } from "./rpc.js";
6
6
  export { attachOrStartManagedBitcoindService, buildManagedServiceArgsForTesting, readManagedBitcoindServiceStatusForTesting, resolveManagedBitcoindDbcacheMiB, stopManagedBitcoindService, shutdownManagedBitcoindServiceForTesting, writeBitcoinConfForTesting, } from "./service.js";
7
- export { AssumeUtxoBootstrapController, DEFAULT_SNAPSHOT_METADATA, createBootstrapStateForTesting, downloadSnapshotFileForTesting, loadBootstrapStateForTesting, prepareLatestGetblockArchiveForTesting, resolveBootstrapPathsForTesting, resolveGetblockArchivePathsForTesting, resolveReadyGetblockArchiveForTesting, saveBootstrapStateForTesting, validateSnapshotFileForTesting, waitForGetblockArchiveImportForTesting, waitForHeadersForTesting, } from "./bootstrap.js";
7
+ export { AssumeUtxoBootstrapController, DEFAULT_SNAPSHOT_METADATA, createBootstrapStateForTesting, deleteGetblockArchiveRangeForTesting, downloadSnapshotFileForTesting, loadBootstrapStateForTesting, prepareGetblockArchiveRangeForTesting, prepareLatestGetblockArchiveForTesting, resolveBootstrapPathsForTesting, resolveGetblockArchivePathsForTesting, resolveReadyGetblockArchiveForTesting, saveBootstrapStateForTesting, validateSnapshotFileForTesting, waitForGetblockArchiveImportForTesting, waitForHeadersForTesting, } from "./bootstrap.js";
8
8
  export { buildBitcoindArgsForTesting, createRpcClient, launchManagedBitcoindNode, resolveDefaultBitcoindDataDirForTesting, validateNodeConfigForTesting, } from "./node.js";
9
9
  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";
10
10
  export { WritingQuoteRotator, loadWritingQuotesForTesting, shuffleIndicesForTesting, } from "./quotes.js";
@@ -17,28 +17,27 @@ export interface SnapshotChunkManifest {
17
17
  snapshotSha256: string;
18
18
  chunkSha256s: string[];
19
19
  }
20
- export interface GetblockArchiveManifestBlockRecord {
21
- height: number;
22
- blockHash: string;
23
- previousBlockHash: string;
24
- recordOffset: number;
25
- recordLength: number;
26
- rawBlockSizeBytes: number;
27
- }
28
- export interface GetblockArchiveManifest {
20
+ export interface GetblockRangeManifestEntry {
29
21
  formatVersion: number;
30
22
  chain: "main";
31
23
  baseSnapshotHeight: number;
32
24
  firstBlockHeight: number;
33
- endHeight: number;
34
- blockCount: number;
25
+ lastBlockHeight: number;
35
26
  artifactFilename: string;
36
27
  artifactSizeBytes: number;
37
28
  artifactSha256: string;
38
29
  chunkSizeBytes: number;
39
30
  chunkSha256s: string[];
40
- blocks: GetblockArchiveManifestBlockRecord[];
41
31
  }
32
+ export interface GetblockRangeManifest {
33
+ formatVersion: number;
34
+ chain: "main";
35
+ baseSnapshotHeight: number;
36
+ rangeSizeBlocks: number;
37
+ publishedThroughHeight: number;
38
+ ranges: GetblockRangeManifestEntry[];
39
+ }
40
+ export type GetblockArchiveManifest = GetblockRangeManifestEntry;
42
41
  export interface WritingQuote {
43
42
  quote: string;
44
43
  author: string;
@@ -3,7 +3,6 @@ import { resolveWalletRootIdFromLocalArtifacts } from "../../wallet/root-resolut
3
3
  import { usesTtyProgress, writeLine } from "../io.js";
4
4
  import { classifyCliError } from "../output.js";
5
5
  import { createStopSignalWatcher } from "../signals.js";
6
- import { confirmGetblockArchiveRestart } from "./getblock-archive-restart.js";
7
6
  export async function runFollowCommand(parsed, context) {
8
7
  const dbPath = parsed.dbPath ?? context.resolveDefaultClientDatabasePath();
9
8
  const dataDir = parsed.dataDir ?? context.resolveDefaultBitcoindDataDir();
@@ -24,7 +23,6 @@ export async function runFollowCommand(parsed, context) {
24
23
  dataDir,
25
24
  walletRootId: walletRoot.walletRootId,
26
25
  progressOutput: parsed.progressOutput,
27
- confirmGetblockArchiveRestart: async (options) => confirmGetblockArchiveRestart(parsed, context, options),
28
26
  });
29
27
  storeOwned = false;
30
28
  const stopWatcher = createStopSignalWatcher(context.signalSource, context.stderr, client, context.forceExit);
@@ -4,7 +4,6 @@ import { resolveWalletRootIdFromLocalArtifacts } from "../../wallet/root-resolut
4
4
  import { writeLine } from "../io.js";
5
5
  import { classifyCliError } from "../output.js";
6
6
  import { createStopSignalWatcher, waitForCompletionOrStop } from "../signals.js";
7
- import { confirmGetblockArchiveRestart } from "./getblock-archive-restart.js";
8
7
  export async function runSyncCommand(parsed, context) {
9
8
  const dbPath = parsed.dbPath ?? context.resolveDefaultClientDatabasePath();
10
9
  const dataDir = parsed.dataDir ?? context.resolveDefaultBitcoindDataDir();
@@ -25,7 +24,6 @@ export async function runSyncCommand(parsed, context) {
25
24
  dataDir,
26
25
  walletRootId: walletRoot.walletRootId,
27
26
  progressOutput: parsed.progressOutput,
28
- confirmGetblockArchiveRestart: async (options) => confirmGetblockArchiveRestart(parsed, context, options),
29
27
  });
30
28
  storeOwned = false;
31
29
  const stopWatcher = createStopSignalWatcher(context.signalSource, context.stderr, client, context.forceExit);
@@ -19,10 +19,18 @@ export function createStopSignalWatcher(signalSource, stderr, client, forceExit)
19
19
  };
20
20
  const onFirstSignal = () => {
21
21
  closing = true;
22
- writeLine(stderr, "Detaching from managed Cogcoin client...");
22
+ writeLine(stderr, "Detaching from managed Cogcoin client and resuming background indexer follow...");
23
23
  void client.close().then(() => {
24
+ if (resolved) {
25
+ return;
26
+ }
27
+ writeLine(stderr, "Detached cleanly; background indexer follow resumed.");
24
28
  settle(0);
25
29
  }, () => {
30
+ if (resolved) {
31
+ return;
32
+ }
33
+ writeLine(stderr, "Detach failed before background indexer follow was confirmed.");
26
34
  settle(1);
27
35
  });
28
36
  };
@@ -103,5 +111,11 @@ export async function waitForCompletionOrStop(promise, stopWatcher) {
103
111
  }
104
112
  throw outcome.error;
105
113
  }
114
+ if (stopWatcher.isStopping()) {
115
+ return {
116
+ kind: "stopped",
117
+ code: await stopWatcher.promise,
118
+ };
119
+ }
106
120
  return outcome;
107
121
  }
@@ -227,6 +227,9 @@ function resolveAnchorOutpointForSender(state, senderIndex) {
227
227
  vout: anchoredDomain.currentCanonicalAnchorOutpoint.vout,
228
228
  };
229
229
  }
230
+ function isFundingSender(state, sender) {
231
+ return sender.scriptPubKeyHex === state.funding.scriptPubKeyHex;
232
+ }
230
233
  async function confirmAnchor(prompter, operation) {
231
234
  prompter.writeLine(`You are anchoring "${operation.chainDomain.name}" onto dedicated index ${operation.targetIdentity.localIndex}.`);
232
235
  prompter.writeLine("Anchoring is permanent chain state. This flow uses two transactions and is not rolled back automatically.");
@@ -274,10 +277,15 @@ function resolveAnchorOperation(context, domainName, foundingMessageText, foundi
274
277
  if (senderIdentity.readOnly) {
275
278
  throw new Error("wallet_anchor_owner_read_only");
276
279
  }
277
- const sourceAnchorOutpoint = senderIdentity.index === context.localState.state.fundingIndex
280
+ const sourceAnchorOutpoint = isFundingSender(context.localState.state, {
281
+ localIndex: senderIdentity.index,
282
+ scriptPubKeyHex: senderIdentity.scriptPubKeyHex,
283
+ address: senderIdentity.address,
284
+ })
278
285
  ? null
279
286
  : resolveAnchorOutpointForSender(context.localState.state, senderIdentity.index);
280
- if (senderIdentity.index !== context.localState.state.fundingIndex && sourceAnchorOutpoint === null) {
287
+ if (sourceAnchorOutpoint === null
288
+ && senderIdentity.scriptPubKeyHex !== context.localState.state.funding.scriptPubKeyHex) {
281
289
  throw new Error("wallet_anchor_owner_identity_not_supported");
282
290
  }
283
291
  const targetIdentity = selectNextDedicatedIdentityTarget(context.localState.state);
@@ -466,6 +474,8 @@ function buildTx1Plan(options) {
466
474
  expectedReplacementAnchorScriptHex: null,
467
475
  expectedReplacementAnchorValueSats: null,
468
476
  allowedFundingScriptPubKeyHex: options.state.funding.scriptPubKeyHex,
477
+ requiredSenderOutpoint: null,
478
+ requiredProvisionalOutpoint: null,
469
479
  errorPrefix: "wallet_anchor_tx1",
470
480
  };
471
481
  }
@@ -494,6 +504,8 @@ function buildTx1Plan(options) {
494
504
  expectedReplacementAnchorScriptHex: options.operation.sourceSender.scriptPubKeyHex,
495
505
  expectedReplacementAnchorValueSats: BigInt(options.state.anchorValueSats),
496
506
  allowedFundingScriptPubKeyHex: options.state.funding.scriptPubKeyHex,
507
+ requiredSenderOutpoint: options.operation.sourceAnchorOutpoint,
508
+ requiredProvisionalOutpoint: null,
497
509
  errorPrefix: "wallet_anchor_tx1",
498
510
  };
499
511
  }
@@ -538,19 +550,53 @@ function buildTx2Plan(options) {
538
550
  expectedReplacementAnchorScriptHex: null,
539
551
  expectedReplacementAnchorValueSats: null,
540
552
  allowedFundingScriptPubKeyHex: options.state.funding.scriptPubKeyHex,
553
+ requiredSenderOutpoint: null,
554
+ requiredProvisionalOutpoint: {
555
+ txid: provisional.txid,
556
+ vout: provisional.vout,
557
+ },
541
558
  errorPrefix: "wallet_anchor_tx2",
542
559
  };
543
560
  }
561
+ function getDecodedInputScriptPubKeyHex(input) {
562
+ return input.prevout?.scriptPubKey?.hex ?? null;
563
+ }
564
+ function getDecodedInputVout(input) {
565
+ const vout = input.vout;
566
+ return typeof vout === "number" ? vout : null;
567
+ }
568
+ function inputMatchesOutpoint(input, outpoint) {
569
+ return input.txid === outpoint.txid && getDecodedInputVout(input) === outpoint.vout;
570
+ }
571
+ function assertNoUnexpectedAnchorInputs(inputs, allowedScripts, unexpectedInputErrorCode) {
572
+ for (const input of inputs) {
573
+ const scriptPubKeyHex = getDecodedInputScriptPubKeyHex(input);
574
+ if (scriptPubKeyHex === null || !allowedScripts.has(scriptPubKeyHex)) {
575
+ throw new Error(unexpectedInputErrorCode);
576
+ }
577
+ }
578
+ }
544
579
  function validateTx1Draft(decoded, funded, plan) {
545
580
  const inputs = decoded.tx.vin;
546
581
  const outputs = decoded.tx.vout;
547
- if (inputs.length === 0 || inputs[0]?.prevout?.scriptPubKey?.hex !== plan.sender.scriptPubKeyHex) {
582
+ if (inputs.length === 0) {
583
+ throw new Error(`${plan.errorPrefix}_sender_input_mismatch`);
584
+ }
585
+ const firstInputScriptPubKeyHex = getDecodedInputScriptPubKeyHex(inputs[0]);
586
+ if (firstInputScriptPubKeyHex !== plan.sender.scriptPubKeyHex) {
548
587
  throw new Error(`${plan.errorPrefix}_sender_input_mismatch`);
549
588
  }
550
- for (let index = 1; index < inputs.length; index += 1) {
551
- if (inputs[index]?.prevout?.scriptPubKey?.hex !== plan.allowedFundingScriptPubKeyHex) {
589
+ if (plan.requiredSenderOutpoint !== null) {
590
+ if (!inputMatchesOutpoint(inputs[0], plan.requiredSenderOutpoint)) {
591
+ throw new Error(`${plan.errorPrefix}_sender_input_mismatch`);
592
+ }
593
+ if (inputs.length < 2 || getDecodedInputScriptPubKeyHex(inputs[1]) !== plan.allowedFundingScriptPubKeyHex) {
552
594
  throw new Error(`${plan.errorPrefix}_unexpected_funding_input`);
553
595
  }
596
+ assertNoUnexpectedAnchorInputs(inputs.slice(2), new Set([plan.allowedFundingScriptPubKeyHex]), `${plan.errorPrefix}_unexpected_funding_input`);
597
+ }
598
+ else {
599
+ assertNoUnexpectedAnchorInputs(inputs, new Set([plan.allowedFundingScriptPubKeyHex]), `${plan.errorPrefix}_unexpected_funding_input`);
554
600
  }
555
601
  if (outputs[0]?.scriptPubKey?.hex !== plan.expectedOpReturnScriptHex) {
556
602
  throw new Error(`${plan.errorPrefix}_opreturn_mismatch`);
@@ -586,14 +632,18 @@ function validateTx1Draft(decoded, funded, plan) {
586
632
  function validateTx2Draft(decoded, funded, plan) {
587
633
  const inputs = decoded.tx.vin;
588
634
  const outputs = decoded.tx.vout;
589
- if (inputs.length === 0 || inputs[0]?.prevout?.scriptPubKey?.hex !== plan.sender.scriptPubKeyHex) {
635
+ if (inputs.length === 0 || plan.requiredProvisionalOutpoint === null) {
590
636
  throw new Error(`${plan.errorPrefix}_provisional_input_mismatch`);
591
637
  }
592
- for (let index = 1; index < inputs.length; index += 1) {
593
- if (inputs[index]?.prevout?.scriptPubKey?.hex !== plan.allowedFundingScriptPubKeyHex) {
594
- throw new Error(`${plan.errorPrefix}_unexpected_funding_input`);
595
- }
638
+ const firstInputScriptPubKeyHex = getDecodedInputScriptPubKeyHex(inputs[0]);
639
+ if (firstInputScriptPubKeyHex !== plan.sender.scriptPubKeyHex
640
+ || !inputMatchesOutpoint(inputs[0], plan.requiredProvisionalOutpoint)) {
641
+ throw new Error(`${plan.errorPrefix}_provisional_input_mismatch`);
642
+ }
643
+ if (inputs.length < 2 || getDecodedInputScriptPubKeyHex(inputs[1]) !== plan.allowedFundingScriptPubKeyHex) {
644
+ throw new Error(`${plan.errorPrefix}_unexpected_funding_input`);
596
645
  }
646
+ assertNoUnexpectedAnchorInputs(inputs.slice(2), new Set([plan.allowedFundingScriptPubKeyHex]), `${plan.errorPrefix}_unexpected_funding_input`);
597
647
  if (outputs[0]?.scriptPubKey?.hex !== plan.expectedOpReturnScriptHex) {
598
648
  throw new Error(`${plan.errorPrefix}_opreturn_mismatch`);
599
649
  }
@@ -625,6 +675,9 @@ async function buildTx1(options) {
625
675
  validateFundedDraft: validateTx1Draft,
626
676
  finalizeErrorCode: "wallet_anchor_tx1_finalize_failed",
627
677
  mempoolRejectPrefix: "wallet_anchor_tx1_mempool_rejected",
678
+ builderOptions: {
679
+ addInputs: false,
680
+ },
628
681
  });
629
682
  }
630
683
  async function buildTx2(options) {
@@ -636,6 +689,7 @@ async function buildTx2(options) {
636
689
  finalizeErrorCode: "wallet_anchor_tx2_finalize_failed",
637
690
  mempoolRejectPrefix: "wallet_anchor_tx2_mempool_rejected",
638
691
  builderOptions: {
692
+ addInputs: false,
639
693
  includeUnsafe: true,
640
694
  minConf: 0,
641
695
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cogcoin/client",
3
- "version": "0.5.9",
3
+ "version": "0.5.11",
4
4
  "description": "Store-backed Cogcoin client with wallet flows, SQLite persistence, and managed Bitcoin Core integration.",
5
5
  "license": "MIT",
6
6
  "type": "module",