Repository navigation
fix: only sign EHF signals inside the deployment's start/timeout window - #7787
Conversation
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. 🧰 Additional context used📚 Code guidelines (1)No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (2)
Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 0 remain after this review. WalkthroughThe EHF signal handler now signs only when median-time-past is within the deployment’s inclusive start-time-to-timeout window and the start time is not Priority: ⬆️ High Estimated code review effort: 2 (Simple) | ~10 minutes Merge Risk: ⚪ Minimal · up to The change appears mergeable after normal checks. No actionable issue is established for the current head. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to The change restricts when activation signals are requested without changing existing acceptance rules. No introduced security weakness was established, but delayed signing completion and recovery near deployment boundaries remain incompletely validated. Retained concerns Security review detailsSecurity Blast Radius
Security Findings and Attack Paths
Trust Boundaries and Controls
Resilience and Maintainability Implications
Hardening Proposals
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
|
|
This pull request has conflicts, please rebase. |
|
|
||
| def set_test_params(self): | ||
| extra_args = [["-vbparams=testdummy:0:999999999999:0:4:4:4:5:1", "-persistmempool=0"]] * 4 | ||
| extra_args = [["-vbparams=testdummy:0:999999999999:0:4:4:4:5:1", "-vbparams=v24:-2:999999999999", "-persistmempool=0"]] * 4 |
There was a problem hiding this comment.
Should not use v24 in the functional tests to test forks. After activation this fork will be buried and test will require rewritting
There was a problem hiding this comment.
Agreed. dcff069 drops the v24 override. The test now runs the out-of-window checks on testdummy (bit 28): it starts as NEVER_ACTIVE, then gets a window that has already timed out and one that has not started yet, and finally the test checks that the masternode does sign once the start time is reached.
🤖 Posted autonomously by Codex on behalf of pasta.
| self.generate(self.nodes[1], 1) | ||
| return get_bip9_details(self.nodes[0], 'testdummy')['status'] == 'active' | ||
| self.wait_until(lambda: check_ehf_activated(self), sleep=1) | ||
| self.test_no_signal_outside_window(timed_out=False) |
There was a problem hiding this comment.
This test sounds be done at the very beginning but not the last scenario
There was a problem hiding this comment.
Done in dcff069. The checks now run right after the first quorum is mined, before the existing activation flow, instead of as the last scenario. The two extra quorums per round and the 14-day mocktime bumps are gone.
🤖 Posted autonomously by Codex on behalf of pasta.
thepastaclaw
left a comment
There was a problem hiding this comment.
Final validation — Phase 1 + Phase 2
At head 7809670, the producer-side guard correctly excludes NEVER_ACTIVE deployments and enforces the inclusive start/timeout window without changing consensus validation. The branch has a verified merge conflict with develop, and its title fails the configured scope validation. This was a static review: the supplied CI snapshot shows successful container builds and queued manifest jobs, but does not establish completion of the unit or functional tests.
🔴 1 blocking | 💬 1 nitpick(s)
1 finding(s) not shown inline (the lines are not part of this PR's diff)
💬 Nitpick: Remove the unsupported llmq scope from the PR title
.github/workflows/semantic-pull-request.yml:38-52
The PR title uses fix(llmq), but the workflow's allowed scopes do not include llmq. The failed Validate PR title job explicitly reports Unknown scope "llmq" for this title. Retitle the PR to "fix: only sign EHF signals inside the deployment's start/timeout window"; no workflow change is needed.
source: glm-5.3-flash (phase1-reviewer: general)
Review provenance
Source: reviewer 1: glm-5.3-flash (agent: phase1-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: dash-core-commit-history); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)
- Triage:
lowbygpt-6.1-sol(effort low) — The diff adds a small, straightforward deployment-window guard in CEHFSignalsHandler::UpdatedBlockTip() and focused functional coverage without changing consensus validation or signature mechanics. - Phase 1 reviewers:
glm-5.3-flash— general (completed, effort high); agentphase1-reviewer - Phase 1 model:
glm-5.3-flash— zai quota: 5h 99% left, weekly 68% left; passed overgemini-3.8-flash-high(antigravity below 15% reserve: weekly 15% left, 5h 100% left) - Fresh verifier:
gpt-6.1-sol— final-verifier; agentsol-verifier - Phase 2 reviewers:
gpt-6.1-sol— general (completed, effort medium); agentphase2-reviewer,gpt-6.1-sol— dash-core-commit-history (completed, effort medium); agentphase2-reviewer
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `test/functional/feature_mnehf.py`:
- [BLOCKING] test/functional/feature_mnehf.py:33-34: Resolve the functional-test conflict with develop's single-member-quorum conversion
Merging this exact head with the supplied develop base, 88195077594b838b3abd5724bda9bd4ea1677a80, produces a content conflict in set_test_params(): this PR adds the v24 override to the four-node setup, while #7778 replaces that setup with two nodes, one masternode, and set_dash_llmq_test_params(1, 1). The check_merge job independently reports the same file conflict. Rebase and resolve the test adaptation while preserving develop's single-member-quorum changes, including its create_mnehf() and activation-boundary fixes; adapt the new outside-window rounds to the single-member mining helper as well. The current branch cannot merge into its target.
In `.github/workflows/semantic-pull-request.yml`:
- [NITPICK] .github/workflows/semantic-pull-request.yml:38-52: Remove the unsupported llmq scope from the PR title
The PR title uses fix(llmq), but the workflow's allowed scopes do not include llmq. The failed Validate PR title job explicitly reports Unknown scope "llmq" for this title. Retitle the PR to "fix: only sign EHF signals inside the deployment's start/timeout window"; no workflow change is needed.
| extra_args = [["-vbparams=testdummy:0:999999999999:0:4:4:4:5:1", "-vbparams=v24:-2:999999999999", "-persistmempool=0"]] * 4 | ||
| self.set_dash_test_params(4, 3, extra_args=extra_args) |
There was a problem hiding this comment.
🔴 Blocking: Resolve the functional-test conflict with develop's single-member-quorum conversion
Merging this exact head with the supplied develop base, 8819507, produces a content conflict in set_test_params(): this PR adds the v24 override to the four-node setup, while #7778 replaces that setup with two nodes, one masternode, and set_dash_llmq_test_params(1, 1). The check_merge job independently reports the same file conflict. Rebase and resolve the test adaptation while preserving develop's single-member-quorum changes, including its create_mnehf() and activation-boundary fixes; adapt the new outside-window rounds to the single-member mining helper as well. The current branch cannot merge into its target.
source: glm-5.3-flash (phase1-reviewer: general)
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) <noreply@anthropic.com>
7809670 to
dcff069
Compare
…/timeout window dcff069 fix: only sign EHF signals inside the deployment's start/timeout window (pasta) Pull request description: ## Issue being fixed or feature implemented Masternodes create and mine MnEHF (DIP-0023) signals for deployments whose start/timeout window is not open, including deployments that are `NEVER_ACTIVE`. `CEHFSignalsHandler::UpdatedBlockTip()` asks the mnhf quorum to sign a signal for every `useEHF` deployment whose bit has not been mined yet, as long as `CChainParams::IsValidMNActivation()` returns true. That function is the validation check, and it is deliberately permissive: - A `NEVER_ACTIVE` start time is `-2`, so `timePast < nStartTime` is never true and the deployment counts as in range. - A deployment outside of its window is skipped with `continue`, and then the "unknown MnEHF fork bit" fallback returns true. Validation needs that fallback so that old signals on a reused bit stay valid on reindex (`mnhf_bit_reuse_reindex_tests`). So the producer signs regardless of the window. On mainnet today V24 is on bit 13 with `nStartTime = NEVER_ACTIVE`, so as soon as enough mainnet masternodes run this code they will sign and mine a bit-13 signal. Setting a future mainnet start time would not help either: that case goes through the fallback and is signed too. This has happened before. 1be3ac2 ("fix: move DEPLOYMENT_V24 to a fresh version bit") moved V24 off bit 12 because bit 12 "already has mined MNHF signals on mainnet and testnet from before V24 had a start time". That commit moved the bit but left the producer unchanged, so bit 13 would go the same way. ### Why this is a real problem Code path at c14104b (unchanged on current develop): - The producer only checks that the signal is missing and `IsValidMNActivation()`: [src/llmq/ehf_signals.cpp#L45](https://github.com/dashpay/dash/blob/c14104b172a2/src/llmq/ehf_signals.cpp#L45) - `IsValidMNActivation()` skips out-of-window deployments and then accepts unknown bits: [src/chainparams.cpp#L109-L131](https://github.com/dashpay/dash/blob/c14104b172a2/src/chainparams.cpp#L109-L131) - Mainnet V24 is bit 13, `NEVER_ACTIVE`: [src/chainparams.cpp#L212-L213](https://github.com/dashpay/dash/blob/c14104b172a2/src/chainparams.cpp#L212-L213) - Validation uses the same check, so the signal is accepted into the mempool and blocks: [src/evo/mnhftx.cpp#L124-L126](https://github.com/dashpay/dash/blob/c14104b172a2/src/evo/mnhftx.cpp#L124-L126), [src/evo/mnhftx.cpp#L237-L240](https://github.com/dashpay/dash/blob/c14104b172a2/src/evo/mnhftx.cpp#L237-L240) - A mined signal only expires if its MTP is below a positive `nStartTime`, so nodes with `NEVER_ACTIVE` keep it forever and reject any later signal for that bit as `bad-mnhf-duplicate`: [src/evo/mnhftx.cpp#L80](https://github.com/dashpay/dash/blob/c14104b172a2/src/evo/mnhftx.cpp#L80), [src/evo/mnhftx.cpp#L232-L235](https://github.com/dashpay/dash/blob/c14104b172a2/src/evo/mnhftx.cpp#L232-L235) Reproduction: the updated `feature_mnehf.py` from this PR, run against a `dashd` without the `src/llmq/ehf_signals.cpp` change. The test starts the `testdummy` deployment (bit 28) as `NEVER_ACTIVE` (`-vbparams=testdummy:-2:...`), mines two blocks and checks the masternode's log. The unpatched masternode tries to sign it on every new tip: <details> <summary>feature_mnehf.py against unpatched dashd</summary> ``` TestFramework (INFO): Check that masternodes do not sign testdummy while it is NEVER_ACTIVE AssertionError: [node 1] Unexpected message "bit=28 at height" partially matches log: - ... [trySignEHFSignal] [ehf] CEHFSignalsHandler::trySignEHFSignal: already signed bit=28 at height=175 id=a0eee872d7d3170dd20d5c5e8380c92b3aa887da5f63d8033289fafa35a90691 ``` </details> ### Why it matters If v24.0.0 ships like this, upgraded mainnet masternodes will mine a bit-13 signal long before V24 has a start time. Nodes whose V24 start time is still `NEVER_ACTIVE` (v24.0.0 itself) never expire that signal, so when the real signal is mined after a later release sets the start time, those nodes reject that block as `bad-mnhf-duplicate`. If the start time were instead set at or before the early signal, the early signal would count toward activation. In both cases the bit cannot be used cleanly any more, which is what already happened to bit 12. Nothing outside the masternode set can trigger this. It happens on its own once the quorum runs the code. This should also go to v24.0.x: v24.0.0-rc.1 and rc.2 carry V24 on bit 13 with mainnet `NEVER_ACTIVE` and the same producer code. ## What was done? In `CEHFSignalsHandler::UpdatedBlockTip()`, a deployment is now skipped unless its own signalling window is open at the tip MTP: `nStartTime != NEVER_ACTIVE && nStartTime <= MTP && MTP <= nTimeout`. The existing "not mined yet" and `IsValidMNActivation()` conditions are kept as they were. `feature_mnehf.py` gets a new first step, `test_no_signing_outside_window()`, which runs right after the first quorum is mined and before the existing testdummy activation flow: - `testdummy` starts as `NEVER_ACTIVE`, then the nodes are restarted with a window that has already timed out (`0:1`) and one that starts 10 minutes in the future. In each case two blocks are mined and the test checks that the masternode logged no signing attempt for bit 28 and that its mempool has no bit-28 signal. - Mocktime is then moved past the start time and the test waits until the masternode signs `testdummy`, so the negative checks are not passing just because the masternode cannot sign. - The rest of the test is unchanged. It uses `testdummy` rather than `v24`, so it keeps working after v24 is buried. ### Why this is the correct minimal fix The wrong decision is made in the producer, so the fix goes there. `IsValidMNActivation()` cannot be tightened instead. It is the validation check, and its fallback for out-of-window and unknown bits is what keeps old signals valid on reindex after a bit is reused (`mnhf_bit_reuse_reindex_tests`). Changing it would make nodes disagree about blocks they already accepted. `CheckMNHFTx()` and `CMNHFManager::ProcessBlock()` are not touched, so consensus is unchanged: a signal mined by an old node is still accepted exactly as before. The `NEVER_ACTIVE` check has to be explicit. It is the mainnet case, and the plain range check treats `-2` as an already-passed start time. Deployments with `ALWAYS_ACTIVE` (`-1`) or a past start and `NO_TIMEOUT` still pass, so regtest/devnet deployments (start 0) and testnet V24 (its window opened 2026-09-24) are signed as before. Checking the tip MTP is enough. The signal is judged later by the MTP of the block that includes it, and that MTP is greater than or equal to the tip's. I did not add a re-check in `HandleNewRecoveredSig()`. It only submits signals for request ids that `trySignEHFSignal()` registered, and those are only registered while the window is open. ## How Has This Been Tested? macOS arm64, `--enable-debug` build. - `test/functional/feature_mnehf.py` against a `dashd` without the `src/llmq/ehf_signals.cpp` change: fails at the NEVER_ACTIVE check (output above). - `test/functional/feature_mnehf.py` with this PR: passes. - Each condition of the new check was removed in turn, and every variant fails in the step meant to catch it: - Without the `NEVER_ACTIVE` check, the "while it is NEVER_ACTIVE" step fails. - Without `MTP <= nTimeout`, the "after its timeout" step fails. - Without `nStartTime <= MTP`, the "before its start time" step fails. - `test/functional/feature_asset_locks.py` with this PR: passes. It activates regtest v24 (start 0) through masternode-signed EHF. - `src/test/test_dash --run_test=evo_mnhf_tests` (includes `mnhf_bit_reuse_reindex_tests`): passes. - `test/lint/lint-python.py`, `test/lint/lint-whitespace.py` and `contrib/devtools/clang-format-diff.py` on the diff: clean. ## Breaking Changes None. Consensus validation is unchanged. Masternodes no longer create signals for deployments whose window is not open. ## Checklist: - [x] I have performed a self-review of my own code - [ ] I have made corresponding changes to the documentation - [ ] I have assigned this pull request to a milestone _(for repository code-owners and collaborators only)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) ACKs for top commit: knst: utACK dcff069 Tree-SHA512: 627f886a0b45e5ce807e7f306fa39606dd30bd24eb119479f9810929f3dad1c06005eeb237f5c0096d7d8835d35e0017f79fa831f800c22f5f63d8ba1b19d508 (cherry picked from commit 3a22d16)
fbe6526 Merge #7807: feat(consensus): hold EvoNode shares and multiple payouts behind a new evo_shares deployment (pasta) 57fd2d9 Merge #7776: fix: keep a pending ProUpServTx across a registrar update that keeps the operator key (pasta) ca69801 Merge #7803: fix: return non-zero amount of accounts after sethdseed (pasta) 57a8f07 Merge #7796: backport: bitcoin#26720 (remaining), partial bitcoin#29523: weight-check the knapsack exact-match selection (pasta) 4eb82f6 Merge #7786: fix(net): keep a dedicated onion listener when -bind is given and correct the bind release note (pasta) 70626a0 Merge #7793: backport: partial bitcoin#28868 (wallet: reload wallet if migration exited early, keep mixed watchonly txs) (pasta) 250a3cd Merge #7790: fix: guard cached governance object flags with the object lock (pasta) 700c195 Merge #7787: fix: only sign EHF signals inside the deployment's start/timeout window (pasta) a3b0f3c Merge #7785: fix: restore the txindex requirement when governance validation is enabled (pasta) 2d1735d Merge #7794: fix(wallet): floor the denomination gap threshold so rebalancing cannot oscillate (pasta) d9f6592 Merge #7783: fix: stop fish completion from re-running the typed dash-cli command (pasta) 89d44c8 Merge #7795: backport: bitcoin#34272, partial bitcoin#31650 (pass PSBT by const reference in PSBTInputSignedAndVerified) (pasta) a1d7411 Merge #7779: fix!: report legacy evonode Platform addresses when listdiff changes its address (pasta) 73f6ea2 Merge #7789: fix(qt): report malformed lang/font settings instead of aborting at startup (pasta) 40de493 Merge #7781: fix(rpc): use a consistent tip height in quorum dkgstatus (pasta) 7d1a90d Merge #7791: fix(net): request object votes from nPeersPerHashMax peers (pasta) 51b145c Merge #7778: test: use single-member in functional tests: feature_mnehf, feature_notifications, p2p_instantsend (pasta) Pull request description: ## Issue being fixed or feature implemented Backports for v24.0.0-rc.3. `v24.0.x` was cut at the v24.0.0-rc.2 tag (1e239d4), and develop has since moved to 24.1.0 (#7775), so rc.3 is built from this branch instead of being tagged on develop like rc.1 and rc.2. ## What was done? Cherry-picked (`git cherry-pick -m1 -x`) every develop merge since rc.2 that carries `backport-candidate-24.0.x`, plus #7778, which #7807's test needs. They are in develop merge order: - #7778 test: use single-member in functional tests: feature_mnehf, feature_notifications, p2p_instantsend (test-only. Needed because #7807's `feature_evo_shares_activation.py` depends on its `mine_quorum_single_member()` fix and fails without it.) - #7791 fix(net): request object votes from nPeersPerHashMax peers - #7781 fix(rpc): use a consistent tip height in quorum dkgstatus - #7789 fix(qt): report malformed lang/font settings instead of aborting at startup - #7779 fix!: report legacy evonode Platform addresses when listdiff changes its address - #7795 backport: bitcoin#34272, partial bitcoin#31650 (pass PSBT by const reference in PSBTInputSignedAndVerified) - #7783 fix: stop fish completion from re-running the typed dash-cli command - #7794 fix(wallet): floor the denomination gap threshold so rebalancing cannot oscillate - #7785 fix: restore the txindex requirement when governance validation is enabled - #7787 fix: only sign EHF signals inside the deployment's start/timeout window - #7790 fix: guard cached governance object flags with the object lock - #7793 backport: partial bitcoin#28868 (wallet: reload wallet if migration exited early, keep mixed watchonly txs) - #7786 fix(net): keep a dedicated onion listener when -bind is given and correct the bind release note. Includes partial bitcoin#36170 (5f40d56). Upstream's dedicated-onion-bind changes to `feature_torcontrol.py` and `p2p_private_broadcast.py` are not included, because those tests depend on upstream commits (5693833, e74d54e) that Dash does not have. - #7796 backport: bitcoin#26720 (remaining), partial bitcoin#29523: weight-check the knapsack exact-match selection - #7803 fix: return non-zero amount of accounts after sethdseed - #7776 fix: keep a pending ProUpServTx across a registrar update that keeps the operator key - #7807 feat(consensus): hold EvoNode shares and multiple payouts behind a new evo_shares deployment One pick needed conflict resolution, in a test only, described in its commit message: #7793, `test/functional/wallet_migration.py`. `test_conflict_txs()` comes from bitcoin#28542 (#7762), which is not on this branch, so it is left out. The C++ part of #7793 is identical to develop. Merged since rc.2 but intentionally not included: #7775 (24.1 version bump), #7260 and #7804 (new Qt feature and its CI fix), #7584 (refactor), routine upstream backports #7257, #7759, #7762, #7768, #7788, #7801, and test/CI-only changes #7777 and #7797. No version change is needed. `configure.ac` is already 24.0.0 with `CLIENT_VERSION_IS_RELEASE` false, so the build takes its version string from the `v24.0.0-rc.3` tag, as it did for rc.2. ## How Has This Been Tested? Built on macOS arm64 against depends with `--enable-debug --enable-werror` (dashd, dash-qt, tests). - `make check`: all unit test suites pass, including `test_dash-qt`. - Functional tests, all passing: `feature_evo_shares_activation.py`, `feature_masternode_shares.py`, `feature_mnehf.py`, `feature_governance.py --descriptors`, `feature_governance_objects.py`, `feature_governance_txindex_devnet.py`, `feature_proxy.py`, `feature_notifications.py`, `feature_llmq_connections.py`, `feature_llmq_chainlocks_automatic.py`, `feature_llmq_singlenode.py`, `p2p_instantsend.py`, `rpc_blockchain.py` (v1 and v2 transport), `rpc_netinfo.py`, `rpc_quorum.py`, `rpc_verifychainlock.py`, `wallet_hd.py` (legacy and descriptors), `wallet_migration.py`. - `feature_bind_extra.py` (from #7786) only runs on Linux and was skipped locally, so CI covers it. Every touched file was diffed against develop. The remaining differences come only from develop PRs that are not part of this backport. ## Breaking Changes Carried over from the picked PRs: - #7807: consensus change behind the new `evo_shares` deployment (bit 14). Mainnet and testnet are unaffected until it activates. Devnets with `v24` already active and an EvoNode carrying several owner payouts need to check or reset. New RPC `protx shared_register_prepare_evo`. - #7785: outside regtest, a node with `-txindex=0` and without `-disablegovernance` refuses to start again, as before v23.1.0. - #7786: carries a partial bitcoin#36170. A node started with `-bind` but no specific `-bind=<addr:port>=onion` now refuses to start while `-listenonion` is enabled. That is the default when listening, even without Tor configured. A wildcard onion bind is also refused. Such nodes need `-bind=127.0.0.1:<port>=onion` or `-listenonion=0`. Nodes without `-bind`, including those using only `-whitebind`, keep the default `127.0.0.1:9996` (`19996` testnet) onion target. See `doc/release-notes-7300.md`. - #7779: `protx listdiff` now includes `platform_p2p` and `platform_https` for a legacy-address evonode whose core P2P address changes. ## Checklist: - [x] I have performed a self-review of my own code - [ ] I have made corresponding changes to the documentation - [ ] I have assigned this pull request to a milestone 🤖 Generated with [Claude Code](https://claude.com/claude-code) Top commit has no ACKs. Tree-SHA512: 8cb01bdb0234f31b5dd2c105b69673de148922991a7523627395c0e0ccfa22b94be5ffbecc842398d443bc90fd9baf2eee6a9cf6fcdbbd1626eb00b93726a55a
Issue being fixed or feature implemented
Masternodes create and mine MnEHF (DIP-0023) signals for deployments whose start/timeout window is not open, including deployments that are
NEVER_ACTIVE.CEHFSignalsHandler::UpdatedBlockTip()asks the mnhf quorum to sign a signal for everyuseEHFdeployment whose bit has not been mined yet, as long asCChainParams::IsValidMNActivation()returns true. That function is the validation check, and it is deliberately permissive:NEVER_ACTIVEstart time is-2, sotimePast < nStartTimeis never true and the deployment counts as in range.continue, and then the "unknown MnEHF fork bit" fallback returns true. Validation needs that fallback so that old signals on a reused bit stay valid on reindex (mnhf_bit_reuse_reindex_tests).So the producer signs regardless of the window. On mainnet today V24 is on bit 13 with
nStartTime = NEVER_ACTIVE, so as soon as enough mainnet masternodes run this code they will sign and mine a bit-13 signal. Setting a future mainnet start time would not help either: that case goes through the fallback and is signed too.This has happened before. 1be3ac2 ("fix: move DEPLOYMENT_V24 to a fresh version bit") moved V24 off bit 12 because bit 12 "already has mined MNHF signals on mainnet and testnet from before V24 had a start time". That commit moved the bit but left the producer unchanged, so bit 13 would go the same way.
Why this is a real problem
Code path at c14104b (unchanged on current develop):
IsValidMNActivation(): src/llmq/ehf_signals.cpp#L45IsValidMNActivation()skips out-of-window deployments and then accepts unknown bits: src/chainparams.cpp#L109-L131NEVER_ACTIVE: src/chainparams.cpp#L212-L213nStartTime, so nodes withNEVER_ACTIVEkeep it forever and reject any later signal for that bit asbad-mnhf-duplicate: src/evo/mnhftx.cpp#L80, src/evo/mnhftx.cpp#L232-L235Reproduction: the updated
feature_mnehf.pyfrom this PR, run against adashdwithout thesrc/llmq/ehf_signals.cppchange. The test starts thetestdummydeployment (bit 28) asNEVER_ACTIVE(-vbparams=testdummy:-2:...), mines two blocks and checks the masternode's log. The unpatched masternode tries to sign it on every new tip:feature_mnehf.py against unpatched dashd
Why it matters
If v24.0.0 ships like this, upgraded mainnet masternodes will mine a bit-13 signal long before V24 has a start time. Nodes whose V24 start time is still
NEVER_ACTIVE(v24.0.0 itself) never expire that signal, so when the real signal is mined after a later release sets the start time, those nodes reject that block asbad-mnhf-duplicate. If the start time were instead set at or before the early signal, the early signal would count toward activation. In both cases the bit cannot be used cleanly any more, which is what already happened to bit 12. Nothing outside the masternode set can trigger this. It happens on its own once the quorum runs the code.This should also go to v24.0.x: v24.0.0-rc.1 and rc.2 carry V24 on bit 13 with mainnet
NEVER_ACTIVEand the same producer code.What was done?
In
CEHFSignalsHandler::UpdatedBlockTip(), a deployment is now skipped unless its own signalling window is open at the tip MTP:nStartTime != NEVER_ACTIVE && nStartTime <= MTP && MTP <= nTimeout. The existing "not mined yet" andIsValidMNActivation()conditions are kept as they were.feature_mnehf.pygets a new first step,test_no_signing_outside_window(), which runs right after the first quorum is mined and before the existing testdummy activation flow:testdummystarts asNEVER_ACTIVE, then the nodes are restarted with a window that has already timed out (0:1) and one that starts 10 minutes in the future. In each case two blocks are mined and the test checks that the masternode logged no signing attempt for bit 28 and that its mempool has no bit-28 signal.testdummy, so the negative checks are not passing just because the masternode cannot sign.testdummyrather thanv24, so it keeps working after v24 is buried.Why this is the correct minimal fix
The wrong decision is made in the producer, so the fix goes there.
IsValidMNActivation()cannot be tightened instead. It is the validation check, and its fallback for out-of-window and unknown bits is what keeps old signals valid on reindex after a bit is reused (mnhf_bit_reuse_reindex_tests). Changing it would make nodes disagree about blocks they already accepted.CheckMNHFTx()andCMNHFManager::ProcessBlock()are not touched, so consensus is unchanged: a signal mined by an old node is still accepted exactly as before.The
NEVER_ACTIVEcheck has to be explicit. It is the mainnet case, and the plain range check treats-2as an already-passed start time. Deployments withALWAYS_ACTIVE(-1) or a past start andNO_TIMEOUTstill pass, so regtest/devnet deployments (start 0) and testnet V24 (its window opened 2026-09-24) are signed as before.Checking the tip MTP is enough. The signal is judged later by the MTP of the block that includes it, and that MTP is greater than or equal to the tip's. I did not add a re-check in
HandleNewRecoveredSig(). It only submits signals for request ids thattrySignEHFSignal()registered, and those are only registered while the window is open.How Has This Been Tested?
macOS arm64,
--enable-debugbuild.test/functional/feature_mnehf.pyagainst adashdwithout thesrc/llmq/ehf_signals.cppchange: fails at the NEVER_ACTIVE check (output above).test/functional/feature_mnehf.pywith this PR: passes.NEVER_ACTIVEcheck, the "while it is NEVER_ACTIVE" step fails.MTP <= nTimeout, the "after its timeout" step fails.nStartTime <= MTP, the "before its start time" step fails.test/functional/feature_asset_locks.pywith this PR: passes. It activates regtest v24 (start 0) through masternode-signed EHF.src/test/test_dash --run_test=evo_mnhf_tests(includesmnhf_bit_reuse_reindex_tests): passes.test/lint/lint-python.py,test/lint/lint-whitespace.pyandcontrib/devtools/clang-format-diff.pyon the diff: clean.Breaking Changes
None. Consensus validation is unchanged. Masternodes no longer create signals for deployments whose window is not open.
Checklist:
🤖 Generated with Claude Code