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/checkMasternodeSafeToStop.js b/packages/dashmate/src/core/quorum/checkMasternodeSafeToStop.js new file mode 100644 index 00000000000..73d110b53f8 --- /dev/null +++ b/packages/dashmate/src/core/quorum/checkMasternodeSafeToStop.js @@ -0,0 +1,28 @@ +import isMasternodeSafeToStopDuringDkg, { + shouldInspectDkgStatusForSafeStop, +} from './isMasternodeSafeToStopDuringDkg.js'; + +/** + * 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} + */ +export default async function checkMasternodeSafeToStop(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 isMasternodeSafeToStopDuringDkg(dkgInfo, dkgStatus, currentHeight); +} diff --git a/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js b/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js index f3c4a768bd5..de5b8840d97 100644 --- a/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js +++ b/packages/dashmate/src/core/quorum/isMasternodeSafeToStopDuringDkg.js @@ -39,7 +39,23 @@ function hasValidDkgInfoShape(dkgInfo) { } /** - * @param {{ active_dkgs: number, next_dkg: number }} dkgInfo + * Malformed entries always block; member and unknown-membership entries + * block when they start within the restart margin. + */ +function isUpcomingDkgBlockingStop(dkg) { + if (!dkg + || typeof dkg !== 'object' + || !isValidDkgCounter(dkg.blocksUntilStart) + || typeof dkg.known !== 'boolean' + || (dkg.known && typeof dkg.isMember !== 'boolean')) { + return true; + } + + return dkg.blocksUntilStart <= MIN_BLOCKS_BEFORE_DKG && (!dkg.known || dkg.isMember); +} + +/** + * @param {Object} dkgInfo * @return {boolean} */ export function shouldInspectDkgStatusForSafeStop(dkgInfo) { @@ -47,49 +63,24 @@ export function shouldInspectDkgStatusForSafeStop(dkgInfo) { return false; } - return dkgInfo.active_dkgs > 0 && dkgInfo.next_dkg > MIN_BLOCKS_BEFORE_DKG; + return dkgInfo.upcoming_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. - * - * Inputs come from three Core RPCs: - * - `quorum dkginfo` → `{ active_dkgs, next_dkg }` - * - `quorum dkgstatus` → `{ session: [{ llmqType, status: { quorumHeight } }, ...] }` - * - `getblockcount` → integer chain tip height + * 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. * - * 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. + * 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 {{ 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. + * @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, @@ -100,13 +91,21 @@ export default function isMasternodeSafeToStopDuringDkg( return false; } - const { active_dkgs: activeDkgs, next_dkg: nextDkg } = 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 (nextDkg <= MIN_BLOCKS_BEFORE_DKG) { + if (dkgInfo.next_dkg <= MIN_BLOCKS_BEFORE_DKG) { 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..475b8bfb499 --- /dev/null +++ b/packages/dashmate/test/unit/core/quorum/checkMasternodeSafeToStop.spec.js @@ -0,0 +1,70 @@ +import checkMasternodeSafeToStop from '../../../../src/core/quorum/checkMasternodeSafeToStop.js'; + +describe('checkMasternodeSafeToStop', () => { + let rpcClient; + + beforeEach(function beforeEach() { + rpcClient = { quorum: this.sinon.stub(), getBlockCount: this.sinon.stub() }; + }); + + it('should allow a non-member immediately using one membership response', async () => { + rpcClient.quorum.withArgs('dkginfo').resolves({ + result: { + active_dkgs: 0, + next_dkg: 1, + 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 block a positive active_dkgs without inspecting dkgstatus', async () => { + rpcClient.quorum.withArgs('dkginfo').resolves({ + result: { + active_dkgs: 1, + next_dkg: 24, + 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 block a member of an imminent DKG', 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 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); + expect(rpcClient.quorum).to.have.been.calledOnceWith('dkginfo'); + }); + + 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 } }] }, + }); + 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 99e536c944e..1088c994022 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 }; @@ -25,6 +26,164 @@ 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('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 { + 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)).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)).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_dkgs: 0, + next_dkg: 1, + upcoming_dkgs: [upcomingDkg(1, false), upcomingDkg(MIN_BLOCKS_BEFORE_DKG, true)], + }; + + 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_dkgs: 0, + next_dkg: MIN_BLOCKS_BEFORE_DKG + 1, + upcoming_dkgs: [upcomingDkg(MIN_BLOCKS_BEFORE_DKG + 1, true)], + }; + + expect(isMasternodeSafeToStopDuringDkg(dkgInfo)).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)).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)).to.equal(true); + }); + + it('should not inspect dkgstatus when upcoming_dkgs is present', () => { + const dkgInfo = { + active_dkgs: 1, + next_dkg: NEXT_DKG_NOT_IMMINENT, + upcoming_dkgs: [upcomingDkg(1, true)], + }; + + expect(shouldInspectDkgStatusForSafeStop(dkgInfo)).to.equal(false); + }); + + describe('fail-safe on malformed upcoming_dkgs', () => { + const malformed = { + '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 }], + 'an entry is missing blocksUntilStart': [{ known: true, isMember: false }], + '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_dkgs: 0, + next_dkg: NEXT_DKG_NOT_IMMINENT, + upcoming_dkgs: upcomingDkgs, + }; + + expect(shouldInspectDkgStatusForSafeStop(dkgInfo)).to.equal(false); + expect(isMasternodeSafeToStopDuringDkg(dkgInfo)).to.equal(false); + }); + }); + }); }); describe('per-session active window from dkgstatus + getblockcount', () => { diff --git a/packages/dashmate/test/unit/core/quorum/waitForDKGWindowPass.spec.js b/packages/dashmate/test/unit/core/quorum/waitForDKGWindowPass.spec.js index 5c2a85065b7..872d5db2a49 100644 --- a/packages/dashmate/test/unit/core/quorum/waitForDKGWindowPass.spec.js +++ b/packages/dashmate/test/unit/core/quorum/waitForDKGWindowPass.spec.js @@ -21,6 +21,77 @@ describe('waitForDKGWindowPass', () => { expect(rpcClient.getBlockCount).to.not.have.been.called(); }); + 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, + upcoming_dkgs: [{ blocksUntilStart: 1, known: true, isMember: false }], + }, + }); + + const promise = waitForDKGWindowPass(rpcClient); + + await clock.tickAsync(0); + await promise; + + expect(rpcClient.quorum.withArgs('dkginfo')).to.have.been.calledOnce(); + 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('should wait while active_dkgs is positive without inspecting dkgstatus', async function it() { + const clock = this.sinon.useFakeTimers(); + rpcClient.quorum.withArgs('dkginfo') + .onFirstCall() + .resolves({ + result: { + active_dkgs: 1, + next_dkg: 1, + upcoming_dkgs: [], + }, + }) + .onSecondCall() + .resolves({ result: { active_dkgs: 0, next_dkg: 1, 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.quorum.withArgs('dkgstatus')).to.not.have.been.called(); + 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