@cogcoin/client 1.1.1 → 1.1.2

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.
@@ -105,7 +105,9 @@ function mapProviderState(provider, localState, existingRuntime) {
105
105
  const miningState = localState.state?.miningState === undefined
106
106
  ? null
107
107
  : normalizeMiningStateRecord(localState.state.miningState);
108
- if (existingRuntime?.currentPhase === "waiting-provider" && existingRuntime.providerState !== null) {
108
+ if (existingRuntime?.currentPhase === "waiting-provider"
109
+ && existingRuntime.providerState !== null
110
+ && (miningState === null || miningState.state === "idle")) {
109
111
  return existingRuntime.providerState;
110
112
  }
111
113
  if (miningState?.state === "paused" && miningState.pauseReason?.includes("rate-limit")) {
@@ -122,6 +124,11 @@ function mapProviderState(provider, localState, existingRuntime) {
122
124
  }
123
125
  return "unavailable";
124
126
  }
127
+ function shouldReuseExistingProviderWait(options) {
128
+ return options.existingRuntime?.currentPhase === "waiting-provider"
129
+ && options.existingRuntime.providerState !== null
130
+ && (options.miningState === null || options.miningState.state === "idle");
131
+ }
125
132
  function mapIndexerDaemonState(indexer) {
126
133
  if (indexer.health === "wallet-root-mismatch") {
127
134
  return "wallet-root-mismatch";
@@ -214,6 +221,10 @@ async function buildMiningRuntimeSnapshot(options) {
214
221
  const indexerDaemonState = mapIndexerDaemonState(options.indexer);
215
222
  const corePublishState = mapCorePublishState(options.nodeHealth, options.nodeStatus);
216
223
  const existing = options.existingRuntime;
224
+ const reuseExistingProviderWait = shouldReuseExistingProviderWait({
225
+ existingRuntime: existing,
226
+ miningState: state,
227
+ });
217
228
  return {
218
229
  schemaVersion: 1,
219
230
  walletRootId: options.localState.walletRootId,
@@ -278,12 +289,16 @@ async function buildMiningRuntimeSnapshot(options) {
278
289
  indexerHealth: options.indexer.health,
279
290
  tipsAligned: options.tipsAligned,
280
291
  lastEventAtUnixMs: options.lastEventAtUnixMs,
281
- lastError: existing?.lastError ?? options.provider.message ?? options.indexer.message ?? null,
292
+ lastError: reuseExistingProviderWait
293
+ ? existing?.lastError ?? null
294
+ : existing?.currentPhase === "waiting-bitcoin-network" || existing?.currentPhase === "waiting-indexer"
295
+ ? existing?.lastError ?? options.provider.message ?? options.indexer.message ?? null
296
+ : options.provider.message ?? options.indexer.message ?? null,
282
297
  note: state?.pauseReason === "zero-reward"
283
298
  ? "Mining is disabled because the target block reward is zero."
284
299
  : existing?.currentPhase === "resuming"
285
300
  ? "Mining discarded stale in-flight work after a large local runtime gap and is rechecking health."
286
- : existing?.currentPhase === "waiting-provider"
301
+ : reuseExistingProviderWait
287
302
  ? "Mining is waiting for the sentence provider to recover."
288
303
  : existing?.currentPhase === "waiting-indexer"
289
304
  ? "Mining is waiting for Bitcoin Core and the indexer to align."
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { assaySentences } from "@cogcoin/scoring";
2
3
  import { attachOrStartManagedBitcoindService, probeManagedBitcoindService, stopManagedBitcoindService } from "../../bitcoind/service.js";
3
4
  import { createRpcClient } from "../../bitcoind/node.js";
4
5
  import type { ProgressOutputMode } from "../../bitcoind/types.js";
@@ -158,6 +159,18 @@ type MiningPublishOutcome = ({
158
159
  note?: null;
159
160
  candidate: MiningCandidate;
160
161
  } & Awaited<ReturnType<typeof publishCandidateOnce>>) | MiningPublishSkipResult | MiningPublishRetryResult;
162
+ interface CompetitivenessDecision {
163
+ allowed: boolean;
164
+ decision: string;
165
+ sameDomainCompetitorSuppressed: boolean;
166
+ higherRankedCompetitorDomainCount: number;
167
+ dedupedCompetitorDomainCount: number;
168
+ competitivenessGateIndeterminate: boolean;
169
+ mempoolSequenceCacheStatus: MiningRuntimeStatusV1["mempoolSequenceCacheStatus"];
170
+ lastMempoolSequence: string | null;
171
+ visibleBoardEntries: MiningSentenceBoardEntry[];
172
+ candidateRank: number | null;
173
+ }
161
174
  interface RunnerDependencies {
162
175
  openReadContext?: typeof openWalletReadContext;
163
176
  attachService?: typeof attachOrStartManagedBitcoindService;
@@ -171,6 +184,11 @@ interface RunnerDependencies {
171
184
  shutdownGraceMs?: number;
172
185
  sleepImpl?: typeof sleep;
173
186
  }
187
+ interface IndexerTruthKey {
188
+ walletRootId: string;
189
+ daemonInstanceId: string;
190
+ snapshotSeq: string;
191
+ }
174
192
  interface MiningLoopState {
175
193
  attemptedTipKey: string | null;
176
194
  currentTipKey: string | null;
@@ -178,6 +196,10 @@ interface MiningLoopState {
178
196
  selectedCandidate: MiningCandidate | null;
179
197
  ui: MiningFollowVisualizerState;
180
198
  waitingNote: string | null;
199
+ providerWaitState: "backoff" | "rate-limited" | "auth-error" | "not-found" | null;
200
+ providerWaitLastError: string | null;
201
+ providerWaitNextRetryAtUnixMs: number | null;
202
+ providerTransientFailureCount: number;
181
203
  bitcoinRecoveryFirstFailureAtUnixMs: number | null;
182
204
  bitcoinRecoveryFirstUnreachableAtUnixMs: number | null;
183
205
  bitcoinRecoveryLastRestartAttemptAtUnixMs: number | null;
@@ -186,6 +208,20 @@ interface MiningLoopState {
186
208
  reconnectSettledUntilUnixMs: number | null;
187
209
  tipSettledUntilUnixMs: number | null;
188
210
  }
211
+ interface MiningSuspendDetector {
212
+ lastHeartbeatMonotonicMs: number;
213
+ detectedAtUnixMs: number | null;
214
+ monotonicNow: () => number;
215
+ nowUnixMs: () => number;
216
+ stop(): void;
217
+ }
218
+ interface MiningSuspendHeartbeatHandle {
219
+ clear(): void;
220
+ }
221
+ interface MiningSuspendScheduler {
222
+ every(intervalMs: number, callback: () => void): MiningSuspendHeartbeatHandle;
223
+ }
224
+ type MiningCooperativeYield = () => Promise<void>;
189
225
  export interface RunForegroundMiningOptions extends RunnerDependencies {
190
226
  dataDir: string;
191
227
  databasePath: string;
@@ -243,7 +279,7 @@ export declare function resolveSettledBoardForTesting(options: {
243
279
  settledBoardEntries: MiningSentenceBoardEntry[];
244
280
  };
245
281
  export declare function getSelectedCandidateForTipForTesting(loopState: MiningLoopState, tipKey: string | null): MiningCandidate | null;
246
- export declare function cacheSelectedCandidateForTipForTesting(loopState: MiningLoopState, tipKey: string | null, candidate: MiningCandidate): void;
282
+ export declare function cacheSelectedCandidateForTipForTesting(loopState: MiningLoopState, tipKey: string | null, candidate: MiningCandidate, liveState?: MiningStateRecord | null): void;
247
283
  export declare function resolveFundingDisplaySatsForTesting(state: WalletStateV1, rpc: MiningRpcClient): Promise<bigint>;
248
284
  export declare function loadMiningVisibleFollowBlockTimesForTesting(options: {
249
285
  rpc: MiningRpcClient;
@@ -302,6 +338,12 @@ export declare function createMiningPlanForTesting(options: {
302
338
  feeRateSatVb: number;
303
339
  };
304
340
  export declare function validateMiningDraftForTesting(decoded: Awaited<ReturnType<MiningRpcClient["decodePsbt"]>>, funded: Awaited<ReturnType<MiningRpcClient["walletCreateFundedPsbt"]>>, plan: ReturnType<typeof createMiningPlan>): void;
341
+ declare function resolveEligibleAnchoredRoots(context: WalletReadContext): Array<{
342
+ domainId: number;
343
+ domainName: string;
344
+ localIndex: number;
345
+ sender: MutationSender;
346
+ }>;
305
347
  export declare function refreshMiningCandidateFromCurrentStateForTesting(context: ReadyMiningReadContext, candidate: MiningCandidate): MiningCandidate | null;
306
348
  export declare function resolveMiningConflictOutpointForTesting(options: {
307
349
  state: WalletStateV1;
@@ -320,6 +362,34 @@ export declare function buildMiningGenerationRequestForTesting(options: {
320
362
  domainExtraPrompts?: Record<string, string>;
321
363
  extraPrompt?: string | null;
322
364
  }): MiningSentenceGenerationRequest;
365
+ declare function generateCandidatesForDomains(options: {
366
+ rpc: MiningRpcClient;
367
+ readContext: WalletReadContext & {
368
+ localState: {
369
+ availability: "ready";
370
+ state: WalletStateV1;
371
+ };
372
+ snapshot: NonNullable<WalletReadContext["snapshot"]>;
373
+ model: NonNullable<WalletReadContext["model"]>;
374
+ };
375
+ domains: ReturnType<typeof resolveEligibleAnchoredRoots>;
376
+ provider: WalletSecretProvider;
377
+ paths: WalletRuntimePaths;
378
+ indexerTruthKey: IndexerTruthKey | null;
379
+ runId?: string | null;
380
+ fetchImpl?: typeof fetch;
381
+ }): Promise<MiningCandidate[]>;
382
+ declare function runCompetitivenessGate(options: {
383
+ rpc: MiningRpcClient;
384
+ readContext: WalletReadContext & {
385
+ snapshot: NonNullable<WalletReadContext["snapshot"]>;
386
+ };
387
+ candidate: MiningCandidate;
388
+ currentTxid: string | null;
389
+ assaySentencesImpl?: typeof assaySentences;
390
+ cooperativeYield?: MiningCooperativeYield;
391
+ cooperativeYieldEvery?: number;
392
+ }): Promise<CompetitivenessDecision>;
323
393
  declare function publishCandidateOnce(options: {
324
394
  readContext: WalletReadContext & {
325
395
  localState: {
@@ -388,9 +458,17 @@ declare function runMiningLoop(options: {
388
458
  stdout?: {
389
459
  write(chunk: string): void;
390
460
  };
461
+ loopState?: MiningLoopState;
391
462
  visualizer?: MiningFollowVisualizer;
392
463
  nowImpl?: () => number;
393
464
  sleepImpl?: typeof sleep;
465
+ suspendMonotonicNowImpl?: () => number;
466
+ suspendScheduler?: MiningSuspendScheduler;
467
+ generateCandidatesForDomainsImpl?: typeof generateCandidatesForDomains;
468
+ runCompetitivenessGateImpl?: typeof runCompetitivenessGate;
469
+ assaySentencesImpl?: typeof assaySentences;
470
+ cooperativeYieldImpl?: MiningCooperativeYield;
471
+ cooperativeYieldEvery?: number;
394
472
  }): Promise<void>;
395
473
  declare function waitForBackgroundHealthy(paths: WalletRuntimePaths): Promise<MiningRuntimeStatusV1 | null>;
396
474
  export declare function runForegroundMining(options: RunForegroundMiningOptions): Promise<void>;
@@ -445,6 +523,11 @@ export declare function performMiningCycleForTesting(options: {
445
523
  };
446
524
  loopState?: MiningLoopState;
447
525
  nowImpl?: () => number;
526
+ generateCandidatesForDomainsImpl?: typeof generateCandidatesForDomains;
527
+ runCompetitivenessGateImpl?: typeof runCompetitivenessGate;
528
+ assaySentencesImpl?: typeof assaySentences;
529
+ cooperativeYieldImpl?: MiningCooperativeYield;
530
+ cooperativeYieldEvery?: number;
448
531
  }): Promise<void>;
449
532
  export declare function runMiningLoopForTesting(options: {
450
533
  dataDir: string;
@@ -464,10 +547,42 @@ export declare function runMiningLoopForTesting(options: {
464
547
  stdout?: {
465
548
  write(chunk: string): void;
466
549
  };
550
+ loopState?: MiningLoopState;
467
551
  visualizer?: MiningFollowVisualizer;
468
552
  nowImpl?: () => number;
469
553
  sleepImpl?: typeof sleep;
554
+ suspendMonotonicNowImpl?: () => number;
555
+ suspendScheduler?: MiningSuspendScheduler;
556
+ generateCandidatesForDomainsImpl?: typeof generateCandidatesForDomains;
557
+ runCompetitivenessGateImpl?: typeof runCompetitivenessGate;
558
+ assaySentencesImpl?: typeof assaySentences;
559
+ cooperativeYieldImpl?: MiningCooperativeYield;
560
+ cooperativeYieldEvery?: number;
470
561
  }): Promise<void>;
562
+ export declare function runCompetitivenessGateForTesting(options: {
563
+ rpc: MiningRpcClient;
564
+ readContext: WalletReadContext & {
565
+ snapshot: NonNullable<WalletReadContext["snapshot"]>;
566
+ };
567
+ candidate: MiningCandidate;
568
+ currentTxid: string | null;
569
+ assaySentencesImpl?: typeof assaySentences;
570
+ cooperativeYieldImpl?: MiningCooperativeYield;
571
+ cooperativeYieldEvery?: number;
572
+ }): Promise<CompetitivenessDecision>;
573
+ export declare function createMiningSuspendDetectorForTesting(options?: {
574
+ monotonicNow?: () => number;
575
+ nowUnixMs?: () => number;
576
+ scheduler?: MiningSuspendScheduler;
577
+ }): MiningSuspendDetector;
578
+ export declare function throwIfMiningSuspendDetectedForTesting(detector: MiningSuspendDetector): void;
579
+ export declare function topologicallyOrderAncestorTxidsForTesting(options: {
580
+ txid: string;
581
+ txContexts: Map<string, {
582
+ txid: string;
583
+ rawTransaction: Awaited<ReturnType<MiningRpcClient["getRawTransaction"]>>;
584
+ }>;
585
+ }): string[] | null;
471
586
  export declare function buildPrePublishStatusOverridesForTesting(options: {
472
587
  state: WalletStateV1;
473
588
  candidate: MiningCandidate;
@@ -32,6 +32,8 @@ const BEST_BLOCK_POLL_INTERVAL_MS = 500;
32
32
  const BACKGROUND_START_TIMEOUT_MS = 15_000;
33
33
  const MINING_BITCOIN_RECOVERY_GRACE_MS = 15_000;
34
34
  const MINING_BITCOIN_RECOVERY_RESTART_COOLDOWN_MS = 60_000;
35
+ const MINING_SUSPEND_HEARTBEAT_INTERVAL_MS = 1_000;
36
+ const MINING_MEMPOOL_COOPERATIVE_YIELD_EVERY = 25;
35
37
  const MINING_BITCOIN_RECOVERY_NOTE = "Mining lost contact with the local Bitcoin RPC service and is waiting for it to recover.";
36
38
  function resolveBip39WordsFromIndices(indices) {
37
39
  if (indices === null || indices === undefined) {
@@ -62,6 +64,38 @@ function resolveSettledWinnerRequiredWords(options) {
62
64
  function resolveSnapshotOverride(override, fallback) {
63
65
  return override === undefined ? fallback : override;
64
66
  }
67
+ function resolveMiningProviderBackoffDelayMs(consecutiveFailureCount) {
68
+ const exponent = Math.max(consecutiveFailureCount - 1, 0);
69
+ return Math.min(MINING_PROVIDER_BACKOFF_BASE_MS * (2 ** exponent), MINING_PROVIDER_BACKOFF_MAX_MS);
70
+ }
71
+ function clearMiningProviderWait(loopState, resetTransientFailureCount = true) {
72
+ loopState.providerWaitState = null;
73
+ loopState.providerWaitLastError = null;
74
+ loopState.providerWaitNextRetryAtUnixMs = null;
75
+ if (resetTransientFailureCount) {
76
+ loopState.providerTransientFailureCount = 0;
77
+ }
78
+ }
79
+ function recordTransientMiningProviderWait(options) {
80
+ options.loopState.providerTransientFailureCount += 1;
81
+ options.loopState.providerWaitState = options.error.providerState === "rate-limited"
82
+ ? "rate-limited"
83
+ : "backoff";
84
+ options.loopState.providerWaitLastError = options.error.message;
85
+ options.loopState.providerWaitNextRetryAtUnixMs = options.nowUnixMs
86
+ + resolveMiningProviderBackoffDelayMs(options.loopState.providerTransientFailureCount);
87
+ }
88
+ function recordTerminalMiningProviderWait(options) {
89
+ clearMiningProviderWait(options.loopState);
90
+ if (options.error.providerState !== "auth-error" && options.error.providerState !== "not-found") {
91
+ throw new Error("mining_provider_wait_state_invalid");
92
+ }
93
+ options.loopState.providerWaitState = options.error.providerState;
94
+ options.loopState.providerWaitLastError = options.error.message;
95
+ }
96
+ function isTransientMiningProviderError(error) {
97
+ return error.providerState === "unavailable" || error.providerState === "rate-limited";
98
+ }
65
99
  class MiningSuspendDetectedError extends Error {
66
100
  detectedAtUnixMs;
67
101
  constructor(detectedAtUnixMs) {
@@ -78,20 +112,75 @@ class MiningPublishRejectedError extends Error {
78
112
  }
79
113
  }
80
114
  const miningGateCache = new Map();
81
- function createMiningSuspendDetector(monotonicNow = performance.now()) {
82
- return {
83
- lastMonotonicMs: monotonicNow,
115
+ const defaultMiningSuspendScheduler = {
116
+ every(intervalMs, callback) {
117
+ const timer = setInterval(callback, intervalMs);
118
+ timer.unref?.();
119
+ return {
120
+ clear() {
121
+ clearInterval(timer);
122
+ },
123
+ };
124
+ },
125
+ };
126
+ function refreshMiningSuspendDetector(detector) {
127
+ if (detector === undefined) {
128
+ return;
129
+ }
130
+ const monotonicNow = detector.monotonicNow();
131
+ const gapMs = monotonicNow - detector.lastHeartbeatMonotonicMs;
132
+ detector.lastHeartbeatMonotonicMs = monotonicNow;
133
+ if (gapMs > MINING_SUSPEND_GAP_THRESHOLD_MS
134
+ && detector.detectedAtUnixMs === null) {
135
+ detector.detectedAtUnixMs = detector.nowUnixMs();
136
+ }
137
+ }
138
+ function createMiningSuspendDetector(options = {}) {
139
+ const monotonicNow = options.monotonicNow ?? (() => performance.now());
140
+ const nowUnixMs = options.nowUnixMs ?? Date.now;
141
+ const scheduler = options.scheduler ?? defaultMiningSuspendScheduler;
142
+ let heartbeat = null;
143
+ const detector = {
144
+ lastHeartbeatMonotonicMs: monotonicNow(),
145
+ detectedAtUnixMs: null,
146
+ monotonicNow,
147
+ nowUnixMs,
148
+ stop() {
149
+ heartbeat?.clear();
150
+ heartbeat = null;
151
+ },
84
152
  };
153
+ heartbeat = scheduler.every(MINING_SUSPEND_HEARTBEAT_INTERVAL_MS, () => {
154
+ refreshMiningSuspendDetector(detector);
155
+ });
156
+ return detector;
85
157
  }
86
- function checkpointMiningSuspendDetector(detector, monotonicNow = performance.now()) {
158
+ function throwIfMiningSuspendDetected(detector) {
87
159
  if (detector === undefined) {
88
160
  return;
89
161
  }
90
- const gapMs = monotonicNow - detector.lastMonotonicMs;
91
- detector.lastMonotonicMs = monotonicNow;
92
- if (gapMs > MINING_SUSPEND_GAP_THRESHOLD_MS) {
93
- throw new MiningSuspendDetectedError(Date.now());
162
+ refreshMiningSuspendDetector(detector);
163
+ if (detector.detectedAtUnixMs === null) {
164
+ return;
94
165
  }
166
+ const detectedAtUnixMs = detector.detectedAtUnixMs;
167
+ detector.detectedAtUnixMs = null;
168
+ throw new MiningSuspendDetectedError(detectedAtUnixMs);
169
+ }
170
+ function stopMiningSuspendDetector(detector) {
171
+ detector?.stop();
172
+ }
173
+ function defaultMiningCooperativeYield() {
174
+ return new Promise((resolve) => {
175
+ setImmediate(resolve);
176
+ });
177
+ }
178
+ async function maybeYieldDuringMempoolScan(options) {
179
+ const yieldEvery = options.cooperativeYieldEvery ?? MINING_MEMPOOL_COOPERATIVE_YIELD_EVERY;
180
+ if (yieldEvery <= 0 || options.iteration === 0 || (options.iteration % yieldEvery) !== 0) {
181
+ return;
182
+ }
183
+ await (options.cooperativeYield ?? defaultMiningCooperativeYield)();
95
184
  }
96
185
  function clearMiningGateCache(walletRootId) {
97
186
  if (walletRootId === null || walletRootId === undefined) {
@@ -473,6 +562,10 @@ function createMiningLoopState() {
473
562
  selectedCandidate: null,
474
563
  ui: createEmptyMiningFollowVisualizerState(),
475
564
  waitingNote: null,
565
+ providerWaitState: null,
566
+ providerWaitLastError: null,
567
+ providerWaitNextRetryAtUnixMs: null,
568
+ providerTransientFailureCount: 0,
476
569
  bitcoinRecoveryFirstFailureAtUnixMs: null,
477
570
  bitcoinRecoveryFirstUnreachableAtUnixMs: null,
478
571
  bitcoinRecoveryLastRestartAttemptAtUnixMs: null,
@@ -527,6 +620,25 @@ function resetMiningUiForTip(loopState, targetBlockHeight) {
527
620
  export function resetMiningUiForTipForTesting(loopState, targetBlockHeight) {
528
621
  resetMiningUiForTip(loopState, targetBlockHeight);
529
622
  }
623
+ function resolveProvisionalBroadcastTxidForCandidate(options) {
624
+ if (options.liveState === null || options.liveState === undefined) {
625
+ return null;
626
+ }
627
+ const liveState = normalizeMiningStateRecord(options.liveState);
628
+ if (liveState.currentTxid === null
629
+ || liveState.currentPublishState !== "in-mempool"
630
+ || liveState.livePublishInMempool !== true) {
631
+ return null;
632
+ }
633
+ if (liveState.currentDomain !== options.candidate.domainName
634
+ || liveState.currentDomainId !== options.candidate.domainId
635
+ || liveState.currentSentence !== options.candidate.sentence
636
+ || liveState.currentBlockTargetHeight !== options.candidate.targetBlockHeight
637
+ || liveState.currentReferencedBlockHashDisplay !== options.candidate.referencedBlockHashDisplay) {
638
+ return null;
639
+ }
640
+ return liveState.currentTxid;
641
+ }
530
642
  function fallbackSettledWinnerDomainName(domainId) {
531
643
  return `domain-${domainId}`;
532
644
  }
@@ -600,13 +712,17 @@ function syncMiningUiForCurrentTip(options) {
600
712
  tipChanged,
601
713
  };
602
714
  }
603
- function setMiningUiCandidate(loopState, candidate) {
715
+ function setMiningUiCandidate(loopState, candidate, liveState) {
604
716
  loopState.ui.latestSentence = candidate.sentence;
605
717
  loopState.ui.provisionalRequiredWords = [...candidate.bip39Words];
606
718
  loopState.ui.provisionalEntry = {
607
719
  domainName: candidate.domainName,
608
720
  sentence: candidate.sentence,
609
721
  };
722
+ loopState.ui.provisionalBroadcastTxid = resolveProvisionalBroadcastTxidForCandidate({
723
+ candidate,
724
+ liveState,
725
+ });
610
726
  }
611
727
  function getSelectedCandidateForTip(loopState, tipKey) {
612
728
  if (tipKey === null || loopState.selectedCandidateTipKey !== tipKey) {
@@ -617,13 +733,13 @@ function getSelectedCandidateForTip(loopState, tipKey) {
617
733
  export function getSelectedCandidateForTipForTesting(loopState, tipKey) {
618
734
  return getSelectedCandidateForTip(loopState, tipKey);
619
735
  }
620
- function cacheSelectedCandidateForTip(loopState, tipKey, candidate) {
736
+ function cacheSelectedCandidateForTip(loopState, tipKey, candidate, liveState) {
621
737
  loopState.selectedCandidateTipKey = tipKey;
622
738
  loopState.selectedCandidate = candidate;
623
- setMiningUiCandidate(loopState, candidate);
739
+ setMiningUiCandidate(loopState, candidate, liveState);
624
740
  }
625
- export function cacheSelectedCandidateForTipForTesting(loopState, tipKey, candidate) {
626
- cacheSelectedCandidateForTip(loopState, tipKey, candidate);
741
+ export function cacheSelectedCandidateForTipForTesting(loopState, tipKey, candidate, liveState) {
742
+ cacheSelectedCandidateForTip(loopState, tipKey, candidate, liveState);
627
743
  }
628
744
  function clearSelectedCandidate(loopState) {
629
745
  loopState.selectedCandidateTipKey = null;
@@ -635,6 +751,7 @@ function clearMiningUiTransientCandidate(loopState) {
635
751
  domainName: null,
636
752
  sentence: null,
637
753
  };
754
+ loopState.ui.provisionalBroadcastTxid = null;
638
755
  loopState.ui.latestSentence = null;
639
756
  }
640
757
  function discardMiningLoopTransientWork(loopState, walletRootId) {
@@ -642,6 +759,7 @@ function discardMiningLoopTransientWork(loopState, walletRootId) {
642
759
  clearSelectedCandidate(loopState);
643
760
  clearMiningUiTransientCandidate(loopState);
644
761
  loopState.waitingNote = null;
762
+ clearMiningProviderWait(loopState);
645
763
  }
646
764
  function resolveMiningBitcoindRecoveryIdentity(value) {
647
765
  const raw = (value ?? {});
@@ -948,39 +1066,61 @@ function getAncestorTxids(context, txContexts) {
948
1066
  .filter((txid) => txid !== null && txContexts.has(txid));
949
1067
  }
950
1068
  function topologicallyOrderAncestorContexts(options) {
951
- const visited = new Set();
952
- const visiting = new Set();
1069
+ const visited = new Map();
953
1070
  const ordered = [];
954
- const visit = (txid) => {
955
- if (visited.has(txid)) {
956
- return true;
957
- }
958
- if (visiting.has(txid)) {
959
- return false;
960
- }
961
- const context = options.txContexts.get(txid);
962
- if (context === undefined) {
963
- return true;
964
- }
965
- visiting.add(txid);
966
- for (const parentTxid of getAncestorTxids(context, options.txContexts)) {
967
- if (!visit(parentTxid)) {
968
- return false;
969
- }
970
- }
971
- visiting.delete(txid);
972
- visited.add(txid);
973
- ordered.push(context);
974
- return true;
975
- };
976
1071
  const root = options.txContexts.get(options.txid);
977
1072
  if (root === undefined) {
978
1073
  return [];
979
1074
  }
980
- for (const parentTxid of getAncestorTxids(root, options.txContexts)) {
981
- if (!visit(parentTxid)) {
1075
+ const stack = getAncestorTxids(root, options.txContexts)
1076
+ .reverse()
1077
+ .map((txid) => ({
1078
+ txid,
1079
+ expanded: false,
1080
+ }));
1081
+ while (stack.length > 0) {
1082
+ const frame = stack.pop();
1083
+ const state = visited.get(frame.txid);
1084
+ if (frame.expanded) {
1085
+ if (state !== "visiting") {
1086
+ continue;
1087
+ }
1088
+ visited.set(frame.txid, "visited");
1089
+ const context = options.txContexts.get(frame.txid);
1090
+ if (context !== undefined) {
1091
+ ordered.push(context);
1092
+ }
1093
+ continue;
1094
+ }
1095
+ if (state === "visited") {
1096
+ continue;
1097
+ }
1098
+ if (state === "visiting") {
982
1099
  return null;
983
1100
  }
1101
+ const context = options.txContexts.get(frame.txid);
1102
+ if (context === undefined) {
1103
+ continue;
1104
+ }
1105
+ visited.set(frame.txid, "visiting");
1106
+ stack.push({
1107
+ txid: frame.txid,
1108
+ expanded: true,
1109
+ });
1110
+ const parents = getAncestorTxids(context, options.txContexts);
1111
+ for (let index = parents.length - 1; index >= 0; index -= 1) {
1112
+ const parentTxid = parents[index];
1113
+ const parentState = visited.get(parentTxid);
1114
+ if (parentState === "visiting") {
1115
+ return null;
1116
+ }
1117
+ if (parentState !== "visited") {
1118
+ stack.push({
1119
+ txid: parentTxid,
1120
+ expanded: false,
1121
+ });
1122
+ }
1123
+ }
984
1124
  }
985
1125
  return ordered;
986
1126
  }
@@ -1096,13 +1236,17 @@ async function resolveOverlayAuthorizedMiningDomain(options) {
1096
1236
  return authorized ? domain : null;
1097
1237
  }
1098
1238
  function buildStatusSnapshot(view, overrides = {}) {
1239
+ const resolvedCurrentPhase = resolveSnapshotOverride(overrides.currentPhase, view.runtime.currentPhase);
1240
+ const clearProviderWaitCarryover = overrides.currentPhase !== undefined
1241
+ && overrides.currentPhase !== "waiting-provider"
1242
+ && view.runtime.currentPhase === "waiting-provider";
1099
1243
  return {
1100
1244
  ...view.runtime,
1101
1245
  runMode: resolveSnapshotOverride(overrides.runMode, view.runtime.runMode),
1102
1246
  backgroundWorkerPid: resolveSnapshotOverride(overrides.backgroundWorkerPid, view.runtime.backgroundWorkerPid),
1103
1247
  backgroundWorkerRunId: resolveSnapshotOverride(overrides.backgroundWorkerRunId, view.runtime.backgroundWorkerRunId),
1104
1248
  backgroundWorkerHeartbeatAtUnixMs: resolveSnapshotOverride(overrides.backgroundWorkerHeartbeatAtUnixMs, view.runtime.backgroundWorkerHeartbeatAtUnixMs),
1105
- currentPhase: resolveSnapshotOverride(overrides.currentPhase, view.runtime.currentPhase),
1249
+ currentPhase: resolvedCurrentPhase,
1106
1250
  currentPublishState: resolveSnapshotOverride(overrides.currentPublishState, view.runtime.currentPublishState),
1107
1251
  targetBlockHeight: resolveSnapshotOverride(overrides.targetBlockHeight, view.runtime.targetBlockHeight),
1108
1252
  referencedBlockHashDisplay: resolveSnapshotOverride(overrides.referencedBlockHashDisplay, view.runtime.referencedBlockHashDisplay),
@@ -1118,7 +1262,9 @@ function buildStatusSnapshot(view, overrides = {}) {
1118
1262
  lastSuspendDetectedAtUnixMs: resolveSnapshotOverride(overrides.lastSuspendDetectedAtUnixMs, view.runtime.lastSuspendDetectedAtUnixMs),
1119
1263
  reconnectSettledUntilUnixMs: resolveSnapshotOverride(overrides.reconnectSettledUntilUnixMs, view.runtime.reconnectSettledUntilUnixMs),
1120
1264
  tipSettledUntilUnixMs: resolveSnapshotOverride(overrides.tipSettledUntilUnixMs, view.runtime.tipSettledUntilUnixMs),
1121
- providerState: resolveSnapshotOverride(overrides.providerState, view.runtime.providerState),
1265
+ providerState: resolveSnapshotOverride(overrides.providerState, clearProviderWaitCarryover
1266
+ ? (view.provider.status === "ready" ? "ready" : "unavailable")
1267
+ : view.runtime.providerState),
1122
1268
  corePublishState: resolveSnapshotOverride(overrides.corePublishState, view.runtime.corePublishState),
1123
1269
  currentPublishDecision: resolveSnapshotOverride(overrides.currentPublishDecision, view.runtime.currentPublishDecision),
1124
1270
  sameDomainCompetitorSuppressed: resolveSnapshotOverride(overrides.sameDomainCompetitorSuppressed, view.runtime.sameDomainCompetitorSuppressed),
@@ -1128,8 +1274,8 @@ function buildStatusSnapshot(view, overrides = {}) {
1128
1274
  mempoolSequenceCacheStatus: resolveSnapshotOverride(overrides.mempoolSequenceCacheStatus, view.runtime.mempoolSequenceCacheStatus),
1129
1275
  lastMempoolSequence: resolveSnapshotOverride(overrides.lastMempoolSequence, view.runtime.lastMempoolSequence),
1130
1276
  lastCompetitivenessGateAtUnixMs: resolveSnapshotOverride(overrides.lastCompetitivenessGateAtUnixMs, view.runtime.lastCompetitivenessGateAtUnixMs),
1131
- lastError: resolveSnapshotOverride(overrides.lastError, view.runtime.lastError),
1132
- note: resolveSnapshotOverride(overrides.note, view.runtime.note),
1277
+ lastError: resolveSnapshotOverride(overrides.lastError, clearProviderWaitCarryover ? null : view.runtime.lastError),
1278
+ note: resolveSnapshotOverride(overrides.note, clearProviderWaitCarryover ? null : view.runtime.note),
1133
1279
  livePublishInMempool: resolveSnapshotOverride(overrides.livePublishInMempool, view.runtime.livePublishInMempool),
1134
1280
  updatedAtUnixMs: Date.now(),
1135
1281
  };
@@ -1740,6 +1886,7 @@ async function runCompetitivenessGate(options) {
1740
1886
  candidateRank: overrides.candidateRank ?? null,
1741
1887
  });
1742
1888
  const walletRootId = options.readContext.localState.walletRootId ?? "uninitialized-wallet-root";
1889
+ const assaySentencesImpl = options.assaySentencesImpl ?? assaySentences;
1743
1890
  const indexerTruthKey = getIndexerTruthKey(options.readContext);
1744
1891
  const excludedTxids = [options.currentTxid].filter((value) => value !== null).sort();
1745
1892
  const localAssayTupleKey = [
@@ -1789,7 +1936,13 @@ async function runCompetitivenessGate(options) {
1789
1936
  txContexts.delete(txid);
1790
1937
  }
1791
1938
  }
1792
- for (const txid of visibleTxids) {
1939
+ for (let index = 0; index < visibleTxids.length; index += 1) {
1940
+ await maybeYieldDuringMempoolScan({
1941
+ iteration: index,
1942
+ cooperativeYield: options.cooperativeYield,
1943
+ cooperativeYieldEvery: options.cooperativeYieldEvery,
1944
+ });
1945
+ const txid = visibleTxids[index];
1793
1946
  if (txContexts.has(txid)) {
1794
1947
  continue;
1795
1948
  }
@@ -1815,7 +1968,13 @@ async function runCompetitivenessGate(options) {
1815
1968
  });
1816
1969
  }
1817
1970
  const entries = new Map();
1818
- for (const txid of visibleTxids) {
1971
+ for (let index = 0; index < visibleTxids.length; index += 1) {
1972
+ await maybeYieldDuringMempoolScan({
1973
+ iteration: index,
1974
+ cooperativeYield: options.cooperativeYield,
1975
+ cooperativeYieldEvery: options.cooperativeYieldEvery,
1976
+ });
1977
+ const txid = visibleTxids[index];
1819
1978
  const context = txContexts.get(txid);
1820
1979
  if (context === undefined || context.payload === null || context.senderScriptHex === null) {
1821
1980
  continue;
@@ -1854,7 +2013,7 @@ async function runCompetitivenessGate(options) {
1854
2013
  if (overlayDomain === null || overlayDomain.name === null || !rootDomain(overlayDomain.name)) {
1855
2014
  continue;
1856
2015
  }
1857
- const assayed = await assaySentences(decoded.domainId, options.candidate.referencedBlockHashInternal, [Buffer.from(decoded.sentenceBytes).toString("utf8")]).catch(() => []);
2016
+ const assayed = await assaySentencesImpl(decoded.domainId, options.candidate.referencedBlockHashInternal, [Buffer.from(decoded.sentenceBytes).toString("utf8")]).catch(() => []);
1858
2017
  const scored = assayed[0];
1859
2018
  if (scored === undefined || !scored.gatesPass || scored.encodedSentenceBytes === null || scored.canonicalBlend === null) {
1860
2019
  continue;
@@ -2475,6 +2634,8 @@ export async function ensureBuiltInMiningSetupIfNeeded(options) {
2475
2634
  }
2476
2635
  async function performMiningCycle(options) {
2477
2636
  const now = options.nowImpl ?? Date.now;
2637
+ const generateCandidatesForDomainsImpl = options.generateCandidatesForDomainsImpl ?? generateCandidatesForDomains;
2638
+ const runCompetitivenessGateImpl = options.runCompetitivenessGateImpl ?? runCompetitivenessGate;
2478
2639
  let readContext = await options.openReadContext({
2479
2640
  dataDir: options.dataDir,
2480
2641
  databasePath: options.databasePath,
@@ -2483,6 +2644,7 @@ async function performMiningCycle(options) {
2483
2644
  });
2484
2645
  let readContextClosed = false;
2485
2646
  try {
2647
+ throwIfMiningSuspendDetected(options.suspendDetector);
2486
2648
  let clearRecoveredBitcoindError = false;
2487
2649
  const saveCycleStatus = async (readContext, overrides, includeVisualizer = true) => {
2488
2650
  const statusNowUnixMs = now();
@@ -2504,7 +2666,6 @@ async function performMiningCycle(options) {
2504
2666
  visualizerState: includeVisualizer ? options.loopState.ui : undefined,
2505
2667
  });
2506
2668
  };
2507
- checkpointMiningSuspendDetector(options.suspendDetector);
2508
2669
  await saveCycleStatus(readContext, {
2509
2670
  runMode: options.runMode,
2510
2671
  backgroundWorkerPid: options.backgroundWorkerPid,
@@ -2512,9 +2673,11 @@ async function performMiningCycle(options) {
2512
2673
  backgroundWorkerHeartbeatAtUnixMs: options.runMode === "background" ? now() : null,
2513
2674
  }, false);
2514
2675
  if (readContext.localState.availability !== "ready" || readContext.localState.state === null) {
2676
+ clearMiningProviderWait(options.loopState);
2515
2677
  await saveCycleStatus(readContext, {
2516
2678
  runMode: options.runMode,
2517
2679
  currentPhase: "waiting",
2680
+ lastError: null,
2518
2681
  note: "Wallet state must be locally available for mining to continue.",
2519
2682
  });
2520
2683
  return;
@@ -2525,7 +2688,7 @@ async function performMiningCycle(options) {
2525
2688
  startHeight: 0,
2526
2689
  walletRootId: readContext.localState.state.walletRootId,
2527
2690
  });
2528
- checkpointMiningSuspendDetector(options.suspendDetector);
2691
+ throwIfMiningSuspendDetected(options.suspendDetector);
2529
2692
  const rpc = options.rpcFactory(service.rpc);
2530
2693
  const reconciliation = await reconcileLiveMiningState({
2531
2694
  state: readContext.localState.state,
@@ -2535,7 +2698,7 @@ async function performMiningCycle(options) {
2535
2698
  snapshotState: readContext.snapshot?.state ?? null,
2536
2699
  });
2537
2700
  const reconciledState = reconciliation.state;
2538
- checkpointMiningSuspendDetector(options.suspendDetector);
2701
+ throwIfMiningSuspendDetected(options.suspendDetector);
2539
2702
  let effectiveReadContext = readContext;
2540
2703
  if (JSON.stringify(reconciledState.miningState) !== JSON.stringify(readContext.localState.state.miningState)) {
2541
2704
  await saveWalletStatePreservingUnlock({
@@ -2576,18 +2739,24 @@ async function performMiningCycle(options) {
2576
2739
  });
2577
2740
  if (tipChanged) {
2578
2741
  setMiningTipSettleWindow(options.loopState, now());
2742
+ if (options.loopState.providerWaitNextRetryAtUnixMs === null) {
2743
+ clearMiningProviderWait(options.loopState);
2744
+ }
2579
2745
  }
2580
2746
  const displaySats = await resolveFundingDisplaySats(effectiveReadContext.localState.state, rpc).catch(() => null);
2581
2747
  syncMiningVisualizerBalances(options.loopState, effectiveReadContext, displaySats);
2582
2748
  if (effectiveReadContext.localState.state.miningState.state === "repair-required") {
2749
+ clearMiningProviderWait(options.loopState);
2583
2750
  await saveCycleStatus(effectiveReadContext, {
2584
2751
  runMode: options.runMode,
2585
2752
  currentPhase: "waiting",
2753
+ lastError: null,
2586
2754
  note: "Mining is blocked until the current mining publish is repaired or reconciled.",
2587
2755
  });
2588
2756
  return;
2589
2757
  }
2590
2758
  if (hasBlockingMutation(effectiveReadContext.localState.state)) {
2759
+ clearMiningProviderWait(options.loopState);
2591
2760
  const nextState = defaultMiningStatePatch(effectiveReadContext.localState.state, {
2592
2761
  state: "paused",
2593
2762
  pauseReason: "wallet-busy",
@@ -2608,12 +2777,14 @@ async function performMiningCycle(options) {
2608
2777
  await saveCycleStatus(effectiveReadContext, {
2609
2778
  runMode: options.runMode,
2610
2779
  currentPhase: "waiting",
2780
+ lastError: null,
2611
2781
  note: "Mining is paused while another wallet mutation is active.",
2612
2782
  });
2613
2783
  return;
2614
2784
  }
2615
2785
  const preemptionRequest = await readMiningPreemptionRequest(options.paths);
2616
2786
  if (preemptionRequest !== null) {
2787
+ clearMiningProviderWait(options.loopState);
2617
2788
  const nextState = defaultMiningStatePatch(effectiveReadContext.localState.state, {
2618
2789
  state: effectiveReadContext.localState.state.miningState.livePublishInMempool
2619
2790
  && effectiveReadContext.localState.state.miningState.state === "paused-stale"
@@ -2635,6 +2806,7 @@ async function performMiningCycle(options) {
2635
2806
  }, {
2636
2807
  runMode: options.runMode,
2637
2808
  currentPhase: "waiting",
2809
+ lastError: null,
2638
2810
  note: "Mining is paused while another wallet command is preempting sentence generation.",
2639
2811
  });
2640
2812
  return;
@@ -2644,7 +2816,7 @@ async function performMiningCycle(options) {
2644
2816
  rpc.getNetworkInfo(),
2645
2817
  rpc.getMempoolInfo(),
2646
2818
  ]);
2647
- checkpointMiningSuspendDetector(options.suspendDetector);
2819
+ throwIfMiningSuspendDetected(options.suspendDetector);
2648
2820
  const corePublishState = determineCorePublishState({
2649
2821
  blockchain: blockchainInfo,
2650
2822
  network: networkInfo,
@@ -2652,6 +2824,7 @@ async function performMiningCycle(options) {
2652
2824
  });
2653
2825
  clearRecoveredBitcoindError = resetMiningBitcoindRecoveryState(options.loopState, effectiveReadContext.nodeStatus?.serviceStatus ?? { pid: service.pid });
2654
2826
  if (corePublishState !== "healthy") {
2827
+ clearMiningProviderWait(options.loopState);
2655
2828
  await saveCycleStatus(effectiveReadContext, {
2656
2829
  runMode: options.runMode,
2657
2830
  currentPhase: "waiting-bitcoin-network",
@@ -2661,6 +2834,7 @@ async function performMiningCycle(options) {
2661
2834
  return;
2662
2835
  }
2663
2836
  if (effectiveReadContext.indexer.health !== "synced" || effectiveReadContext.nodeHealth !== "synced") {
2837
+ clearMiningProviderWait(options.loopState);
2664
2838
  await saveCycleStatus(effectiveReadContext, {
2665
2839
  runMode: options.runMode,
2666
2840
  currentPhase: effectiveReadContext.indexer.health !== "synced"
@@ -2673,6 +2847,7 @@ async function performMiningCycle(options) {
2673
2847
  return;
2674
2848
  }
2675
2849
  if (targetBlockHeight === null) {
2850
+ clearMiningProviderWait(options.loopState);
2676
2851
  await saveCycleStatus(effectiveReadContext, {
2677
2852
  runMode: options.runMode,
2678
2853
  currentPhase: "waiting-bitcoin-network",
@@ -2681,6 +2856,7 @@ async function performMiningCycle(options) {
2681
2856
  return;
2682
2857
  }
2683
2858
  if (getBlockRewardCogtoshi(targetBlockHeight) === 0n) {
2859
+ clearMiningProviderWait(options.loopState);
2684
2860
  const nextState = defaultMiningStatePatch(effectiveReadContext.localState.state, {
2685
2861
  state: "paused",
2686
2862
  pauseReason: "zero-reward",
@@ -2700,6 +2876,7 @@ async function performMiningCycle(options) {
2700
2876
  runMode: options.runMode,
2701
2877
  currentPhase: "idle",
2702
2878
  currentPublishDecision: "publish-skipped-zero-reward",
2879
+ lastError: null,
2703
2880
  note: "Mining is disabled because the target block reward is zero.",
2704
2881
  });
2705
2882
  await appendEvent(options.paths, createEvent("publish-skipped-zero-reward", "Skipped mining because the target block reward is zero.", {
@@ -2709,10 +2886,38 @@ async function performMiningCycle(options) {
2709
2886
  }));
2710
2887
  return;
2711
2888
  }
2889
+ if (options.loopState.providerWaitState !== null
2890
+ && options.loopState.providerWaitLastError !== null) {
2891
+ if (options.loopState.providerWaitNextRetryAtUnixMs !== null
2892
+ && now() < options.loopState.providerWaitNextRetryAtUnixMs) {
2893
+ await saveCycleStatus(effectiveReadContext, {
2894
+ runMode: options.runMode,
2895
+ currentPhase: "waiting-provider",
2896
+ providerState: options.loopState.providerWaitState,
2897
+ lastError: options.loopState.providerWaitLastError,
2898
+ note: "Mining is waiting for the sentence provider to recover.",
2899
+ });
2900
+ return;
2901
+ }
2902
+ if (options.loopState.providerWaitNextRetryAtUnixMs === null
2903
+ && tipKey !== null
2904
+ && options.loopState.attemptedTipKey === tipKey) {
2905
+ await saveCycleStatus(effectiveReadContext, {
2906
+ runMode: options.runMode,
2907
+ currentPhase: "waiting-provider",
2908
+ providerState: options.loopState.providerWaitState,
2909
+ lastError: options.loopState.providerWaitLastError,
2910
+ note: "Mining is waiting for the sentence provider to recover.",
2911
+ });
2912
+ return;
2913
+ }
2914
+ clearMiningProviderWait(options.loopState, options.loopState.providerWaitNextRetryAtUnixMs === null);
2915
+ }
2712
2916
  if (tipKey !== null && options.loopState.attemptedTipKey === tipKey) {
2713
2917
  await saveCycleStatus(effectiveReadContext, {
2714
2918
  runMode: options.runMode,
2715
2919
  currentPhase: "waiting",
2920
+ lastError: null,
2716
2921
  note: options.loopState.waitingNote ?? "Waiting for the next block after the last mining attempt on this tip.",
2717
2922
  });
2718
2923
  return;
@@ -2751,9 +2956,11 @@ async function performMiningCycle(options) {
2751
2956
  if (selectedCandidate === null) {
2752
2957
  const domains = resolveEligibleAnchoredRoots(effectiveReadContext);
2753
2958
  if (domains.length === 0) {
2959
+ clearMiningProviderWait(options.loopState);
2754
2960
  await saveCycleStatus(effectiveReadContext, {
2755
2961
  runMode: options.runMode,
2756
2962
  currentPhase: "idle",
2963
+ lastError: null,
2757
2964
  note: "No locally controlled anchored root domains are currently eligible to mine.",
2758
2965
  });
2759
2966
  return;
@@ -2761,6 +2968,7 @@ async function performMiningCycle(options) {
2761
2968
  await saveCycleStatus(effectiveReadContext, {
2762
2969
  runMode: options.runMode,
2763
2970
  currentPhase: "generating",
2971
+ lastError: null,
2764
2972
  note: "Generating mining sentences for eligible root domains.",
2765
2973
  });
2766
2974
  await appendEvent(options.paths, createEvent("sentence-generation-start", "Started mining sentence generation.", {
@@ -2770,7 +2978,7 @@ async function performMiningCycle(options) {
2770
2978
  }));
2771
2979
  let candidates;
2772
2980
  try {
2773
- candidates = await generateCandidatesForDomains({
2981
+ candidates = await generateCandidatesForDomainsImpl({
2774
2982
  rpc,
2775
2983
  readContext: effectiveReadContext,
2776
2984
  domains,
@@ -2780,18 +2988,30 @@ async function performMiningCycle(options) {
2780
2988
  runId: options.backgroundWorkerRunId,
2781
2989
  fetchImpl: options.fetchImpl,
2782
2990
  });
2783
- checkpointMiningSuspendDetector(options.suspendDetector);
2991
+ throwIfMiningSuspendDetected(options.suspendDetector);
2784
2992
  }
2785
2993
  catch (error) {
2786
2994
  if (error instanceof MiningProviderRequestError) {
2787
- if (tipKey !== null) {
2995
+ if (isTransientMiningProviderError(error)) {
2996
+ recordTransientMiningProviderWait({
2997
+ loopState: options.loopState,
2998
+ error,
2999
+ nowUnixMs: now(),
3000
+ });
3001
+ }
3002
+ else {
3003
+ recordTerminalMiningProviderWait({
3004
+ loopState: options.loopState,
3005
+ error,
3006
+ });
3007
+ }
3008
+ if (!isTransientMiningProviderError(error) && tipKey !== null) {
2788
3009
  options.loopState.attemptedTipKey = tipKey;
2789
- options.loopState.waitingNote = "Mining is waiting for the sentence provider to recover.";
2790
3010
  }
2791
3011
  await saveCycleStatus(effectiveReadContext, {
2792
3012
  runMode: options.runMode,
2793
3013
  currentPhase: "waiting-provider",
2794
- providerState: error.providerState,
3014
+ providerState: options.loopState.providerWaitState ?? error.providerState,
2795
3015
  lastError: error.message,
2796
3016
  note: "Mining is waiting for the sentence provider to recover.",
2797
3017
  });
@@ -2813,6 +3033,7 @@ async function performMiningCycle(options) {
2813
3033
  return;
2814
3034
  }
2815
3035
  if (error instanceof Error && error.message === "mining_generation_stale_indexer_truth") {
3036
+ clearMiningProviderWait(options.loopState);
2816
3037
  clearMiningGateCache(walletRootId);
2817
3038
  await appendEvent(options.paths, createEvent("generation-restarted-indexer-truth", "Detected updated coherent indexer truth during mining; restarting on the next tick.", {
2818
3039
  level: "warn",
@@ -2823,6 +3044,7 @@ async function performMiningCycle(options) {
2823
3044
  return;
2824
3045
  }
2825
3046
  if (error instanceof Error && error.message === "mining_generation_preempted") {
3047
+ clearMiningProviderWait(options.loopState);
2826
3048
  await appendEvent(options.paths, createEvent("generation-paused-preempted", "Stopped sentence generation because another wallet command requested mining preemption.", {
2827
3049
  level: "warn",
2828
3050
  targetBlockHeight,
@@ -2831,6 +3053,7 @@ async function performMiningCycle(options) {
2831
3053
  }));
2832
3054
  return;
2833
3055
  }
3056
+ clearMiningProviderWait(options.loopState);
2834
3057
  const failureMessage = error instanceof Error ? error.message : String(error);
2835
3058
  if (tipKey !== null) {
2836
3059
  options.loopState.attemptedTipKey = tipKey;
@@ -2851,9 +3074,11 @@ async function performMiningCycle(options) {
2851
3074
  }));
2852
3075
  return;
2853
3076
  }
3077
+ clearMiningProviderWait(options.loopState);
2854
3078
  await saveCycleStatus(effectiveReadContext, {
2855
3079
  runMode: options.runMode,
2856
3080
  currentPhase: "scoring",
3081
+ lastError: null,
2857
3082
  note: "Scoring mining candidates for the current tip.",
2858
3083
  });
2859
3084
  const best = await chooseBestLocalCandidate(candidates);
@@ -2880,7 +3105,7 @@ async function performMiningCycle(options) {
2880
3105
  return;
2881
3106
  }
2882
3107
  options.loopState.ui.recentWin = null;
2883
- cacheSelectedCandidateForTip(options.loopState, tipKey, best);
3108
+ cacheSelectedCandidateForTip(options.loopState, tipKey, best, effectiveReadContext.localState.state.miningState);
2884
3109
  selectedCandidate = best;
2885
3110
  await appendEvent(options.paths, createEvent("candidate-selected", `Selected ${best.domainName} with score ${best.canonicalBlend.toString()}.`, {
2886
3111
  targetBlockHeight: best.targetBlockHeight,
@@ -2890,13 +3115,16 @@ async function performMiningCycle(options) {
2890
3115
  score: best.canonicalBlend.toString(),
2891
3116
  runId: options.backgroundWorkerRunId,
2892
3117
  }));
2893
- const gate = await runCompetitivenessGate({
3118
+ const gate = await runCompetitivenessGateImpl({
2894
3119
  rpc,
2895
3120
  readContext: effectiveReadContext,
2896
3121
  candidate: best,
2897
3122
  currentTxid: effectiveReadContext.localState.state.miningState.currentTxid,
3123
+ assaySentencesImpl: options.assaySentencesImpl,
3124
+ cooperativeYield: options.cooperativeYieldImpl,
3125
+ cooperativeYieldEvery: options.cooperativeYieldEvery,
2898
3126
  });
2899
- checkpointMiningSuspendDetector(options.suspendDetector);
3127
+ throwIfMiningSuspendDetected(options.suspendDetector);
2900
3128
  gateSnapshot = {
2901
3129
  higherRankedCompetitorDomainCount: gate.higherRankedCompetitorDomainCount,
2902
3130
  dedupedCompetitorDomainCount: gate.dedupedCompetitorDomainCount,
@@ -2908,7 +3136,7 @@ async function performMiningCycle(options) {
2908
3136
  options.loopState.attemptedTipKey = tipKey;
2909
3137
  }
2910
3138
  clearSelectedCandidate(options.loopState);
2911
- setMiningUiCandidate(options.loopState, best);
3139
+ setMiningUiCandidate(options.loopState, best, effectiveReadContext.localState.state.miningState);
2912
3140
  options.loopState.waitingNote = gate.decision === "suppressed-same-domain-mempool"
2913
3141
  ? "Best local sentence found, but a same-domain mempool competitor already matches or beats it."
2914
3142
  : gate.decision === "suppressed-top5-mempool"
@@ -2949,7 +3177,7 @@ async function performMiningCycle(options) {
2949
3177
  }
2950
3178
  else {
2951
3179
  options.loopState.ui.recentWin = null;
2952
- setMiningUiCandidate(options.loopState, selectedCandidate);
3180
+ setMiningUiCandidate(options.loopState, selectedCandidate, effectiveReadContext.localState.state.miningState);
2953
3181
  }
2954
3182
  if (!await ensureCurrentIndexerTruthOrRestart()) {
2955
3183
  return;
@@ -2965,12 +3193,11 @@ async function performMiningCycle(options) {
2965
3193
  purpose: "wallet-mine",
2966
3194
  walletRootId: effectiveReadContext.localState.state.walletRootId,
2967
3195
  });
2968
- checkpointMiningSuspendDetector(options.suspendDetector);
2969
3196
  try {
2970
3197
  if (!await ensureCurrentIndexerTruthOrRestart()) {
2971
3198
  return;
2972
3199
  }
2973
- checkpointMiningSuspendDetector(options.suspendDetector);
3200
+ throwIfMiningSuspendDetected(options.suspendDetector);
2974
3201
  const published = await publishCandidate({
2975
3202
  dataDir: options.dataDir,
2976
3203
  databasePath: options.databasePath,
@@ -2983,12 +3210,11 @@ async function performMiningCycle(options) {
2983
3210
  candidate: selectedCandidate,
2984
3211
  runId: options.backgroundWorkerRunId,
2985
3212
  });
2986
- checkpointMiningSuspendDetector(options.suspendDetector);
2987
3213
  if (tipKey !== null && published.retryable !== true) {
2988
3214
  options.loopState.attemptedTipKey = tipKey;
2989
3215
  }
2990
3216
  if (published.retryable === true) {
2991
- cacheSelectedCandidateForTip(options.loopState, tipKey, published.candidate);
3217
+ cacheSelectedCandidateForTip(options.loopState, tipKey, published.candidate, published.state.miningState);
2992
3218
  options.loopState.waitingNote = published.note;
2993
3219
  await saveCycleStatus({
2994
3220
  ...effectiveReadContext,
@@ -3014,7 +3240,7 @@ async function performMiningCycle(options) {
3014
3240
  }
3015
3241
  if (published.skipped === true) {
3016
3242
  clearSelectedCandidate(options.loopState);
3017
- setMiningUiCandidate(options.loopState, selectedCandidate);
3243
+ setMiningUiCandidate(options.loopState, selectedCandidate, published.state.miningState);
3018
3244
  options.loopState.waitingNote = published.note;
3019
3245
  const lastError = published.decision === "publish-paused-insufficient-funds"
3020
3246
  ? published.lastError ?? createInsufficientFundsMiningPublishErrorMessage()
@@ -3046,7 +3272,7 @@ async function performMiningCycle(options) {
3046
3272
  if (published.txid !== null) {
3047
3273
  options.loopState.ui.latestTxid = published.txid;
3048
3274
  }
3049
- setMiningUiCandidate(options.loopState, published.candidate);
3275
+ setMiningUiCandidate(options.loopState, published.candidate, published.state.miningState);
3050
3276
  options.loopState.waitingNote = published.decision === "kept-live-publish"
3051
3277
  ? "Existing live mining publish already covers this block attempt. Waiting for the next block."
3052
3278
  : published.txid === null
@@ -3214,59 +3440,71 @@ async function attemptSaveMempool(rpc, paths, runId) {
3214
3440
  }
3215
3441
  }
3216
3442
  async function runMiningLoop(options) {
3217
- const suspendDetector = createMiningSuspendDetector();
3218
- const loopState = createMiningLoopState();
3443
+ const suspendDetector = createMiningSuspendDetector({
3444
+ monotonicNow: options.suspendMonotonicNowImpl,
3445
+ nowUnixMs: options.nowImpl ?? Date.now,
3446
+ scheduler: options.suspendScheduler,
3447
+ });
3448
+ const loopState = options.loopState ?? createMiningLoopState();
3219
3449
  const probeService = options.probeService ?? probeManagedBitcoindService;
3220
3450
  const stopService = options.stopService ?? stopManagedBitcoindService;
3221
3451
  const sleepImpl = options.sleepImpl ?? sleep;
3222
- await appendEvent(options.paths, createEvent("runtime-start", `Started ${options.runMode} mining runtime.`, {
3223
- runId: options.backgroundWorkerRunId,
3224
- }));
3225
- while (!options.signal?.aborted) {
3226
- try {
3227
- checkpointMiningSuspendDetector(suspendDetector);
3228
- }
3229
- catch (error) {
3230
- if (!(error instanceof MiningSuspendDetectedError)) {
3231
- throw error;
3452
+ try {
3453
+ await appendEvent(options.paths, createEvent("runtime-start", `Started ${options.runMode} mining runtime.`, {
3454
+ runId: options.backgroundWorkerRunId,
3455
+ }));
3456
+ while (!options.signal?.aborted) {
3457
+ try {
3458
+ throwIfMiningSuspendDetected(suspendDetector);
3232
3459
  }
3233
- discardMiningLoopTransientWork(loopState, null);
3234
- await handleDetectedMiningRuntimeResume({
3235
- dataDir: options.dataDir,
3236
- databasePath: options.databasePath,
3237
- provider: options.provider,
3238
- paths: options.paths,
3239
- runMode: options.runMode,
3240
- backgroundWorkerPid: options.backgroundWorkerPid,
3241
- backgroundWorkerRunId: options.backgroundWorkerRunId,
3242
- detectedAtUnixMs: error.detectedAtUnixMs,
3243
- openReadContext: options.openReadContext,
3244
- visualizer: options.visualizer,
3460
+ catch (error) {
3461
+ if (!(error instanceof MiningSuspendDetectedError)) {
3462
+ throw error;
3463
+ }
3464
+ discardMiningLoopTransientWork(loopState, null);
3465
+ await handleDetectedMiningRuntimeResume({
3466
+ dataDir: options.dataDir,
3467
+ databasePath: options.databasePath,
3468
+ provider: options.provider,
3469
+ paths: options.paths,
3470
+ runMode: options.runMode,
3471
+ backgroundWorkerPid: options.backgroundWorkerPid,
3472
+ backgroundWorkerRunId: options.backgroundWorkerRunId,
3473
+ detectedAtUnixMs: error.detectedAtUnixMs,
3474
+ openReadContext: options.openReadContext,
3475
+ visualizer: options.visualizer,
3476
+ loopState,
3477
+ });
3478
+ continue;
3479
+ }
3480
+ await performMiningCycle({
3481
+ ...options,
3482
+ suspendDetector,
3483
+ assaySentencesImpl: options.assaySentencesImpl,
3484
+ cooperativeYieldImpl: options.cooperativeYieldImpl,
3485
+ cooperativeYieldEvery: options.cooperativeYieldEvery,
3245
3486
  loopState,
3487
+ probeService,
3488
+ stopService,
3246
3489
  });
3247
- continue;
3490
+ await sleepImpl(Math.min(MINING_LOOP_INTERVAL_MS, MINING_STATUS_HEARTBEAT_INTERVAL_MS), options.signal);
3248
3491
  }
3249
- await performMiningCycle({
3250
- ...options,
3251
- suspendDetector,
3252
- loopState,
3253
- probeService,
3254
- stopService,
3255
- });
3256
- await sleepImpl(Math.min(MINING_LOOP_INTERVAL_MS, MINING_STATUS_HEARTBEAT_INTERVAL_MS), options.signal);
3492
+ const service = await options.attachService({
3493
+ dataDir: options.dataDir,
3494
+ chain: "main",
3495
+ startHeight: 0,
3496
+ walletRootId: undefined,
3497
+ }).catch(() => null);
3498
+ if (service !== null) {
3499
+ await attemptSaveMempool(options.rpcFactory(service.rpc), options.paths, options.backgroundWorkerRunId);
3500
+ }
3501
+ await appendEvent(options.paths, createEvent("runtime-stop", `Stopped ${options.runMode} mining runtime.`, {
3502
+ runId: options.backgroundWorkerRunId,
3503
+ }));
3257
3504
  }
3258
- const service = await options.attachService({
3259
- dataDir: options.dataDir,
3260
- chain: "main",
3261
- startHeight: 0,
3262
- walletRootId: undefined,
3263
- }).catch(() => null);
3264
- if (service !== null) {
3265
- await attemptSaveMempool(options.rpcFactory(service.rpc), options.paths, options.backgroundWorkerRunId);
3505
+ finally {
3506
+ stopMiningSuspendDetector(suspendDetector);
3266
3507
  }
3267
- await appendEvent(options.paths, createEvent("runtime-stop", `Stopped ${options.runMode} mining runtime.`, {
3268
- runId: options.backgroundWorkerRunId,
3269
- }));
3270
3508
  }
3271
3509
  async function waitForBackgroundHealthy(paths) {
3272
3510
  const deadline = Date.now() + BACKGROUND_START_TIMEOUT_MS;
@@ -3579,6 +3817,30 @@ export async function runMiningLoopForTesting(options) {
3579
3817
  ...options,
3580
3818
  });
3581
3819
  }
3820
+ export async function runCompetitivenessGateForTesting(options) {
3821
+ return await runCompetitivenessGate({
3822
+ rpc: options.rpc,
3823
+ readContext: options.readContext,
3824
+ candidate: options.candidate,
3825
+ currentTxid: options.currentTxid,
3826
+ assaySentencesImpl: options.assaySentencesImpl,
3827
+ cooperativeYield: options.cooperativeYieldImpl,
3828
+ cooperativeYieldEvery: options.cooperativeYieldEvery,
3829
+ });
3830
+ }
3831
+ export function createMiningSuspendDetectorForTesting(options = {}) {
3832
+ return createMiningSuspendDetector(options);
3833
+ }
3834
+ export function throwIfMiningSuspendDetectedForTesting(detector) {
3835
+ throwIfMiningSuspendDetected(detector);
3836
+ }
3837
+ export function topologicallyOrderAncestorTxidsForTesting(options) {
3838
+ const ordered = topologicallyOrderAncestorContexts({
3839
+ txid: options.txid,
3840
+ txContexts: options.txContexts,
3841
+ });
3842
+ return ordered?.map((context) => context.txid) ?? null;
3843
+ }
3582
3844
  export function buildPrePublishStatusOverridesForTesting(options) {
3583
3845
  return buildPrePublishStatusOverrides(options);
3584
3846
  }
@@ -18,6 +18,14 @@ function createBuiltInProviderNotFoundError(options) {
18
18
  : `${providerLabel} returned HTTP 404 for model "${options.model}". The configured model override may be invalid. Rerun \`cogcoin mine setup\` to clear or correct it.`;
19
19
  return new MiningProviderRequestError("not-found", message);
20
20
  }
21
+ function createBuiltInProviderTimeoutError(options) {
22
+ const providerName = options.provider === "anthropic" ? "Anthropic" : "OpenAI";
23
+ const seconds = Number.isInteger(options.timeoutMs / 1_000)
24
+ ? String(options.timeoutMs / 1_000)
25
+ : (options.timeoutMs / 1_000).toFixed(1);
26
+ const unit = seconds === "1" ? "second" : "seconds";
27
+ return new MiningProviderRequestError("unavailable", `The built-in ${providerName} mining provider timed out after ${seconds} ${unit}.`);
28
+ }
21
29
  function buildSystemPrompt(extraPrompt) {
22
30
  const lines = [
23
31
  "You are helping generate candidate Cogcoin mining sentences.",
@@ -70,12 +78,39 @@ function parseProviderJsonResponse(options) {
70
78
  }
71
79
  }
72
80
  function createProviderSignal(signal, timeoutMs) {
73
- const timeoutSignal = AbortSignal.timeout(timeoutMs);
74
- return signal === undefined ? timeoutSignal : AbortSignal.any([signal, timeoutSignal]);
81
+ const controller = new AbortController();
82
+ let didTimeout = false;
83
+ const timer = setTimeout(() => {
84
+ didTimeout = true;
85
+ controller.abort(new DOMException("The operation was aborted due to timeout", "TimeoutError"));
86
+ }, timeoutMs);
87
+ timer.unref?.();
88
+ const handleAbort = () => {
89
+ controller.abort(signal?.reason);
90
+ };
91
+ if (signal !== undefined) {
92
+ if (signal.aborted) {
93
+ handleAbort();
94
+ }
95
+ else {
96
+ signal.addEventListener("abort", handleAbort, { once: true });
97
+ }
98
+ }
99
+ return {
100
+ signal: controller.signal,
101
+ didTimeout() {
102
+ return didTimeout;
103
+ },
104
+ dispose() {
105
+ clearTimeout(timer);
106
+ signal?.removeEventListener("abort", handleAbort);
107
+ },
108
+ };
75
109
  }
76
110
  async function requestBuiltInSentences(options) {
77
111
  const fetchImpl = options.fetchImpl ?? fetch;
78
- const providerSignal = createProviderSignal(options.signal, Math.min(MINING_BUILTIN_TIMEOUT_MS, options.request.limits.timeoutMs));
112
+ const timeoutMs = Math.min(MINING_BUILTIN_TIMEOUT_MS, options.request.limits.timeoutMs);
113
+ const providerSignal = createProviderSignal(options.signal, timeoutMs);
79
114
  try {
80
115
  if (options.provider === "openai") {
81
116
  const { effectiveModel: model, usingDefaultModel } = resolveBuiltInProviderModel(options.provider, options.modelOverride);
@@ -98,7 +133,7 @@ async function requestBuiltInSentences(options) {
98
133
  },
99
134
  ],
100
135
  }),
101
- signal: providerSignal,
136
+ signal: providerSignal.signal,
102
137
  });
103
138
  if (response.status === 401 || response.status === 403) {
104
139
  throw new MiningProviderRequestError("auth-error", "The built-in OpenAI mining provider rejected the configured API key.");
@@ -145,7 +180,7 @@ async function requestBuiltInSentences(options) {
145
180
  },
146
181
  ],
147
182
  }),
148
- signal: providerSignal,
183
+ signal: providerSignal.signal,
149
184
  });
150
185
  if (response.status === 401 || response.status === 403) {
151
186
  throw new MiningProviderRequestError("auth-error", "The built-in Anthropic mining provider rejected the configured API key.");
@@ -177,11 +212,21 @@ async function requestBuiltInSentences(options) {
177
212
  if (error instanceof MiningProviderRequestError) {
178
213
  throw error;
179
214
  }
180
- if (error instanceof Error && error.name === "AbortError") {
181
- throw new MiningProviderRequestError("unavailable", "Mining sentence generation was aborted.");
215
+ if (providerSignal.didTimeout()) {
216
+ throw createBuiltInProviderTimeoutError({
217
+ provider: options.provider,
218
+ timeoutMs,
219
+ });
220
+ }
221
+ if (error instanceof Error
222
+ && (error.name === "AbortError" || error.name === "TimeoutError")) {
223
+ throw error;
182
224
  }
183
225
  throw new MiningProviderRequestError("unavailable", error instanceof Error ? error.message : String(error));
184
226
  }
227
+ finally {
228
+ providerSignal.dispose();
229
+ }
185
230
  }
186
231
  function extractOpenAiText(payload) {
187
232
  if (payload !== null && typeof payload === "object") {
@@ -30,6 +30,7 @@ export interface MiningFollowVisualizerState {
30
30
  settledBoardEntries: MiningSentenceBoardEntry[];
31
31
  provisionalRequiredWords: readonly string[];
32
32
  provisionalEntry: MiningProvisionalSentenceEntry;
33
+ provisionalBroadcastTxid: string | null;
33
34
  latestSentence: string | null;
34
35
  latestTxid: string | null;
35
36
  recentWin: MiningRecentWinSummary | null;
@@ -133,6 +133,16 @@ function formatRequiredWordsLine(words) {
133
133
  }
134
134
  return `Required words: ${words.map((word) => word.toUpperCase()).join(", ")}`;
135
135
  }
136
+ function formatProvisionalTxLinkLine(entry, txid) {
137
+ if (entry.domainName === null || entry.sentence === null || txid === null) {
138
+ return "";
139
+ }
140
+ const normalizedTxid = normalizeInlineText(txid);
141
+ if (normalizedTxid.length === 0) {
142
+ return "";
143
+ }
144
+ return `View at: https://mempool.space/tx/${normalizedTxid}`;
145
+ }
136
146
  function formatProvisionalSentenceRow(entry, requiredWords) {
137
147
  if (entry.domainName === null || entry.sentence === null) {
138
148
  return ["", "", ""];
@@ -151,6 +161,7 @@ export function createEmptyMiningFollowVisualizerState() {
151
161
  domainName: null,
152
162
  sentence: null,
153
163
  },
164
+ provisionalBroadcastTxid: null,
154
165
  latestSentence: null,
155
166
  latestTxid: null,
156
167
  recentWin: null,
@@ -173,6 +184,7 @@ function cloneMiningFollowVisualizerState(state) {
173
184
  provisionalEntry: {
174
185
  ...state.provisionalEntry,
175
186
  },
187
+ provisionalBroadcastTxid: state.provisionalBroadcastTxid,
176
188
  recentWin: state.recentWin === null
177
189
  ? null
178
190
  : {
@@ -375,6 +387,7 @@ export class MiningFollowVisualizer {
375
387
  : formatSentenceRow(entry);
376
388
  }).flat(),
377
389
  "----------",
390
+ formatProvisionalTxLinkLine(uiState.provisionalEntry, uiState.provisionalBroadcastTxid),
378
391
  formatRequiredWordsLine(uiState.provisionalRequiredWords),
379
392
  ...formatProvisionalSentenceRow(uiState.provisionalEntry, uiState.provisionalRequiredWords),
380
393
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cogcoin/client",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
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",