@cogcoin/client 1.0.1 → 1.1.0

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 (79) hide show
  1. package/README.md +4 -2
  2. package/dist/bitcoind/client/factory.d.ts +0 -8
  3. package/dist/bitcoind/client/factory.js +1 -59
  4. package/dist/bitcoind/client/managed-client.d.ts +1 -3
  5. package/dist/bitcoind/client/managed-client.js +3 -47
  6. package/dist/bitcoind/indexer-daemon-main.js +173 -28
  7. package/dist/bitcoind/indexer-daemon.d.ts +14 -3
  8. package/dist/bitcoind/indexer-daemon.js +145 -29
  9. package/dist/bitcoind/indexer-monitor.d.ts +12 -0
  10. package/dist/bitcoind/indexer-monitor.js +89 -0
  11. package/dist/bitcoind/progress/follow-scene.d.ts +7 -1
  12. package/dist/bitcoind/progress/follow-scene.js +87 -4
  13. package/dist/bitcoind/progress/tty-renderer.d.ts +2 -0
  14. package/dist/bitcoind/progress/tty-renderer.js +2 -0
  15. package/dist/bitcoind/retryable-rpc.js +3 -0
  16. package/dist/bitcoind/service.d.ts +1 -0
  17. package/dist/bitcoind/service.js +31 -9
  18. package/dist/bitcoind/testing.d.ts +0 -1
  19. package/dist/bitcoind/testing.js +0 -1
  20. package/dist/bitcoind/types.d.ts +5 -2
  21. package/dist/cli/commands/follow.js +44 -49
  22. package/dist/cli/commands/mining-admin.js +65 -2
  23. package/dist/cli/commands/mining-read.js +43 -3
  24. package/dist/cli/commands/mining-runtime.js +91 -73
  25. package/dist/cli/commands/service-runtime.js +42 -2
  26. package/dist/cli/commands/status.js +3 -1
  27. package/dist/cli/commands/sync.js +50 -90
  28. package/dist/cli/commands/update.d.ts +2 -0
  29. package/dist/cli/commands/update.js +101 -0
  30. package/dist/cli/commands/wallet-admin.js +21 -3
  31. package/dist/cli/commands/wallet-read.js +2 -0
  32. package/dist/cli/context.js +36 -1
  33. package/dist/cli/managed-indexer-observer.d.ts +33 -0
  34. package/dist/cli/managed-indexer-observer.js +163 -0
  35. package/dist/cli/mining-format.d.ts +3 -1
  36. package/dist/cli/mining-format.js +63 -0
  37. package/dist/cli/mining-json.d.ts +11 -1
  38. package/dist/cli/mining-json.js +15 -0
  39. package/dist/cli/output.js +74 -2
  40. package/dist/cli/parse.d.ts +1 -1
  41. package/dist/cli/parse.js +28 -0
  42. package/dist/cli/prompt.js +109 -0
  43. package/dist/cli/read-json.d.ts +26 -1
  44. package/dist/cli/read-json.js +48 -0
  45. package/dist/cli/runner.js +8 -2
  46. package/dist/cli/signals.d.ts +12 -0
  47. package/dist/cli/signals.js +31 -13
  48. package/dist/cli/types.d.ts +13 -4
  49. package/dist/cli/update-notifier.js +7 -222
  50. package/dist/cli/update-service.d.ts +34 -0
  51. package/dist/cli/update-service.js +152 -0
  52. package/dist/client/initialization.js +5 -0
  53. package/dist/semver.d.ts +12 -0
  54. package/dist/semver.js +68 -0
  55. package/dist/wallet/lifecycle.d.ts +10 -0
  56. package/dist/wallet/mining/config.js +64 -3
  57. package/dist/wallet/mining/control.d.ts +5 -1
  58. package/dist/wallet/mining/control.js +269 -26
  59. package/dist/wallet/mining/domain-prompts.d.ts +17 -0
  60. package/dist/wallet/mining/domain-prompts.js +130 -0
  61. package/dist/wallet/mining/index.d.ts +2 -1
  62. package/dist/wallet/mining/index.js +1 -0
  63. package/dist/wallet/mining/provider-model.d.ts +30 -0
  64. package/dist/wallet/mining/provider-model.js +134 -0
  65. package/dist/wallet/mining/runner.d.ts +156 -5
  66. package/dist/wallet/mining/runner.js +1019 -399
  67. package/dist/wallet/mining/runtime-artifacts.js +1 -0
  68. package/dist/wallet/mining/sentence-protocol.d.ts +1 -0
  69. package/dist/wallet/mining/sentences.d.ts +2 -2
  70. package/dist/wallet/mining/sentences.js +32 -6
  71. package/dist/wallet/mining/types.d.ts +35 -1
  72. package/dist/wallet/mining/visualizer.d.ts +3 -0
  73. package/dist/wallet/mining/visualizer.js +132 -15
  74. package/dist/wallet/read/context.d.ts +1 -0
  75. package/dist/wallet/read/context.js +15 -7
  76. package/dist/wallet/state/client-password-agent.js +4 -1
  77. package/dist/wallet/state/client-password.js +15 -8
  78. package/dist/wallet/tx/common.js +1 -1
  79. package/package.json +3 -2
@@ -1,16 +1,20 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
+ import { rm } from "node:fs/promises";
4
+ import { join } from "node:path";
3
5
  import { fileURLToPath } from "node:url";
4
6
  import { getBalance, getBlockWinners, lookupDomain, lookupDomainById, } from "@cogcoin/indexer/queries";
5
7
  import { assaySentences, deriveBlendSeed, displayToInternalBlockhash, getWords, settleBlock, } from "@cogcoin/scoring";
8
+ import { wordlist as englishWordlist } from "@scure/bip39/wordlists/english.js";
6
9
  import { probeIndexerDaemon } from "../../bitcoind/indexer-daemon.js";
10
+ import { isRetryableManagedRpcError } from "../../bitcoind/retryable-rpc.js";
7
11
  import { FOLLOW_VISIBLE_PRIOR_BLOCKS } from "../../bitcoind/client/follow-block-times.js";
8
- import { attachOrStartManagedBitcoindService } from "../../bitcoind/service.js";
12
+ import { attachOrStartManagedBitcoindService, probeManagedBitcoindService, stopManagedBitcoindService, } from "../../bitcoind/service.js";
9
13
  import { createRpcClient } from "../../bitcoind/node.js";
10
14
  import { COG_OPCODES, COG_PREFIX } from "../cogop/constants.js";
11
15
  import { extractOpReturnPayloadFromScriptHex } from "../tx/register.js";
12
- import { assertFixedInputPrefixMatches, buildWalletMutationTransaction, outpointKey as walletMutationOutpointKey, isAlreadyAcceptedError, isBroadcastUnknownError, reconcilePersistentPolicyLocks, resolveWalletMutationFeeSelection, saveWalletStatePreservingUnlock, } from "../tx/common.js";
13
- import { acquireFileLock } from "../fs/lock.js";
16
+ import { assertFixedInputPrefixMatches, buildWalletMutationTransaction, isInsufficientFundsError, outpointKey as walletMutationOutpointKey, isAlreadyAcceptedError, isBroadcastUnknownError, reconcilePersistentPolicyLocks, resolveWalletMutationFeeSelection, saveWalletStatePreservingUnlock, } from "../tx/common.js";
17
+ import { FileLockBusyError, acquireFileLock, clearOrphanedFileLock, readLockMetadata, } from "../fs/lock.js";
14
18
  import { isMineableWalletDomain, openWalletReadContext, } from "../read/index.js";
15
19
  import { resolveWalletRuntimePathsForTesting } from "../runtime.js";
16
20
  import { createDefaultWalletSecretProvider, } from "../state/provider.js";
@@ -19,13 +23,45 @@ import { appendMiningEvent, loadMiningRuntimeStatus, saveMiningRuntimeStatus, }
19
23
  import { loadClientConfig } from "./config.js";
20
24
  import { MINING_LOOP_INTERVAL_MS, MINING_NETWORK_SETTLE_WINDOW_MS, MINING_PROVIDER_BACKOFF_BASE_MS, MINING_PROVIDER_BACKOFF_MAX_MS, MINING_SHUTDOWN_GRACE_MS, MINING_STATUS_HEARTBEAT_INTERVAL_MS, MINING_SUSPEND_GAP_THRESHOLD_MS, MINING_TIP_SETTLE_WINDOW_MS, MINING_WORKER_API_VERSION, } from "./constants.js";
21
25
  import { inspectMiningControlPlane, setupBuiltInMining } from "./control.js";
22
- import { isMiningGenerationAbortRequested, markMiningGenerationActive, markMiningGenerationInactive, readMiningPreemptionRequest, requestMiningGenerationPreemption, } from "./coordination.js";
26
+ import { isMiningGenerationAbortRequested, markMiningGenerationActive, markMiningGenerationInactive, readMiningGenerationActivity, readMiningPreemptionRequest, requestMiningGenerationPreemption, } from "./coordination.js";
23
27
  import { clearMiningPublishState, miningPublishIsInMempool, miningPublishMayStillExist, normalizeMiningPublishState, normalizeMiningStateRecord, } from "./state.js";
24
28
  import { createMiningSentenceRequestLimits } from "./sentence-protocol.js";
25
29
  import { generateMiningSentences, MiningProviderRequestError } from "./sentences.js";
26
30
  import { createEmptyMiningFollowVisualizerState, MiningFollowVisualizer, } from "./visualizer.js";
27
31
  const BEST_BLOCK_POLL_INTERVAL_MS = 500;
28
32
  const BACKGROUND_START_TIMEOUT_MS = 15_000;
