Skip to content

fix: proper migration for legacy bls keys to the protx v>=2 - #7472

Draft
knst wants to merge 19 commits into
dashpay:developfrom
knst:fix-providertx-versions-2
Draft

fix: proper migration for legacy bls keys to the protx v>=2#7472
knst wants to merge 19 commits into
dashpay:developfrom
knst:fix-providertx-versions-2

Conversation

@knst

@knst knst commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

depends on #7302 to prevent conflicts ;

but changes are appliable for develop, issue is reproduced on develop too.

Issue being fixed or feature implemented

Issue has been spotted during reviewing #7302, see original comment:

Blocking: Normalize and re-key the operator key during v1-to-v3 upgrades

A post-v24 ProUpServTx can upgrade a v1 state to v3 without changing pubKeyOperator, and a same-key ProUpRegTx follows the same pattern. SetStateVersion() migrates payouts and netInfo but leaves CBLSLazyPublicKey encoded as legacy; the early return preserves that mismatch. Operator-key uniqueness is indexed by the wrapper's serialized GetHash(), while UpdateUniqueProperty() skips updates when two wrappers represent the same mathematical key. A v3 registration using the basic encoding of that existing key can therefore evade bad-protx-dup-key. Snapshot serialization can later normalize the wrapper without rebuilding the index, making acceptance dependent on reconstruction history. Re-encode the key for the target version, re-key the unique-property map when its serialized hash changes, and test duplicate rejection before and after reload.

What was done?

Keep the operator key's BLS encoding a deterministic function of nVersion, matching the SML and
on-disk serialization, so the unique-property index is re-keyed to the same scheme on every node
whether the list was built online or reloaded from a snapshot. Done before the early return because
callers pre-set nVersion, which would otherwise skip a needed re-encode. Runs only under the
v24-gated version transition, so pre-v24 blocks are untouched.

How Has This Been Tested?

Firstly, added new regression tests.

Secondly, there's mockup python script that reproduces issue (first comment on PR). This script is not meant to be merged, because its functionality is covered by regression tests

Breaking Changes

Fixes Dash Core v24 introduced issue, has not been released yet.

Checklist:

Go over all the following points, and put an x in all the boxes that apply.

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have made corresponding changes to the documentation
  • I have assigned this pull request to a milestone (for repository code-owners and collaborators only)

knst added 18 commits July 14, 2026 23:23
…dation

This commit replaces usage of helpers GetPayload+IsTrivialValid to GetValidatedPayload
It unifies validation between regression tests and production code and
useful for the next commits in PR
CProRegTx and CProUpServTx used to be the only type of protx that have
a different version. It is theoretically acceptable in assumption that
there is no new features or version will ever be introduced for protx
special transaction.

Though, for better compatibility for futher version, unification, simplicity
of documentation and to reduce user's confusions for after-v24 version
of CProUpRegTx and CProUpRevTx are allowed to be "ext addresses" even they
don't have any network related fields _at the moment_

So, since now:
 - version 1: legacy BLS, extended addresses disallowed (pre v19 fork)
 - version 2: basic BLS, extended addresses disallowed (since v19 fork)
 - version 3: basic BLS, extended addresses allowed, multi-payouts allowed (since v24 fork)

NOTE: there are also classes CSimplifiedMNListEntry and CDeterministicMNState
use the same enum for its version; moreover CDeterministicMNState inherits
version directly from CProRegTx. This refactoring doesn't contradict or conflict
this behavior
It helps to drop multiple circular dependencies for providertx <-> validation.h as a side effect
@thepastaclaw

thepastaclaw commented Jul 17, 2026

Copy link
Copy Markdown

