Skip to content

Commit 3635fd3

Browse files
committed
mining: fast publish revalidation
1 parent 1f428b0 commit 3635fd3

3 files changed

Lines changed: 761 additions & 60 deletions

File tree

src/wallet/mining/cycle.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,6 +1005,7 @@ export async function runMiningPhaseMachine(options: {
10051005
provider: options.provider,
10061006
paths: options.paths,
10071007
fallbackState: options.readContext.localState.state,
1008+
currentReadContext: options.readContext,
10081009
openReadContext: options.openReadContext,
10091010
attachService: options.attachService,
10101011
rpcFactory: options.rpcFactory,

src/wallet/mining/publish.ts

Lines changed: 239 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { serializeMine } from "../cogop/index.js";
88
import { openWalletReadContext, type WalletReadContext } from "../read/index.js";
99
import type { WalletRuntimePaths } from "../runtime.js";
1010
import type { WalletSecretProvider } from "../state/provider.js";
11+
import { loadWalletState } from "../state/storage.js";
1112
import {
1213
assertFixedInputPrefixMatches,
1314
buildWalletMutationTransaction,
@@ -53,7 +54,9 @@ import {
5354
miningPublishMayStillExist,
5455
} from "./state.js";
5556
import {
57+
ensureIndexerTruthIsCurrent,
5658
refreshMiningCandidateFromCurrentStateDetailed,
59+
getIndexerTruthKey,
5760
type MiningCandidateRefreshFailureReason,
5861
type MiningEligibleAnchoredRoot,
5962
} from "./candidate.js";
@@ -469,6 +472,150 @@ function candidateTargetsCurrentNodeTip(
469472
&& candidate.referencedBlockHashDisplay === nodeBestHash;
470473
}
471474

475+
type PublishFastRevalidationResult =
476+
| { ok: true }
477+
| {
478+
ok: false;
479+
reason: string;
480+
errorName?: string | null;
481+
};
482+
483+
function managedCoreWalletIdentityMatches(
484+
current: WalletStateV1["managedCoreWallet"],
485+
loaded: WalletStateV1["managedCoreWallet"],
486+
): boolean {
487+
return loaded.walletName === current.walletName
488+
&& loaded.internalPassphrase === current.internalPassphrase
489+
&& loaded.descriptorChecksum === current.descriptorChecksum
490+
&& (loaded.walletAddress ?? null) === (current.walletAddress ?? null)
491+
&& (loaded.walletScriptPubKeyHex ?? null) === (current.walletScriptPubKeyHex ?? null)
492+
&& loaded.proofStatus === current.proofStatus;
493+
}
494+
495+
function walletPublishIdentityMatches(
496+
context: ReadyMiningReadContext,
497+
loaded: WalletStateV1,
498+
): boolean {
499+
const current = context.localState.state;
500+
return loaded.stateRevision === current.stateRevision
501+
&& loaded.walletRootId === current.walletRootId
502+
&& loaded.walletRootId === context.localState.walletRootId
503+
&& loaded.network === current.network
504+
&& loaded.descriptor.publicExternal === current.descriptor.publicExternal
505+
&& loaded.descriptor.checksum === current.descriptor.checksum
506+
&& loaded.funding.address === current.funding.address
507+
&& loaded.funding.scriptPubKeyHex === current.funding.scriptPubKeyHex
508+
&& managedCoreWalletIdentityMatches(current.managedCoreWallet, loaded.managedCoreWallet);
509+
}
510+
511+
async function validateFastPublishReadContext(options: {
512+
readContext: ReadyMiningReadContext;
513+
candidate: MiningCandidate;
514+
dataDir: string;
515+
provider: WalletSecretProvider;
516+
paths: WalletRuntimePaths;
517+
throwIfStopping?: () => void;
518+
}): Promise<PublishFastRevalidationResult> {
519+
options.throwIfStopping?.();
520+
521+
if (options.candidate.provenance === undefined) {
522+
return {
523+
ok: false,
524+
reason: "missing-candidate-provenance",
525+
};
526+
}
527+
528+
if (candidateProvenanceSnapshotChanged(options.readContext, options.candidate)) {
529+
return {
530+
ok: false,
531+
reason: "candidate-provenance-changed",
532+
};
533+
}
534+
535+
const truthKey = getIndexerTruthKey(options.readContext);
536+
if (truthKey === null) {
537+
return {
538+
ok: false,
539+
reason: "missing-snapshot-lease",
540+
};
541+
}
542+
543+
try {
544+
await ensureIndexerTruthIsCurrent({
545+
dataDir: options.dataDir,
546+
truthKey,
547+
});
548+
} catch (error) {
549+
return {
550+
ok: false,
551+
reason: "indexer-truth-changed",
552+
errorName: error instanceof Error ? error.name : "unknown",
553+
};
554+
}
555+
556+
let loadedState: WalletStateV1;
557+
try {
558+
loadedState = (await loadWalletState({
559+
primaryPath: options.paths.walletStatePath,
560+
backupPath: options.paths.walletStateBackupPath,
561+
}, {
562+
provider: options.provider,
563+
})).state;
564+
} catch (error) {
565+
return {
566+
ok: false,
567+
reason: "wallet-state-unavailable",
568+
errorName: error instanceof Error ? error.name : "unknown",
569+
};
570+
}
571+
572+
if (!walletPublishIdentityMatches(options.readContext, loadedState)) {
573+
return {
574+
ok: false,
575+
reason: "wallet-state-changed",
576+
};
577+
}
578+
579+
return { ok: true };
580+
}
581+
582+
async function appendFastPublishRevalidationEvent(options: {
583+
appendEventFn: AppendMiningEventFn;
584+
paths: WalletRuntimePaths;
585+
candidate: MiningCandidate;
586+
runId: string | null;
587+
readContext: ReadyMiningReadContext;
588+
durationMs: number;
589+
result: PublishFastRevalidationResult;
590+
}): Promise<void> {
591+
await appendPublishTimingEvent(
592+
options.appendEventFn,
593+
options.paths,
594+
"timing-publish-fast-revalidation",
595+
options.result.ok
596+
? "Validated current mining read context before publish."
597+
: "Current mining read context needs a publish-time refresh.",
598+
createPublishTimingContext({
599+
candidate: options.candidate,
600+
runId: options.runId,
601+
level: options.result.ok ? "info" : "warn",
602+
durationMs: options.durationMs,
603+
metrics: {
604+
outcome: options.result.ok ? "success" : "fallback",
605+
reason: options.result.ok ? null : options.result.reason,
606+
errorName: options.result.ok ? null : options.result.errorName ?? null,
607+
snapshotSeq: options.readContext.snapshot.snapshotSeq ?? options.readContext.indexer.snapshotSeq ?? null,
608+
daemonInstanceId: options.readContext.snapshot.daemonInstanceId ?? options.readContext.indexer.daemonInstanceId ?? null,
609+
stateRevision: options.readContext.localState.state.stateRevision,
610+
coreBestHeight: options.readContext.nodeStatus?.nodeBestHeight ?? null,
611+
coreBestHash: options.readContext.nodeStatus?.nodeBestHashHex ?? null,
612+
indexerTipHeight: options.readContext.snapshot.tip?.height ?? options.readContext.indexer.snapshotTip?.height ?? null,
613+
indexerTipHash: options.readContext.snapshot.tip?.blockHashHex ?? options.readContext.indexer.snapshotTip?.blockHashHex ?? null,
614+
},
615+
}),
616+
);
617+
}
618+
472619
interface MiningCoreTip {
473620
height: number | null;
474621
hash: string | null;
@@ -1178,6 +1325,7 @@ export async function publishCandidate(options: {
11781325
provider: WalletSecretProvider;
11791326
paths: WalletRuntimePaths;
11801327
fallbackState: WalletStateV1;
1328+
currentReadContext?: ReadyMiningReadContext;
11811329
openReadContext: typeof openWalletReadContext;
11821330
attachService: typeof attachOrStartManagedBitcoindService;
11831331
rpcFactory: (config: Parameters<typeof createRpcClient>[0]) => MiningRpcClient;
@@ -1290,66 +1438,9 @@ export async function publishCandidate(options: {
12901438
};
12911439
};
12921440

1293-
options.throwIfStopping?.();
1294-
const readContextRefreshStartedAt = performance.now();
1295-
let lockedReadContext: WalletReadContext;
1296-
try {
1297-
lockedReadContext = await options.openReadContext({
1298-
dataDir: options.dataDir,
1299-
databasePath: options.databasePath,
1300-
secretProvider: options.provider,
1301-
walletControlLockHeld: true,
1302-
paths: options.paths,
1303-
});
1304-
} catch (error) {
1305-
await appendPublishTimingEvent(
1306-
options.appendEventFn,
1307-
options.paths,
1308-
"timing-read-context-refresh",
1309-
"Refreshed mining read context before publish.",
1310-
createPublishTimingContext({
1311-
candidate: options.candidate,
1312-
runId: options.runId,
1313-
level: "warn",
1314-
durationMs: performance.now() - readContextRefreshStartedAt,
1315-
metrics: {
1316-
outcome: "error",
1317-
errorName: error instanceof Error ? error.name : "unknown",
1318-
},
1319-
}),
1320-
);
1321-
throw error;
1322-
}
1323-
1324-
try {
1325-
options.throwIfStopping?.();
1326-
const readyReadContext = resolveReadyMiningReadContext(lockedReadContext);
1327-
await appendPublishTimingEvent(
1328-
options.appendEventFn,
1329-
options.paths,
1330-
"timing-read-context-refresh",
1331-
"Refreshed mining read context before publish.",
1332-
createPublishTimingContext({
1333-
candidate: options.candidate,
1334-
runId: options.runId,
1335-
durationMs: performance.now() - readContextRefreshStartedAt,
1336-
metrics: {
1337-
outcome: readyReadContext === null ? "snapshot-unavailable" : "success",
1338-
indexerTruthSource: lockedReadContext.indexer.source ?? null,
1339-
snapshotSeq: lockedReadContext.snapshot?.snapshotSeq ?? lockedReadContext.indexer.snapshotSeq ?? null,
1340-
daemonInstanceId: lockedReadContext.snapshot?.daemonInstanceId ?? lockedReadContext.indexer.daemonInstanceId ?? null,
1341-
hasSnapshot: lockedReadContext.snapshot !== null,
1342-
hasModel: lockedReadContext.model !== null,
1343-
coreBestHeight: lockedReadContext.nodeStatus?.nodeBestHeight ?? null,
1344-
coreBestHash: lockedReadContext.nodeStatus?.nodeBestHashHex ?? null,
1345-
indexerTipHeight: lockedReadContext.snapshot?.tip?.height ?? lockedReadContext.indexer.snapshotTip?.height ?? null,
1346-
indexerTipHash: lockedReadContext.snapshot?.tip?.blockHashHex ?? lockedReadContext.indexer.snapshotTip?.blockHashHex ?? null,
1347-
},
1348-
}),
1349-
);
1350-
if (readyReadContext === null) {
1351-
return await createSnapshotUnavailableRetryResult();
1352-
}
1441+
const publishWithReadyReadContext = async (
1442+
readyReadContext: ReadyMiningReadContext,
1443+
): Promise<MiningPublishOutcome> => {
13531444
if (!candidateTargetsCurrentNodeTip(readyReadContext, options.candidate)) {
13541445
return await createRestartResult(
13551446
readyReadContext.localState.state,
@@ -1495,6 +1586,94 @@ export async function publishCandidate(options: {
14951586

14961587
throw error;
14971588
}
1589+
};
1590+
1591+
if (options.currentReadContext !== undefined) {
1592+
const fastRevalidationStartedAt = performance.now();
1593+
const fastRevalidationResult = await validateFastPublishReadContext({
1594+
readContext: options.currentReadContext,
1595+
candidate: options.candidate,
1596+
dataDir: options.dataDir,
1597+
provider: options.provider,
1598+
paths: options.paths,
1599+
throwIfStopping: options.throwIfStopping,
1600+
});
1601+
await appendFastPublishRevalidationEvent({
1602+
appendEventFn: options.appendEventFn,
1603+
paths: options.paths,
1604+
candidate: options.candidate,
1605+
runId: options.runId,
1606+
readContext: options.currentReadContext,
1607+
durationMs: performance.now() - fastRevalidationStartedAt,
1608+
result: fastRevalidationResult,
1609+
});
1610+
1611+
if (fastRevalidationResult.ok) {
1612+
return await publishWithReadyReadContext(options.currentReadContext);
1613+
}
1614+
}
1615+
1616+
options.throwIfStopping?.();
1617+
const readContextRefreshStartedAt = performance.now();
1618+
let lockedReadContext: WalletReadContext;
1619+
try {
1620+
lockedReadContext = await options.openReadContext({
1621+
dataDir: options.dataDir,
1622+
databasePath: options.databasePath,
1623+
secretProvider: options.provider,
1624+
walletControlLockHeld: true,
1625+
paths: options.paths,
1626+
});
1627+
} catch (error) {
1628+
await appendPublishTimingEvent(
1629+
options.appendEventFn,
1630+
options.paths,
1631+
"timing-read-context-refresh",
1632+
"Refreshed mining read context before publish.",
1633+
createPublishTimingContext({
1634+
candidate: options.candidate,
1635+
runId: options.runId,
1636+
level: "warn",
1637+
durationMs: performance.now() - readContextRefreshStartedAt,
1638+
metrics: {
1639+
outcome: "error",
1640+
errorName: error instanceof Error ? error.name : "unknown",
1641+
},
1642+
}),
1643+
);
1644+
throw error;
1645+
}
1646+
1647+
try {
1648+
options.throwIfStopping?.();
1649+
const readyReadContext = resolveReadyMiningReadContext(lockedReadContext);
1650+
await appendPublishTimingEvent(
1651+
options.appendEventFn,
1652+
options.paths,
1653+
"timing-read-context-refresh",
1654+
"Refreshed mining read context before publish.",
1655+
createPublishTimingContext({
1656+
candidate: options.candidate,
1657+
runId: options.runId,
1658+
durationMs: performance.now() - readContextRefreshStartedAt,
1659+
metrics: {
1660+
outcome: readyReadContext === null ? "snapshot-unavailable" : "success",
1661+
indexerTruthSource: lockedReadContext.indexer.source ?? null,
1662+
snapshotSeq: lockedReadContext.snapshot?.snapshotSeq ?? lockedReadContext.indexer.snapshotSeq ?? null,
1663+
daemonInstanceId: lockedReadContext.snapshot?.daemonInstanceId ?? lockedReadContext.indexer.daemonInstanceId ?? null,
1664+
hasSnapshot: lockedReadContext.snapshot !== null,
1665+
hasModel: lockedReadContext.model !== null,
1666+
coreBestHeight: lockedReadContext.nodeStatus?.nodeBestHeight ?? null,
1667+
coreBestHash: lockedReadContext.nodeStatus?.nodeBestHashHex ?? null,
1668+
indexerTipHeight: lockedReadContext.snapshot?.tip?.height ?? lockedReadContext.indexer.snapshotTip?.height ?? null,
1669+
indexerTipHash: lockedReadContext.snapshot?.tip?.blockHashHex ?? lockedReadContext.indexer.snapshotTip?.blockHashHex ?? null,
1670+
},
1671+
}),
1672+
);
1673+
if (readyReadContext === null) {
1674+
return await createSnapshotUnavailableRetryResult();
1675+
}
1676+
return await publishWithReadyReadContext(readyReadContext);
14981677
} finally {
14991678
await lockedReadContext.close();
15001679
}

0 commit comments

Comments
 (0)