33
+ const MINING_BITCOIN_RECOVERY_GRACE_MS = 15_000;
34
+ const MINING_BITCOIN_RECOVERY_RESTART_COOLDOWN_MS = 60_000;
35
+ const MINING_BITCOIN_RECOVERY_NOTE = "Mining lost contact with the local Bitcoin RPC service and is waiting for it to recover.";
36
+ function resolveBip39WordsFromIndices(indices) {
37
+ if (indices === null || indices === undefined) {
38
+ return [];
39
+ }
40
+ const words = [];
41
+ for (const index of indices) {
42
+ if (!Number.isInteger(index) || index < 0 || index >= englishWordlist.length) {
43
+ continue;
44
+ }
45
+ words.push(englishWordlist[index]);
46
+ }
47
+ return words;
48
+ }
49
+ function resolveSettledWinnerRequiredWords(options) {
50
+ const storedWords = resolveBip39WordsFromIndices(options.bip39WordIndices);
51
+ if (storedWords.length > 0) {
52
+ return storedWords;
53
+ }
54
+ if (options.snapshotTipPreviousHashHex === null
55
+ || options.snapshotTipPreviousHashHex === undefined
56
+ || !Number.isInteger(options.domainId)
57
+ || options.domainId <= 0) {
58
+ return [];
59
+ }
60
+ return resolveBip39WordsFromIndices(deriveMiningWordIndices(Buffer.from(displayToInternalBlockhash(options.snapshotTipPreviousHashHex), "hex"), options.domainId));
61
+ }
62
+ function resolveSnapshotOverride(override, fallback) {
63
+ return override === undefined ? fallback : override;
64
+ }
29
65
  class MiningSuspendDetectedError extends Error {
30
66
  detectedAtUnixMs;
31
67
  constructor(detectedAtUnixMs) {
@@ -88,6 +124,239 @@ async function isProcessAlive(pid) {
88
124
  return true;
89
125
  }
90
126
  }
127
+ function normalizeMiningPid(value) {
128
+ return typeof value === "number" && Number.isInteger(value) && value > 0
129
+ ? value
130
+ : null;
131
+ }
132
+ function resolveMiningGenerationRequestPath(paths) {
133
+ return join(paths.miningRoot, "generation-request.json");
134
+ }
135
+ function resolveMiningGenerationActivityPath(paths) {
136
+ return join(paths.miningRoot, "generation-activity.json");
137
+ }
138
+ function createTakeoverStoppedMiningNote(livePublishInMempool) {
139
+ return livePublishInMempool
140
+ ? "Mining runtime replaced. The last mining transaction may still confirm from mempool."
141
+ : "Mining runtime replaced.";
142
+ }
143
+ function createStoppedMiningRuntimeSnapshotForTakeover(options) {
144
+ const note = createTakeoverStoppedMiningNote(options.snapshot?.livePublishInMempool);
145
+ if (options.snapshot !== null) {
146
+ return {
147
+ ...options.snapshot,
148
+ updatedAtUnixMs: options.nowUnixMs,
149
+ runMode: "stopped",
150
+ backgroundWorkerPid: null,
151
+ backgroundWorkerRunId: null,
152
+ backgroundWorkerHeartbeatAtUnixMs: null,
153
+ backgroundWorkerHealth: null,
154
+ currentPhase: "idle",
155
+ note,
156
+ };
157
+ }
158
+ return {
159
+ schemaVersion: 1,
160
+ walletRootId: options.walletRootId,
161
+ workerApiVersion: null,
162
+ workerBinaryVersion: null,
163
+ workerBuildId: null,
164
+ updatedAtUnixMs: options.nowUnixMs,
165
+ runMode: "stopped",
166
+ backgroundWorkerPid: null,
167
+ backgroundWorkerRunId: null,
168
+ backgroundWorkerHeartbeatAtUnixMs: null,
169
+ backgroundWorkerHealth: null,
170
+ indexerDaemonState: null,
171
+ indexerDaemonInstanceId: null,
172
+ indexerSnapshotSeq: null,
173
+ indexerSnapshotOpenedAtUnixMs: null,
174
+ indexerTruthSource: undefined,
175
+ indexerHeartbeatAtUnixMs: null,
176
+ coreBestHeight: null,
177
+ coreBestHash: null,
178
+ indexerTipHeight: null,
179
+ indexerTipHash: null,
180
+ indexerReorgDepth: null,
181
+ indexerTipAligned: null,
182
+ corePublishState: null,
183
+ providerState: null,
184
+ lastSuspendDetectedAtUnixMs: null,
185
+ reconnectSettledUntilUnixMs: null,
186
+ tipSettledUntilUnixMs: null,
187
+ miningState: "idle",
188
+ currentPhase: "idle",
189
+ currentPublishState: "none",
190
+ targetBlockHeight: null,
191
+ referencedBlockHashDisplay: null,
192
+ currentDomainId: null,
193
+ currentDomainName: null,
194
+ currentSentenceDisplay: null,
195
+ currentCanonicalBlend: null,
196
+ currentTxid: null,
197
+ currentWtxid: null,
198
+ livePublishInMempool: null,
199
+ currentFeeRateSatVb: null,
200
+ currentAbsoluteFeeSats: null,
201
+ currentBlockFeeSpentSats: "0",
202
+ sessionFeeSpentSats: "0",
203
+ lifetimeFeeSpentSats: "0",
204
+ sameDomainCompetitorSuppressed: null,
205
+ higherRankedCompetitorDomainCount: null,
206
+ dedupedCompetitorDomainCount: null,
207
+ competitivenessGateIndeterminate: null,
208
+ mempoolSequenceCacheStatus: null,
209
+ currentPublishDecision: null,
210
+ lastMempoolSequence: null,
211
+ lastCompetitivenessGateAtUnixMs: null,
212
+ pauseReason: null,
213
+ providerConfigured: false,
214
+ providerKind: null,
215
+ bitcoindHealth: "unavailable",
216
+ bitcoindServiceState: null,
217
+ bitcoindReplicaStatus: null,
218
+ nodeHealth: "unavailable",
219
+ indexerHealth: "unavailable",
220
+ tipsAligned: null,
221
+ lastEventAtUnixMs: null,
222
+ lastError: null,
223
+ note,
224
+ };
225
+ }
226
+ async function waitForMiningProcessExit(pid, timeoutMs, sleepImpl = sleep) {
227
+ const deadline = Date.now() + timeoutMs;
228
+ while (Date.now() < deadline) {
229
+ if (!await isProcessAlive(pid)) {
230
+ return true;
231
+ }
232
+ await sleepImpl(Math.min(250, Math.max(timeoutMs, 1)));
233
+ }
234
+ return !await isProcessAlive(pid);
235
+ }
236
+ async function terminateMiningRuntimePid(options) {
237
+ if (!await isProcessAlive(options.pid)) {
238
+ return false;
239
+ }
240
+ try {
241
+ process.kill(options.pid, "SIGTERM");
242
+ }
243
+ catch (error) {
244
+ if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) {
245
+ throw error;
246
+ }
247
+ }
248
+ if (await waitForMiningProcessExit(options.pid, options.shutdownGraceMs, options.sleepImpl)) {
249
+ return true;
250
+ }
251
+ try {
252
+ process.kill(options.pid, "SIGKILL");
253
+ }
254
+ catch (error) {
255
+ if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) {
256
+ throw error;
257
+ }
258
+ }
259
+ if (await waitForMiningProcessExit(options.pid, options.shutdownGraceMs, options.sleepImpl)) {
260
+ return true;
261
+ }
262
+ throw new Error("mining_process_stop_timeout");
263
+ }
264
+ async function takeOverMiningRuntime(options) {
265
+ const snapshot = await loadMiningRuntimeStatus(options.paths.miningStatusPath).catch(() => null);
266
+ const controlLockMetadata = options.controlLockMetadata ?? (options.clearControlLockFile === true
267
+ ? await readLockMetadata(options.paths.miningControlLockPath).catch(() => null)
268
+ : null);
269
+ const generationActivity = await readMiningGenerationActivity(options.paths).catch(() => null);
270
+ const shutdownGraceMs = options.shutdownGraceMs ?? MINING_SHUTDOWN_GRACE_MS;
271
+ const requestPreemption = options.requestMiningPreemption ?? requestMiningGenerationPreemption;
272
+ const controlLockPid = normalizeMiningPid(controlLockMetadata?.processId);
273
+ const backgroundWorkerPid = normalizeMiningPid(snapshot?.backgroundWorkerPid);
274
+ const generationOwnerPid = normalizeMiningPid(generationActivity?.generationOwnerPid);
275
+ const terminatedPids = [];
276
+ const discoveredPids = new Set();
277
+ for (const pid of [controlLockPid, backgroundWorkerPid, generationOwnerPid]) {
278
+ if (pid === null
279
+ || pid === process.pid
280
+ || discoveredPids.has(pid)
281
+ || !await isProcessAlive(pid)) {
282
+ continue;
283
+ }
284
+ discoveredPids.add(pid);
285
+ }
286
+ const shouldPreemptGeneration = discoveredPids.size > 0 && (generationActivity?.generationActive === true
287
+ || snapshot?.currentPhase === "generating"
288
+ || snapshot?.currentPhase === "scoring");
289
+ const preemption = shouldPreemptGeneration
290
+ ? await requestPreemption({
291
+ paths: options.paths,
292
+ reason: options.reason,
293
+ timeoutMs: Math.min(shutdownGraceMs, 15_000),
294
+ }).catch(() => null)
295
+ : null;
296
+ try {
297
+ for (const pid of discoveredPids) {
298
+ if (await terminateMiningRuntimePid({
299
+ pid,
300
+ shutdownGraceMs,
301
+ sleepImpl: options.sleepImpl,
302
+ })) {
303
+ terminatedPids.push(pid);
304
+ }
305
+ }
306
+ }
307
+ finally {
308
+ await preemption?.release().catch(() => undefined);
309
+ }
310
+ const controlLockCleared = options.clearControlLockFile === true
311
+ ? await clearOrphanedFileLock(options.paths.miningControlLockPath, isProcessAlive).catch(() => false)
312
+ : false;
313
+ await rm(resolveMiningGenerationRequestPath(options.paths), { force: true }).catch(() => undefined);
314
+ await rm(resolveMiningGenerationActivityPath(options.paths), { force: true }).catch(() => undefined);
315
+ const walletRootId = snapshot?.walletRootId
316
+ ?? (typeof controlLockMetadata?.walletRootId === "string" ? controlLockMetadata.walletRootId : null);
317
+ if (snapshot !== null || walletRootId !== null || terminatedPids.length > 0 || controlLockCleared) {
318
+ await saveMiningRuntimeStatus(options.paths.miningStatusPath, createStoppedMiningRuntimeSnapshotForTakeover({
319
+ snapshot,
320
+ walletRootId,
321
+ nowUnixMs: Date.now(),
322
+ }));
323
+ }
324
+ return {
325
+ controlLockCleared,
326
+ replaced: terminatedPids.length > 0,
327
+ snapshot,
328
+ terminatedPids,
329
+ };
330
+ }
331
+ async function acquireMiningStartControlLock(options) {
332
+ while (true) {
333
+ try {
334
+ return await acquireFileLock(options.paths.miningControlLockPath, {
335
+ purpose: options.purpose,
336
+ });
337
+ }
338
+ catch (error) {
339
+ if (!(error instanceof FileLockBusyError)) {
340
+ throw error;
341
+ }
342
+ if (error.existingMetadata?.processId === process.pid) {
343
+ throw error;
344
+ }
345
+ const takeover = await takeOverMiningRuntime({
346
+ paths: options.paths,
347
+ reason: options.takeoverReason,
348
+ clearControlLockFile: true,
349
+ controlLockMetadata: error.existingMetadata,
350
+ requestMiningPreemption: options.requestMiningPreemption,
351
+ shutdownGraceMs: options.shutdownGraceMs,
352
+ sleepImpl: options.sleepImpl,
353
+ });
354
+ if (!takeover.replaced && !takeover.controlLockCleared) {
355
+ throw error;
356
+ }
357
+ }
358
+ }
359
+ }
91
360
  function writeStdout(stream, line) {
92
361
  if (stream === undefined) {
93
362
  return;
@@ -204,11 +473,41 @@ function createMiningLoopState() {
204
473
  selectedCandidate: null,
205
474
  ui: createEmptyMiningFollowVisualizerState(),
206
475
  waitingNote: null,
476
+ bitcoinRecoveryFirstFailureAtUnixMs: null,
477
+ bitcoinRecoveryFirstUnreachableAtUnixMs: null,
478
+ bitcoinRecoveryLastRestartAttemptAtUnixMs: null,
479
+ bitcoinRecoveryServiceInstanceId: null,
480
+ bitcoinRecoveryProcessId: null,
481
+ reconnectSettledUntilUnixMs: null,
482
+ tipSettledUntilUnixMs: null,
207
483
  };
208
484
  }
209
485
  export function createMiningLoopStateForTesting() {
210
486
  return createMiningLoopState();
211
487
  }
488
+ function expireMiningSettleWindows(loopState, nowUnixMs) {
489
+ if (loopState.reconnectSettledUntilUnixMs !== null
490
+ && loopState.reconnectSettledUntilUnixMs <= nowUnixMs) {
491
+ loopState.reconnectSettledUntilUnixMs = null;
492
+ }
493
+ if (loopState.tipSettledUntilUnixMs !== null
494
+ && loopState.tipSettledUntilUnixMs <= nowUnixMs) {
495
+ loopState.tipSettledUntilUnixMs = null;
496
+ }
497
+ }
498
+ function setMiningReconnectSettleWindow(loopState, nowUnixMs) {
499
+ loopState.reconnectSettledUntilUnixMs = nowUnixMs + MINING_NETWORK_SETTLE_WINDOW_MS;
500
+ }
501
+ function setMiningTipSettleWindow(loopState, nowUnixMs) {
502
+ loopState.tipSettledUntilUnixMs = nowUnixMs + MINING_TIP_SETTLE_WINDOW_MS;
503
+ }
504
+ function buildMiningSettleWindowStatusOverrides(loopState, nowUnixMs) {
505
+ expireMiningSettleWindows(loopState, nowUnixMs);
506
+ return {
507
+ reconnectSettledUntilUnixMs: loopState.reconnectSettledUntilUnixMs,
508
+ tipSettledUntilUnixMs: loopState.tipSettledUntilUnixMs,
509
+ };
510
+ }
212
511
  function buildMiningTipKey(bestBlockHash, targetBlockHeight) {
213
512
  if (bestBlockHash === null || targetBlockHeight === null) {
214
513
  return null;
@@ -232,7 +531,7 @@ function fallbackSettledWinnerDomainName(domainId) {
232
531
  return `domain-${domainId}`;
233
532
  }
234
533
  function resolveCurrentMinedBlockBoard(options) {
235
- const settledBlockHeight = options.nodeBestHeight ?? options.snapshotTipHeight ?? null;
534
+ const settledBlockHeight = options.snapshotTipHeight ?? null;
236
535
  if (settledBlockHeight === null) {
237
536
  return {
238
537
  settledBlockHeight,
@@ -245,12 +544,6 @@ function resolveCurrentMinedBlockBoard(options) {
245
544
  settledBoardEntries: [],
246
545
  };
247
546
  }
248
- if (options.nodeBestHeight !== null && (options.snapshotTipHeight ?? -1) < options.nodeBestHeight) {
249
- return {
250
- settledBlockHeight,
251
- settledBoardEntries: [],
252
- };
253
- }
254
547
  const settledBoardEntries = (getBlockWinners(options.snapshotState, settledBlockHeight) ?? [])
255
548
  .slice()
256
549
  .sort((left, right) => left.rank - right.rank || left.txIndex - right.txIndex)
@@ -259,6 +552,11 @@ function resolveCurrentMinedBlockBoard(options) {
259
552
  rank: winner.rank,
260
553
  domainName: lookupDomainById(options.snapshotState, winner.domainId)?.name ?? fallbackSettledWinnerDomainName(winner.domainId),
261
554
  sentence: winner.sentenceText ?? "[unavailable]",
555
+ requiredWords: resolveSettledWinnerRequiredWords({
556
+ domainId: winner.domainId,
557
+ bip39WordIndices: winner.bip39WordIndices,
558
+ snapshotTipPreviousHashHex: options.snapshotTipPreviousHashHex,
559
+ }),
262
560
  }));
263
561
  return {
264
562
  settledBlockHeight,
@@ -266,17 +564,42 @@ function resolveCurrentMinedBlockBoard(options) {
266
564
  };
267
565
  }
268
566
  export function resolveSettledBoardForTesting(options) {
269
- return resolveCurrentMinedBlockBoard(options);
567
+ return resolveCurrentMinedBlockBoard({
568
+ ...options,
569
+ snapshotTipPreviousHashHex: options.snapshotTipPreviousHashHex ?? null,
570
+ });
270
571
  }
271
- function syncMiningUiSettledBoard(loopState, snapshotState, snapshotTipHeight, nodeBestHeight) {
572
+ function syncMiningUiSettledBoard(loopState, snapshotState, snapshotTipHeight, snapshotTipPreviousHashHex) {
272
573
  const settledBoard = resolveCurrentMinedBlockBoard({
273
574
  snapshotState,
274
575
  snapshotTipHeight,
275
- nodeBestHeight,
576
+ snapshotTipPreviousHashHex,
577
+ nodeBestHeight: null,
276
578
  });
277
579
  loopState.ui.settledBlockHeight = settledBoard.settledBlockHeight;
278
580
  loopState.ui.settledBoardEntries = settledBoard.settledBoardEntries;
279
581
  }
582
+ function syncMiningUiForCurrentTip(options) {
583
+ const targetBlockHeight = options.nodeBestHeight === null
584
+ ? null
585
+ : options.nodeBestHeight + 1;
586
+ const tipKey = buildMiningTipKey(options.nodeBestHash, targetBlockHeight);
587
+ const priorTipKey = options.loopState.currentTipKey;
588
+ const tipChanged = tipKey !== null && tipKey !== priorTipKey;
589
+ if (tipKey !== priorTipKey) {
590
+ options.loopState.currentTipKey = tipKey;
591
+ resetMiningUiForTip(options.loopState, targetBlockHeight);
592
+ if (options.recentWin !== null) {
593
+ options.loopState.ui.recentWin = options.recentWin;
594
+ }
595
+ }
596
+ syncMiningUiSettledBoard(options.loopState, options.snapshotState, options.snapshotTipHeight, options.snapshotTipPreviousHashHex);
597
+ return {
598
+ targetBlockHeight,
599
+ tipKey,
600
+ tipChanged,
601
+ };
602
+ }
280
603
  function setMiningUiCandidate(loopState, candidate) {
281
604
  loopState.ui.latestSentence = candidate.sentence;
282
605
  loopState.ui.provisionalRequiredWords = [...candidate.bip39Words];
@@ -306,6 +629,123 @@ function clearSelectedCandidate(loopState) {
306
629
  loopState.selectedCandidateTipKey = null;
307
630
  loopState.selectedCandidate = null;
308
631
  }
632
+ function clearMiningUiTransientCandidate(loopState) {
633
+ loopState.ui.provisionalRequiredWords = [];
634
+ loopState.ui.provisionalEntry = {
635
+ domainName: null,
636
+ sentence: null,
637
+ };
638
+ loopState.ui.latestSentence = null;
639
+ }
640
+ function discardMiningLoopTransientWork(loopState, walletRootId) {
641
+ clearMiningGateCache(walletRootId);
642
+ clearSelectedCandidate(loopState);
643
+ clearMiningUiTransientCandidate(loopState);
644
+ loopState.waitingNote = null;
645
+ }
646
+ function resolveMiningBitcoindRecoveryIdentity(value) {
647
+ const raw = (value ?? {});
648
+ return {
649
+ serviceInstanceId: raw.serviceInstanceId ?? null,
650
+ processId: raw.processId ?? raw.pid ?? null,
651
+ };
652
+ }
653
+ function miningBitcoindRecoveryIdentityMatches(left, right) {
654
+ if (left.serviceInstanceId !== null && right.serviceInstanceId !== null) {
655
+ return left.serviceInstanceId === right.serviceInstanceId;
656
+ }
657
+ if (left.processId !== null && right.processId !== null) {
658
+ return left.processId === right.processId;
659
+ }
660
+ return false;
661
+ }
662
+ function rememberMiningBitcoindRecoveryIdentity(loopState, value) {
663
+ const next = resolveMiningBitcoindRecoveryIdentity(value);
664
+ if (next.serviceInstanceId === null && next.processId === null) {
665
+ return false;
666
+ }
667
+ const previous = {
668
+ serviceInstanceId: loopState.bitcoinRecoveryServiceInstanceId,
669
+ processId: loopState.bitcoinRecoveryProcessId,
670
+ };
671
+ const changed = (previous.serviceInstanceId !== null
672
+ || previous.processId !== null) && !miningBitcoindRecoveryIdentityMatches(previous, next);
673
+ loopState.bitcoinRecoveryServiceInstanceId = next.serviceInstanceId ?? (next.processId !== null && previous.processId === next.processId
674
+ ? previous.serviceInstanceId
675
+ : null);
676
+ loopState.bitcoinRecoveryProcessId = next.processId ?? (next.serviceInstanceId !== null && previous.serviceInstanceId === next.serviceInstanceId
677
+ ? previous.processId
678
+ : null);
679
+ return changed;
680
+ }
681
+ function resetMiningBitcoindRecoveryState(loopState, value) {
682
+ const hadRecovery = loopState.bitcoinRecoveryFirstFailureAtUnixMs !== null;
683
+ loopState.bitcoinRecoveryFirstFailureAtUnixMs = null;
684
+ loopState.bitcoinRecoveryFirstUnreachableAtUnixMs = null;
685
+ loopState.bitcoinRecoveryLastRestartAttemptAtUnixMs = null;
686
+ if (value !== undefined) {
687
+ rememberMiningBitcoindRecoveryIdentity(loopState, value);
688
+ }
689
+ return hadRecovery;
690
+ }
691
+ function isMiningBitcoindRecoveryPidAlive(pid) {
692
+ if (pid === null || pid === undefined || !Number.isInteger(pid) || pid <= 0) {
693
+ return false;
694
+ }
695
+ try {
696
+ process.kill(pid, 0);
697
+ return true;
698
+ }
699
+ catch (error) {
700
+ if (error instanceof Error && "code" in error && error.code === "EPERM") {
701
+ return true;
702
+ }
703
+ return false;
704
+ }
705
+ }
706
+ function describeRecoverableMiningBitcoindError(error) {
707
+ return error instanceof Error ? error.message : String(error);
708
+ }
709
+ function isRecoverableMiningBitcoindError(error) {
710
+ if (isRetryableManagedRpcError(error)) {
711
+ return true;
712
+ }
713
+ if (!(error instanceof Error)) {
714
+ return false;
715
+ }
716
+ if ("code" in error) {
717
+ const code = error.code;
718
+ if (code === "ENOENT" || code === "ECONNREFUSED" || code === "ECONNRESET") {
719
+ return true;
720
+ }
721
+ }
722
+ return error.message === "managed_bitcoind_service_start_timeout"
723
+ || error.message === "bitcoind_cookie_timeout"
724
+ || error.message.includes("cookie file is unavailable")
725
+ || error.message.includes("cookie file could not be read")
726
+ || error.message.includes("ECONNREFUSED")
727
+ || error.message.includes("ECONNRESET")
728
+ || error.message.includes("socket hang up");
729
+ }
730
+ async function attachManagedBitcoindForRecovery(options) {
731
+ try {
732
+ const service = await options.attachService({
733
+ dataDir: options.dataDir,
734
+ chain: "main",
735
+ startHeight: 0,
736
+ walletRootId: options.walletRootId,
737
+ });
738
+ const serviceStatus = await service.refreshServiceStatus?.().catch(() => null);
739
+ rememberMiningBitcoindRecoveryIdentity(options.loopState, serviceStatus ?? { pid: service.pid });
740
+ return true;
741
+ }
742
+ catch (error) {
743
+ if (!isRecoverableMiningBitcoindError(error)) {
744
+ throw error;
745
+ }
746
+ return false;
747
+ }
748
+ }
309
749
  async function resolveFundingDisplaySats(state, rpc) {
310
750
  const utxos = await rpc.listUnspent(state.managedCoreWallet.walletName, 0);
311
751
  return utxos.reduce((sum, entry) => {
@@ -348,6 +788,22 @@ function syncMiningVisualizerBalances(loopState, readContext, balanceSats) {
348
788
  : getBalance(readContext.snapshot.state, readContext.localState.state.funding.scriptPubKeyHex);
349
789
  loopState.ui.balanceSats = balanceSats;
350
790
  }
791
+ function createIndexedMiningFollowVisualizerState(readContext) {
792
+ const uiState = createEmptyMiningFollowVisualizerState();
793
+ const localState = readContext.localState;
794
+ const settledBoard = resolveCurrentMinedBlockBoard({
795
+ snapshotState: readContext.snapshot?.state ?? null,
796
+ snapshotTipHeight: readContext.snapshot?.tip?.height ?? readContext.indexer.snapshotTip?.height ?? null,
797
+ snapshotTipPreviousHashHex: readContext.snapshot?.tip?.previousHashHex ?? readContext.indexer.snapshotTip?.previousHashHex ?? null,
798
+ nodeBestHeight: readContext.nodeStatus?.nodeBestHeight ?? null,
799
+ });
800
+ uiState.settledBlockHeight = settledBoard.settledBlockHeight;
801
+ uiState.settledBoardEntries = settledBoard.settledBoardEntries;
802
+ if (readContext.snapshot !== null && localState.availability === "ready" && localState.state !== null) {
803
+ uiState.balanceCogtoshi = getBalance(readContext.snapshot.state, localState.state.funding.scriptPubKeyHex);
804
+ }
805
+ return uiState;
806
+ }
351
807
  function syncMiningVisualizerBlockTimes(loopState, blockTimesByHeight) {
352
808
  loopState.ui.visibleBlockTimesByHeight = { ...blockTimesByHeight };
353
809
  }
@@ -642,28 +1098,73 @@ async function resolveOverlayAuthorizedMiningDomain(options) {
642
1098
  function buildStatusSnapshot(view, overrides = {}) {
643
1099
  return {
644
1100
  ...view.runtime,
645
- runMode: overrides.runMode ?? view.runtime.runMode,
646
- backgroundWorkerPid: overrides.backgroundWorkerPid ?? view.runtime.backgroundWorkerPid,
647
- backgroundWorkerRunId: overrides.backgroundWorkerRunId ?? view.runtime.backgroundWorkerRunId,
648
- backgroundWorkerHeartbeatAtUnixMs: overrides.backgroundWorkerHeartbeatAtUnixMs ?? view.runtime.backgroundWorkerHeartbeatAtUnixMs,
649
- currentPhase: overrides.currentPhase ?? view.runtime.currentPhase,
650
- lastSuspendDetectedAtUnixMs: overrides.lastSuspendDetectedAtUnixMs ?? view.runtime.lastSuspendDetectedAtUnixMs,
651
- providerState: overrides.providerState ?? view.runtime.providerState,
652
- corePublishState: overrides.corePublishState ?? view.runtime.corePublishState,
653
- currentPublishDecision: overrides.currentPublishDecision ?? view.runtime.currentPublishDecision,
654
- sameDomainCompetitorSuppressed: overrides.sameDomainCompetitorSuppressed ?? view.runtime.sameDomainCompetitorSuppressed,
655
- higherRankedCompetitorDomainCount: overrides.higherRankedCompetitorDomainCount ?? view.runtime.higherRankedCompetitorDomainCount,
656
- dedupedCompetitorDomainCount: overrides.dedupedCompetitorDomainCount ?? view.runtime.dedupedCompetitorDomainCount,
657
- competitivenessGateIndeterminate: overrides.competitivenessGateIndeterminate ?? view.runtime.competitivenessGateIndeterminate,
658
- mempoolSequenceCacheStatus: overrides.mempoolSequenceCacheStatus ?? view.runtime.mempoolSequenceCacheStatus,
659
- lastMempoolSequence: overrides.lastMempoolSequence ?? view.runtime.lastMempoolSequence,
660
- lastCompetitivenessGateAtUnixMs: overrides.lastCompetitivenessGateAtUnixMs ?? view.runtime.lastCompetitivenessGateAtUnixMs,
661
- lastError: overrides.lastError ?? view.runtime.lastError,
662
- note: overrides.note ?? view.runtime.note,
663
- livePublishInMempool: overrides.livePublishInMempool ?? view.runtime.livePublishInMempool,
1101
+ runMode: resolveSnapshotOverride(overrides.runMode, view.runtime.runMode),
1102
+ backgroundWorkerPid: resolveSnapshotOverride(overrides.backgroundWorkerPid, view.runtime.backgroundWorkerPid),
1103
+ backgroundWorkerRunId: resolveSnapshotOverride(overrides.backgroundWorkerRunId, view.runtime.backgroundWorkerRunId),
1104
+ backgroundWorkerHeartbeatAtUnixMs: resolveSnapshotOverride(overrides.backgroundWorkerHeartbeatAtUnixMs, view.runtime.backgroundWorkerHeartbeatAtUnixMs),
1105
+ currentPhase: resolveSnapshotOverride(overrides.currentPhase, view.runtime.currentPhase),
1106
+ currentPublishState: resolveSnapshotOverride(overrides.currentPublishState, view.runtime.currentPublishState),
1107
+ targetBlockHeight: resolveSnapshotOverride(overrides.targetBlockHeight, view.runtime.targetBlockHeight),
1108
+ referencedBlockHashDisplay: resolveSnapshotOverride(overrides.referencedBlockHashDisplay, view.runtime.referencedBlockHashDisplay),
1109
+ currentDomainId: resolveSnapshotOverride(overrides.currentDomainId, view.runtime.currentDomainId),
1110
+ currentDomainName: resolveSnapshotOverride(overrides.currentDomainName, view.runtime.currentDomainName),
1111
+ currentSentenceDisplay: resolveSnapshotOverride(overrides.currentSentenceDisplay, view.runtime.currentSentenceDisplay),
1112
+ currentCanonicalBlend: resolveSnapshotOverride(overrides.currentCanonicalBlend, view.runtime.currentCanonicalBlend),
1113
+ currentTxid: resolveSnapshotOverride(overrides.currentTxid, view.runtime.currentTxid),
1114
+ currentWtxid: resolveSnapshotOverride(overrides.currentWtxid, view.runtime.currentWtxid),
1115
+ currentFeeRateSatVb: resolveSnapshotOverride(overrides.currentFeeRateSatVb, view.runtime.currentFeeRateSatVb),
1116
+ currentAbsoluteFeeSats: resolveSnapshotOverride(overrides.currentAbsoluteFeeSats, view.runtime.currentAbsoluteFeeSats),
1117
+ currentBlockFeeSpentSats: resolveSnapshotOverride(overrides.currentBlockFeeSpentSats, view.runtime.currentBlockFeeSpentSats),
1118
+ lastSuspendDetectedAtUnixMs: resolveSnapshotOverride(overrides.lastSuspendDetectedAtUnixMs, view.runtime.lastSuspendDetectedAtUnixMs),
1119
+ reconnectSettledUntilUnixMs: resolveSnapshotOverride(overrides.reconnectSettledUntilUnixMs, view.runtime.reconnectSettledUntilUnixMs),
1120
+ tipSettledUntilUnixMs: resolveSnapshotOverride(overrides.tipSettledUntilUnixMs, view.runtime.tipSettledUntilUnixMs),
1121
+ providerState: resolveSnapshotOverride(overrides.providerState, view.runtime.providerState),
1122
+ corePublishState: resolveSnapshotOverride(overrides.corePublishState, view.runtime.corePublishState),
1123
+ currentPublishDecision: resolveSnapshotOverride(overrides.currentPublishDecision, view.runtime.currentPublishDecision),
1124
+ sameDomainCompetitorSuppressed: resolveSnapshotOverride(overrides.sameDomainCompetitorSuppressed, view.runtime.sameDomainCompetitorSuppressed),
1125
+ higherRankedCompetitorDomainCount: resolveSnapshotOverride(overrides.higherRankedCompetitorDomainCount, view.runtime.higherRankedCompetitorDomainCount),
1126
+ dedupedCompetitorDomainCount: resolveSnapshotOverride(overrides.dedupedCompetitorDomainCount, view.runtime.dedupedCompetitorDomainCount),
1127
+ competitivenessGateIndeterminate: resolveSnapshotOverride(overrides.competitivenessGateIndeterminate, view.runtime.competitivenessGateIndeterminate),
1128
+ mempoolSequenceCacheStatus: resolveSnapshotOverride(overrides.mempoolSequenceCacheStatus, view.runtime.mempoolSequenceCacheStatus),
1129
+ lastMempoolSequence: resolveSnapshotOverride(overrides.lastMempoolSequence, view.runtime.lastMempoolSequence),
1130
+ lastCompetitivenessGateAtUnixMs: resolveSnapshotOverride(overrides.lastCompetitivenessGateAtUnixMs, view.runtime.lastCompetitivenessGateAtUnixMs),
1131
+ lastError: resolveSnapshotOverride(overrides.lastError, view.runtime.lastError),
1132
+ note: resolveSnapshotOverride(overrides.note, view.runtime.note),
1133
+ livePublishInMempool: resolveSnapshotOverride(overrides.livePublishInMempool, view.runtime.livePublishInMempool),
664
1134
  updatedAtUnixMs: Date.now(),
665
1135
  };
666
1136
  }
1137
+ function buildPrePublishStatusOverrides(options) {
1138
+ const replacing = options.state.miningState.currentTxid !== null;
1139
+ const replacingAcrossTips = replacing && !livePublishTargetsCandidateTip({
1140
+ liveState: options.state.miningState,
1141
+ candidate: options.candidate,
1142
+ });
1143
+ return {
1144
+ currentPhase: replacing ? "replacing" : "publishing",
1145
+ currentPublishDecision: replacing ? "replacing" : "publishing",
1146
+ targetBlockHeight: options.candidate.targetBlockHeight,
1147
+ referencedBlockHashDisplay: options.candidate.referencedBlockHashDisplay,
1148
+ currentDomainId: options.candidate.domainId,
1149
+ currentDomainName: options.candidate.domainName,
1150
+ currentSentenceDisplay: options.candidate.sentence,
1151
+ currentCanonicalBlend: options.candidate.canonicalBlend.toString(),
1152
+ note: replacing
1153
+ ? "Replacing the live mining transaction for the current tip."
1154
+ : "Broadcasting the best mining candidate for the current tip.",
1155
+ ...(replacingAcrossTips
1156
+ ? {
1157
+ currentPublishState: "none",
1158
+ currentTxid: null,
1159
+ currentWtxid: null,
1160
+ livePublishInMempool: false,
1161
+ currentFeeRateSatVb: null,
1162
+ currentAbsoluteFeeSats: null,
1163
+ currentBlockFeeSpentSats: "0",
1164
+ }
1165
+ : {}),
1166
+ };
1167
+ }
667
1168
  async function refreshAndSaveStatus(options) {
668
1169
  const view = await inspectMiningControlPlane({
669
1170
  provider: options.provider,
@@ -682,6 +1183,93 @@ async function refreshAndSaveStatus(options) {
682
1183
  async function appendEvent(paths, event) {
683
1184
  await appendMiningEvent(paths.miningEventsPath, event);
684
1185
  }
1186
+ async function handleRecoverableMiningBitcoindFailure(options) {
1187
+ const failureMessage = describeRecoverableMiningBitcoindError(options.error);
1188
+ const walletRootId = options.readContext.localState.walletRootId ?? undefined;
1189
+ if (options.loopState.bitcoinRecoveryFirstFailureAtUnixMs === null) {
1190
+ options.loopState.bitcoinRecoveryFirstFailureAtUnixMs = options.nowUnixMs;
1191
+ }
1192
+ let restartedService = false;
1193
+ const probe = await options.probeService({
1194
+ dataDir: options.dataDir,
1195
+ chain: "main",
1196
+ startHeight: 0,
1197
+ walletRootId,
1198
+ }).catch((probeError) => {
1199
+ if (!isRecoverableMiningBitcoindError(probeError)) {
1200
+ throw probeError;
1201
+ }
1202
+ return null;
1203
+ });
1204
+ if (probe !== null) {
1205
+ if (probe.compatibility === "compatible") {
1206
+ rememberMiningBitcoindRecoveryIdentity(options.loopState, probe.status);
1207
+ options.loopState.bitcoinRecoveryFirstUnreachableAtUnixMs = null;
1208
+ }
1209
+ else if (probe.compatibility === "unreachable") {
1210
+ const identityChanged = rememberMiningBitcoindRecoveryIdentity(options.loopState, probe.status);
1211
+ const livePid = isMiningBitcoindRecoveryPidAlive(probe.status?.processId ?? null);
1212
+ if (identityChanged || options.loopState.bitcoinRecoveryFirstUnreachableAtUnixMs === null) {
1213
+ options.loopState.bitcoinRecoveryFirstUnreachableAtUnixMs = options.nowUnixMs;
1214
+ }
1215
+ if (!livePid) {
1216
+ restartedService = await attachManagedBitcoindForRecovery({
1217
+ dataDir: options.dataDir,
1218
+ walletRootId,
1219
+ attachService: options.attachService,
1220
+ loopState: options.loopState,
1221
+ });
1222
+ }
1223
+ else {
1224
+ const graceElapsed = (options.loopState.bitcoinRecoveryFirstUnreachableAtUnixMs !== null
1225
+ && options.nowUnixMs - options.loopState.bitcoinRecoveryFirstUnreachableAtUnixMs
1226
+ >= MINING_BITCOIN_RECOVERY_GRACE_MS);
1227
+ const cooldownElapsed = (options.loopState.bitcoinRecoveryLastRestartAttemptAtUnixMs === null
1228
+ || options.nowUnixMs - options.loopState.bitcoinRecoveryLastRestartAttemptAtUnixMs
1229
+ >= MINING_BITCOIN_RECOVERY_RESTART_COOLDOWN_MS);
1230
+ if (graceElapsed && cooldownElapsed) {
1231
+ options.loopState.bitcoinRecoveryLastRestartAttemptAtUnixMs = options.nowUnixMs;
1232
+ await options.stopService({
1233
+ dataDir: options.dataDir,
1234
+ walletRootId,
1235
+ }).catch((stopError) => {
1236
+ if (!isRecoverableMiningBitcoindError(stopError)) {
1237
+ throw stopError;
1238
+ }
1239
+ });
1240
+ await attachManagedBitcoindForRecovery({
1241
+ dataDir: options.dataDir,
1242
+ walletRootId,
1243
+ attachService: options.attachService,
1244
+ loopState: options.loopState,
1245
+ });
1246
+ restartedService = true;
1247
+ }
1248
+ }
1249
+ }
1250
+ else {
1251
+ throw new Error(probe.error ?? "managed_bitcoind_protocol_error");
1252
+ }
1253
+ }
1254
+ if (restartedService) {
1255
+ discardMiningLoopTransientWork(options.loopState, walletRootId);
1256
+ setMiningReconnectSettleWindow(options.loopState, options.nowUnixMs);
1257
+ }
1258
+ await refreshAndSaveStatus({
1259
+ paths: options.paths,
1260
+ provider: options.provider,
1261
+ readContext: options.readContext,
1262
+ overrides: {
1263
+ runMode: options.runMode,
1264
+ currentPhase: "waiting-bitcoin-network",
1265
+ lastError: failureMessage,
1266
+ note: MINING_BITCOIN_RECOVERY_NOTE,
1267
+ ...buildMiningSettleWindowStatusOverrides(options.loopState, options.nowUnixMs),
1268
+ },
1269
+ visualizer: options.visualizer,
1270
+ visualizerState: options.loopState.ui,
1271
+ });
1272
+ }
685
1273
  async function handleDetectedMiningRuntimeResume(options) {
686
1274
  const readContext = await options.openReadContext({
687
1275
  dataDir: options.dataDir,
@@ -691,6 +1279,7 @@ async function handleDetectedMiningRuntimeResume(options) {
691
1279
  });
692
1280
  try {
693
1281
  clearMiningGateCache(readContext.localState.walletRootId);
1282
+ setMiningReconnectSettleWindow(options.loopState, options.detectedAtUnixMs);
694
1283
  await refreshAndSaveStatus({
695
1284
  paths: options.paths,
696
1285
  provider: options.provider,
@@ -703,8 +1292,10 @@ async function handleDetectedMiningRuntimeResume(options) {
703
1292
  currentPhase: "resuming",
704
1293
  lastSuspendDetectedAtUnixMs: options.detectedAtUnixMs,
705
1294
  note: "Mining discarded stale in-flight work after a large local runtime gap and is rechecking health.",
1295
+ ...buildMiningSettleWindowStatusOverrides(options.loopState, options.detectedAtUnixMs),
706
1296
  },
707
1297
  visualizer: options.visualizer,
1298
+ visualizerState: createIndexedMiningFollowVisualizerState(readContext),
708
1299
  });
709
1300
  }
710
1301
  finally {
@@ -768,7 +1359,7 @@ function determineCorePublishState(info) {
768
1359
  }
769
1360
  function createMiningPlan(options) {
770
1361
  const fundingUtxos = options.allUtxos.filter((entry) => entry.scriptPubKey === options.state.funding.scriptPubKeyHex
771
- && entry.confirmations >= 1
1362
+ && entry.confirmations >= MINING_FUNDING_MIN_CONF
772
1363
  && entry.spendable !== false
773
1364
  && entry.safe !== false
774
1365
  && !(options.conflictOutpoint !== null
@@ -821,6 +1412,7 @@ async function buildMiningTransaction(options) {
821
1412
  finalizeErrorCode: "wallet_mining_finalize_failed",
822
1413
  mempoolRejectPrefix: "wallet_mining_mempool_rejected",
823
1414
  feeRate: options.plan.feeRateSatVb,
1415
+ availableFundingMinConf: MINING_FUNDING_MIN_CONF,
824
1416
  });
825
1417
  }
826
1418
  export function createMiningPlanForTesting(options) {
@@ -897,6 +1489,37 @@ function createStaleMiningCandidateWaitingNote() {
897
1489
  function createRetryableMiningPublishWaitingNote() {
898
1490
  return "Selected mining candidate did not reach mempool and will be retried on the current tip with refreshed wallet state.";
899
1491
  }
1492
+ const MINING_FUNDING_MIN_CONF = 0;
1493
+ function createInsufficientFundsMiningPublishWaitingNote() {
1494
+ return "Mining is waiting for enough safe BTC funding that Bitcoin Core can use for the next publish.";
1495
+ }
1496
+ function createInsufficientFundsMiningPublishErrorMessage() {
1497
+ return "Bitcoin Core could not fund the next mining publish with safe BTC.";
1498
+ }
1499
+ function buildMiningGenerationRequest(options) {
1500
+ return {
1501
+ schemaVersion: 1,
1502
+ requestId: options.requestId ?? `mining-${options.targetBlockHeight}-${randomBytes(8).toString("hex")}`,
1503
+ targetBlockHeight: options.targetBlockHeight,
1504
+ referencedBlockHashDisplay: options.referencedBlockHashDisplay,
1505
+ generatedAtUnixMs: options.generatedAtUnixMs ?? Date.now(),
1506
+ extraPrompt: options.extraPrompt,
1507
+ limits: createMiningSentenceRequestLimits(),
1508
+ rootDomains: options.domains.map((domain) => ({
1509
+ domainId: domain.domainId,
1510
+ domainName: domain.domainName,
1511
+ requiredWords: domain.requiredWords,
1512
+ extraPrompt: options.domainExtraPrompts[domain.domainName.toLowerCase()] ?? null,
1513
+ })),
1514
+ };
1515
+ }
1516
+ export function buildMiningGenerationRequestForTesting(options) {
1517
+ return buildMiningGenerationRequest({
1518
+ ...options,
1519
+ domainExtraPrompts: options.domainExtraPrompts ?? {},
1520
+ extraPrompt: options.extraPrompt ?? null,
1521
+ });
1522
+ }
900
1523
  async function generateCandidatesForDomains(options) {
901
1524
  const bestBlockHash = options.readContext.nodeStatus?.nodeBestHashHex;
902
1525
  if (bestBlockHash === null || bestBlockHash === undefined) {
@@ -908,6 +1531,10 @@ async function generateCandidatesForDomains(options) {
908
1531
  ...domain,
909
1532
  requiredWords: getWords(domain.domainId, referencedBlockHashInternal),
910
1533
  }));
1534
+ const clientConfig = await loadClientConfig({
1535
+ path: options.paths.clientConfigPath,
1536
+ provider: options.provider,
1537
+ }).catch(() => null);
911
1538
  const abortController = new AbortController();
912
1539
  let stale = false;
913
1540
  let staleIndexerTruth = false;
@@ -946,20 +1573,13 @@ async function generateCandidatesForDomains(options) {
946
1573
  runId: options.runId ?? null,
947
1574
  pid: process.pid ?? null,
948
1575
  });
949
- const generationRequest = {
950
- schemaVersion: 1,
951
- requestId: `mining-${targetBlockHeight}-${randomBytes(8).toString("hex")}`,
1576
+ const generationRequest = buildMiningGenerationRequest({
952
1577
  targetBlockHeight,
953
1578
  referencedBlockHashDisplay: bestBlockHash,
954
- generatedAtUnixMs: Date.now(),
955
- extraPrompt: null,
956
- limits: createMiningSentenceRequestLimits(),
957
- rootDomains: rootDomains.map((domain) => ({
958
- domainId: domain.domainId,
959
- domainName: domain.domainName,
960
- requiredWords: domain.requiredWords,
961
- })),
962
- };
1579
+ domains: rootDomains,
1580
+ domainExtraPrompts: clientConfig?.mining.domainExtraPrompts ?? {},
1581
+ extraPrompt: clientConfig?.mining.builtIn?.extraPrompt ?? null,
1582
+ });
963
1583
  let generated;
964
1584
  try {
965
1585
  generated = await generateMiningSentences(generationRequest, {
@@ -1103,6 +1723,7 @@ function toSentenceBoardEntries(entries) {
1103
1723
  rank: entry.rank,
1104
1724
  domainName: entry.domainName,
1105
1725
  sentence: entry.sentence,
1726
+ requiredWords: resolveBip39WordsFromIndices(entry.bip39WordIndices),
1106
1727
  }));
1107
1728
  }
1108
1729
  async function runCompetitivenessGate(options) {
@@ -1534,7 +2155,7 @@ async function publishCandidateOnce(options) {
1534
2155
  nodeBestHeight: options.readContext.nodeStatus?.nodeBestHeight ?? null,
1535
2156
  snapshotState: options.readContext.snapshot.state,
1536
2157
  })).state;
1537
- const allUtxos = await rpc.listUnspent(state.managedCoreWallet.walletName, 0);
2158
+ const allUtxos = await rpc.listUnspent(state.managedCoreWallet.walletName, MINING_FUNDING_MIN_CONF);
1538
2159
  const conflictOutpoint = resolveMiningConflictOutpoint({
1539
2160
  state,
1540
2161
  allUtxos,
@@ -1801,6 +2422,29 @@ async function publishCandidate(options) {
1801
2422
  candidate: refreshedCandidate,
1802
2423
  };
1803
2424
  }
2425
+ if (isInsufficientFundsError(error)) {
2426
+ const note = createInsufficientFundsMiningPublishWaitingNote();
2427
+ const lastError = createInsufficientFundsMiningPublishErrorMessage();
2428
+ await appendEventFn(options.paths, createEvent("publish-paused-insufficient-funds", "Paused mining publish because Bitcoin Core could not fund the next mining transaction with safe BTC.", {
2429
+ level: "warn",
2430
+ runId: options.runId,
2431
+ targetBlockHeight: refreshedCandidate.targetBlockHeight,
2432
+ referencedBlockHashDisplay: refreshedCandidate.referencedBlockHashDisplay,
2433
+ domainId: refreshedCandidate.domainId,
2434
+ domainName: refreshedCandidate.domainName,
2435
+ score: refreshedCandidate.canonicalBlend.toString(),
2436
+ reason: "insufficient-funds",
2437
+ }));
2438
+ return {
2439
+ state: readyReadContext.localState.state,
2440
+ txid: null,
2441
+ decision: "publish-paused-insufficient-funds",
2442
+ note,
2443
+ lastError,
2444
+ skipped: true,
2445
+ candidate: null,
2446
+ };
2447
+ }
1804
2448
  throw error;
1805
2449
  }
1806
2450
  }
@@ -1830,6 +2474,7 @@ export async function ensureBuiltInMiningSetupIfNeeded(options) {
1830
2474
  return true;
1831
2475
  }
1832
2476
  async function performMiningCycle(options) {
2477
+ const now = options.nowImpl ?? Date.now;
1833
2478
  let readContext = await options.openReadContext({
1834
2479
  dataDir: options.dataDir,
1835
2480
  databasePath: options.databasePath,
@@ -1838,30 +2483,39 @@ async function performMiningCycle(options) {
1838
2483
  });
1839
2484
  let readContextClosed = false;
1840
2485
  try {
1841
- checkpointMiningSuspendDetector(options.suspendDetector);
1842
- await refreshAndSaveStatus({
1843
- paths: options.paths,
1844
- provider: options.provider,
1845
- readContext,
1846
- overrides: {
1847
- runMode: options.runMode,
1848
- backgroundWorkerPid: options.backgroundWorkerPid,
1849
- backgroundWorkerRunId: options.backgroundWorkerRunId,
1850
- backgroundWorkerHeartbeatAtUnixMs: options.runMode === "background" ? Date.now() : null,
1851
- },
1852
- });
1853
- if (readContext.localState.availability !== "ready" || readContext.localState.state === null) {
1854
- await refreshAndSaveStatus({
2486
+ let clearRecoveredBitcoindError = false;
2487
+ const saveCycleStatus = async (readContext, overrides, includeVisualizer = true) => {
2488
+ const statusNowUnixMs = now();
2489
+ const resolvedOverrides = clearRecoveredBitcoindError && overrides.lastError === undefined
2490
+ ? {
2491
+ ...overrides,
2492
+ lastError: null,
2493
+ }
2494
+ : overrides;
2495
+ return await refreshAndSaveStatus({
1855
2496
  paths: options.paths,
1856
2497
  provider: options.provider,
1857
2498
  readContext,
1858
2499
  overrides: {
1859
- runMode: options.runMode,
1860
- currentPhase: "waiting",
1861
- note: "Wallet state must be locally available for mining to continue.",
2500
+ ...buildMiningSettleWindowStatusOverrides(options.loopState, statusNowUnixMs),
2501
+ ...resolvedOverrides,
1862
2502
  },
1863
- visualizer: options.visualizer,
1864
- visualizerState: options.loopState.ui,
2503
+ visualizer: includeVisualizer ? options.visualizer : undefined,
2504
+ visualizerState: includeVisualizer ? options.loopState.ui : undefined,
2505
+ });
2506
+ };
2507
+ checkpointMiningSuspendDetector(options.suspendDetector);
2508
+ await saveCycleStatus(readContext, {
2509
+ runMode: options.runMode,
2510
+ backgroundWorkerPid: options.backgroundWorkerPid,
2511
+ backgroundWorkerRunId: options.backgroundWorkerRunId,
2512
+ backgroundWorkerHeartbeatAtUnixMs: options.runMode === "background" ? now() : null,
2513
+ }, false);
2514
+ if (readContext.localState.availability !== "ready" || readContext.localState.state === null) {
2515
+ await saveCycleStatus(readContext, {
2516
+ runMode: options.runMode,
2517
+ currentPhase: "waiting",
2518
+ note: "Wallet state must be locally available for mining to continue.",
1865
2519
  });
1866
2520
  return;
1867
2521
  }
@@ -1911,18 +2565,25 @@ async function performMiningCycle(options) {
1911
2565
  indexedTipHashHex: indexedTip?.blockHashHex ?? null,
1912
2566
  }).catch(() => ({}));
1913
2567
  syncMiningVisualizerBlockTimes(options.loopState, visibleBlockTimes);
2568
+ const { targetBlockHeight, tipKey, tipChanged } = syncMiningUiForCurrentTip({
2569
+ loopState: options.loopState,
2570
+ snapshotState: effectiveReadContext.snapshot?.state ?? null,
2571
+ snapshotTipHeight: effectiveReadContext.snapshot?.tip?.height ?? effectiveReadContext.indexer.snapshotTip?.height ?? null,
2572
+ snapshotTipPreviousHashHex: effectiveReadContext.snapshot?.tip?.previousHashHex ?? effectiveReadContext.indexer.snapshotTip?.previousHashHex ?? null,
2573
+ nodeBestHeight: effectiveReadContext.nodeStatus?.nodeBestHeight ?? null,
2574
+ nodeBestHash: effectiveReadContext.nodeStatus?.nodeBestHashHex ?? null,
2575
+ recentWin: reconciliation.recentWin,
2576
+ });
2577
+ if (tipChanged) {
2578
+ setMiningTipSettleWindow(options.loopState, now());
2579
+ }
2580
+ const displaySats = await resolveFundingDisplaySats(effectiveReadContext.localState.state, rpc).catch(() => null);
2581
+ syncMiningVisualizerBalances(options.loopState, effectiveReadContext, displaySats);
1914
2582
  if (effectiveReadContext.localState.state.miningState.state === "repair-required") {
1915
- await refreshAndSaveStatus({
1916
- paths: options.paths,
1917
- provider: options.provider,
1918
- readContext: effectiveReadContext,
1919
- overrides: {
1920
- runMode: options.runMode,
1921
- currentPhase: "waiting",
1922
- note: "Mining is blocked until the current mining publish is repaired or reconciled.",
1923
- },
1924
- visualizer: options.visualizer,
1925
- visualizerState: options.loopState.ui,
2583
+ await saveCycleStatus(effectiveReadContext, {
2584
+ runMode: options.runMode,
2585
+ currentPhase: "waiting",
2586
+ note: "Mining is blocked until the current mining publish is repaired or reconciled.",
1926
2587
  });
1927
2588
  return;
1928
2589
  }
@@ -1944,17 +2605,10 @@ async function performMiningCycle(options) {
1944
2605
  state: nextState,
1945
2606
  },
1946
2607
  };
1947
- await refreshAndSaveStatus({
1948
- paths: options.paths,
1949
- provider: options.provider,
1950
- readContext: effectiveReadContext,
1951
- overrides: {
1952
- runMode: options.runMode,
1953
- currentPhase: "waiting",
1954
- note: "Mining is paused while another wallet mutation is active.",
1955
- },
1956
- visualizer: options.visualizer,
1957
- visualizerState: options.loopState.ui,
2608
+ await saveCycleStatus(effectiveReadContext, {
2609
+ runMode: options.runMode,
2610
+ currentPhase: "waiting",
2611
+ note: "Mining is paused while another wallet mutation is active.",
1958
2612
  });
1959
2613
  return;
1960
2614
  }
@@ -1972,23 +2626,16 @@ async function performMiningCycle(options) {
1972
2626
  provider: options.provider,
1973
2627
  paths: options.paths,
1974
2628
  });
1975
- await refreshAndSaveStatus({
1976
- paths: options.paths,
1977
- provider: options.provider,
1978
- readContext: {
1979
- ...effectiveReadContext,
1980
- localState: {
1981
- ...effectiveReadContext.localState,
1982
- state: nextState,
1983
- },
1984
- },
1985
- overrides: {
1986
- runMode: options.runMode,
1987
- currentPhase: "waiting",
1988
- note: "Mining is paused while another wallet command is preempting sentence generation.",
2629
+ await saveCycleStatus({
2630
+ ...effectiveReadContext,
2631
+ localState: {
2632
+ ...effectiveReadContext.localState,
2633
+ state: nextState,
1989
2634
  },
1990
- visualizer: options.visualizer,
1991
- visualizerState: options.loopState.ui,
2635
+ }, {
2636
+ runMode: options.runMode,
2637
+ currentPhase: "waiting",
2638
+ note: "Mining is paused while another wallet command is preempting sentence generation.",
1992
2639
  });
1993
2640
  return;
1994
2641
  }
@@ -2003,53 +2650,36 @@ async function performMiningCycle(options) {
2003
2650
  network: networkInfo,
2004
2651
  mempool: mempoolInfo,
2005
2652
  });
2653
+ clearRecoveredBitcoindError = resetMiningBitcoindRecoveryState(options.loopState, effectiveReadContext.nodeStatus?.serviceStatus ?? { pid: service.pid });
2006
2654
  if (corePublishState !== "healthy") {
2007
- await refreshAndSaveStatus({
2008
- paths: options.paths,
2009
- provider: options.provider,
2010
- readContext: effectiveReadContext,
2011
- overrides: {
2012
- runMode: options.runMode,
2013
- currentPhase: "waiting-bitcoin-network",
2014
- corePublishState,
2015
- note: "Mining is waiting for the local Bitcoin node to become publishable.",
2016
- },
2017
- visualizer: options.visualizer,
2018
- visualizerState: options.loopState.ui,
2655
+ await saveCycleStatus(effectiveReadContext, {
2656
+ runMode: options.runMode,
2657
+ currentPhase: "waiting-bitcoin-network",
2658
+ corePublishState,
2659
+ note: "Mining is waiting for the local Bitcoin node to become publishable.",
2019
2660
  });
2020
2661
  return;
2021
2662
  }
2022
2663
  if (effectiveReadContext.indexer.health !== "synced" || effectiveReadContext.nodeHealth !== "synced") {
2023
- await refreshAndSaveStatus({
2024
- paths: options.paths,
2025
- provider: options.provider,
2026
- readContext: effectiveReadContext,
2027
- overrides: {
2028
- runMode: options.runMode,
2029
- currentPhase: effectiveReadContext.indexer.health !== "synced"
2030
- ? "waiting-indexer"
2031
- : "waiting-bitcoin-network",
2032
- note: effectiveReadContext.indexer.health !== "synced"
2033
- ? "Mining is waiting for Bitcoin Core and the indexer to align."
2034
- : "Mining is waiting for the local Bitcoin node to become publishable.",
2035
- },
2036
- visualizer: options.visualizer,
2037
- visualizerState: options.loopState.ui,
2664
+ await saveCycleStatus(effectiveReadContext, {
2665
+ runMode: options.runMode,
2666
+ currentPhase: effectiveReadContext.indexer.health !== "synced"
2667
+ ? "waiting-indexer"
2668
+ : "waiting-bitcoin-network",
2669
+ note: effectiveReadContext.indexer.health !== "synced"
2670
+ ? "Mining is waiting for Bitcoin Core and the indexer to align."
2671
+ : "Mining is waiting for the local Bitcoin node to become publishable.",
2038
2672
  });
2039
2673
  return;
2040
2674
  }
2041
- const targetBlockHeight = (effectiveReadContext.nodeStatus?.nodeBestHeight ?? 0) + 1;
2042
- const tipKey = buildMiningTipKey(effectiveReadContext.nodeStatus?.nodeBestHashHex ?? null, targetBlockHeight);
2043
- if (tipKey !== options.loopState.currentTipKey) {
2044
- options.loopState.currentTipKey = tipKey;
2045
- resetMiningUiForTip(options.loopState, targetBlockHeight);
2046
- if (reconciliation.recentWin !== null) {
2047
- options.loopState.ui.recentWin = reconciliation.recentWin;
2048
- }
2675
+ if (targetBlockHeight === null) {
2676
+ await saveCycleStatus(effectiveReadContext, {
2677
+ runMode: options.runMode,
2678
+ currentPhase: "waiting-bitcoin-network",
2679
+ note: "Mining is waiting for the local Bitcoin node to become publishable.",
2680
+ });
2681
+ return;
2049
2682
  }
2050
- syncMiningUiSettledBoard(options.loopState, effectiveReadContext.snapshot?.state ?? null, effectiveReadContext.snapshot?.tip?.height ?? effectiveReadContext.indexer.snapshotTip?.height ?? null, effectiveReadContext.nodeStatus?.nodeBestHeight ?? null);
2051
- const displaySats = await resolveFundingDisplaySats(effectiveReadContext.localState.state, rpc).catch(() => null);
2052
- syncMiningVisualizerBalances(options.loopState, effectiveReadContext, displaySats);
2053
2683
  if (getBlockRewardCogtoshi(targetBlockHeight) === 0n) {
2054
2684
  const nextState = defaultMiningStatePatch(effectiveReadContext.localState.state, {
2055
2685
  state: "paused",
@@ -2060,24 +2690,17 @@ async function performMiningCycle(options) {
2060
2690
  provider: options.provider,
2061
2691
  paths: options.paths,
2062
2692
  });
2063
- await refreshAndSaveStatus({
2064
- paths: options.paths,
2065
- provider: options.provider,
2066
- readContext: {
2067
- ...effectiveReadContext,
2068
- localState: {
2069
- ...effectiveReadContext.localState,
2070
- state: nextState,
2071
- },
2072
- },
2073
- overrides: {
2074
- runMode: options.runMode,
2075
- currentPhase: "idle",
2076
- currentPublishDecision: "publish-skipped-zero-reward",
2077
- note: "Mining is disabled because the target block reward is zero.",
2693
+ await saveCycleStatus({
2694
+ ...effectiveReadContext,
2695
+ localState: {
2696
+ ...effectiveReadContext.localState,
2697
+ state: nextState,
2078
2698
  },
2079
- visualizer: options.visualizer,
2080
- visualizerState: options.loopState.ui,
2699
+ }, {
2700
+ runMode: options.runMode,
2701
+ currentPhase: "idle",
2702
+ currentPublishDecision: "publish-skipped-zero-reward",
2703
+ note: "Mining is disabled because the target block reward is zero.",
2081
2704
  });
2082
2705
  await appendEvent(options.paths, createEvent("publish-skipped-zero-reward", "Skipped mining because the target block reward is zero.", {
2083
2706
  targetBlockHeight,
@@ -2087,17 +2710,10 @@ async function performMiningCycle(options) {
2087
2710
  return;
2088
2711
  }
2089
2712
  if (tipKey !== null && options.loopState.attemptedTipKey === tipKey) {
2090
- await refreshAndSaveStatus({
2091
- paths: options.paths,
2092
- provider: options.provider,
2093
- readContext: effectiveReadContext,
2094
- overrides: {
2095
- runMode: options.runMode,
2096
- currentPhase: "waiting",
2097
- note: options.loopState.waitingNote ?? "Waiting for the next block after the last mining attempt on this tip.",
2098
- },
2099
- visualizer: options.visualizer,
2100
- visualizerState: options.loopState.ui,
2713
+ await saveCycleStatus(effectiveReadContext, {
2714
+ runMode: options.runMode,
2715
+ currentPhase: "waiting",
2716
+ note: options.loopState.waitingNote ?? "Waiting for the next block after the last mining attempt on this tip.",
2101
2717
  });
2102
2718
  return;
2103
2719
  }
@@ -2135,31 +2751,17 @@ async function performMiningCycle(options) {
2135
2751
  if (selectedCandidate === null) {
2136
2752
  const domains = resolveEligibleAnchoredRoots(effectiveReadContext);
2137
2753
  if (domains.length === 0) {
2138
- await refreshAndSaveStatus({
2139
- paths: options.paths,
2140
- provider: options.provider,
2141
- readContext: effectiveReadContext,
2142
- overrides: {
2143
- runMode: options.runMode,
2144
- currentPhase: "idle",
2145
- note: "No locally controlled anchored root domains are currently eligible to mine.",
2146
- },
2147
- visualizer: options.visualizer,
2148
- visualizerState: options.loopState.ui,
2754
+ await saveCycleStatus(effectiveReadContext, {
2755
+ runMode: options.runMode,
2756
+ currentPhase: "idle",
2757
+ note: "No locally controlled anchored root domains are currently eligible to mine.",
2149
2758
  });
2150
2759
  return;
2151
2760
  }
2152
- await refreshAndSaveStatus({
2153
- paths: options.paths,
2154
- provider: options.provider,
2155
- readContext: effectiveReadContext,
2156
- overrides: {
2157
- runMode: options.runMode,
2158
- currentPhase: "generating",
2159
- note: "Generating mining sentences for eligible root domains.",
2160
- },
2161
- visualizer: options.visualizer,
2162
- visualizerState: options.loopState.ui,
2761
+ await saveCycleStatus(effectiveReadContext, {
2762
+ runMode: options.runMode,
2763
+ currentPhase: "generating",
2764
+ note: "Generating mining sentences for eligible root domains.",
2163
2765
  });
2164
2766
  await appendEvent(options.paths, createEvent("sentence-generation-start", "Started mining sentence generation.", {
2165
2767
  targetBlockHeight,
@@ -2186,19 +2788,12 @@ async function performMiningCycle(options) {
2186
2788
  options.loopState.attemptedTipKey = tipKey;
2187
2789
  options.loopState.waitingNote = "Mining is waiting for the sentence provider to recover.";
2188
2790
  }
2189
- await refreshAndSaveStatus({
2190
- paths: options.paths,
2191
- provider: options.provider,
2192
- readContext: effectiveReadContext,
2193
- overrides: {
2194
- runMode: options.runMode,
2195
- currentPhase: "waiting-provider",
2196
- providerState: error.providerState,
2197
- lastError: error.message,
2198
- note: "Mining is waiting for the sentence provider to recover.",
2199
- },
2200
- visualizer: options.visualizer,
2201
- visualizerState: options.loopState.ui,
2791
+ await saveCycleStatus(effectiveReadContext, {
2792
+ runMode: options.runMode,
2793
+ currentPhase: "waiting-provider",
2794
+ providerState: error.providerState,
2795
+ lastError: error.message,
2796
+ note: "Mining is waiting for the sentence provider to recover.",
2202
2797
  });
2203
2798
  await appendEvent(options.paths, createEvent("publish-paused-provider", error.message, {
2204
2799
  level: "warn",
@@ -2241,19 +2836,12 @@ async function performMiningCycle(options) {
2241
2836
  options.loopState.attemptedTipKey = tipKey;
2242
2837
  options.loopState.waitingNote = "Mining sentence generation failed for the current tip.";
2243
2838
  }
2244
- await refreshAndSaveStatus({
2245
- paths: options.paths,
2246
- provider: options.provider,
2247
- readContext: effectiveReadContext,
2248
- overrides: {
2249
- runMode: options.runMode,
2250
- currentPhase: "waiting-provider",
2251
- providerState: "unavailable",
2252
- lastError: failureMessage,
2253
- note: "Mining sentence generation failed for the current tip.",
2254
- },
2255
- visualizer: options.visualizer,
2256
- visualizerState: options.loopState.ui,
2839
+ await saveCycleStatus(effectiveReadContext, {
2840
+ runMode: options.runMode,
2841
+ currentPhase: "waiting-provider",
2842
+ providerState: "unavailable",
2843
+ lastError: failureMessage,
2844
+ note: "Mining sentence generation failed for the current tip.",
2257
2845
  });
2258
2846
  await appendEvent(options.paths, createEvent("sentence-generation-failed", failureMessage, {
2259
2847
  level: "error",
@@ -2263,17 +2851,10 @@ async function performMiningCycle(options) {
2263
2851
  }));
2264
2852
  return;
2265
2853
  }
2266
- await refreshAndSaveStatus({
2267
- paths: options.paths,
2268
- provider: options.provider,
2269
- readContext: effectiveReadContext,
2270
- overrides: {
2271
- runMode: options.runMode,
2272
- currentPhase: "scoring",
2273
- note: "Scoring mining candidates for the current tip.",
2274
- },
2275
- visualizer: options.visualizer,
2276
- visualizerState: options.loopState.ui,
2854
+ await saveCycleStatus(effectiveReadContext, {
2855
+ runMode: options.runMode,
2856
+ currentPhase: "scoring",
2857
+ note: "Scoring mining candidates for the current tip.",
2277
2858
  });
2278
2859
  const best = await chooseBestLocalCandidate(candidates);
2279
2860
  if (best === null) {
@@ -2282,18 +2863,11 @@ async function performMiningCycle(options) {
2282
2863
  options.loopState.waitingNote = "No publishable mining candidate passed scoring gates for the current tip.";
2283
2864
  }
2284
2865
  clearSelectedCandidate(options.loopState);
2285
- await refreshAndSaveStatus({
2286
- paths: options.paths,
2287
- provider: options.provider,
2288
- readContext: effectiveReadContext,
2289
- overrides: {
2290
- runMode: options.runMode,
2291
- currentPhase: "idle",
2292
- currentPublishDecision: "publish-skipped-no-candidate",
2293
- note: "No publishable mining candidate passed scoring gates for the current tip.",
2294
- },
2295
- visualizer: options.visualizer,
2296
- visualizerState: options.loopState.ui,
2866
+ await saveCycleStatus(effectiveReadContext, {
2867
+ runMode: options.runMode,
2868
+ currentPhase: "idle",
2869
+ currentPublishDecision: "publish-skipped-no-candidate",
2870
+ note: "No publishable mining candidate passed scoring gates for the current tip.",
2297
2871
  });
2298
2872
  await appendEvent(options.paths, createEvent("publish-skipped-no-candidate", "No publishable mining candidate passed scoring gates.", {
2299
2873
  targetBlockHeight,
@@ -2340,25 +2914,18 @@ async function performMiningCycle(options) {
2340
2914
  : gate.decision === "suppressed-top5-mempool"
2341
2915
  ? `Best local sentence found, but ${gate.higherRankedCompetitorDomainCount} stronger competitor root domains are already in mempool.`
2342
2916
  : "Mining skipped this tick because the mempool competitiveness gate could not be verified safely.";
2343
- await refreshAndSaveStatus({
2344
- paths: options.paths,
2345
- provider: options.provider,
2346
- readContext: effectiveReadContext,
2347
- overrides: {
2348
- runMode: options.runMode,
2349
- currentPhase: "waiting",
2350
- currentPublishDecision: gate.decision,
2351
- sameDomainCompetitorSuppressed: gate.sameDomainCompetitorSuppressed,
2352
- higherRankedCompetitorDomainCount: gate.higherRankedCompetitorDomainCount,
2353
- dedupedCompetitorDomainCount: gate.dedupedCompetitorDomainCount,
2354
- competitivenessGateIndeterminate: gate.competitivenessGateIndeterminate,
2355
- mempoolSequenceCacheStatus: gate.mempoolSequenceCacheStatus,
2356
- lastMempoolSequence: gate.lastMempoolSequence,
2357
- lastCompetitivenessGateAtUnixMs: Date.now(),
2358
- note: options.loopState.waitingNote,
2359
- },
2360
- visualizer: options.visualizer,
2361
- visualizerState: options.loopState.ui,
2917
+ await saveCycleStatus(effectiveReadContext, {
2918
+ runMode: options.runMode,
2919
+ currentPhase: "waiting",
2920
+ currentPublishDecision: gate.decision,
2921
+ sameDomainCompetitorSuppressed: gate.sameDomainCompetitorSuppressed,
2922
+ higherRankedCompetitorDomainCount: gate.higherRankedCompetitorDomainCount,
2923
+ dedupedCompetitorDomainCount: gate.dedupedCompetitorDomainCount,
2924
+ competitivenessGateIndeterminate: gate.competitivenessGateIndeterminate,
2925
+ mempoolSequenceCacheStatus: gate.mempoolSequenceCacheStatus,
2926
+ lastMempoolSequence: gate.lastMempoolSequence,
2927
+ lastCompetitivenessGateAtUnixMs: now(),
2928
+ note: options.loopState.waitingNote,
2362
2929
  });
2363
2930
  await appendEvent(options.paths, createEvent(gate.decision === "suppressed-same-domain-mempool"
2364
2931
  ? "publish-skipped-same-domain-mempool"
@@ -2387,21 +2954,12 @@ async function performMiningCycle(options) {
2387
2954
  if (!await ensureCurrentIndexerTruthOrRestart()) {
2388
2955
  return;
2389
2956
  }
2390
- await refreshAndSaveStatus({
2391
- paths: options.paths,
2392
- provider: options.provider,
2393
- readContext: effectiveReadContext,
2394
- overrides: {
2395
- runMode: options.runMode,
2396
- currentPhase: effectiveReadContext.localState.state.miningState.currentTxid === null
2397
- ? "publishing"
2398
- : "replacing",
2399
- note: effectiveReadContext.localState.state.miningState.currentTxid === null
2400
- ? "Broadcasting the best mining candidate for the current tip."
2401
- : "Replacing the live mining transaction for the current tip.",
2402
- },
2403
- visualizer: options.visualizer,
2404
- visualizerState: options.loopState.ui,
2957
+ await saveCycleStatus(effectiveReadContext, {
2958
+ runMode: options.runMode,
2959
+ ...buildPrePublishStatusOverrides({
2960
+ state: effectiveReadContext.localState.state,
2961
+ candidate: selectedCandidate,
2962
+ }),
2405
2963
  });
2406
2964
  const publishLock = await acquireFileLock(options.paths.walletControlLockPath, {
2407
2965
  purpose: "wallet-mine",
@@ -2432,32 +2990,25 @@ async function performMiningCycle(options) {
2432
2990
  if (published.retryable === true) {
2433
2991
  cacheSelectedCandidateForTip(options.loopState, tipKey, published.candidate);
2434
2992
  options.loopState.waitingNote = published.note;
2435
- await refreshAndSaveStatus({
2436
- paths: options.paths,
2437
- provider: options.provider,
2438
- readContext: {
2439
- ...effectiveReadContext,
2440
- localState: {
2441
- ...effectiveReadContext.localState,
2442
- state: published.state,
2443
- },
2444
- },
2445
- overrides: {
2446
- runMode: options.runMode,
2447
- currentPhase: "waiting",
2448
- currentPublishDecision: published.decision,
2449
- sameDomainCompetitorSuppressed: false,
2450
- higherRankedCompetitorDomainCount: gateSnapshot.higherRankedCompetitorDomainCount,
2451
- dedupedCompetitorDomainCount: gateSnapshot.dedupedCompetitorDomainCount,
2452
- competitivenessGateIndeterminate: false,
2453
- mempoolSequenceCacheStatus: gateSnapshot.mempoolSequenceCacheStatus,
2454
- lastMempoolSequence: gateSnapshot.lastMempoolSequence,
2455
- lastCompetitivenessGateAtUnixMs: Date.now(),
2456
- note: published.note,
2457
- livePublishInMempool: published.state.miningState.livePublishInMempool,
2993
+ await saveCycleStatus({
2994
+ ...effectiveReadContext,
2995
+ localState: {
2996
+ ...effectiveReadContext.localState,
2997
+ state: published.state,
2458
2998
  },
2459
- visualizer: options.visualizer,
2460
- visualizerState: options.loopState.ui,
2999
+ }, {
3000
+ runMode: options.runMode,
3001
+ currentPhase: "waiting",
3002
+ currentPublishDecision: published.decision,
3003
+ sameDomainCompetitorSuppressed: false,
3004
+ higherRankedCompetitorDomainCount: gateSnapshot.higherRankedCompetitorDomainCount,
3005
+ dedupedCompetitorDomainCount: gateSnapshot.dedupedCompetitorDomainCount,
3006
+ competitivenessGateIndeterminate: false,
3007
+ mempoolSequenceCacheStatus: gateSnapshot.mempoolSequenceCacheStatus,
3008
+ lastMempoolSequence: gateSnapshot.lastMempoolSequence,
3009
+ lastCompetitivenessGateAtUnixMs: now(),
3010
+ note: published.note,
3011
+ livePublishInMempool: published.state.miningState.livePublishInMempool,
2461
3012
  });
2462
3013
  return;
2463
3014
  }
@@ -2465,32 +3016,29 @@ async function performMiningCycle(options) {
2465
3016
  clearSelectedCandidate(options.loopState);
2466
3017
  setMiningUiCandidate(options.loopState, selectedCandidate);
2467
3018
  options.loopState.waitingNote = published.note;
2468
- await refreshAndSaveStatus({
2469
- paths: options.paths,
2470
- provider: options.provider,
2471
- readContext: {
2472
- ...effectiveReadContext,
2473
- localState: {
2474
- ...effectiveReadContext.localState,
2475
- state: published.state,
2476
- },
2477
- },
2478
- overrides: {
2479
- runMode: options.runMode,
2480
- currentPhase: "waiting",
2481
- currentPublishDecision: published.decision,
2482
- sameDomainCompetitorSuppressed: false,
2483
- higherRankedCompetitorDomainCount: gateSnapshot.higherRankedCompetitorDomainCount,
2484
- dedupedCompetitorDomainCount: gateSnapshot.dedupedCompetitorDomainCount,
2485
- competitivenessGateIndeterminate: false,
2486
- mempoolSequenceCacheStatus: gateSnapshot.mempoolSequenceCacheStatus,
2487
- lastMempoolSequence: gateSnapshot.lastMempoolSequence,
2488
- lastCompetitivenessGateAtUnixMs: Date.now(),
2489
- note: published.note,
2490
- livePublishInMempool: published.state.miningState.livePublishInMempool,
3019
+ const lastError = published.decision === "publish-paused-insufficient-funds"
3020
+ ? published.lastError ?? createInsufficientFundsMiningPublishErrorMessage()
3021
+ : undefined;
3022
+ await saveCycleStatus({
3023
+ ...effectiveReadContext,
3024
+ localState: {
3025
+ ...effectiveReadContext.localState,
3026
+ state: published.state,
2491
3027
  },
2492
- visualizer: options.visualizer,
2493
- visualizerState: options.loopState.ui,
3028
+ }, {
3029
+ runMode: options.runMode,
3030
+ currentPhase: "waiting",
3031
+ currentPublishDecision: published.decision,
3032
+ sameDomainCompetitorSuppressed: false,
3033
+ higherRankedCompetitorDomainCount: gateSnapshot.higherRankedCompetitorDomainCount,
3034
+ dedupedCompetitorDomainCount: gateSnapshot.dedupedCompetitorDomainCount,
3035
+ competitivenessGateIndeterminate: false,
3036
+ mempoolSequenceCacheStatus: gateSnapshot.mempoolSequenceCacheStatus,
3037
+ lastMempoolSequence: gateSnapshot.lastMempoolSequence,
3038
+ lastCompetitivenessGateAtUnixMs: now(),
3039
+ lastError,
3040
+ note: published.note,
3041
+ livePublishInMempool: published.state.miningState.livePublishInMempool,
2494
3042
  });
2495
3043
  return;
2496
3044
  }
@@ -2506,32 +3054,25 @@ async function performMiningCycle(options) {
2506
3054
  : `Mining candidate ${published.decision === "replaced"
2507
3055
  ? "replaced"
2508
3056
  : "broadcast"} as ${published.txid}. Waiting for the next block.`;
2509
- await refreshAndSaveStatus({
2510
- paths: options.paths,
2511
- provider: options.provider,
2512
- readContext: {
2513
- ...effectiveReadContext,
2514
- localState: {
2515
- ...effectiveReadContext.localState,
2516
- state: published.state,
2517
- },
2518
- },
2519
- overrides: {
2520
- runMode: options.runMode,
2521
- currentPhase: "waiting",
2522
- currentPublishDecision: published.decision,
2523
- sameDomainCompetitorSuppressed: false,
2524
- higherRankedCompetitorDomainCount: gateSnapshot.higherRankedCompetitorDomainCount,
2525
- dedupedCompetitorDomainCount: gateSnapshot.dedupedCompetitorDomainCount,
2526
- competitivenessGateIndeterminate: false,
2527
- mempoolSequenceCacheStatus: gateSnapshot.mempoolSequenceCacheStatus,
2528
- lastMempoolSequence: gateSnapshot.lastMempoolSequence,
2529
- lastCompetitivenessGateAtUnixMs: Date.now(),
2530
- note: options.loopState.waitingNote,
2531
- livePublishInMempool: published.state.miningState.livePublishInMempool,
3057
+ await saveCycleStatus({
3058
+ ...effectiveReadContext,
3059
+ localState: {
3060
+ ...effectiveReadContext.localState,
3061
+ state: published.state,
2532
3062
  },
2533
- visualizer: options.visualizer,
2534
- visualizerState: options.loopState.ui,
3063
+ }, {
3064
+ runMode: options.runMode,
3065
+ currentPhase: "waiting",
3066
+ currentPublishDecision: published.decision,
3067
+ sameDomainCompetitorSuppressed: false,
3068
+ higherRankedCompetitorDomainCount: gateSnapshot.higherRankedCompetitorDomainCount,
3069
+ dedupedCompetitorDomainCount: gateSnapshot.dedupedCompetitorDomainCount,
3070
+ competitivenessGateIndeterminate: false,
3071
+ mempoolSequenceCacheStatus: gateSnapshot.mempoolSequenceCacheStatus,
3072
+ lastMempoolSequence: gateSnapshot.lastMempoolSequence,
3073
+ lastCompetitivenessGateAtUnixMs: now(),
3074
+ note: options.loopState.waitingNote,
3075
+ livePublishInMempool: published.state.miningState.livePublishInMempool,
2535
3076
  });
2536
3077
  }
2537
3078
  finally {
@@ -2540,6 +3081,7 @@ async function performMiningCycle(options) {
2540
3081
  }
2541
3082
  catch (error) {
2542
3083
  if (error instanceof MiningSuspendDetectedError) {
3084
+ discardMiningLoopTransientWork(options.loopState, readContext?.localState.walletRootId ?? undefined);
2543
3085
  if (readContext !== null && !readContextClosed) {
2544
3086
  await readContext.close();
2545
3087
  readContextClosed = true;
@@ -2555,6 +3097,24 @@ async function performMiningCycle(options) {
2555
3097
  detectedAtUnixMs: error.detectedAtUnixMs,
2556
3098
  openReadContext: options.openReadContext,
2557
3099
  visualizer: options.visualizer,
3100
+ loopState: options.loopState,
3101
+ });
3102
+ return;
3103
+ }
3104
+ if (readContext !== null && isRecoverableMiningBitcoindError(error)) {
3105
+ await handleRecoverableMiningBitcoindFailure({
3106
+ error,
3107
+ dataDir: options.dataDir,
3108
+ provider: options.provider,
3109
+ paths: options.paths,
3110
+ runMode: options.runMode,
3111
+ readContext,
3112
+ loopState: options.loopState,
3113
+ attachService: options.attachService,
3114
+ probeService: options.probeService,
3115
+ stopService: options.stopService,
3116
+ nowUnixMs: now(),
3117
+ visualizer: options.visualizer,
2558
3118
  });
2559
3119
  return;
2560
3120
  }
@@ -2656,6 +3216,9 @@ async function attemptSaveMempool(rpc, paths, runId) {
2656
3216
  async function runMiningLoop(options) {
2657
3217
  const suspendDetector = createMiningSuspendDetector();
2658
3218
  const loopState = createMiningLoopState();
3219
+ const probeService = options.probeService ?? probeManagedBitcoindService;
3220
+ const stopService = options.stopService ?? stopManagedBitcoindService;
3221
+ const sleepImpl = options.sleepImpl ?? sleep;
2659
3222
  await appendEvent(options.paths, createEvent("runtime-start", `Started ${options.runMode} mining runtime.`, {
2660
3223
  runId: options.backgroundWorkerRunId,
2661
3224
  }));
@@ -2667,6 +3230,7 @@ async function runMiningLoop(options) {
2667
3230
  if (!(error instanceof MiningSuspendDetectedError)) {
2668
3231
  throw error;
2669
3232
  }
3233
+ discardMiningLoopTransientWork(loopState, null);
2670
3234
  await handleDetectedMiningRuntimeResume({
2671
3235
  dataDir: options.dataDir,
2672
3236
  databasePath: options.databasePath,
@@ -2678,6 +3242,7 @@ async function runMiningLoop(options) {
2678
3242
  detectedAtUnixMs: error.detectedAtUnixMs,
2679
3243
  openReadContext: options.openReadContext,
2680
3244
  visualizer: options.visualizer,
3245
+ loopState,
2681
3246
  });
2682
3247
  continue;
2683
3248
  }
@@ -2685,8 +3250,10 @@ async function runMiningLoop(options) {
2685
3250
  ...options,
2686
3251
  suspendDetector,
2687
3252
  loopState,
3253
+ probeService,
3254
+ stopService,
2688
3255
  });
2689
- await sleep(Math.min(MINING_LOOP_INTERVAL_MS, MINING_STATUS_HEARTBEAT_INTERVAL_MS), options.signal);
3256
+ await sleepImpl(Math.min(MINING_LOOP_INTERVAL_MS, MINING_STATUS_HEARTBEAT_INTERVAL_MS), options.signal);
2690
3257
  }
2691
3258
  const service = await options.attachService({
2692
3259
  dataDir: options.dataDir,
@@ -2723,36 +3290,52 @@ export async function runForegroundMining(options) {
2723
3290
  const openReadContext = options.openReadContext ?? openWalletReadContext;
2724
3291
  const attachService = options.attachService ?? attachOrStartManagedBitcoindService;
2725
3292
  const rpcFactory = options.rpcFactory ?? createRpcClient;
2726
- const controlLock = await acquireFileLock(paths.miningControlLockPath, {
3293
+ const requestMiningPreemption = options.requestMiningPreemption ?? requestMiningGenerationPreemption;
3294
+ const runMiningLoopImpl = options.runMiningLoopImpl ?? runMiningLoop;
3295
+ const saveStopSnapshotImpl = options.saveStopSnapshotImpl ?? saveStopSnapshot;
3296
+ let visualizer = null;
3297
+ const setupReady = options.builtInSetupEnsured === true
3298
+ ? true
3299
+ : await ensureBuiltInMiningSetupIfNeeded({
3300
+ provider,
3301
+ prompter: options.prompter,
3302
+ paths,
3303
+ });
3304
+ if (!setupReady) {
3305
+ throw new Error("Built-in mining provider is not configured. Run `cogcoin mine setup`.");
3306
+ }
3307
+ const controlLock = await acquireMiningStartControlLock({
3308
+ paths,
2727
3309
  purpose: "mine-foreground",
3310
+ takeoverReason: "mine-foreground-replace",
3311
+ requestMiningPreemption,
3312
+ shutdownGraceMs: options.shutdownGraceMs,
3313
+ sleepImpl: options.sleepImpl,
2728
3314
  });
2729
- let visualizer = null;
3315
+ const abortController = new AbortController();
3316
+ const abortListener = () => {
3317
+ abortController.abort();
3318
+ };
3319
+ const handleSigint = () => abortController.abort();
3320
+ const handleSigterm = () => abortController.abort();
2730
3321
  try {
2731
- const existing = await loadMiningRuntimeStatus(paths.miningStatusPath).catch(() => null);
2732
- if (existing?.runMode === "background") {
2733
- throw new Error("Background mining is already active. Run `cogcoin mine stop` first.");
2734
- }
2735
- const setupReady = options.builtInSetupEnsured === true
2736
- ? true
2737
- : await ensureBuiltInMiningSetupIfNeeded({
2738
- provider,
2739
- prompter: options.prompter,
2740
- paths,
2741
- });
2742
- if (!setupReady) {
2743
- throw new Error("Built-in mining provider is not configured. Run `cogcoin mine setup`.");
2744
- }
3322
+ await takeOverMiningRuntime({
3323
+ paths,
3324
+ reason: "mine-foreground-replace",
3325
+ requestMiningPreemption,
3326
+ shutdownGraceMs: options.shutdownGraceMs,
3327
+ sleepImpl: options.sleepImpl,
3328
+ });
2745
3329
  visualizer = new MiningFollowVisualizer({
3330
+ clientVersion: options.clientVersion,
3331
+ updateAvailable: options.updateAvailable,
2746
3332
  progressOutput: options.progressOutput ?? "auto",
2747
3333
  stream: options.stderr,
2748
3334
  });
2749
- const abortController = new AbortController();
2750
- options.signal?.addEventListener("abort", () => {
2751
- abortController.abort();
2752
- }, { once: true });
2753
- process.on("SIGINT", () => abortController.abort());
2754
- process.on("SIGTERM", () => abortController.abort());
2755
- await runMiningLoop({
3335
+ options.signal?.addEventListener("abort", abortListener, { once: true });
3336
+ process.on("SIGINT", handleSigint);
3337
+ process.on("SIGTERM", handleSigterm);
3338
+ await runMiningLoopImpl({
2756
3339
  dataDir: options.dataDir,
2757
3340
  databasePath: options.databasePath,
2758
3341
  provider,
@@ -2768,7 +3351,7 @@ export async function runForegroundMining(options) {
2768
3351
  stdout: options.stdout,
2769
3352
  visualizer,
2770
3353
  });
2771
- await saveStopSnapshot({
3354
+ await saveStopSnapshotImpl({
2772
3355
  dataDir: options.dataDir,
2773
3356
  databasePath: options.databasePath,
2774
3357
  provider,
@@ -2780,6 +3363,9 @@ export async function runForegroundMining(options) {
2780
3363
  });
2781
3364
  }
2782
3365
  finally {
3366
+ options.signal?.removeEventListener("abort", abortListener);
3367
+ process.off("SIGINT", handleSigint);
3368
+ process.off("SIGTERM", handleSigterm);
2783
3369
  visualizer?.close();
2784
3370
  await controlLock.release();
2785
3371
  }
@@ -2787,35 +3373,50 @@ export async function runForegroundMining(options) {
2787
3373
  export async function startBackgroundMining(options) {
2788
3374
  const provider = options.provider ?? createDefaultWalletSecretProvider();
2789
3375
  const paths = options.paths ?? resolveWalletRuntimePathsForTesting();
2790
- const controlLock = await acquireFileLock(paths.miningControlLockPath, {
2791
- purpose: "mine-start",
2792
- });
3376
+ const requestMiningPreemption = options.requestMiningPreemption ?? requestMiningGenerationPreemption;
3377
+ const spawnWorkerProcess = options.spawnWorkerProcess ?? spawn;
3378
+ const waitForBackgroundHealthyImpl = options.waitForBackgroundHealthyImpl ?? waitForBackgroundHealthy;
3379
+ const setupReady = options.builtInSetupEnsured === true
3380
+ ? true
3381
+ : await ensureBuiltInMiningSetupIfNeeded({
3382
+ provider,
3383
+ prompter: options.prompter,
3384
+ paths,
3385
+ });
3386
+ if (!setupReady) {
3387
+ throw new Error("Built-in mining provider is not configured. Run `cogcoin mine setup`.");
3388
+ }
3389
+ let controlLock;
2793
3390
  try {
2794
- const existing = await loadMiningRuntimeStatus(paths.miningStatusPath).catch(() => null);
2795
- if (existing?.runMode === "background"
2796
- && existing.backgroundWorkerPid !== null
2797
- && await isProcessAlive(existing.backgroundWorkerPid)) {
3391
+ controlLock = await acquireMiningStartControlLock({
3392
+ paths,
3393
+ purpose: "mine-start",
3394
+ takeoverReason: "mine-start-replace",
3395
+ requestMiningPreemption,
3396
+ shutdownGraceMs: options.shutdownGraceMs,
3397
+ sleepImpl: options.sleepImpl,
3398
+ });
3399
+ }
3400
+ catch (error) {
3401
+ if (error instanceof FileLockBusyError && error.existingMetadata?.processId === process.pid) {
2798
3402
  return {
2799
3403
  started: false,
2800
- snapshot: existing,
3404
+ snapshot: await loadMiningRuntimeStatus(paths.miningStatusPath).catch(() => null),
2801
3405
  };
2802
3406
  }
2803
- if (existing?.runMode === "foreground") {
2804
- throw new Error("Foreground mining is already active. Interrupt that process directly.");
2805
- }
2806
- const setupReady = options.builtInSetupEnsured === true
2807
- ? true
2808
- : await ensureBuiltInMiningSetupIfNeeded({
2809
- provider,
2810
- prompter: options.prompter,
2811
- paths,
2812
- });
2813
- if (!setupReady) {
2814
- throw new Error("Built-in mining provider is not configured. Run `cogcoin mine setup`.");
2815
- }
3407
+ throw error;
3408
+ }
3409
+ try {
3410
+ await takeOverMiningRuntime({
3411
+ paths,
3412
+ reason: "mine-start-replace",
3413
+ requestMiningPreemption,
3414
+ shutdownGraceMs: options.shutdownGraceMs,
3415
+ sleepImpl: options.sleepImpl,
3416
+ });
2816
3417
  const runId = randomBytes(16).toString("hex");
2817
3418
  const workerMainPath = fileURLToPath(new URL("./worker-main.js", import.meta.url));
2818
- const child = spawn(process.execPath, [
3419
+ const child = spawnWorkerProcess(process.execPath, [
2819
3420
  workerMainPath,
2820
3421
  `--data-dir=${options.dataDir}`,
2821
3422
  `--database-path=${options.databasePath}`,
@@ -2825,7 +3426,7 @@ export async function startBackgroundMining(options) {
2825
3426
  stdio: "ignore",
2826
3427
  });
2827
3428
  child.unref();
2828
- const snapshot = await waitForBackgroundHealthy(paths);
3429
+ const snapshot = await waitForBackgroundHealthyImpl(paths);
2829
3430
  return {
2830
3431
  started: true,
2831
3432
  snapshot,
@@ -2957,14 +3558,33 @@ export async function runBackgroundMiningWorker(options) {
2957
3558
  });
2958
3559
  }
2959
3560
  export async function handleDetectedMiningRuntimeResumeForTesting(options) {
2960
- await handleDetectedMiningRuntimeResume(options);
3561
+ await handleDetectedMiningRuntimeResume({
3562
+ ...options,
3563
+ loopState: options.loopState ?? createMiningLoopState(),
3564
+ });
3565
+ }
3566
+ export async function takeOverMiningRuntimeForTesting(options) {
3567
+ return await takeOverMiningRuntime(options);
2961
3568
  }
2962
3569
  export async function performMiningCycleForTesting(options) {
2963
3570
  await performMiningCycle({
2964
3571
  ...options,
3572
+ probeService: options.probeService ?? probeManagedBitcoindService,
3573
+ stopService: options.stopService ?? stopManagedBitcoindService,
2965
3574
  loopState: options.loopState ?? createMiningLoopState(),
2966
3575
  });
2967
3576
  }
3577
+ export async function runMiningLoopForTesting(options) {
3578
+ await runMiningLoop({
3579
+ ...options,
3580
+ });
3581
+ }
3582
+ export function buildPrePublishStatusOverridesForTesting(options) {
3583
+ return buildPrePublishStatusOverrides(options);
3584
+ }
3585
+ export function buildStatusSnapshotForTesting(view, overrides = {}) {
3586
+ return buildStatusSnapshot(view, overrides);
3587
+ }
2968
3588
  export function shouldKeepCurrentTipLivePublishForTesting(options) {
2969
3589
  return livePublishTargetsCandidateTip(options);
2970
3590
  }