From dcff0698fd013bf8465dc311b3b4ad717f0f17e5 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 3 Oct 2026 23:44:44 -0400 Subject: [PATCH] fix: only sign EHF signals inside the deployment's start/timeout window CEHFSignalsHandler::UpdatedBlockTip() asked the mnhf quorum to sign a signal for every useEHF deployment whose bit had not been mined yet, as long as IsValidMNActivation() returned true. That check is meant for validation and is permissive: a NEVER_ACTIVE start time (-2) is never greater than the MTP, and a deployment outside of its window is skipped and then accepted by the unknown-bit fallback. So masternodes signed and mined signals for deployments that had no open window, e.g. V24 on mainnet, which is still NEVER_ACTIVE. Skip a deployment in the producer unless its start time is not NEVER_ACTIVE and the tip's MTP is within [nStartTime, nTimeout]. Validation (CheckMNHFTx, CMNHFManager::ProcessBlock) is unchanged. feature_mnehf.py now starts testdummy as NEVER_ACTIVE and, before the existing activation flow, checks that the masternode does not try to sign it while it is NEVER_ACTIVE, after its timeout, or before its start time, and that it does sign once the start time is reached. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/llmq/ehf_signals.cpp | 12 ++++++++++- test/functional/feature_mnehf.py | 35 +++++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/llmq/ehf_signals.cpp b/src/llmq/ehf_signals.cpp index a5e26f80f0fd..6f0077455c98 100644 --- a/src/llmq/ehf_signals.cpp +++ b/src/llmq/ehf_signals.cpp @@ -33,16 +33,26 @@ CEHFSignalsHandler::~CEHFSignalsHandler() sigman.UnregisterRecoveredSigsListener(this); } +static bool IsSignalWindowOpen(const Consensus::BIP9Deployment& deployment, int64_t time_past) +{ + return deployment.nStartTime != Consensus::BIP9Deployment::NEVER_ACTIVE && deployment.nStartTime <= time_past && + time_past <= deployment.nTimeout; +} + void CEHFSignalsHandler::UpdatedBlockTip(const CBlockIndex* const pindexNew) { if (!DeploymentActiveAfter(pindexNew, Params().GetConsensus(), Consensus::DEPLOYMENT_V20)) return; const auto ehfSignals = m_chainman.ActiveChainstate().ChainHelper().ehf_manager->GetSignalsStage(pindexNew); + const int64_t time_past = pindexNew->GetMedianTimePast(); for (const auto& deployment : Params().GetConsensus().vDeployments) { // Skip deployments that do not use dip0023 if (!deployment.useEHF) continue; + // IsValidMNActivation() is permissive for validation (it accepts bits of NEVER_ACTIVE deployments + // and bits outside of any window), so only sign inside the deployment's own start/timeout window + if (!IsSignalWindowOpen(deployment, time_past)) continue; // Try to sign only activable deployments that haven't been mined yet - if (ehfSignals.find(deployment.bit) == ehfSignals.end() && Params().IsValidMNActivation(deployment.bit, pindexNew->GetMedianTimePast())) { + if (ehfSignals.find(deployment.bit) == ehfSignals.end() && Params().IsValidMNActivation(deployment.bit, time_past)) { trySignEHFSignal(deployment.bit, pindexNew); } } diff --git a/test/functional/feature_mnehf.py b/test/functional/feature_mnehf.py index cd4bcaecef1b..98fa8259f47f 100755 --- a/test/functional/feature_mnehf.py +++ b/test/functional/feature_mnehf.py @@ -30,7 +30,8 @@ def add_options(self, parser): self.add_wallet_options(parser) def set_test_params(self): - extra_args = [["-vbparams=testdummy:0:999999999999:0:4:4:4:5:1", "-persistmempool=0"]] * 2 + # testdummy starts as NEVER_ACTIVE, run_test opens its signalling window later + extra_args = [["-vbparams=testdummy:-2:999999999999:0:4:4:4:5:1", "-persistmempool=0"]] * 2 # One masternode is enough: the EHF signal only needs a recovered signature of any quorum self.set_dash_test_params(2, 1, extra_args=extra_args) self.set_dash_llmq_test_params(1, 1) @@ -99,6 +100,37 @@ def check_fork(self, expected): self.log.info(f"height: {self.nodes[0].getblockcount()} status: {status}") assert_equal(status, expected) + def ehf_signals_in_mempool(self, node, version_bit): + return [txid for txid in node.getrawmempool() + if node.getrawtransaction(txid, True).get('mnhfTx', {}).get('signal', {}).get('versionBit') == version_bit] + + def check_no_ehf_signing(self, reason): + self.log.info(f"Check that masternodes do not sign testdummy {reason}") + mn_node = self.mninfo[0].get_node(self) + # Masternodes try to sign on every new tip, so an attempt is logged once the tip callbacks are done + with mn_node.assert_debug_log(expected_msgs=[], unexpected_msgs=["bit=28 at height"]): + self.generate(self.nodes[0], 2, sync_fun=self.sync_blocks) + mn_node.syncwithvalidationinterfacequeue() + assert_equal(self.ehf_signals_in_mempool(mn_node, 28), []) + + def test_no_signing_outside_window(self): + node = self.nodes[0] + self.check_no_ehf_signing("while it is NEVER_ACTIVE") + + self.restart_all_nodes(params=[0, 1]) + self.check_no_ehf_signing("after its timeout") + + start_time = self.mocktime + 600 + self.restart_all_nodes(params=[start_time, 999999999999]) + self.check_no_ehf_signing("before its start time") + + self.log.info("Check that masternodes sign testdummy once its start time is reached") + self.bump_mocktime(600, update_schedulers=False) + while node.getblockheader(node.getbestblockhash())['mediantime'] < start_time: + self.generate(node, 1) + mn_node = self.mninfo[0].get_node(self) + self.wait_until(lambda: len(self.ehf_signals_in_mempool(mn_node, 28)) == 1) + def ensure_tx_is_not_mined(self, tx_id): try: assert_equal(self.nodes[0].getrawtransaction(tx_id, 1)['height'], -1) @@ -131,6 +163,7 @@ def run_test(self): self.set_sporks() self.log.info("Mine a quorum...") self.mine_quorum_single_member() + self.test_no_signing_outside_window() self.check_fork('defined') key = ECKey()