From 926a74e4a863abbb9cb7b9c8bcf5490b95076841 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 30 Sep 2026 16:54:46 -0500 Subject: [PATCH 1/6] fix(dashmate): only block stops for DKGs this masternode is in The safe-stop check refused to stop, or with --safe waited, whenever `quorum dkginfo` reported next_dkg <= 6. next_dkg is the minimum across every LLMQ type and stays at 1 for the whole rotation signing window, so on mainnet about a third of block heights blocked a stop, for up to 39 blocks, even on nodes that were not selected for any DKG. Missing a DKG you are not a member of carries no PoSe penalty. Dash Core v24 (dashpay/dash#7428) adds `upcoming_dkgs` to `quorum dkginfo`, with per-DKG membership for the local proTxHash. When it is present, an imminent DKG blocks the stop only if this node is a member or Core cannot determine membership. Older Core keeps the next_dkg rule. Malformed data fails safe. Core lists DKGs strictly above the tip, so a rotated session starting at the tip only shows up in active_dkgs once Core initializes it. When membership data is what cleared an imminent DKG, the safe verdict is re-checked after 5 seconds. The Core RPC sequence is moved into checkMasternodeSafeToStop so `stop` and `--safe` share it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../core/quorum/checkMasternodeSafeToStop.js | 55 +++++++ .../quorum/isMasternodeSafeToStopDuringDkg.js | 89 +++++++++-- .../src/core/quorum/waitForDKGWindowPass.js | 26 +--- .../src/listr/tasks/stopNodeTaskFactory.js | 20 +-- .../quorum/checkMasternodeSafeToStop.spec.js | 85 +++++++++++ .../isMasternodeSafeToStopDuringDkg.spec.js | 139 +++++++++++++++++- .../core/quorum/waitForDKGWindowPass.spec.js | 47 ++++++ 7 files changed, 409 insertions(+), 52 deletions(-) create mode 100644 packages/dashmate/src/core/quorum/checkMasternodeSafeToStop.js create mode 100644 packages/dashmate/test/unit/core/quorum/checkMasternodeSafeToStop.spec.js diff --git a/packages/dashmate/src/core/quorum/checkMasternodeSafeToStop.js b/packages/dashmate/src/core/quorum/checkMasternodeSafeToStop.js new file mode 100644 index 00000000000..911070e1e7b --- /dev/null +++ b/packages/dashmate/src/core/quorum/checkMasternodeSafeToStop.js @@ -0,0 +1,55 @@ +import wait from '../../util/wait.js'; +import isMasternodeSafeToStopDuringDkg, { + needsSafeStopConfirmation, + shouldInspectDkgStatusForSafeStop, +} from './isMasternodeSafeToStopDuringDkg.js'; + +// Comfortably longer than Core's delay between connecting a block and +// tracking a DKG session that starts at it. +export const SAFE_STOP_CONFIRMATION_DELAY_MS = 5000; + +/** + * @param {RpcClient} rpcClient + * @return {Promise<{ isSafe: boolean, dkgInfo: Object }>} + */ +async function evaluateSafeToStop(rpcClient) { + const { result: dkgInfo } = await rpcClient.quorum('dkginfo'); + + let dkgStatus; + let currentHeight; + if (shouldInspectDkgStatusForSafeStop(dkgInfo)) { + [ + { result: dkgStatus }, + { result: currentHeight }, + ] = await Promise.all([ + rpcClient.quorum('dkgstatus'), + rpcClient.getBlockCount(), + ]); + } + + return { + isSafe: isMasternodeSafeToStopDuringDkg(dkgInfo, dkgStatus, currentHeight), + dkgInfo, + }; +} + +/** + * Query Core and decide whether the masternode can be stopped without + * disrupting a DKG session. See {@link isMasternodeSafeToStopDuringDkg} + * for the rule and {@link needsSafeStopConfirmation} for why a safe + * verdict is sometimes re-checked. + * + * @param {RpcClient} rpcClient + * @return {Promise} + */ +export default async function checkMasternodeSafeToStop(rpcClient) { + const { isSafe, dkgInfo } = await evaluateSafeToStop(rpcClient); + + if (!isSafe || !needsSafeStopConfirmation(dkgInfo)) { + return isSafe; + } + + await wait(SAFE_STOP_CONFIRMATION_DELAY_MS); + + return (await evaluateSafeToStop(rpcClient)).isSafe; +} diff --git a/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js b/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js index f3c4a768bd5..b960b1971c2 100644 --- a/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js +++ b/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js @@ -39,7 +39,67 @@ function hasValidDkgInfoShape(dkgInfo) { } /** - * @param {{ active_dkgs: number, next_dkg: number }} dkgInfo + * An upcoming DKG blocks the stop when it starts within + * `MIN_BLOCKS_BEFORE_DKG` blocks and the node is selected for it, or + * Core could not determine membership. Malformed entries fail safe. + * + * @param {*} upcomingDkg + * @return {boolean} + */ +function isUpcomingDkgBlockingStop(upcomingDkg) { + if (!upcomingDkg + || typeof upcomingDkg !== 'object' + || !isValidDkgCounter(upcomingDkg.blocksUntilStart) + || typeof upcomingDkg.known !== 'boolean' + || (upcomingDkg.known && typeof upcomingDkg.isMember !== 'boolean')) { + return true; + } + + if (upcomingDkg.blocksUntilStart > MIN_BLOCKS_BEFORE_DKG) { + return false; + } + + return !upcomingDkg.known || upcomingDkg.isMember; +} + +/** + * @param {{ next_dkg: number, upcoming_dkgs?: Array }} dkgInfo + * @return {boolean} + */ +function isImminentDkgBlockingStop(dkgInfo) { + const { upcoming_dkgs: upcomingDkgs } = dkgInfo; + + // Dash Core < v24 does not report membership, so any imminent DKG blocks. + if (upcomingDkgs === undefined) { + return dkgInfo.next_dkg <= MIN_BLOCKS_BEFORE_DKG; + } + + if (!Array.isArray(upcomingDkgs)) { + return true; + } + + return upcomingDkgs.some(isUpcomingDkgBlockingStop); +} + +/** + * Core lists upcoming DKGs strictly above the chain tip, so a rotated + * session that starts at the tip block has no `upcoming_dkgs` entry. It + * only becomes visible through `active_dkgs`, which Core updates shortly + * after the block is connected. When membership data is what cleared an + * otherwise imminent DKG (`next_dkg` stays 1 across the rotation window), + * a safe verdict has to be confirmed once that lag has passed. + * + * @param {{ next_dkg: number, upcoming_dkgs?: Array }} dkgInfo + * @return {boolean} + */ +export function needsSafeStopConfirmation(dkgInfo) { + return hasValidDkgInfoShape(dkgInfo) + && Array.isArray(dkgInfo.upcoming_dkgs) + && dkgInfo.next_dkg <= MIN_BLOCKS_BEFORE_DKG; +} + +/** + * @param {{ active_dkgs: number, next_dkg: number, upcoming_dkgs?: Array }} dkgInfo * @return {boolean} */ export function shouldInspectDkgStatusForSafeStop(dkgInfo) { @@ -47,7 +107,7 @@ export function shouldInspectDkgStatusForSafeStop(dkgInfo) { return false; } - return dkgInfo.active_dkgs > 0 && dkgInfo.next_dkg > MIN_BLOCKS_BEFORE_DKG; + return dkgInfo.active_dkgs > 0 && !isImminentDkgBlockingStop(dkgInfo); } /** @@ -56,13 +116,24 @@ export function shouldInspectDkgStatusForSafeStop(dkgInfo) { * DKG session. * * Inputs come from three Core RPCs: - * - `quorum dkginfo` → `{ active_dkgs, next_dkg }` + * - `quorum dkginfo` → `{ active_dkgs, next_dkg, upcoming_dkgs? }` * - `quorum dkgstatus` → `{ session: [{ llmqType, status: { quorumHeight } }, ...] }` * - `getblockcount` → integer chain tip height * * Decision rules: - * 1. `next_dkg <= MIN_BLOCKS_BEFORE_DKG` — a new cycle could begin - * before the restart completes. Unsafe regardless of sessions. + * 1. Imminent DKG — a new session could begin before the restart + * completes. Unsafe regardless of active sessions when: + * - Dash Core v24+ (`upcoming_dkgs` present): any upcoming DKG + * with `blocksUntilStart <= MIN_BLOCKS_BEFORE_DKG` that the + * node is a member of (`isMember`), or whose membership is + * not `known`. Core lists a DKG once its work block is mined, + * at least 8 blocks before it starts, so every DKG inside this + * window is listed. DKGs the node is not selected for are + * ignored: missing them carries no PoSe penalty. See + * {@link needsSafeStopConfirmation} for sessions starting at + * the tip. + * - Older Dash Core (no `upcoming_dkgs`): + * `next_dkg <= MIN_BLOCKS_BEFORE_DKG`, for any LLMQ type. * 2. `active_dkgs === 0` — no sessions tracked locally. Safe. * 3. `active_dkgs > 0` — `active_dkgs` in Core is * `dkgdbgman.GetSessionCount()`, an aggregate counter spanning @@ -81,7 +152,7 @@ export function shouldInspectDkgStatusForSafeStop(dkgInfo) { * window are treated as stale and ignored. * If every session is past its window, the stop is safe. * - * @param {{ active_dkgs: number, next_dkg: number }} dkgInfo + * @param {{ active_dkgs: number, next_dkg: number, upcoming_dkgs?: Array }} dkgInfo * Result of `quorum dkginfo`. * @param {{ session?: Array<{ llmqType?: string, status?: { quorumHeight?: number } }> }} [dkgStatus] * Result of `quorum dkgstatus`. Only consulted when @@ -100,13 +171,11 @@ export default function isMasternodeSafeToStopDuringDkg( return false; } - const { active_dkgs: activeDkgs, next_dkg: nextDkg } = dkgInfo; - - if (nextDkg <= MIN_BLOCKS_BEFORE_DKG) { + if (isImminentDkgBlockingStop(dkgInfo)) { return false; } - if (activeDkgs === 0) { + if (dkgInfo.active_dkgs === 0) { return true; } diff --git a/packages/dashmate/src/core/quorum/waitForDKGWindowPass.js b/packages/dashmate/src/core/quorum/waitForDKGWindowPass.js index da3ce30acb4..1bd52daa0ab 100644 --- a/packages/dashmate/src/core/quorum/waitForDKGWindowPass.js +++ b/packages/dashmate/src/core/quorum/waitForDKGWindowPass.js @@ -1,13 +1,11 @@ import wait from '../../util/wait.js'; -import isMasternodeSafeToStopDuringDkg, { - shouldInspectDkgStatusForSafeStop, -} from './isMasternodeSafeToStopDuringDkg.js'; +import checkMasternodeSafeToStop from './checkMasternodeSafeToStop.js'; const CHECK_INTERVAL_MS = 10000; /** * Poll Core until the masternode is safe to stop without disrupting a - * DKG session. See {@link isMasternodeSafeToStopDuringDkg} for the + * DKG session. See {@link checkMasternodeSafeToStop} for the * safety rule. The only acceptable exit is reaching a safe state, so * that `--safe` cannot silently fall back to an unsafe restart. * @@ -15,25 +13,7 @@ const CHECK_INTERVAL_MS = 10000; * @return {Promise} */ export default async function waitForDKGWindowPass(rpcClient) { - for (;;) { - const { result: dkgInfo } = await rpcClient.quorum('dkginfo'); - - let dkgStatus; - let currentHeight; - if (shouldInspectDkgStatusForSafeStop(dkgInfo)) { - [ - { result: dkgStatus }, - { result: currentHeight }, - ] = await Promise.all([ - rpcClient.quorum('dkgstatus'), - rpcClient.getBlockCount(), - ]); - } - - if (isMasternodeSafeToStopDuringDkg(dkgInfo, dkgStatus, currentHeight)) { - return; - } - + while (!await checkMasternodeSafeToStop(rpcClient)) { await wait(CHECK_INTERVAL_MS); } } diff --git a/packages/dashmate/src/listr/tasks/stopNodeTaskFactory.js b/packages/dashmate/src/listr/tasks/stopNodeTaskFactory.js index b70b3e500ed..d03a7dbbe1d 100644 --- a/packages/dashmate/src/listr/tasks/stopNodeTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/stopNodeTaskFactory.js @@ -1,9 +1,7 @@ /* eslint-disable no-console */ import { Listr } from 'listr2'; import waitForDKGWindowPass from '../../core/quorum/waitForDKGWindowPass.js'; -import isMasternodeSafeToStopDuringDkg, { - shouldInspectDkgStatusForSafeStop, -} from '../../core/quorum/isMasternodeSafeToStopDuringDkg.js'; +import checkMasternodeSafeToStop from '../../core/quorum/checkMasternodeSafeToStop.js'; /** * @param {DockerCompose} dockerCompose @@ -56,21 +54,7 @@ export default function stopNodeTaskFactory( host: await getConnectionHost(config, 'core', 'core.rpc.host'), }); - const { result: dkgInfo } = await rpcClient.quorum('dkginfo'); - - let dkgStatus; - let currentHeight; - if (shouldInspectDkgStatusForSafeStop(dkgInfo)) { - [ - { result: dkgStatus }, - { result: currentHeight }, - ] = await Promise.all([ - rpcClient.quorum('dkgstatus'), - rpcClient.getBlockCount(), - ]); - } - - if (!isMasternodeSafeToStopDuringDkg(dkgInfo, dkgStatus, currentHeight)) { + if (!await checkMasternodeSafeToStop(rpcClient)) { throw new Error('Your node is currently participating in a DKG exchange session ' + '(or one is about to start) and stopping it right now may result in a PoSe ban. ' + 'Try again later, or continue with --force or --safe flags'); diff --git a/packages/dashmate/test/unit/core/quorum/checkMasternodeSafeToStop.spec.js b/packages/dashmate/test/unit/core/quorum/checkMasternodeSafeToStop.spec.js new file mode 100644 index 00000000000..fbec3509548 --- /dev/null +++ b/packages/dashmate/test/unit/core/quorum/checkMasternodeSafeToStop.spec.js @@ -0,0 +1,85 @@ +import checkMasternodeSafeToStop, { + SAFE_STOP_CONFIRMATION_DELAY_MS, +} from '../../../../src/core/quorum/checkMasternodeSafeToStop.js'; + +describe('checkMasternodeSafeToStop', () => { + let rpcClient; + + beforeEach(function beforeEach() { + rpcClient = { + quorum: this.sinon.stub(), + getBlockCount: this.sinon.stub(), + }; + }); + + it('should return true without confirmation when Core reports no membership data', async () => { + rpcClient.quorum.withArgs('dkginfo').resolves({ result: { active_dkgs: 0, next_dkg: 24 } }); + + expect(await checkMasternodeSafeToStop(rpcClient)).to.equal(true); + expect(rpcClient.quorum).to.have.been.calledOnceWith('dkginfo'); + }); + + it('should return false without confirmation when the first check is unsafe', async () => { + rpcClient.quorum.withArgs('dkginfo').resolves({ + result: { + active_dkgs: 0, + next_dkg: 1, + upcoming_dkgs: [{ blocksUntilStart: 1, known: true, isMember: true }], + }, + }); + + expect(await checkMasternodeSafeToStop(rpcClient)).to.equal(false); + expect(rpcClient.quorum).to.have.been.calledOnceWith('dkginfo'); + }); + + it('should confirm a safe verdict when membership data cleared an imminent DKG', async function it() { + const clock = this.sinon.useFakeTimers(); + + rpcClient.quorum.withArgs('dkginfo').resolves({ + result: { + active_dkgs: 0, + next_dkg: 1, + upcoming_dkgs: [{ blocksUntilStart: 1, known: true, isMember: false }], + }, + }); + + const promise = checkMasternodeSafeToStop(rpcClient); + + await clock.tickAsync(0); + expect(rpcClient.quorum.withArgs('dkginfo')).to.have.been.calledOnce(); + + await clock.tickAsync(SAFE_STOP_CONFIRMATION_DELAY_MS); + + expect(await promise).to.equal(true); + expect(rpcClient.quorum.withArgs('dkginfo')).to.have.been.calledTwice(); + expect(rpcClient.getBlockCount).to.not.have.been.called(); + }); + + it('should return false when confirmation reveals a member session that started at the tip', async function it() { + // A rotated session starting at the tip block is not listed in + // upcoming_dkgs, and active_dkgs only counts it once Core has + // initialized the session shortly after the block is connected. + const clock = this.sinon.useFakeTimers(); + + rpcClient.quorum.withArgs('dkginfo') + .onFirstCall() + .resolves({ result: { active_dkgs: 0, next_dkg: 1, upcoming_dkgs: [] } }) + .onSecondCall() + .resolves({ result: { active_dkgs: 1, next_dkg: 1, upcoming_dkgs: [] } }); + rpcClient.quorum.withArgs('dkgstatus').resolves({ + result: { + session: [ + { llmqType: 'llmq_60_75', status: { quorumHeight: 1000 } }, + ], + }, + }); + rpcClient.getBlockCount.resolves({ result: 1000 }); + + const promise = checkMasternodeSafeToStop(rpcClient); + + await clock.tickAsync(SAFE_STOP_CONFIRMATION_DELAY_MS); + + expect(await promise).to.equal(false); + expect(rpcClient.quorum.withArgs('dkgstatus')).to.have.been.calledOnce(); + }); +}); diff --git a/packages/dashmate/test/unit/core/quorum/isMasternodeSafeToStopDuringDkg.spec.js b/packages/dashmate/test/unit/core/quorum/isMasternodeSafeToStopDuringDkg.spec.js index 99e536c944e..f2c97197c00 100644 --- a/packages/dashmate/test/unit/core/quorum/isMasternodeSafeToStopDuringDkg.spec.js +++ b/packages/dashmate/test/unit/core/quorum/isMasternodeSafeToStopDuringDkg.spec.js @@ -1,5 +1,6 @@ import isMasternodeSafeToStopDuringDkg, { DKG_MINING_WINDOW_START_BY_LLMQ_TYPE, + shouldInspectDkgStatusForSafeStop, } from '../../../../src/core/quorum/isMasternodeSafeToStopDuringDkg.js'; import { MIN_BLOCKS_BEFORE_DKG } from '../../../../src/constants.js'; @@ -11,7 +12,7 @@ const PLATFORM_WINDOW = DKG_MINING_WINDOW_START_BY_LLMQ_TYPE.llmq_test_platform; const LARGE_QUORUM_WINDOW = DKG_MINING_WINDOW_START_BY_LLMQ_TYPE.llmq_400_60; // 20 describe('isMasternodeSafeToStopDuringDkg', () => { - describe('imminent DKG guard from dkginfo.next_dkg', () => { + describe('imminent DKG guard from dkginfo.next_dkg (Dash Core < v24)', () => { it('blocks when next_dkg <= MIN_BLOCKS_BEFORE_DKG even when active_dkgs is 0', () => { const dkgInfo = { active_dkgs: 0, next_dkg: MIN_BLOCKS_BEFORE_DKG }; @@ -27,6 +28,142 @@ describe('isMasternodeSafeToStopDuringDkg', () => { }); }); + describe('imminent DKG membership from dkginfo.upcoming_dkgs (Dash Core v24+)', () => { + // Shape of a `quorum dkginfo` upcoming_dkgs entry once membership is known. + function upcomingDkg(blocksUntilStart, isMember) { + return { + llmqType: 106, + quorumIndex: 0, + quorumHeight: 1000 + blocksUntilStart, + blocksUntilStart, + known: true, + isMember, + workBlockHeight: 1000 + blocksUntilStart - 8, + workBlockHash: '00'.repeat(32), + }; + } + + function unknownUpcomingDkg(blocksUntilStart) { + return { + llmqType: 103, + quorumIndex: 0, + quorumHeight: 1000 + blocksUntilStart, + blocksUntilStart, + known: false, + reason: 'rotated quorum snapshots are not available yet', + }; + } + + it('should allow the stop when an imminent DKG does not include this node', () => { + const dkgInfo = { + active_dkgs: 0, + next_dkg: 1, + upcoming_dkgs: [upcomingDkg(1, false), upcomingDkg(MIN_BLOCKS_BEFORE_DKG, false)], + }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, undefined, 1000)).to.equal(true); + }); + + it('should allow the stop when next_dkg is imminent but no upcoming DKG is listed', () => { + // next_dkg reports 1 for the whole rotation signing window, even + // after the last rotated index has started. + const dkgInfo = { active_dkgs: 0, next_dkg: 1, upcoming_dkgs: [] }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, undefined, 1000)).to.equal(true); + }); + + it('should block when this node is a member of an imminent DKG', () => { + const dkgInfo = { + active_dkgs: 0, + next_dkg: 1, + upcoming_dkgs: [upcomingDkg(1, false), upcomingDkg(MIN_BLOCKS_BEFORE_DKG, true)], + }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, undefined, 1000)).to.equal(false); + }); + + it('should allow the stop when this node is a member of a DKG that is not yet imminent', () => { + const dkgInfo = { + active_dkgs: 0, + next_dkg: MIN_BLOCKS_BEFORE_DKG + 1, + upcoming_dkgs: [upcomingDkg(MIN_BLOCKS_BEFORE_DKG + 1, true)], + }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, undefined, 1000)).to.equal(true); + }); + + it('should block when membership of an imminent DKG is unknown', () => { + const dkgInfo = { + active_dkgs: 0, + next_dkg: 1, + upcoming_dkgs: [unknownUpcomingDkg(1)], + }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, undefined, 1000)).to.equal(false); + }); + + it('should allow the stop when membership is unknown only for a DKG that is not yet imminent', () => { + const dkgInfo = { + active_dkgs: 0, + next_dkg: MIN_BLOCKS_BEFORE_DKG + 1, + upcoming_dkgs: [unknownUpcomingDkg(MIN_BLOCKS_BEFORE_DKG + 1)], + }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, undefined, 1000)).to.equal(true); + }); + + it('should still block on an active session when the imminent DKG does not include this node', () => { + const dkgInfo = { + active_dkgs: 1, + next_dkg: 1, + upcoming_dkgs: [upcomingDkg(1, false)], + }; + const dkgStatus = { + session: [ + { llmqType: 'llmq_400_60', status: { quorumHeight: 995 } }, + ], + }; + + expect(shouldInspectDkgStatusForSafeStop(dkgInfo)).to.equal(true); + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, dkgStatus, 1000)).to.equal(false); + }); + + it('should not inspect dkgstatus when a member DKG is imminent', () => { + const dkgInfo = { + active_dkgs: 1, + next_dkg: 1, + upcoming_dkgs: [upcomingDkg(1, true)], + }; + + expect(shouldInspectDkgStatusForSafeStop(dkgInfo)).to.equal(false); + }); + + describe('fail-safe on malformed upcoming_dkgs', () => { + const malformed = { + 'upcoming_dkgs is not an array': {}, + 'an entry is null': [null], + 'an entry is missing known': [{ blocksUntilStart: 1, isMember: false }], + 'a known entry is missing isMember': [{ blocksUntilStart: 1, known: true }], + 'an entry is missing blocksUntilStart': [{ known: true, isMember: false }], + 'an entry has a negative blocksUntilStart': [ + { blocksUntilStart: -1, known: true, isMember: false }, + ], + }; + + Object.entries(malformed).forEach(([description, upcomingDkgs]) => { + it(`should block when ${description}`, () => { + const dkgInfo = { + active_dkgs: 0, + next_dkg: NEXT_DKG_NOT_IMMINENT, + upcoming_dkgs: upcomingDkgs, + }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, undefined, 1000)).to.equal(false); + }); + }); + }); + }); + describe('per-session active window from dkgstatus + getblockcount', () => { it('blocks a platform session at offset 0 (quorumHeight == currentHeight)', () => { const dkgInfo = { active_dkgs: 1, next_dkg: NEXT_DKG_NOT_IMMINENT }; diff --git a/packages/dashmate/test/unit/core/quorum/waitForDKGWindowPass.spec.js b/packages/dashmate/test/unit/core/quorum/waitForDKGWindowPass.spec.js index 5c2a85065b7..c38d9b756a3 100644 --- a/packages/dashmate/test/unit/core/quorum/waitForDKGWindowPass.spec.js +++ b/packages/dashmate/test/unit/core/quorum/waitForDKGWindowPass.spec.js @@ -1,4 +1,5 @@ import waitForDKGWindowPass from '../../../../src/core/quorum/waitForDKGWindowPass.js'; +import { SAFE_STOP_CONFIRMATION_DELAY_MS } from '../../../../src/core/quorum/checkMasternodeSafeToStop.js'; const CHECK_INTERVAL_MS = 10000; @@ -21,6 +22,52 @@ describe('waitForDKGWindowPass', () => { expect(rpcClient.getBlockCount).to.not.have.been.called(); }); + it('should resolve after confirmation when an imminent DKG does not include this node', async function it() { + const clock = this.sinon.useFakeTimers(); + + rpcClient.quorum.withArgs('dkginfo').resolves({ + result: { + active_dkgs: 0, + next_dkg: 1, + upcoming_dkgs: [{ blocksUntilStart: 1, known: true, isMember: false }], + }, + }); + + const promise = waitForDKGWindowPass(rpcClient); + + await clock.tickAsync(SAFE_STOP_CONFIRMATION_DELAY_MS); + await promise; + + expect(rpcClient.quorum.withArgs('dkginfo')).to.have.been.calledTwice(); + expect(rpcClient.getBlockCount).to.not.have.been.called(); + }); + + it('should keep waiting while this node is a member of an imminent DKG', async function it() { + const clock = this.sinon.useFakeTimers(); + + rpcClient.quorum.withArgs('dkginfo') + .onFirstCall() + .resolves({ + result: { + active_dkgs: 0, + next_dkg: 1, + upcoming_dkgs: [{ blocksUntilStart: 1, known: true, isMember: true }], + }, + }) + .onSecondCall() + .resolves({ result: { active_dkgs: 0, next_dkg: 24, upcoming_dkgs: [] } }); + + const promise = waitForDKGWindowPass(rpcClient); + + await clock.tickAsync(0); + expect(rpcClient.quorum.withArgs('dkginfo')).to.have.been.calledOnce(); + + await clock.tickAsync(CHECK_INTERVAL_MS); + await promise; + + expect(rpcClient.quorum.withArgs('dkginfo')).to.have.been.calledTwice(); + }); + it('waits through an active platform session and resolves once the window has passed', async function it() { // Block height advances from 1005 (offset 5, in window) to 1010 // (offset 10, window closed) between polls; active_dkgs stays > 0 From b5d072118a71d0d47e19c812ea22156b0365b0f7 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 30 Sep 2026 16:56:23 -0500 Subject: [PATCH 2/6] test(dashmate): cover needsSafeStopConfirmation directly Co-Authored-By: Claude Opus 5.5 (1M context) --- .../quorum/isMasternodeSafeToStopDuringDkg.spec.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/dashmate/test/unit/core/quorum/isMasternodeSafeToStopDuringDkg.spec.js b/packages/dashmate/test/unit/core/quorum/isMasternodeSafeToStopDuringDkg.spec.js index f2c97197c00..3b0c13fd6c1 100644 --- a/packages/dashmate/test/unit/core/quorum/isMasternodeSafeToStopDuringDkg.spec.js +++ b/packages/dashmate/test/unit/core/quorum/isMasternodeSafeToStopDuringDkg.spec.js @@ -1,5 +1,6 @@ import isMasternodeSafeToStopDuringDkg, { DKG_MINING_WINDOW_START_BY_LLMQ_TYPE, + needsSafeStopConfirmation, shouldInspectDkgStatusForSafeStop, } from '../../../../src/core/quorum/isMasternodeSafeToStopDuringDkg.js'; import { MIN_BLOCKS_BEFORE_DKG } from '../../../../src/constants.js'; @@ -138,6 +139,16 @@ describe('isMasternodeSafeToStopDuringDkg', () => { expect(shouldInspectDkgStatusForSafeStop(dkgInfo)).to.equal(false); }); + it('should need confirmation only when membership data clears an imminent DKG', () => { + expect(needsSafeStopConfirmation({ active_dkgs: 0, next_dkg: 1, upcoming_dkgs: [] })) + .to.equal(true); + expect(needsSafeStopConfirmation({ active_dkgs: 0, next_dkg: 1 })).to.equal(false); + expect(needsSafeStopConfirmation({ + active_dkgs: 0, next_dkg: MIN_BLOCKS_BEFORE_DKG + 1, upcoming_dkgs: [], + })).to.equal(false); + expect(needsSafeStopConfirmation({ next_dkg: 1, upcoming_dkgs: [] })).to.equal(false); + }); + describe('fail-safe on malformed upcoming_dkgs', () => { const malformed = { 'upcoming_dkgs is not an array': {}, From 052c18a79117501c7ef87899f408773b72eeb4ab Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 7 Oct 2026 00:54:25 -0500 Subject: [PATCH 3/6] fix(dashmate): use complete DKG membership without confirmation delay --- .../core/quorum/checkMasternodeSafeToStop.js | 39 +----- .../quorum/isMasternodeSafeToStopDuringDkg.js | 130 +++++------------- .../quorum/checkMasternodeSafeToStop.spec.js | 85 +++++------- .../isMasternodeSafeToStopDuringDkg.spec.js | 75 ++++++++-- .../core/quorum/waitForDKGWindowPass.spec.js | 36 ++++- 5 files changed, 162 insertions(+), 203 deletions(-) diff --git a/packages/dashmate/src/core/quorum/checkMasternodeSafeToStop.js b/packages/dashmate/src/core/quorum/checkMasternodeSafeToStop.js index 911070e1e7b..73d110b53f8 100644 --- a/packages/dashmate/src/core/quorum/checkMasternodeSafeToStop.js +++ b/packages/dashmate/src/core/quorum/checkMasternodeSafeToStop.js @@ -1,18 +1,15 @@ -import wait from '../../util/wait.js'; import isMasternodeSafeToStopDuringDkg, { - needsSafeStopConfirmation, shouldInspectDkgStatusForSafeStop, } from './isMasternodeSafeToStopDuringDkg.js'; -// Comfortably longer than Core's delay between connecting a block and -// tracking a DKG session that starts at it. -export const SAFE_STOP_CONFIRMATION_DELAY_MS = 5000; - /** + * Query Core and decide whether stopping would disrupt a DKG session. + * Older Core responses require the existing per-session status checks. + * * @param {RpcClient} rpcClient - * @return {Promise<{ isSafe: boolean, dkgInfo: Object }>} + * @return {Promise} */ -async function evaluateSafeToStop(rpcClient) { +export default async function checkMasternodeSafeToStop(rpcClient) { const { result: dkgInfo } = await rpcClient.quorum('dkginfo'); let dkgStatus; @@ -27,29 +24,5 @@ async function evaluateSafeToStop(rpcClient) { ]); } - return { - isSafe: isMasternodeSafeToStopDuringDkg(dkgInfo, dkgStatus, currentHeight), - dkgInfo, - }; -} - -/** - * Query Core and decide whether the masternode can be stopped without - * disrupting a DKG session. See {@link isMasternodeSafeToStopDuringDkg} - * for the rule and {@link needsSafeStopConfirmation} for why a safe - * verdict is sometimes re-checked. - * - * @param {RpcClient} rpcClient - * @return {Promise} - */ -export default async function checkMasternodeSafeToStop(rpcClient) { - const { isSafe, dkgInfo } = await evaluateSafeToStop(rpcClient); - - if (!isSafe || !needsSafeStopConfirmation(dkgInfo)) { - return isSafe; - } - - await wait(SAFE_STOP_CONFIRMATION_DELAY_MS); - - return (await evaluateSafeToStop(rpcClient)).isSafe; + return isMasternodeSafeToStopDuringDkg(dkgInfo, dkgStatus, currentHeight); } diff --git a/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js b/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js index b960b1971c2..baf4304a8f9 100644 --- a/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js +++ b/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js @@ -39,67 +39,41 @@ function hasValidDkgInfoShape(dkgInfo) { } /** - * An upcoming DKG blocks the stop when it starts within - * `MIN_BLOCKS_BEFORE_DKG` blocks and the node is selected for it, or - * Core could not determine membership. Malformed entries fail safe. - * - * @param {*} upcomingDkg - * @return {boolean} + * Unknown membership and malformed entries block the stop. Upcoming + * sessions only matter within the restart margin; current_dkgs already + * contains only sessions inside their active window. */ -function isUpcomingDkgBlockingStop(upcomingDkg) { - if (!upcomingDkg - || typeof upcomingDkg !== 'object' - || !isValidDkgCounter(upcomingDkg.blocksUntilStart) - || typeof upcomingDkg.known !== 'boolean' - || (upcomingDkg.known && typeof upcomingDkg.isMember !== 'boolean')) { +function isDkgBlockingStop(dkg, blocksField, maxBlocks = Infinity) { + if (!dkg + || typeof dkg !== 'object' + || !isValidDkgCounter(dkg[blocksField]) + || typeof dkg.known !== 'boolean' + || (dkg.known && typeof dkg.isMember !== 'boolean')) { return true; } - if (upcomingDkg.blocksUntilStart > MIN_BLOCKS_BEFORE_DKG) { - return false; - } - - return !upcomingDkg.known || upcomingDkg.isMember; + return dkg[blocksField] <= maxBlocks && (!dkg.known || dkg.isMember); } -/** - * @param {{ next_dkg: number, upcoming_dkgs?: Array }} dkgInfo - * @return {boolean} - */ -function isImminentDkgBlockingStop(dkgInfo) { - const { upcoming_dkgs: upcomingDkgs } = dkgInfo; +function isDkgMembershipBlockingStop(dkgInfo) { + const { current_dkgs: currentDkgs, upcoming_dkgs: upcomingDkgs } = dkgInfo; - // Dash Core < v24 does not report membership, so any imminent DKG blocks. - if (upcomingDkgs === undefined) { + // Earlier Core versions omit current-tip membership while their DKG + // worker initializes. Keep the legacy guard until both lists are available. + if (currentDkgs === undefined) { return dkgInfo.next_dkg <= MIN_BLOCKS_BEFORE_DKG; } - if (!Array.isArray(upcomingDkgs)) { + if (!Array.isArray(currentDkgs) || !Array.isArray(upcomingDkgs)) { return true; } - return upcomingDkgs.some(isUpcomingDkgBlockingStop); + return currentDkgs.some((dkg) => isDkgBlockingStop(dkg, 'blocksSinceStart')) + || upcomingDkgs.some((dkg) => isDkgBlockingStop(dkg, 'blocksUntilStart', MIN_BLOCKS_BEFORE_DKG)); } /** - * Core lists upcoming DKGs strictly above the chain tip, so a rotated - * session that starts at the tip block has no `upcoming_dkgs` entry. It - * only becomes visible through `active_dkgs`, which Core updates shortly - * after the block is connected. When membership data is what cleared an - * otherwise imminent DKG (`next_dkg` stays 1 across the rotation window), - * a safe verdict has to be confirmed once that lag has passed. - * - * @param {{ next_dkg: number, upcoming_dkgs?: Array }} dkgInfo - * @return {boolean} - */ -export function needsSafeStopConfirmation(dkgInfo) { - return hasValidDkgInfoShape(dkgInfo) - && Array.isArray(dkgInfo.upcoming_dkgs) - && dkgInfo.next_dkg <= MIN_BLOCKS_BEFORE_DKG; -} - -/** - * @param {{ active_dkgs: number, next_dkg: number, upcoming_dkgs?: Array }} dkgInfo + * @param {Object} dkgInfo * @return {boolean} */ export function shouldInspectDkgStatusForSafeStop(dkgInfo) { @@ -107,60 +81,24 @@ export function shouldInspectDkgStatusForSafeStop(dkgInfo) { return false; } - return dkgInfo.active_dkgs > 0 && !isImminentDkgBlockingStop(dkgInfo); + return dkgInfo.current_dkgs === undefined + && dkgInfo.active_dkgs > 0 + && dkgInfo.next_dkg > MIN_BLOCKS_BEFORE_DKG; } /** - * Determine whether a masternode can be safely stopped without - * risking a PoSe penalty from disrupting an in-progress or imminent - * DKG session. + * Core's current_dkgs and upcoming_dkgs report membership independently + * of asynchronous local session tracking. Any current member or unknown + * session blocks stopping, as does an upcoming one within the restart margin. * - * Inputs come from three Core RPCs: - * - `quorum dkginfo` → `{ active_dkgs, next_dkg, upcoming_dkgs? }` - * - `quorum dkgstatus` → `{ session: [{ llmqType, status: { quorumHeight } }, ...] }` - * - `getblockcount` → integer chain tip height + * Without current_dkgs, use the legacy next_dkg guard and resolve tracked + * sessions against dkgstatus and getblockcount. Unknown quorum types or + * malformed status fail safe; sessions past dkgMiningWindowStart are ignored. * - * Decision rules: - * 1. Imminent DKG — a new session could begin before the restart - * completes. Unsafe regardless of active sessions when: - * - Dash Core v24+ (`upcoming_dkgs` present): any upcoming DKG - * with `blocksUntilStart <= MIN_BLOCKS_BEFORE_DKG` that the - * node is a member of (`isMember`), or whose membership is - * not `known`. Core lists a DKG once its work block is mined, - * at least 8 blocks before it starts, so every DKG inside this - * window is listed. DKGs the node is not selected for are - * ignored: missing them carries no PoSe penalty. See - * {@link needsSafeStopConfirmation} for sessions starting at - * the tip. - * - Older Dash Core (no `upcoming_dkgs`): - * `next_dkg <= MIN_BLOCKS_BEFORE_DKG`, for any LLMQ type. - * 2. `active_dkgs === 0` — no sessions tracked locally. Safe. - * 3. `active_dkgs > 0` — `active_dkgs` in Core is - * `dkgdbgman.GetSessionCount()`, an aggregate counter spanning - * all LLMQs the node knows about and can linger past a session's - * true active window. Resolve the ambiguity per-session against - * `quorum dkgstatus` + the chain tip: - * - For each session, look up its llmqType's - * `dkgMiningWindowStart`. If the llmqType is unknown or - * `quorumHeight` is missing/malformed, fail safe (unsafe) — - * we cannot reason about a session we cannot identify. - * - A session is still active when - * `0 <= currentHeight - quorumHeight < dkgMiningWindowStart`. - * Any such session blocks the stop. - * - A negative offset is inconsistent with Core's tracked - * sessions and fails safe. Sessions whose offset is past the - * window are treated as stale and ignored. - * If every session is past its window, the stop is safe. - * - * @param {{ active_dkgs: number, next_dkg: number, upcoming_dkgs?: Array }} dkgInfo - * Result of `quorum dkginfo`. - * @param {{ session?: Array<{ llmqType?: string, status?: { quorumHeight?: number } }> }} [dkgStatus] - * Result of `quorum dkgstatus`. Only consulted when - * `dkgInfo.active_dkgs > 0`. - * @param {number} [currentHeight] - * Current block height from `getblockcount`. Only consulted when - * `dkgInfo.active_dkgs > 0`. - * @return {boolean} `true` when the node can be safely stopped. + * @param {Object} dkgInfo Result of quorum dkginfo. + * @param {Object} [dkgStatus] Result of quorum dkgstatus for older Core. + * @param {number} [currentHeight] Chain tip height for older Core. + * @return {boolean} */ export default function isMasternodeSafeToStopDuringDkg( dkgInfo, @@ -171,11 +109,11 @@ export default function isMasternodeSafeToStopDuringDkg( return false; } - if (isImminentDkgBlockingStop(dkgInfo)) { + if (isDkgMembershipBlockingStop(dkgInfo)) { return false; } - if (dkgInfo.active_dkgs === 0) { + if (dkgInfo.current_dkgs !== undefined || dkgInfo.active_dkgs === 0) { return true; } diff --git a/packages/dashmate/test/unit/core/quorum/checkMasternodeSafeToStop.spec.js b/packages/dashmate/test/unit/core/quorum/checkMasternodeSafeToStop.spec.js index fbec3509548..69baa2b752b 100644 --- a/packages/dashmate/test/unit/core/quorum/checkMasternodeSafeToStop.spec.js +++ b/packages/dashmate/test/unit/core/quorum/checkMasternodeSafeToStop.spec.js @@ -1,85 +1,62 @@ -import checkMasternodeSafeToStop, { - SAFE_STOP_CONFIRMATION_DELAY_MS, -} from '../../../../src/core/quorum/checkMasternodeSafeToStop.js'; +import checkMasternodeSafeToStop from '../../../../src/core/quorum/checkMasternodeSafeToStop.js'; describe('checkMasternodeSafeToStop', () => { let rpcClient; beforeEach(function beforeEach() { - rpcClient = { - quorum: this.sinon.stub(), - getBlockCount: this.sinon.stub(), - }; + rpcClient = { quorum: this.sinon.stub(), getBlockCount: this.sinon.stub() }; }); - it('should return true without confirmation when Core reports no membership data', async () => { - rpcClient.quorum.withArgs('dkginfo').resolves({ result: { active_dkgs: 0, next_dkg: 24 } }); - + it('should allow a non-member immediately using one complete membership response', async () => { + rpcClient.quorum.withArgs('dkginfo').resolves({ + result: { + active_dkgs: 1, + next_dkg: 1, + current_dkgs: [{ blocksSinceStart: 0, known: true, isMember: false }], + upcoming_dkgs: [{ blocksUntilStart: 1, known: true, isMember: false }], + }, + }); expect(await checkMasternodeSafeToStop(rpcClient)).to.equal(true); expect(rpcClient.quorum).to.have.been.calledOnceWith('dkginfo'); + expect(rpcClient.getBlockCount).to.not.have.been.called(); }); - it('should return false without confirmation when the first check is unsafe', async () => { + it('should block a member before Core publishes a local session', async () => { rpcClient.quorum.withArgs('dkginfo').resolves({ result: { active_dkgs: 0, next_dkg: 1, - upcoming_dkgs: [{ blocksUntilStart: 1, known: true, isMember: true }], + current_dkgs: [{ blocksSinceStart: 0, known: true, isMember: true }], + upcoming_dkgs: [], }, }); - expect(await checkMasternodeSafeToStop(rpcClient)).to.equal(false); expect(rpcClient.quorum).to.have.been.calledOnceWith('dkginfo'); + expect(rpcClient.getBlockCount).to.not.have.been.called(); }); - it('should confirm a safe verdict when membership data cleared an imminent DKG', async function it() { - const clock = this.sinon.useFakeTimers(); - + it('should retain the imminent-DKG guard for earlier v24 responses', async () => { rpcClient.quorum.withArgs('dkginfo').resolves({ - result: { - active_dkgs: 0, - next_dkg: 1, - upcoming_dkgs: [{ blocksUntilStart: 1, known: true, isMember: false }], - }, + result: { active_dkgs: 0, next_dkg: 1, upcoming_dkgs: [] }, }); - - const promise = checkMasternodeSafeToStop(rpcClient); - - await clock.tickAsync(0); - expect(rpcClient.quorum.withArgs('dkginfo')).to.have.been.calledOnce(); - - await clock.tickAsync(SAFE_STOP_CONFIRMATION_DELAY_MS); - - expect(await promise).to.equal(true); - expect(rpcClient.quorum.withArgs('dkginfo')).to.have.been.calledTwice(); - expect(rpcClient.getBlockCount).to.not.have.been.called(); + expect(await checkMasternodeSafeToStop(rpcClient)).to.equal(false); + expect(rpcClient.quorum).to.have.been.calledOnceWith('dkginfo'); }); - it('should return false when confirmation reveals a member session that started at the tip', async function it() { - // A rotated session starting at the tip block is not listed in - // upcoming_dkgs, and active_dkgs only counts it once Core has - // initialized the session shortly after the block is connected. - const clock = this.sinon.useFakeTimers(); + it('should allow a legacy response with no active or imminent DKG', async () => { + rpcClient.quorum.withArgs('dkginfo').resolves({ result: { active_dkgs: 0, next_dkg: 24 } }); + expect(await checkMasternodeSafeToStop(rpcClient)).to.equal(true); + expect(rpcClient.quorum).to.have.been.calledOnceWith('dkginfo'); + }); - rpcClient.quorum.withArgs('dkginfo') - .onFirstCall() - .resolves({ result: { active_dkgs: 0, next_dkg: 1, upcoming_dkgs: [] } }) - .onSecondCall() - .resolves({ result: { active_dkgs: 1, next_dkg: 1, upcoming_dkgs: [] } }); + it('should inspect active sessions for older Core', async () => { + rpcClient.quorum.withArgs('dkginfo').resolves({ result: { active_dkgs: 1, next_dkg: 24 } }); rpcClient.quorum.withArgs('dkgstatus').resolves({ - result: { - session: [ - { llmqType: 'llmq_60_75', status: { quorumHeight: 1000 } }, - ], - }, + result: { session: [{ llmqType: 'llmq_60_75', status: { quorumHeight: 1000 } }] }, }); - rpcClient.getBlockCount.resolves({ result: 1000 }); - - const promise = checkMasternodeSafeToStop(rpcClient); - - await clock.tickAsync(SAFE_STOP_CONFIRMATION_DELAY_MS); - - expect(await promise).to.equal(false); + rpcClient.getBlockCount.resolves({ result: 1001 }); + expect(await checkMasternodeSafeToStop(rpcClient)).to.equal(false); expect(rpcClient.quorum.withArgs('dkgstatus')).to.have.been.calledOnce(); + expect(rpcClient.getBlockCount).to.have.been.calledOnce(); }); }); diff --git a/packages/dashmate/test/unit/core/quorum/isMasternodeSafeToStopDuringDkg.spec.js b/packages/dashmate/test/unit/core/quorum/isMasternodeSafeToStopDuringDkg.spec.js index 3b0c13fd6c1..85feb87f7dd 100644 --- a/packages/dashmate/test/unit/core/quorum/isMasternodeSafeToStopDuringDkg.spec.js +++ b/packages/dashmate/test/unit/core/quorum/isMasternodeSafeToStopDuringDkg.spec.js @@ -1,6 +1,5 @@ import isMasternodeSafeToStopDuringDkg, { DKG_MINING_WINDOW_START_BY_LLMQ_TYPE, - needsSafeStopConfirmation, shouldInspectDkgStatusForSafeStop, } from '../../../../src/core/quorum/isMasternodeSafeToStopDuringDkg.js'; import { MIN_BLOCKS_BEFORE_DKG } from '../../../../src/constants.js'; @@ -29,7 +28,7 @@ describe('isMasternodeSafeToStopDuringDkg', () => { }); }); - describe('imminent DKG membership from dkginfo.upcoming_dkgs (Dash Core v24+)', () => { + describe('imminent DKG membership from dkginfo.upcoming_dkgs (Core with current_dkgs)', () => { // Shape of a `quorum dkginfo` upcoming_dkgs entry once membership is known. function upcomingDkg(blocksUntilStart, isMember) { return { @@ -57,6 +56,7 @@ describe('isMasternodeSafeToStopDuringDkg', () => { it('should allow the stop when an imminent DKG does not include this node', () => { const dkgInfo = { + current_dkgs: [], active_dkgs: 0, next_dkg: 1, upcoming_dkgs: [upcomingDkg(1, false), upcomingDkg(MIN_BLOCKS_BEFORE_DKG, false)], @@ -68,13 +68,14 @@ describe('isMasternodeSafeToStopDuringDkg', () => { it('should allow the stop when next_dkg is imminent but no upcoming DKG is listed', () => { // next_dkg reports 1 for the whole rotation signing window, even // after the last rotated index has started. - const dkgInfo = { active_dkgs: 0, next_dkg: 1, upcoming_dkgs: [] }; + const dkgInfo = { current_dkgs: [], active_dkgs: 0, next_dkg: 1, upcoming_dkgs: [] }; expect(isMasternodeSafeToStopDuringDkg(dkgInfo, undefined, 1000)).to.equal(true); }); it('should block when this node is a member of an imminent DKG', () => { const dkgInfo = { + current_dkgs: [], active_dkgs: 0, next_dkg: 1, upcoming_dkgs: [upcomingDkg(1, false), upcomingDkg(MIN_BLOCKS_BEFORE_DKG, true)], @@ -85,6 +86,7 @@ describe('isMasternodeSafeToStopDuringDkg', () => { it('should allow the stop when this node is a member of a DKG that is not yet imminent', () => { const dkgInfo = { + current_dkgs: [], active_dkgs: 0, next_dkg: MIN_BLOCKS_BEFORE_DKG + 1, upcoming_dkgs: [upcomingDkg(MIN_BLOCKS_BEFORE_DKG + 1, true)], @@ -95,6 +97,7 @@ describe('isMasternodeSafeToStopDuringDkg', () => { it('should block when membership of an imminent DKG is unknown', () => { const dkgInfo = { + current_dkgs: [], active_dkgs: 0, next_dkg: 1, upcoming_dkgs: [unknownUpcomingDkg(1)], @@ -105,6 +108,7 @@ describe('isMasternodeSafeToStopDuringDkg', () => { it('should allow the stop when membership is unknown only for a DKG that is not yet imminent', () => { const dkgInfo = { + current_dkgs: [], active_dkgs: 0, next_dkg: MIN_BLOCKS_BEFORE_DKG + 1, upcoming_dkgs: [unknownUpcomingDkg(MIN_BLOCKS_BEFORE_DKG + 1)], @@ -113,8 +117,9 @@ describe('isMasternodeSafeToStopDuringDkg', () => { expect(isMasternodeSafeToStopDuringDkg(dkgInfo, undefined, 1000)).to.equal(true); }); - it('should still block on an active session when the imminent DKG does not include this node', () => { + it('should block a current member even when the imminent DKG does not include this node', () => { const dkgInfo = { + current_dkgs: [{ blocksSinceStart: 5, known: true, isMember: true }], active_dkgs: 1, next_dkg: 1, upcoming_dkgs: [upcomingDkg(1, false)], @@ -125,12 +130,13 @@ describe('isMasternodeSafeToStopDuringDkg', () => { ], }; - expect(shouldInspectDkgStatusForSafeStop(dkgInfo)).to.equal(true); + expect(shouldInspectDkgStatusForSafeStop(dkgInfo)).to.equal(false); expect(isMasternodeSafeToStopDuringDkg(dkgInfo, dkgStatus, 1000)).to.equal(false); }); it('should not inspect dkgstatus when a member DKG is imminent', () => { const dkgInfo = { + current_dkgs: [], active_dkgs: 1, next_dkg: 1, upcoming_dkgs: [upcomingDkg(1, true)], @@ -139,16 +145,6 @@ describe('isMasternodeSafeToStopDuringDkg', () => { expect(shouldInspectDkgStatusForSafeStop(dkgInfo)).to.equal(false); }); - it('should need confirmation only when membership data clears an imminent DKG', () => { - expect(needsSafeStopConfirmation({ active_dkgs: 0, next_dkg: 1, upcoming_dkgs: [] })) - .to.equal(true); - expect(needsSafeStopConfirmation({ active_dkgs: 0, next_dkg: 1 })).to.equal(false); - expect(needsSafeStopConfirmation({ - active_dkgs: 0, next_dkg: MIN_BLOCKS_BEFORE_DKG + 1, upcoming_dkgs: [], - })).to.equal(false); - expect(needsSafeStopConfirmation({ next_dkg: 1, upcoming_dkgs: [] })).to.equal(false); - }); - describe('fail-safe on malformed upcoming_dkgs', () => { const malformed = { 'upcoming_dkgs is not an array': {}, @@ -164,6 +160,7 @@ describe('isMasternodeSafeToStopDuringDkg', () => { Object.entries(malformed).forEach(([description, upcomingDkgs]) => { it(`should block when ${description}`, () => { const dkgInfo = { + current_dkgs: [], active_dkgs: 0, next_dkg: NEXT_DKG_NOT_IMMINENT, upcoming_dkgs: upcomingDkgs, @@ -175,6 +172,54 @@ describe('isMasternodeSafeToStopDuringDkg', () => { }); }); + describe('current DKG membership independent of local session tracking', () => { + const cases = [ + ['a member at the start block', [{ blocksSinceStart: 0, known: true, isMember: true }], false], + ['a member after the chain advances', [{ blocksSinceStart: 1, known: true, isMember: true }], false], + ['a non-member', [{ blocksSinceStart: 0, known: true, isMember: false }], true], + ['unknown membership', [{ blocksSinceStart: 0, known: false }], false], + ['a malformed list', {}, false], + ['a null entry', [null], false], + ['a missing start offset', [{ known: true, isMember: false }], false], + ['a negative start offset', [{ blocksSinceStart: -1, known: true, isMember: false }], false], + ['a missing membership flag', [{ blocksSinceStart: 0, known: true }], false], + ['no current session', [], true], + ]; + + cases.forEach(([description, currentDkgs, expected]) => { + it(`should return ${expected} for ${description} before local initialization`, () => { + const dkgInfo = { + active_dkgs: 0, + next_dkg: 1, + current_dkgs: currentDkgs, + upcoming_dkgs: [], + }; + expect(isMasternodeSafeToStopDuringDkg(dkgInfo)).to.equal(expected); + expect(shouldInspectDkgStatusForSafeStop(dkgInfo)).to.equal(false); + }); + }); + + it('should block when current membership is present but upcoming membership is missing', () => { + expect(isMasternodeSafeToStopDuringDkg({ + active_dkgs: 0, next_dkg: 24, current_dkgs: [], + })).to.equal(false); + }); + + it('should ignore stale local records when the complete membership lists are clear', () => { + expect(isMasternodeSafeToStopDuringDkg({ + active_dkgs: 1, next_dkg: 1, current_dkgs: [], upcoming_dkgs: [], + })).to.equal(true); + }); + + it('should keep the legacy guard when only upcoming membership is available', () => { + expect(isMasternodeSafeToStopDuringDkg({ + active_dkgs: 0, + next_dkg: 1, + upcoming_dkgs: [{ blocksUntilStart: 1, known: true, isMember: false }], + })).to.equal(false); + }); + }); + describe('per-session active window from dkgstatus + getblockcount', () => { it('blocks a platform session at offset 0 (quorumHeight == currentHeight)', () => { const dkgInfo = { active_dkgs: 1, next_dkg: NEXT_DKG_NOT_IMMINENT }; diff --git a/packages/dashmate/test/unit/core/quorum/waitForDKGWindowPass.spec.js b/packages/dashmate/test/unit/core/quorum/waitForDKGWindowPass.spec.js index c38d9b756a3..e2ae9cdae59 100644 --- a/packages/dashmate/test/unit/core/quorum/waitForDKGWindowPass.spec.js +++ b/packages/dashmate/test/unit/core/quorum/waitForDKGWindowPass.spec.js @@ -1,5 +1,4 @@ import waitForDKGWindowPass from '../../../../src/core/quorum/waitForDKGWindowPass.js'; -import { SAFE_STOP_CONFIRMATION_DELAY_MS } from '../../../../src/core/quorum/checkMasternodeSafeToStop.js'; const CHECK_INTERVAL_MS = 10000; @@ -22,23 +21,24 @@ describe('waitForDKGWindowPass', () => { expect(rpcClient.getBlockCount).to.not.have.been.called(); }); - it('should resolve after confirmation when an imminent DKG does not include this node', async function it() { + it('should resolve immediately when current and imminent DKGs do not include this node', async function it() { const clock = this.sinon.useFakeTimers(); rpcClient.quorum.withArgs('dkginfo').resolves({ result: { active_dkgs: 0, next_dkg: 1, + current_dkgs: [], upcoming_dkgs: [{ blocksUntilStart: 1, known: true, isMember: false }], }, }); const promise = waitForDKGWindowPass(rpcClient); - await clock.tickAsync(SAFE_STOP_CONFIRMATION_DELAY_MS); + await clock.tickAsync(0); await promise; - expect(rpcClient.quorum.withArgs('dkginfo')).to.have.been.calledTwice(); + expect(rpcClient.quorum.withArgs('dkginfo')).to.have.been.calledOnce(); expect(rpcClient.getBlockCount).to.not.have.been.called(); }); @@ -51,11 +51,12 @@ describe('waitForDKGWindowPass', () => { result: { active_dkgs: 0, next_dkg: 1, + current_dkgs: [], upcoming_dkgs: [{ blocksUntilStart: 1, known: true, isMember: true }], }, }) .onSecondCall() - .resolves({ result: { active_dkgs: 0, next_dkg: 24, upcoming_dkgs: [] } }); + .resolves({ result: { active_dkgs: 0, next_dkg: 24, current_dkgs: [], upcoming_dkgs: [] } }); const promise = waitForDKGWindowPass(rpcClient); @@ -68,6 +69,31 @@ describe('waitForDKGWindowPass', () => { expect(rpcClient.quorum.withArgs('dkginfo')).to.have.been.calledTwice(); }); + it('should wait for an uninitialized current member session to leave its window', async function it() { + const clock = this.sinon.useFakeTimers(); + rpcClient.quorum.withArgs('dkginfo') + .onFirstCall() + .resolves({ + result: { + active_dkgs: 0, + next_dkg: 1, + current_dkgs: [{ blocksSinceStart: 0, known: true, isMember: true }], + upcoming_dkgs: [], + }, + }) + .onSecondCall() + .resolves({ result: { active_dkgs: 0, next_dkg: 1, current_dkgs: [], upcoming_dkgs: [] } }); + + const promise = waitForDKGWindowPass(rpcClient); + await clock.tickAsync(0); + expect(rpcClient.quorum.withArgs('dkginfo')).to.have.been.calledOnce(); + await clock.tickAsync(CHECK_INTERVAL_MS); + await promise; + + expect(rpcClient.quorum.withArgs('dkginfo')).to.have.been.calledTwice(); + expect(rpcClient.getBlockCount).to.not.have.been.called(); + }); + it('waits through an active platform session and resolves once the window has passed', async function it() { // Block height advances from 1005 (offset 5, in window) to 1010 // (offset 10, window closed) between polls; active_dkgs stays > 0 From dff904463c4e31984045bbec991b037c74062581 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 7 Oct 2026 01:13:15 -0500 Subject: [PATCH 4/6] fix(dashmate): follow active DKG session RPC contract --- .../quorum/isMasternodeSafeToStopDuringDkg.js | 19 +++--- .../quorum/checkMasternodeSafeToStop.spec.js | 8 +-- .../isMasternodeSafeToStopDuringDkg.spec.js | 63 +++++++++++-------- .../core/quorum/waitForDKGWindowPass.spec.js | 12 ++-- 4 files changed, 56 insertions(+), 46 deletions(-) diff --git a/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js b/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js index baf4304a8f9..df77ca61b40 100644 --- a/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js +++ b/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js @@ -40,7 +40,7 @@ function hasValidDkgInfoShape(dkgInfo) { /** * Unknown membership and malformed entries block the stop. Upcoming - * sessions only matter within the restart margin; current_dkgs already + * sessions only matter within the restart margin; active_dkg_sessions already * contains only sessions inside their active window. */ function isDkgBlockingStop(dkg, blocksField, maxBlocks = Infinity) { @@ -56,19 +56,20 @@ function isDkgBlockingStop(dkg, blocksField, maxBlocks = Infinity) { } function isDkgMembershipBlockingStop(dkgInfo) { - const { current_dkgs: currentDkgs, upcoming_dkgs: upcomingDkgs } = dkgInfo; + const { active_dkg_sessions: activeDkgSessions, upcoming_dkgs: upcomingDkgs } = dkgInfo; // Earlier Core versions omit current-tip membership while their DKG // worker initializes. Keep the legacy guard until both lists are available. - if (currentDkgs === undefined) { + if (activeDkgSessions === undefined) { return dkgInfo.next_dkg <= MIN_BLOCKS_BEFORE_DKG; } - if (!Array.isArray(currentDkgs) || !Array.isArray(upcomingDkgs)) { + if (!Array.isArray(activeDkgSessions) || !Array.isArray(upcomingDkgs)) { return true; } - return currentDkgs.some((dkg) => isDkgBlockingStop(dkg, 'blocksSinceStart')) + return dkgInfo.active_dkgs > 0 + || activeDkgSessions.some((dkg) => isDkgBlockingStop(dkg, 'blocksSinceStart')) || upcomingDkgs.some((dkg) => isDkgBlockingStop(dkg, 'blocksUntilStart', MIN_BLOCKS_BEFORE_DKG)); } @@ -81,17 +82,17 @@ export function shouldInspectDkgStatusForSafeStop(dkgInfo) { return false; } - return dkgInfo.current_dkgs === undefined + return dkgInfo.active_dkg_sessions === undefined && dkgInfo.active_dkgs > 0 && dkgInfo.next_dkg > MIN_BLOCKS_BEFORE_DKG; } /** - * Core's current_dkgs and upcoming_dkgs report membership independently + * Core's active_dkg_sessions and upcoming_dkgs report membership independently * of asynchronous local session tracking. Any current member or unknown * session blocks stopping, as does an upcoming one within the restart margin. * - * Without current_dkgs, use the legacy next_dkg guard and resolve tracked + * Without active_dkg_sessions, use the legacy next_dkg guard and resolve tracked * sessions against dkgstatus and getblockcount. Unknown quorum types or * malformed status fail safe; sessions past dkgMiningWindowStart are ignored. * @@ -113,7 +114,7 @@ export default function isMasternodeSafeToStopDuringDkg( return false; } - if (dkgInfo.current_dkgs !== undefined || dkgInfo.active_dkgs === 0) { + if (dkgInfo.active_dkg_sessions !== undefined || dkgInfo.active_dkgs === 0) { return true; } diff --git a/packages/dashmate/test/unit/core/quorum/checkMasternodeSafeToStop.spec.js b/packages/dashmate/test/unit/core/quorum/checkMasternodeSafeToStop.spec.js index 69baa2b752b..c68c52ab1ed 100644 --- a/packages/dashmate/test/unit/core/quorum/checkMasternodeSafeToStop.spec.js +++ b/packages/dashmate/test/unit/core/quorum/checkMasternodeSafeToStop.spec.js @@ -10,9 +10,9 @@ describe('checkMasternodeSafeToStop', () => { it('should allow a non-member immediately using one complete membership response', async () => { rpcClient.quorum.withArgs('dkginfo').resolves({ result: { - active_dkgs: 1, + active_dkgs: 0, next_dkg: 1, - current_dkgs: [{ blocksSinceStart: 0, known: true, isMember: false }], + active_dkg_sessions: [{ blocksSinceStart: 0, known: true, isMember: false }], upcoming_dkgs: [{ blocksUntilStart: 1, known: true, isMember: false }], }, }); @@ -24,9 +24,9 @@ describe('checkMasternodeSafeToStop', () => { it('should block a member before Core publishes a local session', async () => { rpcClient.quorum.withArgs('dkginfo').resolves({ result: { - active_dkgs: 0, + active_dkgs: 1, next_dkg: 1, - current_dkgs: [{ blocksSinceStart: 0, known: true, isMember: true }], + active_dkg_sessions: [{ blocksSinceStart: 0, known: true, isMember: true }], upcoming_dkgs: [], }, }); diff --git a/packages/dashmate/test/unit/core/quorum/isMasternodeSafeToStopDuringDkg.spec.js b/packages/dashmate/test/unit/core/quorum/isMasternodeSafeToStopDuringDkg.spec.js index 85feb87f7dd..bfaab9875d0 100644 --- a/packages/dashmate/test/unit/core/quorum/isMasternodeSafeToStopDuringDkg.spec.js +++ b/packages/dashmate/test/unit/core/quorum/isMasternodeSafeToStopDuringDkg.spec.js @@ -28,7 +28,7 @@ describe('isMasternodeSafeToStopDuringDkg', () => { }); }); - describe('imminent DKG membership from dkginfo.upcoming_dkgs (Core with current_dkgs)', () => { + describe('imminent DKG membership from dkginfo.upcoming_dkgs (Core with active_dkg_sessions)', () => { // Shape of a `quorum dkginfo` upcoming_dkgs entry once membership is known. function upcomingDkg(blocksUntilStart, isMember) { return { @@ -56,7 +56,7 @@ describe('isMasternodeSafeToStopDuringDkg', () => { it('should allow the stop when an imminent DKG does not include this node', () => { const dkgInfo = { - current_dkgs: [], + active_dkg_sessions: [], active_dkgs: 0, next_dkg: 1, upcoming_dkgs: [upcomingDkg(1, false), upcomingDkg(MIN_BLOCKS_BEFORE_DKG, false)], @@ -68,14 +68,14 @@ describe('isMasternodeSafeToStopDuringDkg', () => { it('should allow the stop when next_dkg is imminent but no upcoming DKG is listed', () => { // next_dkg reports 1 for the whole rotation signing window, even // after the last rotated index has started. - const dkgInfo = { current_dkgs: [], active_dkgs: 0, next_dkg: 1, upcoming_dkgs: [] }; + const dkgInfo = { active_dkg_sessions: [], active_dkgs: 0, next_dkg: 1, upcoming_dkgs: [] }; expect(isMasternodeSafeToStopDuringDkg(dkgInfo, undefined, 1000)).to.equal(true); }); it('should block when this node is a member of an imminent DKG', () => { const dkgInfo = { - current_dkgs: [], + active_dkg_sessions: [], active_dkgs: 0, next_dkg: 1, upcoming_dkgs: [upcomingDkg(1, false), upcomingDkg(MIN_BLOCKS_BEFORE_DKG, true)], @@ -86,7 +86,7 @@ describe('isMasternodeSafeToStopDuringDkg', () => { it('should allow the stop when this node is a member of a DKG that is not yet imminent', () => { const dkgInfo = { - current_dkgs: [], + active_dkg_sessions: [], active_dkgs: 0, next_dkg: MIN_BLOCKS_BEFORE_DKG + 1, upcoming_dkgs: [upcomingDkg(MIN_BLOCKS_BEFORE_DKG + 1, true)], @@ -97,7 +97,7 @@ describe('isMasternodeSafeToStopDuringDkg', () => { it('should block when membership of an imminent DKG is unknown', () => { const dkgInfo = { - current_dkgs: [], + active_dkg_sessions: [], active_dkgs: 0, next_dkg: 1, upcoming_dkgs: [unknownUpcomingDkg(1)], @@ -108,7 +108,7 @@ describe('isMasternodeSafeToStopDuringDkg', () => { it('should allow the stop when membership is unknown only for a DKG that is not yet imminent', () => { const dkgInfo = { - current_dkgs: [], + active_dkg_sessions: [], active_dkgs: 0, next_dkg: MIN_BLOCKS_BEFORE_DKG + 1, upcoming_dkgs: [unknownUpcomingDkg(MIN_BLOCKS_BEFORE_DKG + 1)], @@ -119,7 +119,7 @@ describe('isMasternodeSafeToStopDuringDkg', () => { it('should block a current member even when the imminent DKG does not include this node', () => { const dkgInfo = { - current_dkgs: [{ blocksSinceStart: 5, known: true, isMember: true }], + active_dkg_sessions: [{ blocksSinceStart: 5, known: true, isMember: true }], active_dkgs: 1, next_dkg: 1, upcoming_dkgs: [upcomingDkg(1, false)], @@ -136,7 +136,7 @@ describe('isMasternodeSafeToStopDuringDkg', () => { it('should not inspect dkgstatus when a member DKG is imminent', () => { const dkgInfo = { - current_dkgs: [], + active_dkg_sessions: [], active_dkgs: 1, next_dkg: 1, upcoming_dkgs: [upcomingDkg(1, true)], @@ -160,7 +160,7 @@ describe('isMasternodeSafeToStopDuringDkg', () => { Object.entries(malformed).forEach(([description, upcomingDkgs]) => { it(`should block when ${description}`, () => { const dkgInfo = { - current_dkgs: [], + active_dkg_sessions: [], active_dkgs: 0, next_dkg: NEXT_DKG_NOT_IMMINENT, upcoming_dkgs: upcomingDkgs, @@ -174,24 +174,24 @@ describe('isMasternodeSafeToStopDuringDkg', () => { describe('current DKG membership independent of local session tracking', () => { const cases = [ - ['a member at the start block', [{ blocksSinceStart: 0, known: true, isMember: true }], false], - ['a member after the chain advances', [{ blocksSinceStart: 1, known: true, isMember: true }], false], - ['a non-member', [{ blocksSinceStart: 0, known: true, isMember: false }], true], - ['unknown membership', [{ blocksSinceStart: 0, known: false }], false], - ['a malformed list', {}, false], - ['a null entry', [null], false], - ['a missing start offset', [{ known: true, isMember: false }], false], - ['a negative start offset', [{ blocksSinceStart: -1, known: true, isMember: false }], false], - ['a missing membership flag', [{ blocksSinceStart: 0, known: true }], false], - ['no current session', [], true], + ['a member at the start block', [{ blocksSinceStart: 0, known: true, isMember: true }], 1, false], + ['a member after the chain advances', [{ blocksSinceStart: 1, known: true, isMember: true }], 1, false], + ['a non-member', [{ blocksSinceStart: 0, known: true, isMember: false }], 0, true], + ['unknown membership', [{ blocksSinceStart: 0, known: false }], 1, false], + ['a malformed list', {}, 0, false], + ['a null entry', [null], 0, false], + ['a missing start offset', [{ known: true, isMember: false }], 0, false], + ['a negative start offset', [{ blocksSinceStart: -1, known: true, isMember: false }], 0, false], + ['a missing membership flag', [{ blocksSinceStart: 0, known: true }], 0, false], + ['no current session', [], 0, true], ]; - cases.forEach(([description, currentDkgs, expected]) => { + cases.forEach(([description, activeDkgSessions, activeDkgs, expected]) => { it(`should return ${expected} for ${description} before local initialization`, () => { const dkgInfo = { - active_dkgs: 0, + active_dkgs: activeDkgs, next_dkg: 1, - current_dkgs: currentDkgs, + active_dkg_sessions: activeDkgSessions, upcoming_dkgs: [], }; expect(isMasternodeSafeToStopDuringDkg(dkgInfo)).to.equal(expected); @@ -201,14 +201,23 @@ describe('isMasternodeSafeToStopDuringDkg', () => { it('should block when current membership is present but upcoming membership is missing', () => { expect(isMasternodeSafeToStopDuringDkg({ - active_dkgs: 0, next_dkg: 24, current_dkgs: [], + active_dkgs: 0, next_dkg: 24, active_dkg_sessions: [], })).to.equal(false); }); - it('should ignore stale local records when the complete membership lists are clear', () => { + it('should block a positive active count even when the detail lists are clear', () => { expect(isMasternodeSafeToStopDuringDkg({ - active_dkgs: 1, next_dkg: 1, current_dkgs: [], upcoming_dkgs: [], - })).to.equal(true); + active_dkgs: 1, next_dkg: 1, active_dkg_sessions: [], upcoming_dkgs: [], + })).to.equal(false); + }); + + it('should block a member detail even if the active count is incorrectly zero', () => { + expect(isMasternodeSafeToStopDuringDkg({ + active_dkgs: 0, + next_dkg: 24, + active_dkg_sessions: [{ blocksSinceStart: 0, known: true, isMember: true }], + upcoming_dkgs: [], + })).to.equal(false); }); it('should keep the legacy guard when only upcoming membership is available', () => { diff --git a/packages/dashmate/test/unit/core/quorum/waitForDKGWindowPass.spec.js b/packages/dashmate/test/unit/core/quorum/waitForDKGWindowPass.spec.js index e2ae9cdae59..1a86063a4ca 100644 --- a/packages/dashmate/test/unit/core/quorum/waitForDKGWindowPass.spec.js +++ b/packages/dashmate/test/unit/core/quorum/waitForDKGWindowPass.spec.js @@ -28,7 +28,7 @@ describe('waitForDKGWindowPass', () => { result: { active_dkgs: 0, next_dkg: 1, - current_dkgs: [], + active_dkg_sessions: [], upcoming_dkgs: [{ blocksUntilStart: 1, known: true, isMember: false }], }, }); @@ -51,12 +51,12 @@ describe('waitForDKGWindowPass', () => { result: { active_dkgs: 0, next_dkg: 1, - current_dkgs: [], + active_dkg_sessions: [], upcoming_dkgs: [{ blocksUntilStart: 1, known: true, isMember: true }], }, }) .onSecondCall() - .resolves({ result: { active_dkgs: 0, next_dkg: 24, current_dkgs: [], upcoming_dkgs: [] } }); + .resolves({ result: { active_dkgs: 0, next_dkg: 24, active_dkg_sessions: [], upcoming_dkgs: [] } }); const promise = waitForDKGWindowPass(rpcClient); @@ -75,14 +75,14 @@ describe('waitForDKGWindowPass', () => { .onFirstCall() .resolves({ result: { - active_dkgs: 0, + active_dkgs: 1, next_dkg: 1, - current_dkgs: [{ blocksSinceStart: 0, known: true, isMember: true }], + active_dkg_sessions: [{ blocksSinceStart: 0, known: true, isMember: true }], upcoming_dkgs: [], }, }) .onSecondCall() - .resolves({ result: { active_dkgs: 0, next_dkg: 1, current_dkgs: [], upcoming_dkgs: [] } }); + .resolves({ result: { active_dkgs: 0, next_dkg: 1, active_dkg_sessions: [], upcoming_dkgs: [] } }); const promise = waitForDKGWindowPass(rpcClient); await clock.tickAsync(0); From acc0f3aa0da687ce083553b52ca803004be9b6d3 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 7 Oct 2026 01:34:23 -0500 Subject: [PATCH 5/6] docs(dashmate): clarify DKG membership safety assumptions --- packages/dashmate/src/constants.js | 3 ++- .../src/core/quorum/isMasternodeSafeToStopDuringDkg.js | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/dashmate/src/constants.js b/packages/dashmate/src/constants.js index f4377e181b3..58d71b59703 100644 --- a/packages/dashmate/src/constants.js +++ b/packages/dashmate/src/constants.js @@ -38,7 +38,8 @@ export const QUORUM_TYPES = { export const MASTERNODE_COLLATERAL_AMOUNT = 1000; export const HPMN_COLLATERAL_AMOUNT = 4000; -// number of blocks to wait before core DKG exchange session +// Restart margin before a Core DKG session. Keep at most Core's WORK_DIFF_DEPTH +// (8): upcoming_dkgs only includes sessions whose work block is already mined. export const MIN_BLOCKS_BEFORE_DKG = 6; export const PACKAGE_ROOT_DIR = path.join(url.fileURLToPath(import.meta.url), '../..'); diff --git a/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js b/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js index df77ca61b40..623371a3eb3 100644 --- a/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js +++ b/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js @@ -58,8 +58,8 @@ function isDkgBlockingStop(dkg, blocksField, maxBlocks = Infinity) { function isDkgMembershipBlockingStop(dkgInfo) { const { active_dkg_sessions: activeDkgSessions, upcoming_dkgs: upcomingDkgs } = dkgInfo; - // Earlier Core versions omit current-tip membership while their DKG - // worker initializes. Keep the legacy guard until both lists are available. + // Older Core, and current Core without a proTxHash, omit membership lists. + // Keep the legacy guard when active_dkg_sessions is unavailable. if (activeDkgSessions === undefined) { return dkgInfo.next_dkg <= MIN_BLOCKS_BEFORE_DKG; } From 3b540cc2b3e40afb0db75e1eb8d0dc7b64f04fda Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 7 Oct 2026 13:30:27 -0500 Subject: [PATCH 6/6] refactor(dashmate): key DKG membership safety on upcoming_dkgs Core no longer reports active_dkg_sessions (dashpay/dash#7812 was slimmed). When upcoming_dkgs is present, stopping is safe only if active_dkgs is 0 and no member, unknown-membership or malformed upcoming DKG starts within MIN_BLOCKS_BEFORE_DKG. The legacy next_dkg guard and dkgstatus refinement are kept for responses without upcoming_dkgs. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../quorum/isMasternodeSafeToStopDuringDkg.js | 57 +++---- .../quorum/checkMasternodeSafeToStop.spec.js | 22 ++- .../isMasternodeSafeToStopDuringDkg.spec.js | 143 ++++++------------ .../core/quorum/waitForDKGWindowPass.spec.js | 10 +- 4 files changed, 93 insertions(+), 139 deletions(-) diff --git a/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js b/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js index 623371a3eb3..de5b8840d97 100644 --- a/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js +++ b/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js @@ -39,38 +39,19 @@ function hasValidDkgInfoShape(dkgInfo) { } /** - * Unknown membership and malformed entries block the stop. Upcoming - * sessions only matter within the restart margin; active_dkg_sessions already - * contains only sessions inside their active window. + * Malformed entries always block; member and unknown-membership entries + * block when they start within the restart margin. */ -function isDkgBlockingStop(dkg, blocksField, maxBlocks = Infinity) { +function isUpcomingDkgBlockingStop(dkg) { if (!dkg || typeof dkg !== 'object' - || !isValidDkgCounter(dkg[blocksField]) + || !isValidDkgCounter(dkg.blocksUntilStart) || typeof dkg.known !== 'boolean' || (dkg.known && typeof dkg.isMember !== 'boolean')) { return true; } - return dkg[blocksField] <= maxBlocks && (!dkg.known || dkg.isMember); -} - -function isDkgMembershipBlockingStop(dkgInfo) { - const { active_dkg_sessions: activeDkgSessions, upcoming_dkgs: upcomingDkgs } = dkgInfo; - - // Older Core, and current Core without a proTxHash, omit membership lists. - // Keep the legacy guard when active_dkg_sessions is unavailable. - if (activeDkgSessions === undefined) { - return dkgInfo.next_dkg <= MIN_BLOCKS_BEFORE_DKG; - } - - if (!Array.isArray(activeDkgSessions) || !Array.isArray(upcomingDkgs)) { - return true; - } - - return dkgInfo.active_dkgs > 0 - || activeDkgSessions.some((dkg) => isDkgBlockingStop(dkg, 'blocksSinceStart')) - || upcomingDkgs.some((dkg) => isDkgBlockingStop(dkg, 'blocksUntilStart', MIN_BLOCKS_BEFORE_DKG)); + return dkg.blocksUntilStart <= MIN_BLOCKS_BEFORE_DKG && (!dkg.known || dkg.isMember); } /** @@ -82,19 +63,19 @@ export function shouldInspectDkgStatusForSafeStop(dkgInfo) { return false; } - return dkgInfo.active_dkg_sessions === undefined + return dkgInfo.upcoming_dkgs === undefined && dkgInfo.active_dkgs > 0 && dkgInfo.next_dkg > MIN_BLOCKS_BEFORE_DKG; } /** - * Core's active_dkg_sessions and upcoming_dkgs report membership independently - * of asynchronous local session tracking. Any current member or unknown - * session blocks stopping, as does an upcoming one within the restart margin. + * With upcoming_dkgs, stopping is safe when no DKG counts toward active_dkgs + * and no member or unknown-membership DKG starts within the restart margin. * - * Without active_dkg_sessions, use the legacy next_dkg guard and resolve tracked - * sessions against dkgstatus and getblockcount. Unknown quorum types or - * malformed status fail safe; sessions past dkgMiningWindowStart are ignored. + * Without upcoming_dkgs (older Core, or no proTxHash), use the legacy next_dkg + * guard and resolve tracked sessions against dkgstatus and getblockcount. + * Unknown quorum types or malformed status fail safe; sessions past + * dkgMiningWindowStart are ignored. * * @param {Object} dkgInfo Result of quorum dkginfo. * @param {Object} [dkgStatus] Result of quorum dkgstatus for older Core. @@ -110,11 +91,21 @@ export default function isMasternodeSafeToStopDuringDkg( return false; } - if (isDkgMembershipBlockingStop(dkgInfo)) { + const { upcoming_dkgs: upcomingDkgs } = dkgInfo; + + // Relies on active_dkgs counting member and unknown-membership sessions + // (dashpay/dash#7812). + if (upcomingDkgs !== undefined) { + return dkgInfo.active_dkgs === 0 + && Array.isArray(upcomingDkgs) + && !upcomingDkgs.some(isUpcomingDkgBlockingStop); + } + + if (dkgInfo.next_dkg <= MIN_BLOCKS_BEFORE_DKG) { return false; } - if (dkgInfo.active_dkg_sessions !== undefined || dkgInfo.active_dkgs === 0) { + if (dkgInfo.active_dkgs === 0) { return true; } diff --git a/packages/dashmate/test/unit/core/quorum/checkMasternodeSafeToStop.spec.js b/packages/dashmate/test/unit/core/quorum/checkMasternodeSafeToStop.spec.js index c68c52ab1ed..475b8bfb499 100644 --- a/packages/dashmate/test/unit/core/quorum/checkMasternodeSafeToStop.spec.js +++ b/packages/dashmate/test/unit/core/quorum/checkMasternodeSafeToStop.spec.js @@ -7,12 +7,11 @@ describe('checkMasternodeSafeToStop', () => { rpcClient = { quorum: this.sinon.stub(), getBlockCount: this.sinon.stub() }; }); - it('should allow a non-member immediately using one complete membership response', async () => { + it('should allow a non-member immediately using one membership response', async () => { rpcClient.quorum.withArgs('dkginfo').resolves({ result: { active_dkgs: 0, next_dkg: 1, - active_dkg_sessions: [{ blocksSinceStart: 0, known: true, isMember: false }], upcoming_dkgs: [{ blocksUntilStart: 1, known: true, isMember: false }], }, }); @@ -21,12 +20,11 @@ describe('checkMasternodeSafeToStop', () => { expect(rpcClient.getBlockCount).to.not.have.been.called(); }); - it('should block a member before Core publishes a local session', async () => { + it('should block a positive active_dkgs without inspecting dkgstatus', async () => { rpcClient.quorum.withArgs('dkginfo').resolves({ result: { active_dkgs: 1, - next_dkg: 1, - active_dkg_sessions: [{ blocksSinceStart: 0, known: true, isMember: true }], + next_dkg: 24, upcoming_dkgs: [], }, }); @@ -35,14 +33,24 @@ describe('checkMasternodeSafeToStop', () => { expect(rpcClient.getBlockCount).to.not.have.been.called(); }); - it('should retain the imminent-DKG guard for earlier v24 responses', async () => { + it('should block a member of an imminent DKG', async () => { rpcClient.quorum.withArgs('dkginfo').resolves({ - result: { active_dkgs: 0, next_dkg: 1, upcoming_dkgs: [] }, + result: { + active_dkgs: 0, + next_dkg: 1, + upcoming_dkgs: [{ blocksUntilStart: 1, known: true, isMember: true }], + }, }); expect(await checkMasternodeSafeToStop(rpcClient)).to.equal(false); expect(rpcClient.quorum).to.have.been.calledOnceWith('dkginfo'); }); + it('should retain the imminent-DKG guard without upcoming_dkgs', async () => { + rpcClient.quorum.withArgs('dkginfo').resolves({ result: { active_dkgs: 0, next_dkg: 1 } }); + expect(await checkMasternodeSafeToStop(rpcClient)).to.equal(false); + expect(rpcClient.quorum).to.have.been.calledOnceWith('dkginfo'); + }); + it('should allow a legacy response with no active or imminent DKG', async () => { rpcClient.quorum.withArgs('dkginfo').resolves({ result: { active_dkgs: 0, next_dkg: 24 } }); expect(await checkMasternodeSafeToStop(rpcClient)).to.equal(true); diff --git a/packages/dashmate/test/unit/core/quorum/isMasternodeSafeToStopDuringDkg.spec.js b/packages/dashmate/test/unit/core/quorum/isMasternodeSafeToStopDuringDkg.spec.js index bfaab9875d0..1088c994022 100644 --- a/packages/dashmate/test/unit/core/quorum/isMasternodeSafeToStopDuringDkg.spec.js +++ b/packages/dashmate/test/unit/core/quorum/isMasternodeSafeToStopDuringDkg.spec.js @@ -26,9 +26,18 @@ describe('isMasternodeSafeToStopDuringDkg', () => { expect(isMasternodeSafeToStopDuringDkg(dkgInfo, { session: [] }, 1000)) .to.equal(true); }); + + it('inspects dkgstatus only when active_dkgs > 0 and no DKG is imminent', () => { + expect(shouldInspectDkgStatusForSafeStop({ active_dkgs: 1, next_dkg: NEXT_DKG_NOT_IMMINENT })) + .to.equal(true); + expect(shouldInspectDkgStatusForSafeStop({ active_dkgs: 1, next_dkg: MIN_BLOCKS_BEFORE_DKG })) + .to.equal(false); + expect(shouldInspectDkgStatusForSafeStop({ active_dkgs: 0, next_dkg: NEXT_DKG_NOT_IMMINENT })) + .to.equal(false); + }); }); - describe('imminent DKG membership from dkginfo.upcoming_dkgs (Core with active_dkg_sessions)', () => { + describe('membership from dkginfo.upcoming_dkgs (Core with a proTxHash)', () => { // Shape of a `quorum dkginfo` upcoming_dkgs entry once membership is known. function upcomingDkg(blocksUntilStart, isMember) { return { @@ -56,89 +65,90 @@ describe('isMasternodeSafeToStopDuringDkg', () => { it('should allow the stop when an imminent DKG does not include this node', () => { const dkgInfo = { - active_dkg_sessions: [], active_dkgs: 0, next_dkg: 1, upcoming_dkgs: [upcomingDkg(1, false), upcomingDkg(MIN_BLOCKS_BEFORE_DKG, false)], }; - expect(isMasternodeSafeToStopDuringDkg(dkgInfo, undefined, 1000)).to.equal(true); + expect(isMasternodeSafeToStopDuringDkg(dkgInfo)).to.equal(true); }); it('should allow the stop when next_dkg is imminent but no upcoming DKG is listed', () => { // next_dkg reports 1 for the whole rotation signing window, even // after the last rotated index has started. - const dkgInfo = { active_dkg_sessions: [], active_dkgs: 0, next_dkg: 1, upcoming_dkgs: [] }; + const dkgInfo = { active_dkgs: 0, next_dkg: 1, upcoming_dkgs: [] }; - expect(isMasternodeSafeToStopDuringDkg(dkgInfo, undefined, 1000)).to.equal(true); + expect(isMasternodeSafeToStopDuringDkg(dkgInfo)).to.equal(true); + }); + + it('should block when active_dkgs is positive even if no upcoming DKG blocks', () => { + const dkgInfo = { + active_dkgs: 1, + next_dkg: NEXT_DKG_NOT_IMMINENT, + upcoming_dkgs: [upcomingDkg(1, false)], + }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo)).to.equal(false); + }); + + it('should not use dkgstatus to override a positive active_dkgs', () => { + const dkgInfo = { active_dkgs: 1, next_dkg: NEXT_DKG_NOT_IMMINENT, upcoming_dkgs: [] }; + // Every session is past its window, which would allow the legacy path. + const dkgStatus = { + session: [ + { llmqType: 'llmq_test_platform', status: { quorumHeight: 1000 } }, + ], + }; + + expect(shouldInspectDkgStatusForSafeStop(dkgInfo)).to.equal(false); + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, dkgStatus, 1000 + PLATFORM_WINDOW)) + .to.equal(false); }); it('should block when this node is a member of an imminent DKG', () => { const dkgInfo = { - active_dkg_sessions: [], active_dkgs: 0, next_dkg: 1, upcoming_dkgs: [upcomingDkg(1, false), upcomingDkg(MIN_BLOCKS_BEFORE_DKG, true)], }; - expect(isMasternodeSafeToStopDuringDkg(dkgInfo, undefined, 1000)).to.equal(false); + expect(isMasternodeSafeToStopDuringDkg(dkgInfo)).to.equal(false); }); it('should allow the stop when this node is a member of a DKG that is not yet imminent', () => { const dkgInfo = { - active_dkg_sessions: [], active_dkgs: 0, next_dkg: MIN_BLOCKS_BEFORE_DKG + 1, upcoming_dkgs: [upcomingDkg(MIN_BLOCKS_BEFORE_DKG + 1, true)], }; - expect(isMasternodeSafeToStopDuringDkg(dkgInfo, undefined, 1000)).to.equal(true); + expect(isMasternodeSafeToStopDuringDkg(dkgInfo)).to.equal(true); }); it('should block when membership of an imminent DKG is unknown', () => { const dkgInfo = { - active_dkg_sessions: [], active_dkgs: 0, next_dkg: 1, upcoming_dkgs: [unknownUpcomingDkg(1)], }; - expect(isMasternodeSafeToStopDuringDkg(dkgInfo, undefined, 1000)).to.equal(false); + expect(isMasternodeSafeToStopDuringDkg(dkgInfo)).to.equal(false); }); it('should allow the stop when membership is unknown only for a DKG that is not yet imminent', () => { const dkgInfo = { - active_dkg_sessions: [], active_dkgs: 0, next_dkg: MIN_BLOCKS_BEFORE_DKG + 1, upcoming_dkgs: [unknownUpcomingDkg(MIN_BLOCKS_BEFORE_DKG + 1)], }; - expect(isMasternodeSafeToStopDuringDkg(dkgInfo, undefined, 1000)).to.equal(true); + expect(isMasternodeSafeToStopDuringDkg(dkgInfo)).to.equal(true); }); - it('should block a current member even when the imminent DKG does not include this node', () => { + it('should not inspect dkgstatus when upcoming_dkgs is present', () => { const dkgInfo = { - active_dkg_sessions: [{ blocksSinceStart: 5, known: true, isMember: true }], active_dkgs: 1, - next_dkg: 1, - upcoming_dkgs: [upcomingDkg(1, false)], - }; - const dkgStatus = { - session: [ - { llmqType: 'llmq_400_60', status: { quorumHeight: 995 } }, - ], - }; - - expect(shouldInspectDkgStatusForSafeStop(dkgInfo)).to.equal(false); - expect(isMasternodeSafeToStopDuringDkg(dkgInfo, dkgStatus, 1000)).to.equal(false); - }); - - it('should not inspect dkgstatus when a member DKG is imminent', () => { - const dkgInfo = { - active_dkg_sessions: [], - active_dkgs: 1, - next_dkg: 1, + next_dkg: NEXT_DKG_NOT_IMMINENT, upcoming_dkgs: [upcomingDkg(1, true)], }; @@ -147,7 +157,8 @@ describe('isMasternodeSafeToStopDuringDkg', () => { describe('fail-safe on malformed upcoming_dkgs', () => { const malformed = { - 'upcoming_dkgs is not an array': {}, + 'upcoming_dkgs is an object': {}, + 'upcoming_dkgs is null': null, 'an entry is null': [null], 'an entry is missing known': [{ blocksUntilStart: 1, isMember: false }], 'a known entry is missing isMember': [{ blocksUntilStart: 1, known: true }], @@ -155,80 +166,26 @@ describe('isMasternodeSafeToStopDuringDkg', () => { 'an entry has a negative blocksUntilStart': [ { blocksUntilStart: -1, known: true, isMember: false }, ], + 'an entry outside the margin is malformed': [ + { blocksUntilStart: MIN_BLOCKS_BEFORE_DKG + 1, known: true }, + ], }; Object.entries(malformed).forEach(([description, upcomingDkgs]) => { it(`should block when ${description}`, () => { const dkgInfo = { - active_dkg_sessions: [], active_dkgs: 0, next_dkg: NEXT_DKG_NOT_IMMINENT, upcoming_dkgs: upcomingDkgs, }; - expect(isMasternodeSafeToStopDuringDkg(dkgInfo, undefined, 1000)).to.equal(false); + expect(shouldInspectDkgStatusForSafeStop(dkgInfo)).to.equal(false); + expect(isMasternodeSafeToStopDuringDkg(dkgInfo)).to.equal(false); }); }); }); }); - describe('current DKG membership independent of local session tracking', () => { - const cases = [ - ['a member at the start block', [{ blocksSinceStart: 0, known: true, isMember: true }], 1, false], - ['a member after the chain advances', [{ blocksSinceStart: 1, known: true, isMember: true }], 1, false], - ['a non-member', [{ blocksSinceStart: 0, known: true, isMember: false }], 0, true], - ['unknown membership', [{ blocksSinceStart: 0, known: false }], 1, false], - ['a malformed list', {}, 0, false], - ['a null entry', [null], 0, false], - ['a missing start offset', [{ known: true, isMember: false }], 0, false], - ['a negative start offset', [{ blocksSinceStart: -1, known: true, isMember: false }], 0, false], - ['a missing membership flag', [{ blocksSinceStart: 0, known: true }], 0, false], - ['no current session', [], 0, true], - ]; - - cases.forEach(([description, activeDkgSessions, activeDkgs, expected]) => { - it(`should return ${expected} for ${description} before local initialization`, () => { - const dkgInfo = { - active_dkgs: activeDkgs, - next_dkg: 1, - active_dkg_sessions: activeDkgSessions, - upcoming_dkgs: [], - }; - expect(isMasternodeSafeToStopDuringDkg(dkgInfo)).to.equal(expected); - expect(shouldInspectDkgStatusForSafeStop(dkgInfo)).to.equal(false); - }); - }); - - it('should block when current membership is present but upcoming membership is missing', () => { - expect(isMasternodeSafeToStopDuringDkg({ - active_dkgs: 0, next_dkg: 24, active_dkg_sessions: [], - })).to.equal(false); - }); - - it('should block a positive active count even when the detail lists are clear', () => { - expect(isMasternodeSafeToStopDuringDkg({ - active_dkgs: 1, next_dkg: 1, active_dkg_sessions: [], upcoming_dkgs: [], - })).to.equal(false); - }); - - it('should block a member detail even if the active count is incorrectly zero', () => { - expect(isMasternodeSafeToStopDuringDkg({ - active_dkgs: 0, - next_dkg: 24, - active_dkg_sessions: [{ blocksSinceStart: 0, known: true, isMember: true }], - upcoming_dkgs: [], - })).to.equal(false); - }); - - it('should keep the legacy guard when only upcoming membership is available', () => { - expect(isMasternodeSafeToStopDuringDkg({ - active_dkgs: 0, - next_dkg: 1, - upcoming_dkgs: [{ blocksUntilStart: 1, known: true, isMember: false }], - })).to.equal(false); - }); - }); - describe('per-session active window from dkgstatus + getblockcount', () => { it('blocks a platform session at offset 0 (quorumHeight == currentHeight)', () => { const dkgInfo = { active_dkgs: 1, next_dkg: NEXT_DKG_NOT_IMMINENT }; diff --git a/packages/dashmate/test/unit/core/quorum/waitForDKGWindowPass.spec.js b/packages/dashmate/test/unit/core/quorum/waitForDKGWindowPass.spec.js index 1a86063a4ca..872d5db2a49 100644 --- a/packages/dashmate/test/unit/core/quorum/waitForDKGWindowPass.spec.js +++ b/packages/dashmate/test/unit/core/quorum/waitForDKGWindowPass.spec.js @@ -28,7 +28,6 @@ describe('waitForDKGWindowPass', () => { result: { active_dkgs: 0, next_dkg: 1, - active_dkg_sessions: [], upcoming_dkgs: [{ blocksUntilStart: 1, known: true, isMember: false }], }, }); @@ -51,12 +50,11 @@ describe('waitForDKGWindowPass', () => { result: { active_dkgs: 0, next_dkg: 1, - active_dkg_sessions: [], upcoming_dkgs: [{ blocksUntilStart: 1, known: true, isMember: true }], }, }) .onSecondCall() - .resolves({ result: { active_dkgs: 0, next_dkg: 24, active_dkg_sessions: [], upcoming_dkgs: [] } }); + .resolves({ result: { active_dkgs: 0, next_dkg: 24, upcoming_dkgs: [] } }); const promise = waitForDKGWindowPass(rpcClient); @@ -69,7 +67,7 @@ describe('waitForDKGWindowPass', () => { expect(rpcClient.quorum.withArgs('dkginfo')).to.have.been.calledTwice(); }); - it('should wait for an uninitialized current member session to leave its window', async function it() { + it('should wait while active_dkgs is positive without inspecting dkgstatus', async function it() { const clock = this.sinon.useFakeTimers(); rpcClient.quorum.withArgs('dkginfo') .onFirstCall() @@ -77,12 +75,11 @@ describe('waitForDKGWindowPass', () => { result: { active_dkgs: 1, next_dkg: 1, - active_dkg_sessions: [{ blocksSinceStart: 0, known: true, isMember: true }], upcoming_dkgs: [], }, }) .onSecondCall() - .resolves({ result: { active_dkgs: 0, next_dkg: 1, active_dkg_sessions: [], upcoming_dkgs: [] } }); + .resolves({ result: { active_dkgs: 0, next_dkg: 1, upcoming_dkgs: [] } }); const promise = waitForDKGWindowPass(rpcClient); await clock.tickAsync(0); @@ -91,6 +88,7 @@ describe('waitForDKGWindowPass', () => { await promise; expect(rpcClient.quorum.withArgs('dkginfo')).to.have.been.calledTwice(); + expect(rpcClient.quorum.withArgs('dkgstatus')).to.not.have.been.called(); expect(rpcClient.getBlockCount).to.not.have.been.called(); });