⛔ Blockers found — Sonnet deferred (commit 5f66192)
Canonical validated blockers: 1

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 94f3129802

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/evo/specialtxman.cpp
// v24-gated version transition, so pre-v24 blocks are untouched.
if (state_mn.pubKeyOperator != CBLSLazyPublicKey()) {
const auto pubkey{state_mn.pubKeyOperator.Get()};
state_mn.pubKeyOperator.Set(pubkey, nVersion == ProTxVersion::LegacyBLS);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Force diffs to carry BLS re-encodes

When a v24 update keeps the same operator key, this line changes only the CBLSLazyPublicKey encoding; however CDeterministicMNStateDiff still decides whether to include Field_pubKeyOperator via operator!=, which treats legacy/basic encodings of the same point as equal. BuildDiff therefore omits the key, so nodes reconstructing the MN list from stored/P2P diffs set nVersion to v3 but keep the legacy-encoded key and legacy unique-property hash, reintroducing the reconstruction-dependent duplicate-key behavior this patch is trying to remove.

Useful? React with 👍 / 👎.

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b2ca7817-0945-4544-be93-b4942bdab0b7

📥 Commits

Reviewing files that changed from the base of the PR and between 94f3129 and 5f66192.

📒 Files selected for processing (1)
  • src/rpc/evo.cpp
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/rpc/evo.cpp

Walkthrough

The PR centralizes ProTx version selection and owner-payout extraction around ProTxVersion::ExtAddr. It updates payout serialization, JSON/RPC output, payment processing, bloom filtering, and deterministic-node interfaces to use state-based payout access. Validation separates deployment-based payload limits from trivial transaction checks. State transitions migrate legacy payouts and re-encode operator keys while preserving unique-property indexing. Tests cover v19/v24 versioning, payout encoding, collateral reuse, and key re-indexing.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RPC
  participant GetValidatedPayload
  participant ProTxState
  participant PayoutConsumers
  RPC->>GetValidatedPayload: construct and validate ProTx payload
  GetValidatedPayload->>ProTxState: apply version and payout state
  ProTxState->>PayoutConsumers: expose state-based owner payouts
  PayoutConsumers-->>RPC: serialize or display payout destinations
Loading

Possibly related PRs

  • dashpay/dash#7340: Both update provider payout extraction in bloom-filter processing and related owner-payout handling.

Suggested reviewers: pastapastapasta

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.81% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the main change: migrating legacy BLS keys during ProTx version upgrades.
Description check ✅ Passed The description is directly about the same migration bug, fix, and regression tests described in the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/validation.cpp (1)

5831-5838: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer the ChainstateManager overload here. It keeps both deployment checks on the same path and avoids mixing GetConsensus() with chainman.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/validation.cpp` around lines 5831 - 5838, Update DeploymentToProtxVersion
to use the ChainstateManager overload of DeploymentActiveAfter for the
DEPLOYMENT_V19 check, passing chainman consistently as already done for
DEPLOYMENT_V24; preserve the existing is_basic_override behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/rpc/evo.cpp`:
- Line 1106: Update the ptx.nVersion assignment in the relevant RPC flow to
retain BasicBLS for legacy service updates after v24 instead of unconditionally
using DeploymentToProtxVersion(). Preserve the higher-version selection for
applicable non-legacy updates so SignAndSendSpecialTx() receives a valid
transaction version.
- Around line 1228-1239: Update the empty operator-key handling in the RPC
method using nVersion so a reused legacy operator key is decoded and re-encoded
with the target version when nVersion is ExtAddr, while preserving the same key.
Do not copy the legacy encoding unchanged; ensure the resulting key satisfies
the existing version invariant.

---

Nitpick comments:
In `@src/validation.cpp`:
- Around line 5831-5838: Update DeploymentToProtxVersion to use the
ChainstateManager overload of DeploymentActiveAfter for the DEPLOYMENT_V19
check, passing chainman consistently as already done for DEPLOYMENT_V24;
preserve the existing is_basic_override behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 150955fa-ddc8-4072-95fa-5096a05accef

📥 Commits

Reviewing files that changed from the base of the PR and between ca2912d and 94f3129.

📒 Files selected for processing (28)
  • src/common/bloom.cpp
  • src/evo/core_write.cpp
  • src/evo/deterministicmns.cpp
  • src/evo/deterministicmns.h
  • src/evo/dmnstate.cpp
  • src/evo/dmnstate.h
  • src/evo/netinfo.cpp
  • src/evo/providertx.cpp
  • src/evo/providertx.h
  • src/evo/providertx_util.cpp
  • src/evo/specialtx_filter.cpp
  • src/evo/specialtxman.cpp
  • src/evo/specialtxman.h
  • src/evo/types.h
  • src/masternode/payments.cpp
  • src/node/interfaces.cpp
  • src/rpc/evo.cpp
  • src/rpc/masternode.cpp
  • src/test/data/trivially_invalid.json
  • src/test/evo_deterministicmns_tests.cpp
  • src/test/evo_trivialvalidation.cpp
  • src/validation.cpp
  • src/validation.h
  • test/functional/feature_masternode_payout_shares.py
  • test/functional/feature_protx_version.py
  • test/functional/rpc_netinfo.py
  • test/functional/test_runner.py
  • test/lint/lint-circular-dependencies.py

Comment thread src/rpc/evo.cpp
Comment thread src/rpc/evo.cpp
@knst

knst commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Extra python script to reproduce issue [it is reproducible on top of develop as well]:

repro_protx_dupkey_reload_divergence.py

not an artefact to merge so far as replaced by regression tests

@UdjinM6

UdjinM6 commented Jul 17, 2026

Copy link
Copy Markdown

I noticed this issue too while reviewing #7302 and started with the same approach initially but this can actually stall block production, pls see #7473 for an alternative solution.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preliminary review — Codex only

The reused-key RPC path is fixed at this head, but the BLS re-encoding migration is not preserved in deterministic masternode state diffs. Reconstructing a list from a snapshot and persisted diffs can therefore produce a different operator-key uniqueness index and different block-validation results. The three migration commits should also be made atomic before merge.

Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (failed), gpt-5.6-sol — dash-core-commit-history (failed), gpt-5.6-sol — general (failed), gpt-5.6-sol — dash-core-commit-history (failed), gpt-5.6-sol — general (failed), gpt-5.6-sol — dash-core-commit-history (failed), gpt-5.6-sol — general (failed), gpt-5.6-sol — dash-core-commit-history (failed), gpt-5.6-sol — general (completed), gpt-5.6-sol — dash-core-commit-history (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 1 blocking | 🟡 1 suggestion(s)

1 additional finding(s) omitted (not in diff).

🤖 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 `src/evo/specialtxman.cpp`:
- [BLOCKING] src/evo/specialtxman.cpp:61-63: Persist the encoding-only operator-key change in state diffs
  These lines change a legacy BLS wrapper to basic encoding while preserving the decoded point. `CDeterministicMNStateDiff` uses `operator!=`, which treats wrappers containing the same point as equal, so `BuildDiff()` omits `Field_pubKeyOperator` even though the unique-property hash changed. Replaying the persisted diff from the preceding snapshot updates `nVersion` and `netInfo` but retains the legacy wrapper and legacy uniqueness-map entry. A basic-encoded ProRegTx or ProUpRegTx reusing that point can then pass `HasUniqueProperty()` on the reconstructed node while a node retaining the live list rejects it, making consensus validation depend on cache or restart history. Record encoding changes in the state diff and add a test that reconstructs the migrated list by applying the serialized block diff to a pre-migration snapshot.

In `<commit:48d3735>`:
- [SUGGESTION] <commit:48d3735>:1: Do not place failing regression tests before their fix
  Commit 48d373562f7 adds tests that fail at that commit because operator-key re-encoding and uniqueness-map re-keying arrive only in 94f31298021. The subsequent 5f661926e17 `fixup` commit then completes the same change in the registrar RPC path. Reorder the implementation before the tests or squash these three commits into one atomic migration fix, with the explicit fixup folded into the substantive implementation commit.

Comment thread src/evo/specialtxman.cpp
Comment on lines +61 to +63
if (state_mn.pubKeyOperator != CBLSLazyPublicKey()) {
const auto pubkey{state_mn.pubKeyOperator.Get()};
state_mn.pubKeyOperator.Set(pubkey, nVersion == ProTxVersion::LegacyBLS);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Persist the encoding-only operator-key change in state diffs

These lines change a legacy BLS wrapper to basic encoding while preserving the decoded point. CDeterministicMNStateDiff uses operator!=, which treats wrappers containing the same point as equal, so BuildDiff() omits Field_pubKeyOperator even though the unique-property hash changed. Replaying the persisted diff from the preceding snapshot updates nVersion and netInfo but retains the legacy wrapper and legacy uniqueness-map entry. A basic-encoded ProRegTx or ProUpRegTx reusing that point can then pass HasUniqueProperty() on the reconstructed node while a node retaining the live list rejects it, making consensus validation depend on cache or restart history. Record encoding changes in the state diff and add a test that reconstructs the migrated list by applying the serialized block diff to a pre-migration snapshot.

source: ['codex']

UdjinM6 added a commit to UdjinM6/dash that referenced this pull request Jul 18, 2026
Adopts the maintainer's preferred approach from dashpay#7472 (re-encode the operator key
on a version change) instead of forcing a key rotation to leave LegacyBLS, while
keeping the cross-scheme uniqueness guards this branch added so the re-key cannot
collide and stall block production.

A masternode operator can keep the same BLS private key across the legacy->basic
migration; only the serialized encoding of the public key changes. Rather than
rejecting a same-key ProUpServTx/ProUpRegTx and forcing a rotation (which also
PoSe-bans the masternode), SetStateVersion() now re-encodes the stored key to the
scheme its version implies, and UpdateUniqueProperty() re-keys the scheme-dependent
unique-property map when the encoding changes. The RPCs build a BasicBLS migration
payload for a legacy masternode instead of erroring.

Because dashpay#7472's re-encode collides -- and UpdateMN() throws out of block assembly --
when a squatter already holds the same key under the other encoding (the live
per-encoding registration hole), migration is guarded: CheckProUpServTx,
CheckProUpRegTx and RebuildListFromBlock reject a migration that would collide with
another masternode's key under either scheme (bad-protx-dup-key), and only when the
key actually changes or the version crosses the scheme boundary, so a grandfathered
cross-scheme pair's non-migrating routine update is not blocked.

CDeterministicMNStateDiff also has to capture the re-encoding: its field comparison
used CBLSLazyPublicKey::operator==, which ignores the scheme, so a same-key
migration produced a diff that omitted the key. A node reconstructing the list from
evoDB diffs then kept the old encoding while an online-built list had the new one --
a reconstruction split that full-snapshot serialization does not reveal. The diff
now compares the scheme-dependent hash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@knst
knst marked this pull request as draft July 21, 2026 07:12
@knst

knst commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Likely #7473 to merge instead

UdjinM6 added a commit to UdjinM6/dash that referenced this pull request Jul 25, 2026
Adopts the maintainer's preferred approach from dashpay#7472 (re-encode the operator key
on a version change) instead of forcing a key rotation to leave LegacyBLS, while
keeping the cross-scheme uniqueness guards this branch added so the re-key cannot
collide and stall block production.

A masternode operator can keep the same BLS private key across the legacy->basic
migration; only the serialized encoding of the public key changes. Rather than
rejecting a same-key ProUpServTx/ProUpRegTx and forcing a rotation (which also
PoSe-bans the masternode), SetStateVersion() now re-encodes the stored key to the
scheme its version implies, and UpdateUniqueProperty() re-keys the scheme-dependent
unique-property map when the encoding changes. The RPCs build a BasicBLS migration
payload for a legacy masternode instead of erroring.

Because dashpay#7472's re-encode collides -- and UpdateMN() throws out of block assembly --
when a squatter already holds the same key under the other encoding (the live
per-encoding registration hole), migration is guarded: CheckProUpServTx,
CheckProUpRegTx and RebuildListFromBlock reject a migration that would collide with
another masternode's key under either scheme (bad-protx-dup-key), and only when the
key actually changes or the version crosses the scheme boundary, so a grandfathered
cross-scheme pair's non-migrating routine update is not blocked.

CDeterministicMNStateDiff also has to capture the re-encoding: its field comparison
used CBLSLazyPublicKey::operator==, which ignores the scheme, so a same-key
migration produced a diff that omitted the key. A node reconstructing the list from
evoDB diffs then kept the old encoding while an online-built list had the new one --
a reconstruction split that full-snapshot serialization does not reveal. The diff
now compares the scheme-dependent hash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

This pull request has conflicts, please rebase.

UdjinM6 added a commit to UdjinM6/dash that referenced this pull request Jul 28, 2026
Adopts the maintainer's preferred approach from dashpay#7472 (re-encode the operator key
on a version change) instead of forcing a key rotation to leave LegacyBLS, while
keeping the cross-scheme uniqueness guards this branch added so the re-key cannot
collide and stall block production.

