Skip to content
3 changes: 2 additions & 1 deletion packages/dashmate/src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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), '../..');
Expand Down
28 changes: 28 additions & 0 deletions packages/dashmate/src/core/quorum/checkMasternodeSafeToStop.js
Original file line number Diff line number Diff line change
@@ -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<boolean>}
*/
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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,57 +39,48 @@ 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) {
if (!hasValidDkgInfoShape(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,
Expand All @@ -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);
Comment thread
PastaPastaPasta marked this conversation as resolved.
}

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;
}

Expand Down
26 changes: 3 additions & 23 deletions packages/dashmate/src/core/quorum/waitForDKGWindowPass.js
Original file line number Diff line number Diff line change
@@ -1,39 +1,19 @@
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.
*
* @param {RpcClient} rpcClient
* @return {Promise<void>}
*/
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);
}
}
20 changes: 2 additions & 18 deletions packages/dashmate/src/listr/tasks/stopNodeTaskFactory.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading