diff --git a/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js b/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js new file mode 100644 index 00000000000..f3c4a768bd5 --- /dev/null +++ b/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js @@ -0,0 +1,141 @@ +import { MIN_BLOCKS_BEFORE_DKG } from '../../constants.js'; + +/** + * `dkgMiningWindowStart` values from Dash Core `src/llmq/params.h`, + * indexed by llmqType string as reported in `quorum dkgstatus`. The + * number is how many blocks after a session's `quorumHeight` the + * active DKG window lasts. Once `currentHeight - quorumHeight >= + * window`, the session is past its active phase and a restart no + * longer risks a PoSe penalty for that session. + * + * Keep in sync with `src/llmq/params.h` in Dash Core. + */ +export const DKG_MINING_WINDOW_START_BY_LLMQ_TYPE = { + llmq_test: 10, + llmq_test_instantsend: 10, + llmq_test_v17: 10, + llmq_test_dip0024: 12, + llmq_test_platform: 10, + llmq_devnet: 10, + llmq_devnet_dip0024: 12, + llmq_devnet_platform: 10, + llmq_50_60: 10, + llmq_60_75: 42, + llmq_400_60: 20, + llmq_400_85: 20, + llmq_100_67: 10, + llmq_25_67: 10, +}; + +function isValidDkgCounter(value) { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function hasValidDkgInfoShape(dkgInfo) { + return !!dkgInfo + && typeof dkgInfo === 'object' + && isValidDkgCounter(dkgInfo.active_dkgs) + && isValidDkgCounter(dkgInfo.next_dkg); +} + +/** + * @param {{ active_dkgs: number, next_dkg: number }} dkgInfo + * @return {boolean} + */ +export function shouldInspectDkgStatusForSafeStop(dkgInfo) { + if (!hasValidDkgInfoShape(dkgInfo)) { + return false; + } + + return 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. + * + * Inputs come from three Core RPCs: + * - `quorum dkginfo` → `{ active_dkgs, next_dkg }` + * - `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. + * 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 }} 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. + */ +export default function isMasternodeSafeToStopDuringDkg( + dkgInfo, + dkgStatus, + currentHeight, +) { + if (!hasValidDkgInfoShape(dkgInfo)) { + return false; + } + + const { active_dkgs: activeDkgs, next_dkg: nextDkg } = dkgInfo; + + if (nextDkg <= MIN_BLOCKS_BEFORE_DKG) { + return false; + } + + if (activeDkgs === 0) { + return true; + } + + if (!dkgStatus + || !Array.isArray(dkgStatus.session) + || dkgStatus.session.length === 0 + || typeof currentHeight !== 'number' + || !Number.isFinite(currentHeight)) { + return false; + } + + for (const sessionEntry of dkgStatus.session) { + const llmqType = sessionEntry && sessionEntry.llmqType; + const quorumHeight = sessionEntry && sessionEntry.status + && sessionEntry.status.quorumHeight; + + const windowLength = DKG_MINING_WINDOW_START_BY_LLMQ_TYPE[llmqType]; + + if (windowLength === undefined + || typeof quorumHeight !== 'number' + || !Number.isFinite(quorumHeight)) { + return false; + } + + const offset = currentHeight - quorumHeight; + if (offset < 0 || offset < windowLength) { + return false; + } + } + + return true; +} diff --git a/packages/dashmate/src/core/quorum/waitForDKGWindowPass.js b/packages/dashmate/src/core/quorum/waitForDKGWindowPass.js index 21c4f2ccf25..da3ce30acb4 100644 --- a/packages/dashmate/src/core/quorum/waitForDKGWindowPass.js +++ b/packages/dashmate/src/core/quorum/waitForDKGWindowPass.js @@ -1,40 +1,39 @@ -import { MIN_BLOCKS_BEFORE_DKG } from '../../constants.js'; import wait from '../../util/wait.js'; +import isMasternodeSafeToStopDuringDkg, { + shouldInspectDkgStatusForSafeStop, +} from './isMasternodeSafeToStopDuringDkg.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 + * safety rule. The only acceptable exit is reaching a safe state, so + * that `--safe` cannot silently fall back to an unsafe restart. + * * @param {RpcClient} rpcClient * @return {Promise} */ export default async function waitForDKGWindowPass(rpcClient) { - let startBlockCount; - let startNextDkg; - - let isInDKG = true; - - do { - const [currentBlockCount, currentDkgInfo] = await Promise - .all([rpcClient.getBlockCount(), rpcClient.quorum('dkginfo')]); - - const { result: blockCount } = currentBlockCount; - const { result: dkgInfo } = currentDkgInfo; - - const { next_dkg: nextDkg } = dkgInfo; - - if (!startBlockCount) { - startBlockCount = blockCount; - } - - if (!startNextDkg) { - startNextDkg = nextDkg; + 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(), + ]); } - isInDKG = nextDkg <= MIN_BLOCKS_BEFORE_DKG; - - if (isInDKG && blockCount > startBlockCount + startNextDkg + 1) { - throw new Error(`waitForDKGWindowPass deadline exceeded: dkg did not happen for ${startBlockCount + nextDkg + 1} ${startNextDkg + 1} blocks`); + if (isMasternodeSafeToStopDuringDkg(dkgInfo, dkgStatus, currentHeight)) { + return; } - await wait(10000); + await wait(CHECK_INTERVAL_MS); } - while (isInDKG); } diff --git a/packages/dashmate/src/listr/tasks/stopNodeTaskFactory.js b/packages/dashmate/src/listr/tasks/stopNodeTaskFactory.js index 9fdf39c6c53..b70b3e500ed 100644 --- a/packages/dashmate/src/listr/tasks/stopNodeTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/stopNodeTaskFactory.js @@ -1,7 +1,9 @@ /* eslint-disable no-console */ import { Listr } from 'listr2'; -import { MIN_BLOCKS_BEFORE_DKG } from '../../constants.js'; import waitForDKGWindowPass from '../../core/quorum/waitForDKGWindowPass.js'; +import isMasternodeSafeToStopDuringDkg, { + shouldInspectDkgStatusForSafeStop, +} from '../../core/quorum/isMasternodeSafeToStopDuringDkg.js'; /** * @param {DockerCompose} dockerCompose @@ -55,11 +57,23 @@ export default function stopNodeTaskFactory( }); const { result: dkgInfo } = await rpcClient.quorum('dkginfo'); - const { next_dkg: nextDkg } = dkgInfo; - if (nextDkg <= MIN_BLOCKS_BEFORE_DKG) { - throw new Error('Your node is currently participating in DKG exchange session and ' - + 'stopping it right now may result in PoSE ban. Try again later, or continue with --force or --safe flags'); + let dkgStatus; + let currentHeight; + if (shouldInspectDkgStatusForSafeStop(dkgInfo)) { + [ + { result: dkgStatus }, + { result: currentHeight }, + ] = await Promise.all([ + rpcClient.quorum('dkgstatus'), + rpcClient.getBlockCount(), + ]); + } + + if (!isMasternodeSafeToStopDuringDkg(dkgInfo, dkgStatus, currentHeight)) { + 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/isMasternodeSafeToStopDuringDkg.spec.js b/packages/dashmate/test/unit/core/quorum/isMasternodeSafeToStopDuringDkg.spec.js new file mode 100644 index 00000000000..99e536c944e --- /dev/null +++ b/packages/dashmate/test/unit/core/quorum/isMasternodeSafeToStopDuringDkg.spec.js @@ -0,0 +1,268 @@ +import isMasternodeSafeToStopDuringDkg, { + DKG_MINING_WINDOW_START_BY_LLMQ_TYPE, +} from '../../../../src/core/quorum/isMasternodeSafeToStopDuringDkg.js'; +import { MIN_BLOCKS_BEFORE_DKG } from '../../../../src/constants.js'; + +// A value of next_dkg that is safely above MIN_BLOCKS_BEFORE_DKG so the +// imminent-DKG guard never trips in tests that focus on the per-session +// active-window logic. +const NEXT_DKG_NOT_IMMINENT = MIN_BLOCKS_BEFORE_DKG + 5; +const PLATFORM_WINDOW = DKG_MINING_WINDOW_START_BY_LLMQ_TYPE.llmq_test_platform; // 10 +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', () => { + 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 }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, { session: [] }, 1000)) + .to.equal(false); + }); + + it('allows the stop when active_dkgs is 0 and next_dkg is past the imminent threshold', () => { + const dkgInfo = { active_dkgs: 0, next_dkg: MIN_BLOCKS_BEFORE_DKG + 1 }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, { session: [] }, 1000)) + .to.equal(true); + }); + }); + + 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 }; + const dkgStatus = { + session: [ + { llmqType: 'llmq_test_platform', status: { quorumHeight: 1000 } }, + ], + }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, dkgStatus, 1000)).to.equal(false); + }); + + it('blocks a platform session at the last block of its active window (offset == window - 1)', () => { + const dkgInfo = { active_dkgs: 1, next_dkg: NEXT_DKG_NOT_IMMINENT }; + const dkgStatus = { + session: [ + { llmqType: 'llmq_test_platform', status: { quorumHeight: 1000 } }, + ], + }; + + expect(isMasternodeSafeToStopDuringDkg( + dkgInfo, + dkgStatus, + 1000 + (PLATFORM_WINDOW - 1), + )).to.equal(false); + }); + + it('allows the stop once a platform session reaches offset == window (window closed) even with active_dkgs > 0', () => { + // The regression case this PR cares about: aggregate active_dkgs + // is still > 0 (a non-platform LLMQ in its window elsewhere on + // the node, say), but the platform session at offset 10 is past + // its active phase and should not block. + const dkgInfo = { active_dkgs: 1, next_dkg: NEXT_DKG_NOT_IMMINENT }; + const dkgStatus = { + session: [ + { llmqType: 'llmq_test_platform', status: { quorumHeight: 1000 } }, + ], + }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, dkgStatus, 1000 + PLATFORM_WINDOW)) + .to.equal(true); + }); + + it('blocks a non-platform long-window session (llmq_400_60, offset 15) that is still in its 20-block window', () => { + const dkgInfo = { active_dkgs: 1, next_dkg: NEXT_DKG_NOT_IMMINENT }; + const dkgStatus = { + session: [ + { llmqType: 'llmq_400_60', status: { quorumHeight: 1000 } }, + ], + }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, dkgStatus, 1015)).to.equal(false); + // Sanity: confirm 15 is inside the 20-block llmq_400_60 window. + expect(LARGE_QUORUM_WINDOW).to.be.greaterThan(15); + }); + + it('allows the stop when every session is past its window even with active_dkgs > 0', () => { + const dkgInfo = { active_dkgs: 2, next_dkg: NEXT_DKG_NOT_IMMINENT }; + const dkgStatus = { + session: [ + { llmqType: 'llmq_test_platform', status: { quorumHeight: 1000 } }, + { llmqType: 'llmq_400_60', status: { quorumHeight: 900 } }, + ], + }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, dkgStatus, 1020)).to.equal(true); + }); + + it('blocks when any single session is still in its active window, even if others are stale', () => { + const dkgInfo = { active_dkgs: 2, next_dkg: NEXT_DKG_NOT_IMMINENT }; + const dkgStatus = { + session: [ + // Stale platform session (offset 50). + { llmqType: 'llmq_test_platform', status: { quorumHeight: 950 } }, + // Active llmq_400_60 session (offset 5, window 20). + { llmqType: 'llmq_400_60', status: { quorumHeight: 995 } }, + ], + }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, dkgStatus, 1000)).to.equal(false); + }); + + it('blocks a negative offset (quorumHeight ahead of currentHeight) as inconsistent status', () => { + // Core should only report locally tracked sessions whose quorum + // height has already arrived. If the chain tip is behind the + // reported quorum height while active_dkgs > 0, fail safe. + const dkgInfo = { active_dkgs: 1, next_dkg: NEXT_DKG_NOT_IMMINENT }; + const dkgStatus = { + session: [ + { llmqType: 'llmq_test_platform', status: { quorumHeight: 1005 } }, + ], + }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, dkgStatus, 1000)).to.equal(false); + }); + }); + + describe('fail-safe on malformed dkgInfo', () => { + it('blocks when dkgInfo is undefined', () => { + expect(isMasternodeSafeToStopDuringDkg(undefined, { session: [] }, 1000)) + .to.equal(false); + }); + + it('blocks when dkgInfo is null', () => { + expect(isMasternodeSafeToStopDuringDkg(null, { session: [] }, 1000)) + .to.equal(false); + }); + + it('blocks when next_dkg is missing', () => { + const dkgInfo = { active_dkgs: 0 }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, { session: [] }, 1000)) + .to.equal(false); + }); + + it('blocks when next_dkg is non-numeric', () => { + const dkgInfo = { active_dkgs: 0, next_dkg: '24' }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, { session: [] }, 1000)) + .to.equal(false); + }); + + it('blocks when next_dkg is NaN', () => { + const dkgInfo = { active_dkgs: 0, next_dkg: NaN }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, { session: [] }, 1000)) + .to.equal(false); + }); + + it('blocks when next_dkg is negative', () => { + const dkgInfo = { active_dkgs: 0, next_dkg: -1 }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, { session: [] }, 1000)) + .to.equal(false); + }); + + it('blocks when active_dkgs is missing', () => { + const dkgInfo = { next_dkg: NEXT_DKG_NOT_IMMINENT }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, { session: [] }, 1000)) + .to.equal(false); + }); + + it('blocks when active_dkgs is non-numeric', () => { + const dkgInfo = { active_dkgs: '1', next_dkg: NEXT_DKG_NOT_IMMINENT }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, { session: [] }, 1000)) + .to.equal(false); + }); + + it('blocks when active_dkgs is NaN', () => { + const dkgInfo = { active_dkgs: NaN, next_dkg: NEXT_DKG_NOT_IMMINENT }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, { session: [] }, 1000)) + .to.equal(false); + }); + + it('blocks when active_dkgs is negative', () => { + const dkgInfo = { active_dkgs: -1, next_dkg: NEXT_DKG_NOT_IMMINENT }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, { session: [] }, 1000)) + .to.equal(false); + }); + }); + + describe('fail-safe on malformed inputs while active_dkgs > 0', () => { + it('blocks when a session has an unknown llmqType', () => { + const dkgInfo = { active_dkgs: 1, next_dkg: NEXT_DKG_NOT_IMMINENT }; + const dkgStatus = { + session: [ + { llmqType: 'llmq_future_unknown', status: { quorumHeight: 1000 } }, + ], + }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, dkgStatus, 1000)).to.equal(false); + }); + + it('blocks when a session is missing quorumHeight', () => { + const dkgInfo = { active_dkgs: 1, next_dkg: NEXT_DKG_NOT_IMMINENT }; + const dkgStatus = { + session: [ + { llmqType: 'llmq_test_platform', status: {} }, + ], + }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, dkgStatus, 1000)).to.equal(false); + }); + + it('blocks when a session is missing status entirely', () => { + const dkgInfo = { active_dkgs: 1, next_dkg: NEXT_DKG_NOT_IMMINENT }; + const dkgStatus = { + session: [ + { llmqType: 'llmq_test_platform' }, + ], + }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, dkgStatus, 1000)).to.equal(false); + }); + + it('blocks when dkgStatus is omitted while active_dkgs > 0', () => { + const dkgInfo = { active_dkgs: 1, next_dkg: NEXT_DKG_NOT_IMMINENT }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, undefined, 1000)).to.equal(false); + }); + + it('blocks when dkgStatus.session is not an array while active_dkgs > 0', () => { + const dkgInfo = { active_dkgs: 1, next_dkg: NEXT_DKG_NOT_IMMINENT }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, {}, 1000)).to.equal(false); + }); + + it('blocks when dkgStatus.session is empty while active_dkgs > 0', () => { + const dkgInfo = { active_dkgs: 1, next_dkg: NEXT_DKG_NOT_IMMINENT }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, { session: [] }, 1000)).to.equal(false); + }); + + it('blocks when currentHeight is omitted while active_dkgs > 0', () => { + const dkgInfo = { active_dkgs: 1, next_dkg: NEXT_DKG_NOT_IMMINENT }; + const dkgStatus = { + session: [ + { llmqType: 'llmq_test_platform', status: { quorumHeight: 1000 } }, + ], + }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo, dkgStatus)).to.equal(false); + }); + }); + + describe('window table sanity vs Dash Core src/llmq/params.h', () => { + it('exposes the expected dkgMiningWindowStart values', () => { + expect(DKG_MINING_WINDOW_START_BY_LLMQ_TYPE.llmq_test_platform).to.equal(10); + expect(DKG_MINING_WINDOW_START_BY_LLMQ_TYPE.llmq_test_dip0024).to.equal(12); + expect(DKG_MINING_WINDOW_START_BY_LLMQ_TYPE.llmq_400_60).to.equal(20); + expect(DKG_MINING_WINDOW_START_BY_LLMQ_TYPE.llmq_400_85).to.equal(20); + expect(DKG_MINING_WINDOW_START_BY_LLMQ_TYPE.llmq_60_75).to.equal(42); + }); + }); +}); diff --git a/packages/dashmate/test/unit/core/quorum/waitForDKGWindowPass.spec.js b/packages/dashmate/test/unit/core/quorum/waitForDKGWindowPass.spec.js new file mode 100644 index 00000000000..5c2a85065b7 --- /dev/null +++ b/packages/dashmate/test/unit/core/quorum/waitForDKGWindowPass.spec.js @@ -0,0 +1,99 @@ +import waitForDKGWindowPass from '../../../../src/core/quorum/waitForDKGWindowPass.js'; + +const CHECK_INTERVAL_MS = 10000; + +describe('waitForDKGWindowPass', () => { + let rpcClient; + + beforeEach(function beforeEach() { + rpcClient = { + quorum: this.sinon.stub(), + getBlockCount: this.sinon.stub(), + }; + }); + + it('resolves immediately when there are no active sessions and no imminent cycle', async () => { + rpcClient.quorum.withArgs('dkginfo').resolves({ result: { active_dkgs: 0, next_dkg: 24 } }); + + await waitForDKGWindowPass(rpcClient); + + expect(rpcClient.quorum).to.have.been.calledOnceWith('dkginfo'); + 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 + // to model Dash Core's lingering aggregate counter. + const clock = this.sinon.useFakeTimers(); + + rpcClient.quorum.withArgs('dkginfo').resolves({ result: { active_dkgs: 1, next_dkg: 20 } }); + rpcClient.quorum.withArgs('dkgstatus').resolves({ + result: { + session: [ + { llmqType: 'llmq_test_platform', status: { quorumHeight: 1000 } }, + ], + }, + }); + rpcClient.getBlockCount + .onFirstCall().resolves({ result: 1005 }) + .onSecondCall().resolves({ result: 1010 }); + + const promise = waitForDKGWindowPass(rpcClient); + + // Drain microtasks so the first iteration completes its three RPCs + // and parks on `wait(CHECK_INTERVAL_MS)`. + await clock.tickAsync(0); + expect(rpcClient.getBlockCount).to.have.been.calledOnce(); + + // Advance past the wait so the second iteration runs and returns. + await clock.tickAsync(CHECK_INTERVAL_MS); + await promise; + + expect(rpcClient.getBlockCount).to.have.been.calledTwice(); + }); + + it('keeps waiting while next_dkg is imminent even when there are no sessions', async function it() { + const clock = this.sinon.useFakeTimers(); + + rpcClient.quorum.withArgs('dkginfo') + .onFirstCall() + .resolves({ result: { active_dkgs: 0, next_dkg: 3 } }) + .onSecondCall() + .resolves({ result: { active_dkgs: 0, next_dkg: 24 } }); + + 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('fails safe (keeps waiting) when active_dkgs > 0 and a session has an unknown llmqType', async function it() { + const clock = this.sinon.useFakeTimers(); + + rpcClient.quorum.withArgs('dkginfo') + .onFirstCall() + .resolves({ result: { active_dkgs: 1, next_dkg: 20 } }) + .onSecondCall() + .resolves({ result: { active_dkgs: 0, next_dkg: 20 } }); + rpcClient.quorum.withArgs('dkgstatus').resolves({ + result: { session: [{ llmqType: 'llmq_future_unknown', status: { quorumHeight: 1000 } }] }, + }); + rpcClient.getBlockCount.resolves({ result: 1000 }); + + const promise = waitForDKGWindowPass(rpcClient); + + await clock.tickAsync(0); + expect(rpcClient.quorum.withArgs('dkgstatus')).to.have.been.calledOnce(); + + await clock.tickAsync(CHECK_INTERVAL_MS); + await promise; + + expect(rpcClient.quorum.withArgs('dkgstatus')).to.have.been.calledOnce(); + }); +});