A masternode operator can keep the same BLS private key across the legacy->basic
migration; only the serialized encoding of the public key changes. Rather than
rejecting a same-key ProUpServTx/ProUpRegTx and forcing a rotation (which also
PoSe-bans the masternode), SetStateVersion() now re-encodes the stored key to the
scheme its version implies, and UpdateUniqueProperty() re-keys the scheme-dependent
unique-property map when the encoding changes. The RPCs build a BasicBLS migration
payload for a legacy masternode instead of erroring.

Because dashpay#7472's re-encode collides -- and UpdateMN() throws out of block assembly --
when a squatter already holds the same key under the other encoding (the live
per-encoding registration hole), migration is guarded: CheckProUpServTx,
CheckProUpRegTx and RebuildListFromBlock reject a migration that would collide with
another masternode's key under either scheme (bad-protx-dup-key), and only when the
key actually changes or the version crosses the scheme boundary, so a grandfathered
cross-scheme pair's non-migrating routine update is not blocked.

CDeterministicMNStateDiff also has to capture the re-encoding: its field comparison
used CBLSLazyPublicKey::operator==, which ignores the scheme, so a same-key
migration produced a diff that omitted the key. A node reconstructing the list from
evoDB diffs then kept the old encoding while an online-built list had the new one --
a reconstruction split that full-snapshot serialization does not reveal. The diff
now compares the scheme-dependent hash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PastaPastaPasta added a commit that referenced this pull request Aug 7, 2026
… (desync + cross-scheme uniqueness)

4963125 test: deduplicate BLS migration regression fixtures (PastaClaw)
b6722c2 doc: add release notes for #7473 (UdjinM6)
d62a031 test: cover BLS scheme migration and cross-scheme operator-key uniqueness (UdjinM6)
71e7980 fix: allow legacy->basic operator key migration without key rotation (UdjinM6)
f094101 fix: enforce operator key uniqueness across BLS schemes (UdjinM6)

Pull request description:

  ## Issue being fixed or feature implemented

  Two independent bugs in how a masternode's operator BLS key relates to its state
  version. Both exist on `develop` today and are reproduced by test; neither is
  introduced by this change.

  > **Rebased on #7302.** This branch now sits on top of the merged #7302 (uniform
  > protx versioning: v4 merged into v3, RPC version selection via
  > `DeploymentToProtxVersion`, and the v1→v3 update restriction removed). The fixes
  > below are unchanged in substance; the migration now lands a legacy masternode at
  > **v3** directly (rather than clamping to v2) because #7302 allows v1→v3, and the
  > operator-key re-encoding continues to make that safe.

  **(A) Post-v24 operator-key scheme desync → consensus split.**
  `CDeterministicMNState` derives its operator key's serialization from `nVersion`
  (`CBLSLazyPublicKeyVersionWrapper(key, nVersion == LegacyBLS)`). But after v24 a
  masternode's `nVersion` could rise out of LegacyBLS (v1) to a basic-scheme version
  (v2/v3) while the stored key kept the legacy scheme flag:

  - `ProUpServTx` carries no operator key, yet set `newState->nVersion` from the
    payload.
  - `ProUpRegTx` re-submitting the *same* key basic-encoded left `operator_changed`
    false, because `CBLSLazyPublicKey::operator==` compares the public key and ignores
    the scheme, so the re-encode was skipped while the version still rose.

  Once a state has a basic-scheme `nVersion` with a legacy-flagged key, a list rebuilt
  from block diffs and the same list reloaded from disk hash that key differently, so
  `mnUniquePropertyMap` diverges. Nodes then disagree on `HasUniqueProperty()` and
  therefore on `bad-protx-dup-key` — a chain split determined purely by restart
  history. `update_service` is the routine operation that plants this: any legacy
  masternode updating its service address after v24 would trigger it, no attacker
  required.

  **(B) Operator-key uniqueness enforced per encoding, not per key (live today).**
  `mnUniquePropertyMap` is keyed by `GetUniquePropertyHash()`, which serializes its
  argument, and a BLS key serializes differently under the two schemes, so
  `H(K, legacy) != H(K, basic)`. `CheckProRegTx`'s duplicate check consults that map,
  so it does not see an operator key an existing masternode holds under the *other*
  encoding. A `ProRegTx` never proves ownership of the operator key, so anyone can
  re-register an existing masternode's operator public key for the price of a
  collateral. This is exploitable on mainnet now; it is closed here for post-v24.

  ## What was done?

  The approach is **migrate, don't force rotation** (adopting the maintainer's
  preferred model from #7472), while keeping the cross-scheme uniqueness guards that
  make the re-encode safe. Every new consensus rule is gated on `DEPLOYMENT_V24`.

  A legacy masternode keeps the same operator private key across the migration; only
  the serialized encoding of the public key changes. On a version bump,
  `SetStateVersion()` re-encodes the stored key to the scheme its version implies
  (`Set(Get(), …)`, not `SetLegacy()`, so the cached serialization actually changes),
  and `UpdateUniqueProperty()` re-keys the scheme-dependent unique-property map when
  the encoding changes (it now compares `GetUniquePropertyHash()` rather than the
  scheme-blind `operator==`).

  Re-encoding is only safe if the target slot is free: `UpdateMN()` reports a duplicate
  by *throwing*, and that throw escapes `BlockAssembler::CreateNewBlock`, stalling block
  production. Because bug B leaves the per-encoding registration hole open, a squatter
  can already hold the key under the other encoding — so the migration is guarded at
  every layer, and only when the key actually changes or the version crosses the
  legacy→basic boundary (a grandfathered cross-scheme pair's non-migrating routine
  update is not blocked).

  The unique-property map is deliberately **not** made canonical. It is derived, not
  serialized: `CDeterministicMNList::Unserialize` clears it and rebuilds via `AddMN`. A
  canonical hash would apply retroactively to all history and, if any cross-scheme
  duplicate pair already exists, make `AddMN` throw and nodes fail to sync past it.
  Cross-scheme pairs created before activation are therefore tolerated (nothing
  rehashes the map) and remain usable for non-migrating routine updates; a same-key
  legacy→basic migration by the still-legacy member is rejected `bad-protx-dup-key`
  until the conflicting key is rotated.

  The change is split into two source commits, a test commit, a release-notes commit,
  and a follow-up test-fixture dedup commit; each source commit builds on its own.

  **Commit 1 — enforce uniqueness across schemes.** A helper
  `HasOperatorKeyUnderAnyScheme` probes both encodings (two O(1) lookups, not a scan),
  wired in at every point a key can be claimed:

  - `CheckProRegTx` and `CheckProUpRegTx`, against the previous block's list.
  - `RebuildListFromBlock`, against the list as rebuilt so far (same-block pairs).
  - `AcceptToMemoryPool`. This probe is deliberately **not** v24-gated: block assembly
    does not revalidate special transactions cumulatively, so a pair admitted before
    activation is never evicted and would poison every template afterwards. Keeping the
    pair out of the mempool is what actually closes that, and mempool policy is allowed
    to be stricter than consensus (before v24 a node rejecting the second transaction
    would still accept a consensus-valid block containing the pair; after v24 consensus
    rejects the pair too, so the policy difference cannot split the chain in either regime).

  The registrar probes run only when the operator key is actually changing: an update
  that keeps its own key cannot create a duplicate, and probing it anyway would let a
  pre-activation cross-scheme pair permanently block the affected masternode's
  registrar updates.

  **Commit 2 — allow legacy→basic migration without key rotation.** Handles bug A by
  migrating in place rather than forcing a rotation (adopting #7472's re-encode model):

  - `SetStateVersion()` re-encodes the stored operator key to the scheme its version
    implies (`Set(Get(), …)`, not `SetLegacy()`, so the cached serialization actually
    changes), keeping the invariant that a stored key's encoding always matches its
    `nVersion`; `UpdateUniqueProperty()` re-keys the scheme-dependent map on an encoding
    change (comparing `GetUniquePropertyHash()` rather than the scheme-blind
    `operator==`).
  - The migration is guarded against collisions via `HasOperatorKeyUnderAnyScheme` in
    `CheckProUpServTx`, `CheckProUpRegTx` and `RebuildListFromBlock` — the last against
    the list as rebuilt so far, since per-transaction checks run against `pindexPrev`
    and are blind to an earlier transaction in the same block — so a re-encode that
    would collide with another masternode's key cannot throw out of block assembly. The
    guard fires only when the operator key changes or the version crosses the
    legacy→basic boundary (a shared `IsSchemeMigration` predicate), so a grandfathered
    cross-scheme pair's non-migrating update is not blocked.
  - `CDeterministicMNStateDiff` now compares the scheme-dependent hash for the operator
    key field. Its comparison used `operator==` (scheme-blind), so a same-key migration
    produced a diff that omitted the key — a node reconstructing the list from evoDB
    diffs kept the old encoding while an online-built list had the new one, a
    reconstruction split that full-snapshot serialization does not reveal.
  - RPC (`rpc/evo.cpp`): `protx update_service` / `protx update_registrar` migrate a
    legacy masternode to the basic scheme (keeping the same key, or supplying a new
    one), instead of erroring or forcing a rotation. Version selection flows through
    #7302's `DeploymentToProtxVersion`, so post-v24 the migration payload is **v3**
    (ExtAddr) — the legacy→BasicBLS clamp #7302 removed is not reintroduced; the
    stored/reused operator key is re-encoded to the basic scheme so it matches the
    target version. The legacy-BLS RPC variants keep the masternode on the legacy
    scheme.

  **Commit 3 — tests.** Unit tests in `evo_deterministicmns_tests.cpp` and the
  functional-test extension in `feature_protx_version.py` (renamed from
  `feature_dip3_v19.py` by #7302) covering both fixes (enumerated below).

  **Commit 4 — release notes.** `doc/release-notes-7473.md` documenting the post-v24
  per-key uniqueness enforcement and the in-place migration behavior of
  `protx update_service` / `protx update_registrar`.

  **Commit 5 — test fixture dedup.** Deduplicates the v24-activation scaffolding in
  `evo_deterministicmns_tests.cpp` into a shared `TestMNChainSetup` fixture; no
  behavior change (test-only refactor, contributed by a reviewer).

  ## How Has This Been Tested?

  Built with `--enable-werror`; the full unit suite (`./src/test/test_dash`, 778 cases)
  and the touched functional test (`feature_protx_version.py`) both pass, and lint is
  clean for the changed files. Each source commit was rebuilt independently and compiles
  on its own under `--enable-werror`; the test commit adds the coverage below, and the
  full `evo_dip3_activation_tests` suite passes.

  Unit tests (`src/test/evo_deterministicmns_tests.cpp`) — each rejection test was
  watched fail first (by flipping the v24 activation height):

  - `proupserv_migrates_legacy`, `proupreg_migrates_legacy_same_key` — the happy-path
    migrations: a legacy masternode raises its version keeping the same operator key,
    the stored key re-encodes to basic, and the masternode is *not* PoSe-banned.
  - `migration_rejected_when_key_squatted` — a squatter already holds the key under the
    other encoding; the migration is rejected `bad-protx-dup-key` at the per-tx layer
    rather than throwing.
  - `same_mn_same_block_migration_consistent`,
    `same_mn_same_block_version_crossing_key_rotation` — same-masternode transitions
    within one block reconstruct identically.
  - `same_block_cross_scheme_key_pair_rejected`, `mempool_rejects_cross_scheme_key_race`,
    `pre_v24_cross_scheme_pair_cannot_become_resident` — the same-block, mempool, and
    activation-boundary pairings that motivated the rebuild-time and ungated mempool
    probes.
  - `proregtx_rejects_cross_scheme_key_reuse`, `proupreg_rejects_cross_scheme_key_reuse`
    — bug B at the registration and registrar paths, both directions, with fresh-key and
    self-key non-false-positive cases.
  - `statediff_captures_operator_key_reencoding` — the evoDB-diff reconstruction path:
    a same-key migration must emit the re-encoded key in the diff.
  - `has_operator_key_under_any_scheme` — the two-encoding lookup helper.
  - `stale_special_tx_does_not_poison_template` — a resident-but-invalid special tx
    seeded via `addUnchecked`; without the package recheck `CreateNewBlock` throws.
  - `pre_v24_behaviour_unchanged` — the non-retroactivity guard: asserts the new rules
    are inert before v24, so nothing on live mainnet/testnet changes.

  Functional test (`test/functional/feature_protx_version.py`): extended to assert,
  after v24 activation, that a legacy masternode's `update_service` migrates it to the
  basic scheme in place (v3 payload, `state.version == 3`, same operator key re-encoded
  to a different hex, `PoSeBanHeight == -1`), and that the list reloads from disk
  identically after the migration. The same-key migration runs alongside #7302's
  rotated-key `update_registrar` migration and is sequenced last (with a reconnect after
  the key re-encoding, which churns the migrated node's masternode connections) so it
  does not destabilise the surrounding checks.

  ## Breaking Changes

  All consensus rules here are gated on `DEPLOYMENT_V24`, which is `NEVER_ACTIVE` on
  mainnet and testnet, so there is no consensus change on any live network;
  `pre_v24_behaviour_unchanged` asserts this. After v24 activates:

  - Absent a cross-scheme key collision, a LegacyBLS masternode migrates to the basic
    scheme in place on its next version bump (`update_service` or `update_registrar`),
    keeping the same operator key; the stored public key is re-encoded legacy→basic. No
    forced key rotation, no PoSe ban.
  - Registering or updating to an operator public key already held by another masternode
    is rejected regardless of which BLS encoding either side uses, and a migration that
    would collide with such a key is rejected `bad-protx-dup-key` rather than stalling
    block assembly.

  The `AcceptToMemoryPool` cross-scheme check is mempool policy (ungated) rather than
  consensus, so a node may reject a second in-flight transaction that another node's
  mempool accepted; both would still accept a consensus-valid block containing it before
  v24, and after v24 consensus rejects the pair, so the policy difference cannot split
  the chain.

  Not addressed here, and worth stating: cross-scheme duplicate pairs created *before*
  activation are tolerated, not resolved; and bug B is only closed post-v24, so the
  registration hole remains open on mainnet until v24 activates.

  ## Checklist:

  - [x] I have performed a self-review of my own code
  - [x] I have commented my code, particularly in hard-to-understand areas
  - [x] I have added or updated relevant unit/integration/functional/e2e tests
  - [x] I have made corresponding changes to the documentation (release notes)
  - [x] I have assigned this pull request to a milestone

Top commit has no ACKs.

Tree-SHA512: 257bb3de1c69d3291b833f325dc6dca59bfc4da87539c55509e3d1bc7d227072c018bca49649e2028c73748e9371da74bcf32b370ce3511a05f9c3cc518c129d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants