From 611768972e58ccdd62edfcee96341e9aa8eb40e4 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 18 Jun 2026 17:40:03 +0700 Subject: [PATCH 01/13] chore(deps): bump rust-dashcore to 64d9267 (DashPay account-index path fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps all rust-dashcore workspace deps from 5c0113e7 to 64d9267d, picking up: - key-wallet#813 — honor the account index in the DashPay derivation path (additive/backward-compatible; no platform-side change needed). - rpc-json#808 — Core 23 restructured masternode platform ports: the flat platform_p2p_port / platform_http_port are now legacy_* and a nested `addresses` (MasternodeAddresses) was added. #808 migration (behavior-preserving): drive-abci's masternode/validator state reads the legacy flat port pair, exactly as before — MasternodeStateV0 keeps its own field names, so platform's serialized state is unchanged (no state migration, no consensus change). The Core 23 nested `addresses` are intentionally left unconsumed (DMNState -> MasternodeStateV0 drops them; the reverse sets addresses: None); adopting per-protocol addresses is a separate, reviewable change. Deprecated-field reads are scoped under #[allow(deprecated)] with rationale. Workspace compiles clean (all targets, 0 warnings); masternode identity unit tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 58 +++++++++---------- Cargo.toml | 16 ++--- .../create_operator_identity/v0/mod.rs | 6 +- .../create_owner_identity/v0/mod.rs | 6 +- .../create_voter_identity/v0/mod.rs | 6 +- .../get_operator_identifier/v0/mod.rs | 6 +- .../update_operator_identity/v0/mod.rs | 51 +++++++++------- .../update_state_masternode_list/v0/mod.rs | 12 +++- .../state_transition/state_transitions/mod.rs | 11 ++-- .../src/platform_types/masternode/v0/mod.rs | 22 +++++-- .../src/platform_types/validator/v0/mod.rs | 7 ++- .../masternode_list_item_helpers.rs | 17 ++++-- .../tests/strategy_tests/masternodes.rs | 18 ++++-- 13 files changed, 144 insertions(+), 92 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e296c3aebdb..424b89a3013 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,7 +120,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -131,7 +131,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1206,7 +1206,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1637,7 +1637,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=64d9267d414da2c01c604c12d95dd1b12a764bb1#64d9267d414da2c01c604c12d95dd1b12a764bb1" dependencies = [ "bincode", "bincode_derive", @@ -1648,7 +1648,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=64d9267d414da2c01c604c12d95dd1b12a764bb1#64d9267d414da2c01c604c12d95dd1b12a764bb1" dependencies = [ "dash-network", ] @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=64d9267d414da2c01c604c12d95dd1b12a764bb1#64d9267d414da2c01c604c12d95dd1b12a764bb1" dependencies = [ "async-trait", "chrono", @@ -1754,7 +1754,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=64d9267d414da2c01c604c12d95dd1b12a764bb1#64d9267d414da2c01c604c12d95dd1b12a764bb1" dependencies = [ "anyhow", "base64-compat", @@ -1780,12 +1780,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=64d9267d414da2c01c604c12d95dd1b12a764bb1#64d9267d414da2c01c604c12d95dd1b12a764bb1" [[package]] name = "dashcore-rpc" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=64d9267d414da2c01c604c12d95dd1b12a764bb1#64d9267d414da2c01c604c12d95dd1b12a764bb1" dependencies = [ "dashcore-rpc-json", "hex", @@ -1798,7 +1798,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=64d9267d414da2c01c604c12d95dd1b12a764bb1#64d9267d414da2c01c604c12d95dd1b12a764bb1" dependencies = [ "bincode", "dashcore", @@ -1813,7 +1813,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=64d9267d414da2c01c604c12d95dd1b12a764bb1#64d9267d414da2c01c604c12d95dd1b12a764bb1" dependencies = [ "bincode", "dashcore-private", @@ -2428,7 +2428,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2489,7 +2489,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2867,7 +2867,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=64d9267d414da2c01c604c12d95dd1b12a764bb1#64d9267d414da2c01c604c12d95dd1b12a764bb1" [[package]] name = "glob" @@ -3552,7 +3552,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -3803,7 +3803,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4034,7 +4034,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=64d9267d414da2c01c604c12d95dd1b12a764bb1#64d9267d414da2c01c604c12d95dd1b12a764bb1" dependencies = [ "aes", "async-trait", @@ -4063,7 +4063,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=64d9267d414da2c01c604c12d95dd1b12a764bb1#64d9267d414da2c01c604c12d95dd1b12a764bb1" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4079,7 +4079,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=64d9267d414da2c01c604c12d95dd1b12a764bb1#64d9267d414da2c01c604c12d95dd1b12a764bb1" dependencies = [ "async-trait", "bincode", @@ -4596,7 +4596,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5485,7 +5485,7 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "itertools 0.14.0", "log", "multimap", @@ -5656,7 +5656,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls", - "socket2 0.6.4", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -5694,7 +5694,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -6486,7 +6486,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6499,7 +6499,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6558,7 +6558,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7176,7 +7176,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7418,7 +7418,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8867,7 +8867,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5d5f2960f1d..c386ac6674c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,14 +50,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "64d9267d414da2c01c604c12d95dd1b12a764bb1" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "64d9267d414da2c01c604c12d95dd1b12a764bb1" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "64d9267d414da2c01c604c12d95dd1b12a764bb1" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "64d9267d414da2c01c604c12d95dd1b12a764bb1" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "64d9267d414da2c01c604c12d95dd1b12a764bb1" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "64d9267d414da2c01c604c12d95dd1b12a764bb1" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "64d9267d414da2c01c604c12d95dd1b12a764bb1" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "64d9267d414da2c01c604c12d95dd1b12a764bb1" } tokio-metrics = "0.5" diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_operator_identity/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_operator_identity/v0/mod.rs index 8e9be14377c..888f15ed8ed 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_operator_identity/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_operator_identity/v0/mod.rs @@ -30,6 +30,7 @@ where } #[cfg(test)] +#[allow(deprecated)] // fixtures build mock DMNState via deprecated legacy platform ports mod tests { use crate::platform_types::platform::Platform; use crate::rpc::core::MockCoreRPCLike; @@ -65,8 +66,9 @@ mod tests { pub_key_operator: vec![1u8; 48], operator_payout_address, platform_node_id, - platform_p2p_port: None, - platform_http_port: None, + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses: None, }, } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_owner_identity/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_owner_identity/v0/mod.rs index 503fe5ac07c..cb3c702df82 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_owner_identity/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_owner_identity/v0/mod.rs @@ -34,6 +34,7 @@ where } #[cfg(test)] +#[allow(deprecated)] // fixtures build mock DMNState via deprecated legacy platform ports mod tests { use crate::platform_types::platform::Platform; use crate::rpc::core::MockCoreRPCLike; @@ -66,8 +67,9 @@ mod tests { pub_key_operator: vec![0u8; 48], operator_payout_address: None, platform_node_id: None, - platform_p2p_port: None, - platform_http_port: None, + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses: None, }, } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_voter_identity/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_voter_identity/v0/mod.rs index b5a1b148cbd..4a42decca0c 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_voter_identity/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/create_voter_identity/v0/mod.rs @@ -41,6 +41,7 @@ where } #[cfg(test)] +#[allow(deprecated)] // fixtures build mock DMNState via deprecated legacy platform ports mod tests { use crate::platform_types::platform::Platform; use crate::rpc::core::MockCoreRPCLike; @@ -74,8 +75,9 @@ mod tests { pub_key_operator: vec![0u8; 48], operator_payout_address: None, platform_node_id: None, - platform_p2p_port: None, - platform_http_port: None, + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses: None, }, } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/get_operator_identifier/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/get_operator_identifier/v0/mod.rs index 8cb6c517ca9..338b2bf7fe7 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/get_operator_identifier/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/get_operator_identifier/v0/mod.rs @@ -20,6 +20,7 @@ where } #[cfg(test)] +#[allow(deprecated)] // fixtures build mock DMNState via deprecated legacy platform ports mod tests { use crate::platform_types::platform::Platform; use crate::rpc::core::MockCoreRPCLike; @@ -51,8 +52,9 @@ mod tests { pub_key_operator, operator_payout_address: None, platform_node_id: None, - platform_p2p_port: None, - platform_http_port: None, + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses: None, }, } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_operator_identity/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_operator_identity/v0/mod.rs index 334872be2b3..e1831806b34 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_operator_identity/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_operator_identity/v0/mod.rs @@ -387,6 +387,7 @@ where } #[cfg(test)] +#[allow(deprecated)] // fixtures build mock DMNState via deprecated legacy platform ports mod tests { use crate::platform_types::platform_state::PlatformStateV0Methods; use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; @@ -542,8 +543,9 @@ mod tests { pub_key_operator: pub_key_operator.clone(), operator_payout_address: Some(operator_payout_address), platform_node_id: Some(node_id_bytes), - platform_p2p_port: None, - platform_http_port: None, + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses: None, }, }; @@ -619,8 +621,9 @@ mod tests { pub_key_operator: pub_key_operator.clone(), operator_payout_address: Some(operator_payout_address), platform_node_id: Some(node_id), - platform_p2p_port: None, - platform_http_port: None, + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses: None, }, }; @@ -679,8 +682,9 @@ mod tests { pub_key_operator: pub_key_operator.clone(), operator_payout_address: Some(new_operator_payout_address), platform_node_id: Some(node_id), - platform_p2p_port: None, - platform_http_port: None, + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses: None, }, }; @@ -752,8 +756,9 @@ mod tests { pub_key_operator: pub_key_operator.clone(), operator_payout_address: Some(operator_payout_address), platform_node_id: Some(node_id_bytes), - platform_p2p_port: None, - platform_http_port: None, + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses: None, }, }; @@ -829,8 +834,9 @@ mod tests { pub_key_operator: pub_key_operator.clone(), operator_payout_address: Some(operator_payout_address), platform_node_id: Some(original_node_id), - platform_p2p_port: None, - platform_http_port: None, + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses: None, }, }; @@ -890,8 +896,9 @@ mod tests { pub_key_operator: pub_key_operator.clone(), operator_payout_address: Some(operator_payout_address), platform_node_id: Some(new_platform_node_id), - platform_p2p_port: None, - platform_http_port: None, + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses: None, }, }; @@ -978,8 +985,9 @@ mod tests { pub_key_operator: original_pub_key_operator.clone(), operator_payout_address: Some(operator_payout_address), platform_node_id: Some(node_id_bytes), - platform_p2p_port: None, - platform_http_port: None, + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses: None, }, }; @@ -1108,8 +1116,9 @@ mod tests { pub_key_operator: original_pub_key_operator.clone(), operator_payout_address: Some(operator_payout_address), platform_node_id: Some(node_id_bytes), - platform_p2p_port: None, - platform_http_port: None, + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses: None, }, }; @@ -1209,8 +1218,9 @@ mod tests { pub_key_operator: original_pub_key_operator.clone(), operator_payout_address: Some(operator_payout_address), platform_node_id: Some(node_id_bytes), - platform_p2p_port: None, - platform_http_port: None, + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses: None, }, }; @@ -1270,8 +1280,9 @@ mod tests { pub_key_operator: new_pub_key_operator.clone(), operator_payout_address: Some(operator_payout_address), platform_node_id: Some(node_id_bytes), - platform_p2p_port: None, - platform_http_port: None, + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses: None, }, }; diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs index 0c5750d8947..ae89113738f 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs @@ -51,6 +51,9 @@ where /// * `dmn_state_diff` - The `DMNStateDiff` containing the updated masternode information /// * `validator_sets` - A mutable reference to the `IndexMap` /// representing the validator sets with the quorum hash as the key + // Reads the legacy flat platform ports (deprecated in favor of Core 23+ nested + // `addresses`); platform tracks only the legacy pair — behavior-preserving. + #[allow(deprecated)] fn update_masternode_in_validator_sets( pro_tx_hash: &ProTxHash, dmn_state_diff: &DMNStateDiff, @@ -68,17 +71,20 @@ where validator.node_ip = address.ip().to_string(); } - if let Some(p2p_port) = dmn_state_diff.platform_p2p_port { + if let Some(p2p_port) = dmn_state_diff.legacy_platform_p2p_port { validator.platform_p2p_port = p2p_port as u16; } - if let Some(http_port) = dmn_state_diff.platform_http_port { + if let Some(http_port) = dmn_state_diff.legacy_platform_http_port { validator.platform_http_port = http_port as u16; } } }); } + // `update_masternode_in_validator_sets` below reads the deprecated legacy + // platform port (behavior-preserving; Core 23+ nested addresses deferred). + #[allow(deprecated)] pub(crate) fn update_state_masternode_list_v0( &self, state: &mut PlatformState, @@ -143,7 +149,7 @@ where // validator sets if state_diff.pose_ban_height.is_some() || state_diff.service.is_some() - || state_diff.platform_p2p_port.is_some() + || state_diff.legacy_platform_p2p_port.is_some() { // we updated the ban status the IP or the platform port, we need to update the validator in the validator list Self::update_masternode_in_validator_sets( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs index c0cc7df3325..096bdd15ed1 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs @@ -85,6 +85,7 @@ impl ValidationMode { pub(in crate::execution) mod test_helpers; #[cfg(test)] +#[allow(deprecated)] // fixtures build mock DMNState via deprecated legacy platform ports pub(in crate::execution) mod tests { use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; @@ -692,8 +693,9 @@ pub(in crate::execution) mod tests { pub_key_operator: vec![], operator_payout_address: None, platform_node_id: None, - platform_p2p_port: None, - platform_http_port: None, + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses: None, }, }, ); @@ -781,8 +783,9 @@ pub(in crate::execution) mod tests { pub_key_operator: vec![], operator_payout_address: None, platform_node_id: None, - platform_p2p_port: None, - platform_http_port: None, + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses: None, }, }, ); diff --git a/packages/rs-drive-abci/src/platform_types/masternode/v0/mod.rs b/packages/rs-drive-abci/src/platform_types/masternode/v0/mod.rs index 43e9dba0359..7870190381d 100644 --- a/packages/rs-drive-abci/src/platform_types/masternode/v0/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/masternode/v0/mod.rs @@ -138,6 +138,11 @@ pub struct MasternodeStateV0 { } impl From for MasternodeStateV0 { + // Core 23+ moved the platform ports into a nested `addresses` structure and + // marked the flat ports `legacy_*`. Platform state has only ever tracked the + // single legacy port pair, so we keep reading those (behavior-preserving) and + // leave the nested addresses for a future, separately-reviewed change. + #[allow(deprecated)] fn from(value: DMNState) -> Self { let DMNState { service, @@ -151,8 +156,9 @@ impl From for MasternodeStateV0 { pub_key_operator, operator_payout_address, platform_node_id, - platform_p2p_port, - platform_http_port, + legacy_platform_p2p_port, + legacy_platform_http_port, + addresses: _, } = value; Self { @@ -167,13 +173,16 @@ impl From for MasternodeStateV0 { pub_key_operator, operator_payout_address, platform_node_id, - platform_p2p_port, - platform_http_port, + platform_p2p_port: legacy_platform_p2p_port, + platform_http_port: legacy_platform_http_port, } } } impl From for DMNState { + // Reverse of the conversion above: platform only holds the legacy port pair, + // so the Core 23+ nested `addresses` are reconstructed as `None`. + #[allow(deprecated)] fn from(value: MasternodeStateV0) -> Self { let MasternodeStateV0 { service, @@ -203,8 +212,9 @@ impl From for DMNState { pub_key_operator, operator_payout_address, platform_node_id, - platform_p2p_port, - platform_http_port, + legacy_platform_p2p_port: platform_p2p_port, + legacy_platform_http_port: platform_http_port, + addresses: None, } } } diff --git a/packages/rs-drive-abci/src/platform_types/validator/v0/mod.rs b/packages/rs-drive-abci/src/platform_types/validator/v0/mod.rs index f52b335b54e..2b05ff3a5bb 100644 --- a/packages/rs-drive-abci/src/platform_types/validator/v0/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/validator/v0/mod.rs @@ -15,6 +15,9 @@ pub(crate) trait NewValidatorIfMasternodeInState { impl NewValidatorIfMasternodeInState for ValidatorV0 { /// Makes a validator if the masternode is in the list and is valid + // Reads the legacy flat platform ports (deprecated in favor of Core 23+ nested + // `addresses`); platform tracks only the legacy pair — behavior-preserving. + #[allow(deprecated)] fn new_validator_if_masternode_in_state( pro_tx_hash: ProTxHash, public_key: Option>, @@ -26,8 +29,8 @@ impl NewValidatorIfMasternodeInState for ValidatorV0 { service, platform_node_id, pose_ban_height, - platform_p2p_port, - platform_http_port, + legacy_platform_p2p_port: platform_p2p_port, + legacy_platform_http_port: platform_http_port, .. } = state; let Some(platform_http_port) = platform_http_port else { diff --git a/packages/rs-drive-abci/tests/strategy_tests/masternode_list_item_helpers.rs b/packages/rs-drive-abci/tests/strategy_tests/masternode_list_item_helpers.rs index 82f61aaf0ba..ecaae69fc25 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/masternode_list_item_helpers.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/masternode_list_item_helpers.rs @@ -1,3 +1,7 @@ +// Fixtures build mock masternode states via the legacy flat platform ports, +// deprecated upstream in favor of Core 23+ nested `addresses` (unused in tests). +#![allow(deprecated)] + use crate::BlsPrivateKey; use dpp::bls_signatures::Bls12381G2Impl; use dpp::dashcore_rpc::json::MasternodeListItem; @@ -15,8 +19,8 @@ impl UpdateMasternodeListItem for MasternodeListItem { .filter(|&field_idx| match field_idx { 4 => self.state.operator_payout_address.is_some(), 5 => self.state.platform_node_id.is_some(), - 6 => self.state.platform_p2p_port.is_some(), - 7 => self.state.platform_http_port.is_some(), + 6 => self.state.legacy_platform_p2p_port.is_some(), + 7 => self.state.legacy_platform_http_port.is_some(), _ => true, }) .collect(); @@ -56,12 +60,12 @@ impl UpdateMasternodeListItem for MasternodeListItem { } } 6 => { - if let Some(ref mut port) = self.state.platform_p2p_port { + if let Some(ref mut port) = self.state.legacy_platform_p2p_port { *port = rng.gen_range(1024..=65535); } } 7 => { - if let Some(ref mut port) = self.state.platform_http_port { + if let Some(ref mut port) = self.state.legacy_platform_http_port { *port = rng.gen_range(1024..=65535); } } @@ -116,8 +120,9 @@ mod tests { pub_key_operator, operator_payout_address: None, platform_node_id: None, - platform_p2p_port: None, - platform_http_port: None, + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses: None, }, }; diff --git a/packages/rs-drive-abci/tests/strategy_tests/masternodes.rs b/packages/rs-drive-abci/tests/strategy_tests/masternodes.rs index 55c2794bf59..4a779e23462 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/masternodes.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/masternodes.rs @@ -1,3 +1,7 @@ +// Fixtures build mock masternode states via the legacy flat platform ports, +// deprecated upstream in favor of Core 23+ nested `addresses` (unused in tests). +#![allow(deprecated)] + use crate::masternode_list_item_helpers::UpdateMasternodeListItem; use dpp::bls_signatures::{Bls12381G2Impl, SecretKey as BlsPrivateKey}; use dpp::dashcore::hashes::Hash; @@ -259,8 +263,9 @@ pub fn generate_test_masternodes( pub_key_operator, operator_payout_address: None, platform_node_id: None, - platform_p2p_port: None, - platform_http_port: None, + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses: None, }, }; @@ -396,8 +401,9 @@ pub fn generate_test_masternodes( pub_key_operator, operator_payout_address: None, platform_node_id: Some(rng.gen::<[u8; 20]>()), - platform_p2p_port: Some(3010), - platform_http_port: Some(8080), + legacy_platform_p2p_port: Some(3010), + legacy_platform_http_port: Some(8080), + addresses: None, }, }; @@ -530,12 +536,12 @@ pub fn generate_test_masternodes( SocketAddr::new(IpAddr::V4(random_ip), old_port); } if update.p2p_port { - if let Some(port) = hpmn_list_item_b.state.platform_p2p_port.as_mut() { + if let Some(port) = hpmn_list_item_b.state.legacy_platform_p2p_port.as_mut() { *port += 1 } } if update.http_port { - if let Some(port) = hpmn_list_item_b.state.platform_http_port.as_mut() { + if let Some(port) = hpmn_list_item_b.state.legacy_platform_http_port.as_mut() { *port += 1 } } From 4e3659eece2edbbd8485482e96d15348c7823e53 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 16 Jun 2026 21:53:48 +0700 Subject: [PATCH 02/13] fix(sdk): correct stale sdk_builder_default_seeds_atomic_to_floor expectation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3886 (2026-06-14) raised the per-network protocol floors (min_protocol_version: Mainnet=11, others=12). The build-time clamp applies them as max(DEFAULT_INITIAL_PROTOCOL_VERSION, min_protocol_version(network)), which silently invalidated this test (added earlier by #3809): it still asserted the unpinned mock SDK seeds to DEFAULT_INITIAL_PROTOCOL_VERSION (10), but new_mock() defaults to Mainnet, so the seed is now 11. v3.1-dev has been red on this test since #3886; the DashPay merge pulled the breakage in. Assert the SDK's documented post-clamp contract instead (max(DEFAULT_INITIAL_PROTOCOL_VERSION, PROTOCOL_VERSION_11) == 11). Verified locally: left:11 right:10 ✖ before, 1 passed ✔ after. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/rs-sdk/tests/fetch/document_query_v0_v1.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs b/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs index eb139437649..1a0bf77ae4c 100644 --- a/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs +++ b/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs @@ -220,13 +220,16 @@ fn encoder_dispatches_v0_via_query_settings_without_sdk() { #[test] fn sdk_builder_default_seeds_atomic_to_floor() { - // Auto-detect default: the atomic seeds to the floor - // `DEFAULT_INITIAL_PROTOCOL_VERSION`, which `version()` returns until the - // first response ratchets it upward. + // An unpinned SDK seeds to the upgrade-safe floor and `version()` returns + // it until the first network response ratchets it upward. That floor is the + // build-time clamp `max(DEFAULT_INITIAL_PROTOCOL_VERSION, + // min_protocol_version(network))` (see `Sdk::version`). `new_mock()` + // defaults to Mainnet, whose floor `PROTOCOL_VERSION_11` exceeds the + // unpinned default of 10 (raised by #3886), so the seed is 11. let sdk_default = SdkBuilder::new_mock().build().expect("mock sdk"); assert_eq!( sdk_default.version().protocol_version, - DEFAULT_INITIAL_PROTOCOL_VERSION + DEFAULT_INITIAL_PROTOCOL_VERSION.max(dpp::version::v11::PROTOCOL_VERSION_11) ); } From 3530df88210aa2934d4f26eaeb48af8e02bcdaf5 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 18 Jun 2026 19:54:15 +0700 Subject: [PATCH 03/13] fix(drive-abci): resolve Core 23 nested platform ports in masternode conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the blocking review on #3936. The legacy-only port mapping read only DMNState.legacy_platform_*_port, which are `None` for Core 23 masternodes (Core 23 moved the ports into the nested `addresses` object). That stored `platform_p2p_port = None`, and new_validator_if_masternode_in_state then excluded those nodes from validator sets — a consensus bug, not just "multi-address unsupported". Fix: resolve the port through DMNState's `platform_p2p_address()` / `platform_http_address()` accessors (prefer nested `addresses`, fall back to the legacy flat field) in both From and new_validator_if_masternode_in_state. Core 22 entries map byte-identically; Core 23 entries now yield their port. MasternodeStateV0 still stores only a port — no on-disk layout change. The two sites no longer touch deprecated fields, so their #[allow(deprecated)] is dropped. Tests (red→green): from_dmn_state_resolves_core23_nested_platform_ports would read None and fail under the old legacy-only path; from_dmn_state_falls_back_to_legacy_ports pins the Core 22 path unchanged. Review nits also addressed: saturating_add in the port-bump strategy-test helper (overflow guard); corrected the "below"→"above" helper-location comment. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../update_state_masternode_list/v0/mod.rs | 6 +- .../src/platform_types/masternode/v0/mod.rs | 83 ++++++++++++++++--- .../src/platform_types/validator/v0/mod.rs | 36 +++----- .../tests/strategy_tests/masternodes.rs | 4 +- 4 files changed, 92 insertions(+), 37 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs index ae89113738f..2379aa89a75 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs @@ -82,8 +82,10 @@ where }); } - // `update_masternode_in_validator_sets` below reads the deprecated legacy - // platform port (behavior-preserving; Core 23+ nested addresses deferred). + // `update_masternode_in_validator_sets` above reads the legacy flat platform + // port off the `DMNStateDiff` (the diff type has no nested-address accessor); + // a port change that arrives only via Core 23 `addresses` is reconciled on the + // next full-state ingest, which resolves it through the accessors. #[allow(deprecated)] pub(crate) fn update_state_masternode_list_v0( &self, diff --git a/packages/rs-drive-abci/src/platform_types/masternode/v0/mod.rs b/packages/rs-drive-abci/src/platform_types/masternode/v0/mod.rs index 7870190381d..4c4f6bfe334 100644 --- a/packages/rs-drive-abci/src/platform_types/masternode/v0/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/masternode/v0/mod.rs @@ -138,12 +138,16 @@ pub struct MasternodeStateV0 { } impl From for MasternodeStateV0 { - // Core 23+ moved the platform ports into a nested `addresses` structure and - // marked the flat ports `legacy_*`. Platform state has only ever tracked the - // single legacy port pair, so we keep reading those (behavior-preserving) and - // leave the nested addresses for a future, separately-reviewed change. - #[allow(deprecated)] + // Core 23+ moved the platform ports into a nested `addresses` object and marked + // the flat ports `legacy_*`. Resolve each port via DMNState's accessor, which + // prefers the nested address and falls back to the legacy flat field: a Core 22 + // entry maps byte-identically, while a Core 23 entry (legacy = None) still + // yields its port instead of being dropped and excluded from validator sets. + // Platform state stores only the port; the host pairing is delegated to the + // validator path. fn from(value: DMNState) -> Self { + let platform_p2p_port = value.platform_p2p_address().map(|(_host, port)| port); + let platform_http_port = value.platform_http_address().map(|(_host, port)| port); let DMNState { service, registered_height, @@ -156,9 +160,7 @@ impl From for MasternodeStateV0 { pub_key_operator, operator_payout_address, platform_node_id, - legacy_platform_p2p_port, - legacy_platform_http_port, - addresses: _, + .. } = value; Self { @@ -173,8 +175,8 @@ impl From for MasternodeStateV0 { pub_key_operator, operator_payout_address, platform_node_id, - platform_p2p_port: legacy_platform_p2p_port, - platform_http_port: legacy_platform_http_port, + platform_p2p_port, + platform_http_port, } } } @@ -218,3 +220,64 @@ impl From for DMNState { } } } + +#[cfg(test)] +mod tests { + use super::*; + use dpp::dashcore_rpc::dashcore_rpc_json::{DMNState, MasternodeAddresses}; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + #[allow(deprecated)] + fn base_dmn_state() -> DMNState { + DMNState { + service: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 2)), 19999), + registered_height: 1, + pose_revived_height: None, + pose_ban_height: None, + revocation_reason: 0, + owner_address: [0u8; 20], + voting_address: [0u8; 20], + payout_address: [0u8; 20], + pub_key_operator: vec![1u8; 48], + operator_payout_address: None, + platform_node_id: Some([7u8; 20]), + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses: None, + } + } + + // A Core 23 masternode reports its platform ports in the nested `addresses` + // object with the legacy flat fields absent. The DMNState -> MasternodeStateV0 + // conversion must resolve the port from `addresses`; reading only the empty + // legacy field would drop the port and exclude the node from validator sets. + #[test] + fn from_dmn_state_resolves_core23_nested_platform_ports() { + let mut dmn = base_dmn_state(); + dmn.addresses = Some(MasternodeAddresses { + core_p2p: vec!["192.0.2.2:9999".to_string()], + platform_p2p: vec!["192.0.2.2:36656".to_string()], + platform_https: vec!["192.0.2.2:443".to_string()], + }); + + let state = MasternodeStateV0::from(dmn); + assert_eq!(state.platform_p2p_port, Some(36656)); + assert_eq!(state.platform_http_port, Some(443)); + } + + // A Core 22 masternode reports the deprecated flat ports; the conversion stays + // byte-identical by falling back to the legacy field. + #[test] + #[allow(deprecated)] + fn from_dmn_state_falls_back_to_legacy_ports() { + let dmn = DMNState { + legacy_platform_p2p_port: Some(26656), + legacy_platform_http_port: Some(8443), + ..base_dmn_state() + }; + + let state = MasternodeStateV0::from(dmn); + assert_eq!(state.platform_p2p_port, Some(26656)); + assert_eq!(state.platform_http_port, Some(8443)); + } +} diff --git a/packages/rs-drive-abci/src/platform_types/validator/v0/mod.rs b/packages/rs-drive-abci/src/platform_types/validator/v0/mod.rs index 2b05ff3a5bb..60f5274ed83 100644 --- a/packages/rs-drive-abci/src/platform_types/validator/v0/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/validator/v0/mod.rs @@ -15,9 +15,6 @@ pub(crate) trait NewValidatorIfMasternodeInState { impl NewValidatorIfMasternodeInState for ValidatorV0 { /// Makes a validator if the masternode is in the list and is valid - // Reads the legacy flat platform ports (deprecated in favor of Core 23+ nested - // `addresses`); platform tracks only the legacy pair — behavior-preserving. - #[allow(deprecated)] fn new_validator_if_masternode_in_state( pro_tx_hash: ProTxHash, public_key: Option>, @@ -25,30 +22,23 @@ impl NewValidatorIfMasternodeInState for ValidatorV0 { ) -> Option { let MasternodeListItem { state, .. } = state.hpmn_masternode_list().get(&pro_tx_hash)?; - let DMNState { - service, - platform_node_id, - pose_ban_height, - legacy_platform_p2p_port: platform_p2p_port, - legacy_platform_http_port: platform_http_port, - .. - } = state; - let Some(platform_http_port) = platform_http_port else { - return None; - }; - let Some(platform_p2p_port) = platform_p2p_port else { - return None; - }; - let platform_node_id = (*platform_node_id)?; + // Resolve the platform ports via the accessors so a Core 23 entry (whose + // ports live in the nested `addresses` object, legacy fields = None) still + // produces a validator instead of being dropped; Core 22 falls back to the + // legacy flat ports unchanged. A masternode with no resolvable platform + // port on either form is not a valid HPMN validator. + let (_, platform_p2p_port) = state.platform_p2p_address()?; + let (_, platform_http_port) = state.platform_http_address()?; + let platform_node_id = state.platform_node_id?; Some(ValidatorV0 { pro_tx_hash, public_key, - node_ip: service.ip().to_string(), + node_ip: state.service.ip().to_string(), node_id: PubkeyHash::from_byte_array(platform_node_id), - core_port: service.port(), - platform_http_port: *platform_http_port as u16, - platform_p2p_port: *platform_p2p_port as u16, - is_banned: pose_ban_height.is_some(), + core_port: state.service.port(), + platform_http_port: platform_http_port as u16, + platform_p2p_port: platform_p2p_port as u16, + is_banned: state.pose_ban_height.is_some(), }) } } diff --git a/packages/rs-drive-abci/tests/strategy_tests/masternodes.rs b/packages/rs-drive-abci/tests/strategy_tests/masternodes.rs index 4a779e23462..53eb650e639 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/masternodes.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/masternodes.rs @@ -537,12 +537,12 @@ pub fn generate_test_masternodes( } if update.p2p_port { if let Some(port) = hpmn_list_item_b.state.legacy_platform_p2p_port.as_mut() { - *port += 1 + *port = port.saturating_add(1) } } if update.http_port { if let Some(port) = hpmn_list_item_b.state.legacy_platform_http_port.as_mut() { - *port += 1 + *port = port.saturating_add(1) } } From ccd1b0e94e845068f3479eec7711a73d8a44e3a0 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 18 Jun 2026 20:01:57 +0700 Subject: [PATCH 04/13] fix(drive-abci): drop now-unused DMNState import in validator/v0 new_validator_if_masternode_in_state resolves ports via the DMNState accessors instead of destructuring the struct, so the DMNState type import is unused. CI denies warnings, so remove it. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/rs-drive-abci/src/platform_types/validator/v0/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rs-drive-abci/src/platform_types/validator/v0/mod.rs b/packages/rs-drive-abci/src/platform_types/validator/v0/mod.rs index 60f5274ed83..205751e5590 100644 --- a/packages/rs-drive-abci/src/platform_types/validator/v0/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/validator/v0/mod.rs @@ -4,7 +4,7 @@ use dpp::bls_signatures::{Bls12381G2Impl, PublicKey as BlsPublicKey}; pub use dpp::core_types::validator::v0::*; use dpp::dashcore::hashes::Hash; use dpp::dashcore::{ProTxHash, PubkeyHash}; -use dpp::dashcore_rpc::json::{DMNState, MasternodeListItem}; +use dpp::dashcore_rpc::json::MasternodeListItem; pub(crate) trait NewValidatorIfMasternodeInState { fn new_validator_if_masternode_in_state( pro_tx_hash: ProTxHash, From d119dd1e1ff0b381fd2f6f7abb386b7f115e9f5b Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 18 Jun 2026 21:20:14 +0700 Subject: [PATCH 05/13] fix(drive-abci): resolve Core 23 platform ports in the masternode-diff path too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the From accessor fix: update_masternode_in_validator_sets and its trigger guard still read DMNStateDiff.legacy_platform_*_port directly, which is None for Core 23 nodes — so a Core 23 platform-port change carried in the diff's nested `addresses` would not propagate to the validator until the next full validator-set rebuild. Unlike DMNState's accessor, DMNStateDiff::platform_p2p_address() does NOT fall back to the legacy field (it reads only the nested addresses), so OR the accessor with the legacy field rather than replacing it — otherwise a Core 22 port change (in the legacy flat field) would be missed. The change-detection guard is widened the same way so a Core-23-only address change still triggers the validator-set update. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../update_state_masternode_list/v0/mod.rs | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs index 2379aa89a75..3470fb312a0 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs @@ -51,8 +51,11 @@ where /// * `dmn_state_diff` - The `DMNStateDiff` containing the updated masternode information /// * `validator_sets` - A mutable reference to the `IndexMap` /// representing the validator sets with the quorum hash as the key - // Reads the legacy flat platform ports (deprecated in favor of Core 23+ nested - // `addresses`); platform tracks only the legacy pair — behavior-preserving. + // A platform-port change can arrive as either a Core 23 nested `addresses` delta + // (resolved via the accessor) or a legacy flat-field delta. Unlike `DMNState`'s + // accessor, `DMNStateDiff`'s reads ONLY the nested addresses, so we OR it with + // the legacy field rather than replacing it — otherwise a Core 22 port change + // (carried in the legacy flat field) would be missed. #[allow(deprecated)] fn update_masternode_in_validator_sets( pro_tx_hash: &ProTxHash, @@ -71,21 +74,28 @@ where validator.node_ip = address.ip().to_string(); } - if let Some(p2p_port) = dmn_state_diff.legacy_platform_p2p_port { + if let Some(p2p_port) = dmn_state_diff + .platform_p2p_address() + .map(|(_host, port)| port) + .or(dmn_state_diff.legacy_platform_p2p_port) + { validator.platform_p2p_port = p2p_port as u16; } - if let Some(http_port) = dmn_state_diff.legacy_platform_http_port { + if let Some(http_port) = dmn_state_diff + .platform_http_address() + .map(|(_host, port)| port) + .or(dmn_state_diff.legacy_platform_http_port) + { validator.platform_http_port = http_port as u16; } } }); } - // `update_masternode_in_validator_sets` above reads the legacy flat platform - // port off the `DMNStateDiff` (the diff type has no nested-address accessor); - // a port change that arrives only via Core 23 `addresses` is reconciled on the - // next full-state ingest, which resolves it through the accessors. + // `update_masternode_in_validator_sets` above still reads the legacy flat port + // off the `DMNStateDiff` (as one of two delta sources, alongside the nested- + // address accessor), so the deprecation allow carries here too. #[allow(deprecated)] pub(crate) fn update_state_masternode_list_v0( &self, @@ -151,6 +161,7 @@ where // validator sets if state_diff.pose_ban_height.is_some() || state_diff.service.is_some() + || state_diff.platform_p2p_address().is_some() || state_diff.legacy_platform_p2p_port.is_some() { // we updated the ban status the IP or the platform port, we need to update the validator in the validator list From 1199b878dddaa7be540e553bab793c4988ba3a16 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 18 Jun 2026 22:27:29 +0700 Subject: [PATCH 06/13] fix(drive-abci): refresh validators on http/addresses diffs + cover diff-port resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review suggestion that the validator-refresh trigger only consulted the p2p axis: a Core 23 diff changing only platform_https, or clearing `addresses` (Some(None)), left platform_p2p_address() == None and the legacy field None, so the trigger never fired and the cached validator kept a stale http/p2p port. The guard now also checks the http resolver and the raw `addresses` presence (three-state: None / Some(None) / Some(Some)). Extracted the diff port resolution into `diff_platform_p2p_port` / `diff_platform_http_port` (prefer the Core 23 nested-addresses accessor — which on DMNStateDiff, unlike DMNState, has NO legacy fallback — then fall back to the legacy flat field). This makes the resolution unit-testable and consolidates the deprecated legacy reads into the two helpers, so the #[allow(deprecated)] drops off both update_masternode_in_validator_sets and update_state_masternode_list_v0. Tests: nested-Core-23 (both ports), http-only Core-23 (the missed case), Core-22 legacy fallback, and no-change → None. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../update_state_masternode_list/v0/mod.rs | 136 ++++++++++++++---- 1 file changed, 112 insertions(+), 24 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs index 3470fb312a0..ab2b8bb3012 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs @@ -51,12 +51,6 @@ where /// * `dmn_state_diff` - The `DMNStateDiff` containing the updated masternode information /// * `validator_sets` - A mutable reference to the `IndexMap` /// representing the validator sets with the quorum hash as the key - // A platform-port change can arrive as either a Core 23 nested `addresses` delta - // (resolved via the accessor) or a legacy flat-field delta. Unlike `DMNState`'s - // accessor, `DMNStateDiff`'s reads ONLY the nested addresses, so we OR it with - // the legacy field rather than replacing it — otherwise a Core 22 port change - // (carried in the legacy flat field) would be missed. - #[allow(deprecated)] fn update_masternode_in_validator_sets( pro_tx_hash: &ProTxHash, dmn_state_diff: &DMNStateDiff, @@ -74,29 +68,17 @@ where validator.node_ip = address.ip().to_string(); } - if let Some(p2p_port) = dmn_state_diff - .platform_p2p_address() - .map(|(_host, port)| port) - .or(dmn_state_diff.legacy_platform_p2p_port) - { + if let Some(p2p_port) = diff_platform_p2p_port(dmn_state_diff) { validator.platform_p2p_port = p2p_port as u16; } - if let Some(http_port) = dmn_state_diff - .platform_http_address() - .map(|(_host, port)| port) - .or(dmn_state_diff.legacy_platform_http_port) - { + if let Some(http_port) = diff_platform_http_port(dmn_state_diff) { validator.platform_http_port = http_port as u16; } } }); } - // `update_masternode_in_validator_sets` above still reads the legacy flat port - // off the `DMNStateDiff` (as one of two delta sources, alongside the nested- - // address accessor), so the deprecation allow carries here too. - #[allow(deprecated)] pub(crate) fn update_state_masternode_list_v0( &self, state: &mut PlatformState, @@ -157,12 +139,19 @@ where if let Some(hpmn_list_item) = state.hpmn_masternode_list_mut().get_mut(pro_tx_hash) { hpmn_list_item.state.apply_diff(state_diff.clone()); - // these 3 fields are the only fields that are useful for validators. If they change we need to update - // validator sets + // Refresh the validator entry on any change to the fields it + // carries: ban status, service IP, or either platform port. + // A platform-port change can be a resolvable p2p/http port + // (Core 23 nested addresses OR a legacy flat field) or an + // `addresses` delta that clears/empties a port — `addresses` + // is three-state (None / Some(None) / Some(Some)), so check + // its presence too, otherwise an http-only or address-clearing + // diff would leave a stale port on the cached validator. if state_diff.pose_ban_height.is_some() || state_diff.service.is_some() - || state_diff.platform_p2p_address().is_some() - || state_diff.legacy_platform_p2p_port.is_some() + || diff_platform_p2p_port(state_diff).is_some() + || diff_platform_http_port(state_diff).is_some() + || state_diff.addresses.is_some() { // we updated the ban status the IP or the platform port, we need to update the validator in the validator list Self::update_masternode_in_validator_sets( @@ -200,3 +189,102 @@ where ) } } + +/// Resolve a masternode diff's platform **P2P** port change, preferring the Core 23 +/// nested `addresses` (via `DMNStateDiff::platform_p2p_address`, which — unlike +/// `DMNState`'s accessor — does NOT fall back to the legacy field) and falling back +/// to the legacy flat field. `None` when the diff carries no resolvable p2p port. +#[allow(deprecated)] +fn diff_platform_p2p_port(diff: &DMNStateDiff) -> Option { + diff.platform_p2p_address() + .map(|(_host, port)| port) + .or(diff.legacy_platform_p2p_port) +} + +/// Resolve a masternode diff's platform **HTTPS** port change — the http analogue of +/// [`diff_platform_p2p_port`]. +#[allow(deprecated)] +fn diff_platform_http_port(diff: &DMNStateDiff) -> Option { + diff.platform_http_address() + .map(|(_host, port)| port) + .or(diff.legacy_platform_http_port) +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::dashcore_rpc::dashcore_rpc_json::MasternodeAddresses; + + #[allow(deprecated)] + fn empty_diff() -> DMNStateDiff { + DMNStateDiff { + service: None, + registered_height: None, + last_paid_height: None, + consecutive_payments: None, + pose_penalty: None, + pose_revived_height: None, + pose_ban_height: None, + revocation_reason: None, + owner_address: None, + voting_address: None, + payout_address: None, + pub_key_operator: None, + operator_payout_address: None, + platform_node_id: None, + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses: None, + } + } + + // A Core 23 diff carries the ports in the nested `addresses` (legacy fields + // absent); the diff accessor reads them and our resolver returns them. + #[test] + fn diff_resolves_core23_nested_ports() { + let mut diff = empty_diff(); + diff.addresses = Some(Some(MasternodeAddresses { + core_p2p: vec!["192.0.2.2:9999".to_string()], + platform_p2p: vec!["192.0.2.2:36656".to_string()], + platform_https: vec!["192.0.2.2:443".to_string()], + })); + assert_eq!(diff_platform_p2p_port(&diff), Some(36656)); + assert_eq!(diff_platform_http_port(&diff), Some(443)); + } + + // An http-only Core 23 diff (empty platform_p2p) must still resolve the http + // port — the case the refresh-trigger guard previously missed. + #[test] + fn diff_resolves_http_only_core23() { + let mut diff = empty_diff(); + diff.addresses = Some(Some(MasternodeAddresses { + core_p2p: vec![], + platform_p2p: vec![], + platform_https: vec!["192.0.2.2:443".to_string()], + })); + assert_eq!(diff_platform_p2p_port(&diff), None); + assert_eq!(diff_platform_http_port(&diff), Some(443)); + } + + // A Core 22 diff carries the deprecated flat ports; the resolver falls back to + // them (the diff accessor alone returns None). + #[test] + #[allow(deprecated)] + fn diff_falls_back_to_legacy_ports() { + let diff = DMNStateDiff { + legacy_platform_p2p_port: Some(26656), + legacy_platform_http_port: Some(8443), + ..empty_diff() + }; + assert_eq!(diff_platform_p2p_port(&diff), Some(26656)); + assert_eq!(diff_platform_http_port(&diff), Some(8443)); + } + + // A diff with no platform-port change resolves to nothing on either axis. + #[test] + fn diff_without_port_change_is_none() { + let diff = empty_diff(); + assert_eq!(diff_platform_p2p_port(&diff), None); + assert_eq!(diff_platform_http_port(&diff), None); + } +} From 8e19e2ad37dafe0fc4da0e5fc1e9d9eda31b630b Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 19 Jun 2026 09:12:20 +0700 Subject: [PATCH 07/13] fix(drive-abci): drop zeroed legacy platform ports + drop validators when ports vanish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Core 23 masternode-diff correctness fixes (rust-dashcore#808 context): 1. Zeroed legacy port must not surface. Core 23 (ProTx v3) entries zero the deprecated flat platformP2PPort/HTTPPort (the real port lives in the nested `addresses`). The diff accessor already drops a zero addresses port, but the legacy fallback in diff_platform_{p2p,http}_port read the raw legacy field — so an edge diff (addresses unresolvable + legacy 0) would set a validator's platform port to 0, the exact failure #808 fixed. The fallback now drops 0. 2. Remove cached validators when platform ports disappear. The widened refresh guard reaches update_masternode_in_validator_sets for any addresses delta, but the helper only writes ports when they resolve — so a Core 23 diff clearing `addresses` (Some(None)) or zeroing the ports left the cached ValidatorV0 with its stale ports, and Tenderdash kept getting a dead endpoint. After apply_diff we now check the updated state via resolves_platform_validator_ports() (both ports mandatory, matching new_validator_if_masternode_in_state) and drop the validator from the sets when it no longer resolves them. Tests: zeroed-legacy-dropped; resolves-for-core23-addresses / for-legacy; does-not-resolve-when-ports-disappear (cleared + http-only). 10 tests green, clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../update_state_masternode_list/v0/mod.rs | 149 ++++++++++++++++-- 1 file changed, 139 insertions(+), 10 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs index ab2b8bb3012..f039d6e38a9 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs @@ -8,7 +8,9 @@ use crate::platform_types::validator_set::v0::ValidatorSetV0Getters; use crate::platform_types::validator_set::ValidatorSet; use crate::rpc::core::CoreRPCLike; use dpp::dashcore::{ProTxHash, QuorumHash}; -use dpp::dashcore_rpc::dashcore_rpc_json::{DMNStateDiff, MasternodeListDiff, MasternodeType}; +use dpp::dashcore_rpc::dashcore_rpc_json::{ + DMNState, DMNStateDiff, MasternodeListDiff, MasternodeType, +}; use indexmap::IndexMap; use std::collections::{BTreeMap, BTreeSet}; @@ -139,6 +141,13 @@ where if let Some(hpmn_list_item) = state.hpmn_masternode_list_mut().get_mut(pro_tx_hash) { hpmn_list_item.state.apply_diff(state_diff.clone()); + // The updated HPMN state is the source of truth for whether this + // node is still a valid platform validator: both platform ports + // are mandatory (`new_validator_if_masternode_in_state` rejects a + // node missing either). Computed before the validator-set borrow + // so the `hpmn_list_item` borrow ends first. + let resolves_platform_ports = + resolves_platform_validator_ports(&hpmn_list_item.state); // Refresh the validator entry on any change to the fields it // carries: ban status, service IP, or either platform port. // A platform-port change can be a resolvable p2p/http port @@ -153,12 +162,24 @@ where || diff_platform_http_port(state_diff).is_some() || state_diff.addresses.is_some() { - // we updated the ban status the IP or the platform port, we need to update the validator in the validator list - Self::update_masternode_in_validator_sets( - pro_tx_hash, - state_diff, - state.validator_sets_mut(), - ); + if resolves_platform_ports { + // Update the ban status / IP / platform port on the cached + // validator entry. + Self::update_masternode_in_validator_sets( + pro_tx_hash, + state_diff, + state.validator_sets_mut(), + ); + } else { + // Platform ports disappeared (Core 23 `addresses` cleared, + // or a zeroed legacy port with no addresses) → the node is + // no longer a valid HPMN validator. Drop the stale cached + // entry so we stop advertising a dead platform endpoint. + Self::remove_masternode_in_validator_sets( + pro_tx_hash, + state.validator_sets_mut(), + ); + } } } } @@ -194,26 +215,42 @@ where /// nested `addresses` (via `DMNStateDiff::platform_p2p_address`, which — unlike /// `DMNState`'s accessor — does NOT fall back to the legacy field) and falling back /// to the legacy flat field. `None` when the diff carries no resolvable p2p port. +/// +/// The legacy fallback drops `0`: Core 23 (ProTx v3) entries zero the deprecated flat +/// port (the real port lives in `addresses`), and the nested-address accessor already +/// drops zero, so surfacing a legacy `0` here would set a validator's platform port to +/// `0` — the exact failure [rust-dashcore#808] fixed. #[allow(deprecated)] fn diff_platform_p2p_port(diff: &DMNStateDiff) -> Option { diff.platform_p2p_address() .map(|(_host, port)| port) - .or(diff.legacy_platform_p2p_port) + .or_else(|| diff.legacy_platform_p2p_port.filter(|&port| port != 0)) } /// Resolve a masternode diff's platform **HTTPS** port change — the http analogue of -/// [`diff_platform_p2p_port`]. +/// [`diff_platform_p2p_port`] (same Core-23-nested-first, non-zero-legacy-fallback rule). #[allow(deprecated)] fn diff_platform_http_port(diff: &DMNStateDiff) -> Option { diff.platform_http_address() .map(|(_host, port)| port) - .or(diff.legacy_platform_http_port) + .or_else(|| diff.legacy_platform_http_port.filter(|&port| port != 0)) +} + +/// Whether a masternode's (post-`apply_diff`) state still resolves **both** platform +/// ports — the validity condition `new_validator_if_masternode_in_state` enforces (it +/// rejects a node missing either). Uses the full-state accessors, which prefer the +/// Core 23 nested addresses and fall back to the non-zero legacy port paired with the +/// node's service IP. When this is false the node can no longer be a platform +/// validator, so its cached entry must be dropped rather than left stale. +fn resolves_platform_validator_ports(state: &DMNState) -> bool { + state.platform_p2p_address().is_some() && state.platform_http_address().is_some() } #[cfg(test)] mod tests { use super::*; use dpp::dashcore_rpc::dashcore_rpc_json::MasternodeAddresses; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; #[allow(deprecated)] fn empty_diff() -> DMNStateDiff { @@ -287,4 +324,96 @@ mod tests { assert_eq!(diff_platform_p2p_port(&diff), None); assert_eq!(diff_platform_http_port(&diff), None); } + + // The legacy flat port is zeroed for Core 23 (v3) entries; the resolver must drop + // it rather than surface a validator port of 0 (rust-dashcore#808). + #[test] + #[allow(deprecated)] + fn diff_drops_zero_legacy_port() { + let diff = DMNStateDiff { + legacy_platform_p2p_port: Some(0), + legacy_platform_http_port: Some(0), + ..empty_diff() + }; + assert_eq!(diff_platform_p2p_port(&diff), None); + assert_eq!(diff_platform_http_port(&diff), None); + } + + #[allow(deprecated)] + fn base_dmn_state() -> DMNState { + DMNState { + service: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 2)), 19999), + registered_height: 1, + pose_revived_height: None, + pose_ban_height: None, + revocation_reason: 0, + owner_address: [0u8; 20], + voting_address: [0u8; 20], + payout_address: [0u8; 20], + pub_key_operator: vec![1u8; 48], + operator_payout_address: None, + platform_node_id: Some([7u8; 20]), + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses: None, + } + } + + // A Core 23 node resolves both ports from `addresses` even though the legacy flat + // fields are zeroed → still a valid platform validator. + #[test] + #[allow(deprecated)] + fn resolves_ports_for_core23_addresses() { + let state = DMNState { + legacy_platform_p2p_port: Some(0), + legacy_platform_http_port: Some(0), + addresses: Some(MasternodeAddresses { + core_p2p: vec![], + platform_p2p: vec!["192.0.2.2:36656".to_string()], + platform_https: vec!["192.0.2.2:443".to_string()], + }), + ..base_dmn_state() + }; + assert!(resolves_platform_validator_ports(&state)); + } + + // A Core 22 node resolves both ports from the non-zero legacy fields. + #[test] + #[allow(deprecated)] + fn resolves_ports_for_legacy() { + let state = DMNState { + legacy_platform_p2p_port: Some(26656), + legacy_platform_http_port: Some(8443), + ..base_dmn_state() + }; + assert!(resolves_platform_validator_ports(&state)); + } + + // Ports gone (zeroed legacy + empty addresses, or only one port present) → no + // longer a valid platform validator, so the cached entry must be dropped. + #[test] + #[allow(deprecated)] + fn does_not_resolve_when_ports_disappear() { + let cleared = DMNState { + legacy_platform_p2p_port: Some(0), + legacy_platform_http_port: Some(0), + addresses: Some(MasternodeAddresses { + core_p2p: vec![], + platform_p2p: vec![], + platform_https: vec![], + }), + ..base_dmn_state() + }; + assert!(!resolves_platform_validator_ports(&cleared)); + + let http_only = DMNState { + addresses: Some(MasternodeAddresses { + core_p2p: vec![], + platform_p2p: vec![], + platform_https: vec!["192.0.2.2:443".to_string()], + }), + ..base_dmn_state() + }; + assert!(!resolves_platform_validator_ports(&http_only)); + } } From 369f563b947f03535440bd49f109210a064d47bf Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 19 Jun 2026 13:35:18 +0700 Subject: [PATCH 08/13] fix(drive-abci): harden Core 23 masternode/validator port handling (multi-reviewer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review pass (3 reviewers) over the #808 Core-23 handling. All findings are P2P-connectivity, not consensus (validator_sets live in the non-hashed `aux` CF and Tenderdash's committed ValidatorSet hash excludes node_address/ports/membership), but each leaves a validator advertising a wrong/stale/dead platform endpoint: F1 — Persisted round-trip masked an `addresses` clear after restart. The reverse `From for DMNState` stuffed the resolved port into `legacy_*` with `addresses: None`; after restart an `addresses: Some(None)` clear left the stale legacy port, the full-state accessor fell back to it, and the de-platformed validator was wrongly retained. Now reconstructs the Core-23 `addresses` shape (host = service IP), `legacy_*: None`, so a later clear is honored. F2 — Two sources of truth. Validity was judged on the post-apply full state but the validator was patched from the raw diff; since `apply_diff` wholesale-replaces `addresses`, a partial Core-23 diff could clobber the unchanged axis. Replaced `resolves_platform_validator_ports` + diff-patching `update_masternode_in_validator_sets` with a single `validator_refresh_from_state` (Some → `apply_validator_refresh`, None → remove), deriving every field once from the post-apply full state. F3 — `validator_refresh_from_state` now requires `platform_node_id` (matching `new_validator_if_masternode_in_state`); an HPMN that lost it is no longer valid. F4 — Validator `node_ip` now uses the accessor's platform p2p host (= service IP for a legacy node, the Core-23 platform host otherwise) instead of always `service.ip()`. F5 — Port resolution uses `u16::try_from` (drop out-of-range) instead of `as u16` truncation, in both the diff resolvers and the full-state derivation — an out-of-range legacy port can no longer truncate to 0 (the #808 shape). Tests (red→green where applicable): reverse-From reconstructs addresses + a clear now drops the endpoint (RED on old legacy-shaped round-trip); refresh resolves Core-23 with the platform host / legacy with service IP; refresh None on ports-disappear, missing node-id; diff resolver drops zero + out-of-range legacy ports. 180 masternode/validator lib tests green, clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../update_state_masternode_list/v0/mod.rs | 190 +++++++++++------- .../src/platform_types/masternode/v0/mod.rs | 96 ++++++++- .../src/platform_types/validator/v0/mod.rs | 16 +- 3 files changed, 216 insertions(+), 86 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs index f039d6e38a9..c0b3e437c82 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs @@ -40,43 +40,25 @@ where }); } - /// Updates a masternode in the validator sets. - /// - /// This function updates the properties of the masternode that matches the given `pro_tx_hash`. - /// The properties are updated based on the provided `dmn_state_diff` information. - /// If a matching masternode is found, the function updates its ban status, service address, - /// platform P2P port, and platform HTTP port accordingly. - /// - /// # Arguments - /// - /// * `pro_tx_hash` - The `ProTxHash` of the masternode to be updated - /// * `dmn_state_diff` - The `DMNStateDiff` containing the updated masternode information - /// * `validator_sets` - A mutable reference to the `IndexMap` - /// representing the validator sets with the quorum hash as the key - fn update_masternode_in_validator_sets( + /// Apply a [`ValidatorRefresh`] (derived from the post-`apply_diff` full state) to + /// the matching validator in every validator set. Deriving every field from the + /// full state — rather than patching each field from the raw diff — keeps the + /// validity decision and the written values consistent: a partial Core 23 + /// `addresses` diff overwrites the whole nested object, so the diff alone is not a + /// reliable source for the unchanged axis. + fn apply_validator_refresh( pro_tx_hash: &ProTxHash, - dmn_state_diff: &DMNStateDiff, + refresh: &ValidatorRefresh, validator_sets: &mut IndexMap, ) { validator_sets .iter_mut() .for_each(|(_quorum_hash, validator_set)| { if let Some(validator) = validator_set.members_mut().get_mut(pro_tx_hash) { - if let Some(maybe_ban_height) = dmn_state_diff.pose_ban_height { - // the ban_height was changed - validator.is_banned = maybe_ban_height.is_some(); - } - if let Some(address) = dmn_state_diff.service { - validator.node_ip = address.ip().to_string(); - } - - if let Some(p2p_port) = diff_platform_p2p_port(dmn_state_diff) { - validator.platform_p2p_port = p2p_port as u16; - } - - if let Some(http_port) = diff_platform_http_port(dmn_state_diff) { - validator.platform_http_port = http_port as u16; - } + validator.is_banned = refresh.is_banned; + validator.node_ip = refresh.node_ip.clone(); + validator.platform_p2p_port = refresh.platform_p2p_port; + validator.platform_http_port = refresh.platform_http_port; } }); } @@ -141,13 +123,13 @@ where if let Some(hpmn_list_item) = state.hpmn_masternode_list_mut().get_mut(pro_tx_hash) { hpmn_list_item.state.apply_diff(state_diff.clone()); - // The updated HPMN state is the source of truth for whether this - // node is still a valid platform validator: both platform ports - // are mandatory (`new_validator_if_masternode_in_state` rejects a - // node missing either). Computed before the validator-set borrow - // so the `hpmn_list_item` borrow ends first. - let resolves_platform_ports = - resolves_platform_validator_ports(&hpmn_list_item.state); + // Derive the validator's fields from the post-`apply_diff` full + // state — the single source of truth (the same accessors as + // `new_validator_if_masternode_in_state`). `Some` means the node is + // still a valid HPMN platform validator (both ports + a node id); + // `None` means it no longer is. Computed before the validator-set + // borrow so the `hpmn_list_item` borrow ends first. + let refresh = validator_refresh_from_state(&hpmn_list_item.state); // Refresh the validator entry on any change to the fields it // carries: ban status, service IP, or either platform port. // A platform-port change can be a resolvable p2p/http port @@ -162,23 +144,23 @@ where || diff_platform_http_port(state_diff).is_some() || state_diff.addresses.is_some() { - if resolves_platform_ports { - // Update the ban status / IP / platform port on the cached - // validator entry. - Self::update_masternode_in_validator_sets( + match &refresh { + // Still a valid platform validator → rewrite its fields + // from the full state. + Some(refresh) => Self::apply_validator_refresh( pro_tx_hash, - state_diff, + refresh, state.validator_sets_mut(), - ); - } else { - // Platform ports disappeared (Core 23 `addresses` cleared, - // or a zeroed legacy port with no addresses) → the node is - // no longer a valid HPMN validator. Drop the stale cached - // entry so we stop advertising a dead platform endpoint. - Self::remove_masternode_in_validator_sets( + ), + // Platform endpoint disappeared (Core 23 `addresses` + // cleared, a zeroed legacy port with no addresses, or a + // missing node id) → the node is no longer a valid HPMN + // validator. Drop the stale cached entry so we stop + // advertising a dead platform endpoint. + None => Self::remove_masternode_in_validator_sets( pro_tx_hash, state.validator_sets_mut(), - ); + ), } } } @@ -221,29 +203,53 @@ where /// drops zero, so surfacing a legacy `0` here would set a validator's platform port to /// `0` — the exact failure [rust-dashcore#808] fixed. #[allow(deprecated)] -fn diff_platform_p2p_port(diff: &DMNStateDiff) -> Option { +fn diff_platform_p2p_port(diff: &DMNStateDiff) -> Option { diff.platform_p2p_address() .map(|(_host, port)| port) - .or_else(|| diff.legacy_platform_p2p_port.filter(|&port| port != 0)) + .or(diff.legacy_platform_p2p_port) + .and_then(|port| u16::try_from(port).ok()) + .filter(|&port| port != 0) } /// Resolve a masternode diff's platform **HTTPS** port change — the http analogue of /// [`diff_platform_p2p_port`] (same Core-23-nested-first, non-zero-legacy-fallback rule). #[allow(deprecated)] -fn diff_platform_http_port(diff: &DMNStateDiff) -> Option { +fn diff_platform_http_port(diff: &DMNStateDiff) -> Option { diff.platform_http_address() .map(|(_host, port)| port) - .or_else(|| diff.legacy_platform_http_port.filter(|&port| port != 0)) + .or(diff.legacy_platform_http_port) + .and_then(|port| u16::try_from(port).ok()) + .filter(|&port| port != 0) +} + +/// The mutable validator fields a refresh writes, derived **once** from the +/// post-`apply_diff` full state so the validity decision and the written values can +/// never disagree. +struct ValidatorRefresh { + node_ip: String, + platform_p2p_port: u16, + platform_http_port: u16, + is_banned: bool, } -/// Whether a masternode's (post-`apply_diff`) state still resolves **both** platform -/// ports — the validity condition `new_validator_if_masternode_in_state` enforces (it -/// rejects a node missing either). Uses the full-state accessors, which prefer the -/// Core 23 nested addresses and fall back to the non-zero legacy port paired with the -/// node's service IP. When this is false the node can no longer be a platform -/// validator, so its cached entry must be dropped rather than left stale. -fn resolves_platform_validator_ports(state: &DMNState) -> bool { - state.platform_p2p_address().is_some() && state.platform_http_address().is_some() +/// Derive the [`ValidatorRefresh`] for a masternode from its (post-`apply_diff`) full +/// state, or `None` if it is no longer a valid HPMN platform validator. Mirrors the +/// exact validity gate of `new_validator_if_masternode_in_state`: both platform ports +/// must resolve (Core 23 nested addresses preferred, non-zero legacy fallback paired +/// with the service IP) **and** a `platform_node_id` must be present — a non-HPMN or +/// de-platformed node has none. The advertised `node_ip` is the platform p2p host (the +/// service IP for a legacy node, the Core 23 platform host otherwise); ports go through +/// `u16::try_from` so an out-of-range value drops the node rather than truncating. +fn validator_refresh_from_state(state: &DMNState) -> Option { + let (node_ip, platform_p2p_port) = state.platform_p2p_address()?; + let (_http_host, platform_http_port) = state.platform_http_address()?; + state.platform_node_id?; + Some(ValidatorRefresh { + node_ip, + platform_p2p_port: u16::try_from(platform_p2p_port).ok()?, + platform_http_port: u16::try_from(platform_http_port).ok()?, + is_banned: state.pose_ban_height.is_some(), + }) } #[cfg(test)] @@ -359,41 +365,61 @@ mod tests { } } + // The diff resolver drops an out-of-range legacy port instead of truncating it via + // `as u16` (65536 would otherwise become 0 — rust-dashcore#808). + #[test] + #[allow(deprecated)] + fn diff_drops_out_of_range_legacy_port() { + let diff = DMNStateDiff { + legacy_platform_p2p_port: Some(65536), + ..empty_diff() + }; + assert_eq!(diff_platform_p2p_port(&diff), None); + } + // A Core 23 node resolves both ports from `addresses` even though the legacy flat - // fields are zeroed → still a valid platform validator. + // fields are zeroed → still a valid platform validator. The advertised `node_ip` is + // the Core 23 platform host from `addresses`, NOT the core service IP. #[test] #[allow(deprecated)] - fn resolves_ports_for_core23_addresses() { + fn refresh_resolves_core23_addresses_with_platform_host() { let state = DMNState { legacy_platform_p2p_port: Some(0), legacy_platform_http_port: Some(0), addresses: Some(MasternodeAddresses { core_p2p: vec![], - platform_p2p: vec!["192.0.2.2:36656".to_string()], - platform_https: vec!["192.0.2.2:443".to_string()], + platform_p2p: vec!["203.0.113.7:36656".to_string()], + platform_https: vec!["203.0.113.7:443".to_string()], }), - ..base_dmn_state() + ..base_dmn_state() // service IP is 192.0.2.2 — deliberately different }; - assert!(resolves_platform_validator_ports(&state)); + let refresh = validator_refresh_from_state(&state).expect("valid v3 validator"); + assert_eq!(refresh.node_ip, "203.0.113.7"); + assert_eq!(refresh.platform_p2p_port, 36656); + assert_eq!(refresh.platform_http_port, 443); } - // A Core 22 node resolves both ports from the non-zero legacy fields. + // A Core 22 node resolves both ports from the non-zero legacy fields, paired with + // the core service IP. #[test] #[allow(deprecated)] - fn resolves_ports_for_legacy() { + fn refresh_resolves_legacy_with_service_ip() { let state = DMNState { legacy_platform_p2p_port: Some(26656), legacy_platform_http_port: Some(8443), ..base_dmn_state() }; - assert!(resolves_platform_validator_ports(&state)); + let refresh = validator_refresh_from_state(&state).expect("valid legacy validator"); + assert_eq!(refresh.node_ip, "192.0.2.2"); + assert_eq!(refresh.platform_p2p_port, 26656); + assert_eq!(refresh.platform_http_port, 8443); } // Ports gone (zeroed legacy + empty addresses, or only one port present) → no - // longer a valid platform validator, so the cached entry must be dropped. + // longer a valid platform validator; the cached entry must be dropped. #[test] #[allow(deprecated)] - fn does_not_resolve_when_ports_disappear() { + fn refresh_none_when_ports_disappear() { let cleared = DMNState { legacy_platform_p2p_port: Some(0), legacy_platform_http_port: Some(0), @@ -404,7 +430,7 @@ mod tests { }), ..base_dmn_state() }; - assert!(!resolves_platform_validator_ports(&cleared)); + assert!(validator_refresh_from_state(&cleared).is_none()); let http_only = DMNState { addresses: Some(MasternodeAddresses { @@ -414,6 +440,20 @@ mod tests { }), ..base_dmn_state() }; - assert!(!resolves_platform_validator_ports(&http_only)); + assert!(validator_refresh_from_state(&http_only).is_none()); + } + + // An HPMN that resolves both ports but has no `platform_node_id` is not a valid + // platform validator (mirrors new_validator_if_masternode_in_state's node-id gate). + #[test] + #[allow(deprecated)] + fn refresh_none_without_platform_node_id() { + let state = DMNState { + legacy_platform_p2p_port: Some(26656), + legacy_platform_http_port: Some(8443), + platform_node_id: None, + ..base_dmn_state() + }; + assert!(validator_refresh_from_state(&state).is_none()); } } diff --git a/packages/rs-drive-abci/src/platform_types/masternode/v0/mod.rs b/packages/rs-drive-abci/src/platform_types/masternode/v0/mod.rs index 4c4f6bfe334..dea677bd1fd 100644 --- a/packages/rs-drive-abci/src/platform_types/masternode/v0/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/masternode/v0/mod.rs @@ -2,7 +2,7 @@ pub mod accessors; use dpp::bincode::{Decode, Encode}; -use dpp::dashcore_rpc::dashcore_rpc_json::{DMNState, MasternodeType}; +use dpp::dashcore_rpc::dashcore_rpc_json::{DMNState, MasternodeAddresses, MasternodeType}; use dpp::dashcore_rpc::json::MasternodeListItem; use std::fmt::{Debug, Formatter}; @@ -182,8 +182,14 @@ impl From for MasternodeStateV0 { } impl From for DMNState { - // Reverse of the conversion above: platform only holds the legacy port pair, - // so the Core 23+ nested `addresses` are reconstructed as `None`. + // Reverse of the conversion above (used by the persisted-state round-trip). + // Reconstruct the Core 23 nested `addresses` shape rather than the legacy flat + // fields: platform deploys on the masternode's core IP, so pair each stored port + // with `service.ip()`. Leaving the ports here in `legacy_*` instead would make a + // later `addresses: Some(None)` clear diff a no-op — the full-state accessor + // would keep falling back to the stale legacy port and wrongly retain a validator + // whose platform endpoint Core has removed. `MasternodeStateV0` carries no host, + // so `service.ip()` is the faithful (and accessor-consistent) reconstruction. #[allow(deprecated)] fn from(value: MasternodeStateV0) -> Self { let MasternodeStateV0 { @@ -202,6 +208,21 @@ impl From for DMNState { platform_http_port, } = value; + let host = service.ip(); + let addresses = (platform_p2p_port.is_some() || platform_http_port.is_some()).then(|| { + MasternodeAddresses { + core_p2p: vec![], + platform_p2p: platform_p2p_port + .map(|port| format!("{host}:{port}")) + .into_iter() + .collect(), + platform_https: platform_http_port + .map(|port| format!("{host}:{port}")) + .into_iter() + .collect(), + } + }); + Self { service, registered_height, @@ -214,9 +235,9 @@ impl From for DMNState { pub_key_operator, operator_payout_address, platform_node_id, - legacy_platform_p2p_port: platform_p2p_port, - legacy_platform_http_port: platform_http_port, - addresses: None, + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses, } } } @@ -280,4 +301,67 @@ mod tests { assert_eq!(state.platform_p2p_port, Some(26656)); assert_eq!(state.platform_http_port, Some(8443)); } + + // Persisting a Core 23 masternode collapses its addresses-resolved port into a bare + // port in MasternodeStateV0. The reverse conversion must rebuild it in `addresses` + // (host = service IP), leaving the legacy fields None — otherwise a later + // `addresses: Some(None)` clear diff is masked by a stale legacy port and the + // de-platformed validator is wrongly retained after a restart. + #[test] + #[allow(deprecated)] + fn reverse_from_reconstructs_core23_addresses_then_honors_a_clear() { + use dpp::dashcore_rpc::dashcore_rpc_json::DMNStateDiff; + + let stored = MasternodeStateV0 { + service: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 2)), 19999), + registered_height: 1, + pose_revived_height: None, + pose_ban_height: None, + revocation_reason: 0, + owner_address: [0u8; 20], + voting_address: [0u8; 20], + payout_address: [0u8; 20], + pub_key_operator: vec![1u8; 48], + operator_payout_address: None, + platform_node_id: Some([7u8; 20]), + platform_p2p_port: Some(36656), + platform_http_port: Some(443), + }; + + let mut dmn: DMNState = stored.into(); + // Reconstructed as `addresses`, not legacy. + assert_eq!(dmn.legacy_platform_p2p_port, None); + assert_eq!(dmn.legacy_platform_http_port, None); + let addrs = dmn.addresses.clone().expect("addresses reconstructed"); + assert_eq!(addrs.platform_p2p, vec!["192.0.2.2:36656".to_string()]); + assert_eq!(addrs.platform_https, vec!["192.0.2.2:443".to_string()]); + // Still resolves after a plain restart. + assert!(dmn.platform_p2p_address().is_some()); + + // A subsequent Core 23 `addresses: Some(None)` clear (legacy untouched) now + // actually drops the platform endpoint. With the old legacy-shaped round-trip + // the legacy fallback would keep it resolvable → stale validator retained. + let clear = DMNStateDiff { + service: None, + registered_height: None, + last_paid_height: None, + consecutive_payments: None, + pose_penalty: None, + pose_revived_height: None, + pose_ban_height: None, + revocation_reason: None, + owner_address: None, + voting_address: None, + payout_address: None, + pub_key_operator: None, + operator_payout_address: None, + platform_node_id: None, + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses: Some(None), + }; + dmn.apply_diff(clear); + assert!(dmn.platform_p2p_address().is_none()); + assert!(dmn.platform_http_address().is_none()); + } } diff --git a/packages/rs-drive-abci/src/platform_types/validator/v0/mod.rs b/packages/rs-drive-abci/src/platform_types/validator/v0/mod.rs index 205751e5590..f479dd18101 100644 --- a/packages/rs-drive-abci/src/platform_types/validator/v0/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/validator/v0/mod.rs @@ -27,17 +27,23 @@ impl NewValidatorIfMasternodeInState for ValidatorV0 { // produces a validator instead of being dropped; Core 22 falls back to the // legacy flat ports unchanged. A masternode with no resolvable platform // port on either form is not a valid HPMN validator. - let (_, platform_p2p_port) = state.platform_p2p_address()?; - let (_, platform_http_port) = state.platform_http_address()?; + // The accessor returns the platform p2p `(host, port)`. Use the host as the + // validator's advertised `node_ip`: it equals the core service IP for a + // legacy (Core 22) node and is the Core 23 platform host otherwise. + // `node_address` is `node_id@node_ip:platform_p2p_port`, so the p2p host is + // authoritative. `u16::try_from` (not `as`) rejects an out-of-range port + // rather than silently truncating it (e.g. a malformed `0` — the #808 shape). + let (platform_p2p_host, platform_p2p_port) = state.platform_p2p_address()?; + let (_platform_http_host, platform_http_port) = state.platform_http_address()?; let platform_node_id = state.platform_node_id?; Some(ValidatorV0 { pro_tx_hash, public_key, - node_ip: state.service.ip().to_string(), + node_ip: platform_p2p_host, node_id: PubkeyHash::from_byte_array(platform_node_id), core_port: state.service.port(), - platform_http_port: platform_http_port as u16, - platform_p2p_port: platform_p2p_port as u16, + platform_http_port: u16::try_from(platform_http_port).ok()?, + platform_p2p_port: u16::try_from(platform_p2p_port).ok()?, is_banned: state.pose_ban_height.is_some(), }) } From a05da4d36c7b54d60d2968fd76a7b77c34d5aadd Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 19 Jun 2026 16:55:33 +0700 Subject: [PATCH 09/13] feat(drive-abci): persist Core 23 split platform/core host (MasternodeStateV1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core 23 (DEPLOYMENT_V24) decouples a masternode's platform endpoints from its core service address via the nested `addresses` object. The live validator path already advertises the platform host, but persisted state (MasternodeStateV0) stored only the ports, so a normal restart — which loads the masternode list from disk and applies incremental Core diffs — reconstructed the host as service.ip(), silently advertising the wrong endpoint for a split-host node until the next `addresses` diff. Add a versioned MasternodeStateV1/MasternodeV1/Masternode::V1 that retains the platform host, gated to protocol v12 via a new DRIVE_ABCI_STRUCTURE_VERSIONS_V2 (masternode: 1). MasternodeStateV0 stays byte-identical (the persisted format is released; a regression test deserializes real testnet-v0/devnet-v8 state); v1-v11 keep writing V0. The read path is variant-tag driven (no version gate), so any binary decodes either variant. Consensus-safe: the serialized platform state lives in the non-hashed aux CF (put_aux), not grovedb's hashed tree, so it is never in app_hash; fingerprint() is diagnostic-only; and no ABCI state-sync handler ships the blob between nodes. A binary downgrade past V1 fails loud at startup, never a silent misdecode. Also close a refresh-trigger gap introduced by the earlier zero/range port filter: extract diff_triggers_validator_refresh as a superset of validator_refresh_from_state's inputs so a diff carrying only a zeroed legacy port or a platform_node_id rotation still wakes the refresh (the port helpers drop those). Tests (red->green embedded in each): - v1 round-trip preserves a distinct platform host where V0 collapses it to service.ip() - legacy/Core-22 node falls back to service.ip() (matches V0) - MasternodeListItem save/load round-trip keeps the host - guard fires on zeroed-legacy-port and node-id-rotation diffs the port helpers drop Design + consensus-safety analysis (4-agent reviewed): docs/CORE23_SPLIT_HOST_PERSISTENCE_SPEC.md Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/CORE23_SPLIT_HOST_PERSISTENCE_SPEC.md | 167 ++++++++ .../update_state_masternode_list/v0/mod.rs | 81 +++- .../platform_types/masternode/accessors.rs | 6 + .../src/platform_types/masternode/mod.rs | 13 +- .../src/platform_types/masternode/v1/mod.rs | 374 ++++++++++++++++++ .../drive_abci_structure_versions/mod.rs | 1 + .../drive_abci_structure_versions/v2.rs | 14 + .../rs-platform-version/src/version/v12.rs | 6 +- 8 files changed, 645 insertions(+), 17 deletions(-) create mode 100644 docs/CORE23_SPLIT_HOST_PERSISTENCE_SPEC.md create mode 100644 packages/rs-drive-abci/src/platform_types/masternode/v1/mod.rs create mode 100644 packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_structure_versions/v2.rs diff --git a/docs/CORE23_SPLIT_HOST_PERSISTENCE_SPEC.md b/docs/CORE23_SPLIT_HOST_PERSISTENCE_SPEC.md new file mode 100644 index 00000000000..73ad44fa52b --- /dev/null +++ b/docs/CORE23_SPLIT_HOST_PERSISTENCE_SPEC.md @@ -0,0 +1,167 @@ +# Core 23 split-host masternode persistence (`MasternodeStateV1`) + +## Problem + +Core 23 (`DEPLOYMENT_V24`) decouples a masternode's **platform** endpoints from its +**core** service address: the nested `addresses` object (`MasternodeAddresses`) carries +`platform_p2p` / `platform_https` as `host:port` strings whose host may differ from the +core `service` IP. The live validator path already honors this — `ValidatorV0.node_ip` +is taken from `DMNState::platform_p2p_address().0` (the platform host), not `service.ip()`. + +But the **persisted** masternode state (`MasternodeStateV0`) stores only the resolved +**ports** (`platform_p2p_port`, `platform_http_port: Option`); it has no host field. +The reverse conversion `From for DMNState` therefore reconstructs the +nested `addresses` using `service.ip()` as the host. + +Consequence: after a normal restart (which **loads the persisted masternode list from disk +and applies incremental Core diffs** — only `is_init_chain` re-fetches the full list), any +Core 23 HPMN whose platform host differs from its core service IP is reconstructed with the +**wrong host**. A validator rebuilt from that state (`new_validator_if_masternode_in_state`) +or refreshed (`validator_refresh_from_state`) advertises `node_id@service_ip:port` to +Tenderdash until the next Core `addresses` diff for that node overwrites it. + +Today this is **latent** (mainnet/testnet have `DEPLOYMENT_V24` `NEVER_ACTIVE`, so platform +and core hosts are collocated), but the live path already ships and tests the distinct-host +case, so persistence is inconsistent with the live path's claimed support. + +## Chosen approach + +Persist the platform host by introducing a **new versioned `MasternodeStateV1`** that adds +the host, gated to **protocol version 12 only**. + +- Keep `MasternodeStateV0` / `MasternodeV0` **byte-identical** (a regression test + deserializes real testnet-v0 and devnet-v8 persisted state — backward compat is required). +- Add `MasternodeStateV1` = `MasternodeStateV0` + `platform_host: Option` (the single + platform host paired with both platform ports — matches `ValidatorV0`'s single-`node_ip` + model; `platform_p2p` and `platform_https` resolve to the same host in the validator path). +- Add `MasternodeV1` (= `MasternodeV0` with `state: MasternodeStateV1`) and a new + `Masternode::V1(MasternodeV1)` enum variant. +- **Write gate:** `drive_abci.structs.masternode` switches `0 → 1` **only for protocol v12**. + The structure-versions are currently a single shared const (`DRIVE_ABCI_STRUCTURE_VERSIONS_V1`, + `masternode: 0`) referenced by v1–v12. Add a second const + `DRIVE_ABCI_STRUCTURE_VERSIONS_V2` (`masternode: 1`, otherwise identical) and point **only + `v12.rs`** at it. v1–v11 keep V1 (write `Masternode::V0`, byte-identical, zero change). +- **Read:** `Masternode`'s `Decode` handles whichever variant tag is present (V0 or V1) + regardless of gate. All `match self { Masternode::V0(..) }` sites gain a `V1` arm. The + 6 top-level accessors + `From for MasternodeListItem` are the only match sites. + +### Conversions (all four required — the outer `MasternodeV1 ↔ MasternodeListItem` pair is the actual save/load entry point, not the inner `DMNState` pair) + +- `From for MasternodeV1`: mirrors `From for MasternodeV0` + (`v0/mod.rs:48`); `state: value.state.into()` lands in the `DMNState → MasternodeStateV1` + impl. This is what the write gate's `1 => Ok(Self::V1(value.into()))` arm calls. +- `From for MasternodeListItem`: mirrors `v0/mod.rs:72`; the read path + `From for MasternodeListItem`'s `V1(v1) => v1.into()` arm calls it. +- `From for MasternodeStateV1`: extract ports via the existing accessors **and** + `platform_host = platform_p2p_address().map(|(host, _)| host)` (the platform host; `None` + when no platform p2p address resolves — Core 22 / non-HPMN). +- `From for DMNState`: reconstruct `addresses` **only when a platform port + is present** — carry over the V0 guard `(platform_p2p_port.is_some() || platform_http_port.is_some()).then(...)` + so a host-only/no-ports state never builds an empty `addresses`. Pair each stored port with + `platform_host` when present, falling back to `service.ip()` when absent (Core 22 / legacy + round-trips, preserving today's behavior, and reproducing exactly the host the live path + would advertise for such a node). Leave `legacy_*` ports `None` (same rationale as V0: a + later `addresses: Some(None)` clear must actually drop the endpoint). +- `MasternodeV1` needs a **hand-written `Debug` impl** copied from `MasternodeV0` (`v0/mod.rs:34`) + — V0's Debug is manual (renders `ProTxHash` via `.to_string()`), not derived. A plain + `#[derive(Debug)]` would compile but diverge. Derives otherwise carry over: `MasternodeV1` + → `Clone, PartialEq, Encode, Decode`; `MasternodeStateV1` → `Clone, PartialEq, Eq, Debug, Encode, Decode`. + +### Read path is tag-driven — must stay version-agnostic + +The `structs.masternode` gate is consulted at **exactly one** site: the write conversion +`Masternode::try_from_platform_versioned(MasternodeListItem)`. The read side (`From +for MasternodeListItem` + the 6 accessors) is a plain variant `match` with **no** version check +— do NOT add one. This is the same write-gated/read-all design already shipped for +`PlatformStateForSaving` (`platform_state/mod.rs:233` reads all variants; only the write +consults its gate). It also makes a protocol-rollback safe: a v12-binary node that rolls its +*protocol* back to v11 still decodes a previously-written V1 blob (the shared enum knows the +tag). Only a genuine *binary* downgrade to a pre-V1 build fails — and it fails **loud at +startup** (`fetch_platform_state` propagates the decode error → `CorruptedCachedState`), never +a silent misdecode. + +### Why tail-appending a field to `MasternodeStateV0` is NOT an option + +Considered and rejected: adding `Option` as a trailing field to `MasternodeStateV0` +to avoid `MasternodeV1`. bincode is non-self-describing and fixed-layout, and masternodes are +stored in a `BTreeMap` (length-prefixed sequential entries). Decoding an old blob would read +the new `Option` discriminant byte from the **next** map entry's bytes — silent misalignment, +not a clean `None`. The versioned-enum (new variant tag) is the only safe path. + +### Why this is consensus-safe (verified) + +- The serialized platform state is stored via `put_aux` → **non-hashed aux CF**, NOT grovedb's + hashed tree → **not part of `app_hash`**. A format change cannot fork the chain. +- `PlatformState::fingerprint()` (`hash_double(serialize_to_bytes())`) is **diagnostic-only** + — every caller is a `tracing` log field (`init_chain`, `run_block_proposal`, `abci/handler/info`). +- **No ABCI state-sync snapshot handlers** exist in drive-abci → the aux-CF platform-state blob + is never shipped between nodes → no "V1-writer → V0-reader" hazard during rolling upgrade. +- Gating the write to v12 (in-development, not on mainnet/testnet) means **released versions + v1–v11 are byte-identical** — zero behavior change where it matters. + +## Files + +- `rs-platform-version/.../drive_abci_structure_versions/mod.rs` (add `pub mod v2;`). +- `rs-platform-version/.../drive_abci_structure_versions/v2.rs` (new const + `DRIVE_ABCI_STRUCTURE_VERSIONS_V2`, identical to V1 except `masternode: 1`). +- `rs-platform-version/.../version/v12.rs` (point `structs` at `..._V2`; currently `..._V1`). +- `rs-drive-abci/.../masternode/v0/mod.rs` (unchanged V0; keep as the byte-identical baseline). +- `rs-drive-abci/.../masternode/v1/mod.rs` (new `MasternodeV1` + `MasternodeStateV1`, manual + `Debug` for `MasternodeV1`, and all four `From` impls listed above). +- `rs-drive-abci/.../masternode/mod.rs` (add `pub mod v1;`; enum `V1` variant; + `try_from_platform_versioned` gate arm `1 => Ok(Self::V1(value.into()))`; + `From for MasternodeListItem` `V1(v1) => v1.into()` arm). +- `rs-drive-abci/.../masternode/accessors.rs` (6 accessors gain a `V1` arm). +- (also the guard-completeness fix in `update_state_masternode_list/v0` — separate, see below.) + +Established-pattern confirmation (from review): every other `drive_abci_versions/*` family +(`method_versions` v1–v8, `validation_versions` v1–v8, `query_versions` v0–v1, +`withdrawal_constants` v1–v2) evolves by adding a new numbered const file and re-pointing only +the target `vN.rs` — `structure_versions` is simply the one family that never needed a 2nd +const yet. This change follows that convention exactly. + +## Guard-completeness fix (independent, same PR) + +`update_state_masternode_list_v0`'s refresh-trigger guard must mirror the predicate +`validator_refresh_from_state`'s full gate. The zero/range filter in `diff_platform_*_port` +means a diff carrying only `legacy_platform_*_port: Some(0)` (or out-of-range), or only a +`platform_node_id` change, won't trip the guard, so a stale validator is retained. Add +`state_diff.legacy_platform_p2p_port.is_some() || state_diff.legacy_platform_http_port.is_some() +|| state_diff.platform_node_id.is_some()` to the guard. + +The guard is **intentionally a superset** of the validity predicate: a false-positive trigger +just recomputes `validator_refresh_from_state` and rewrites identical fields (idempotent), while +a false-negative leaves a stale validator advertised — so the guard must fire on every field +that can change validity, even ones `diff_platform_*_port` deliberately drops (`Some(0)`). Add a +one-line comment to that effect so a future reader doesn't "fix" the apparent inconsistency with +the zero-dropping helpers. This is validator-set in-memory state feeding Tenderdash P2P +advertisement — connectivity only, never `app_hash`. + +## Failure modes + +- **Protocol rollback (same v12 binary → protocol v11)**: safe. The shared `Masternode` enum + knows tag `V1`, and the read path is tag-driven, so a previously-written V1 blob still decodes. +- **Genuine binary downgrade (to a pre-V1 build)**: fails **loud at startup** — bincode hits an + unknown enum discriminant, `fetch_platform_state` propagates the error → `CorruptedCachedState`. + Never a silent misdecode. Not a rolling-upgrade concern because platform state never crosses + nodes (no state-sync handlers) and a V1 blob can only be produced under protocol v12, which is + not activated on any released network. +- **`platform_host` is a hostname, not an IP**: stored verbatim as `String`; the validator path + already treats `node_ip` as a `String`, so no parsing assumption is added. +- **Core 22 / non-HPMN on v12**: `platform_host = None`; reverse conversion falls back to + `service.ip()`, identical to V0 behavior. + +## Test plan + +- **Restart round-trip through the real path** (`MasternodeListItem`/`DMNState`, mirroring the + v0 test at `masternode/v0/mod.rs:312`, not the bare struct): a `MasternodeListItem` whose + `DMNState` carries a Core-23 `addresses` host ≠ service IP → `MasternodeV1` (captures host) → + back to `MasternodeListItem` → `state.platform_p2p_address().0 == addresses_host`. RED against + V0 (which collapses to `service.ip()`). +- v12 saving path writes `Masternode::V1` (gate = 1); v11 writes `Masternode::V0`. +- Backward-compat: existing testnet-v0 / devnet-v8 deserialization tests still pass (V0 untouched). +- Reverse fallback: `MasternodeStateV1 { platform_host: None }` → `service.ip()` host. +- Guard-completeness: a diff with only `legacy_*: Some(0)` / only `platform_node_id` trips the + refresh (RED before the guard extension). + + diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs index c0b3e437c82..a48cdcb4f1d 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs @@ -130,20 +130,7 @@ where // `None` means it no longer is. Computed before the validator-set // borrow so the `hpmn_list_item` borrow ends first. let refresh = validator_refresh_from_state(&hpmn_list_item.state); - // Refresh the validator entry on any change to the fields it - // carries: ban status, service IP, or either platform port. - // A platform-port change can be a resolvable p2p/http port - // (Core 23 nested addresses OR a legacy flat field) or an - // `addresses` delta that clears/empties a port — `addresses` - // is three-state (None / Some(None) / Some(Some)), so check - // its presence too, otherwise an http-only or address-clearing - // diff would leave a stale port on the cached validator. - if state_diff.pose_ban_height.is_some() - || state_diff.service.is_some() - || diff_platform_p2p_port(state_diff).is_some() - || diff_platform_http_port(state_diff).is_some() - || state_diff.addresses.is_some() - { + if diff_triggers_validator_refresh(state_diff) { match &refresh { // Still a valid platform validator → rewrite its fields // from the full state. @@ -252,6 +239,30 @@ fn validator_refresh_from_state(state: &DMNState) -> Option { }) } +/// Whether a masternode state diff can change any field a cached validator advertises, +/// i.e. whether `validator_refresh_from_state` must be re-consulted for it. +/// +/// This is deliberately a **superset** of that predicate's inputs: a false-positive +/// trigger merely recomputes the refresh and rewrites identical fields (idempotent), +/// whereas a false-negative leaves a stale endpoint advertised to Tenderdash. So it must +/// fire on every field that can flip validity — including the zeroed/out-of-range legacy +/// ports and `platform_node_id` changes that the `diff_platform_*_port` helpers +/// intentionally drop (a `legacy_platform_*_port: Some(0)` delta resolves to `None` +/// through them, and a `platform_node_id` change has no port helper at all). The +/// `addresses` field is three-state (None / Some(None) / Some(Some)), so its mere +/// presence is a change. Validator-set state feeds P2P advertisement only — never `app_hash`. +#[allow(deprecated)] +fn diff_triggers_validator_refresh(diff: &DMNStateDiff) -> bool { + diff.pose_ban_height.is_some() + || diff.service.is_some() + || diff_platform_p2p_port(diff).is_some() + || diff_platform_http_port(diff).is_some() + || diff.addresses.is_some() + || diff.legacy_platform_p2p_port.is_some() + || diff.legacy_platform_http_port.is_some() + || diff.platform_node_id.is_some() +} + #[cfg(test)] mod tests { use super::*; @@ -456,4 +467,46 @@ mod tests { }; assert!(validator_refresh_from_state(&state).is_none()); } + + // The refresh trigger must mirror the validity predicate's full input set. A diff that + // zeroes only the legacy platform port (no `addresses`, no service/ban change) flips a + // node from valid to invalid, but `diff_platform_p2p_port` drops the `0` — so the + // previous guard (which relied on those helpers) never fired and the stale validator + // stayed advertised. The trigger keys directly on the raw legacy field to catch it. + #[test] + #[allow(deprecated)] + fn refresh_trigger_fires_on_zeroed_legacy_port() { + let diff = DMNStateDiff { + legacy_platform_p2p_port: Some(0), + ..empty_diff() + }; + // The port helper drops the zero — proof the old guard would have missed this. + assert_eq!(diff_platform_p2p_port(&diff), None); + assert!(diff_triggers_validator_refresh(&diff)); + } + + // A diff that changes only `platform_node_id` (a node-id rotation) changes the + // `node_id@host:port` a validator advertises, but no platform-port helper observes it, + // so the previous guard missed it. The trigger keys on the field directly. + #[test] + #[allow(deprecated)] + fn refresh_trigger_fires_on_platform_node_id_change() { + let diff = DMNStateDiff { + platform_node_id: Some([9u8; 20]), + ..empty_diff() + }; + assert_eq!(diff_platform_p2p_port(&diff), None); + assert_eq!(diff_platform_http_port(&diff), None); + assert!(diff_triggers_validator_refresh(&diff)); + } + + // A diff that touches none of the validator-relevant fields must not trigger a refresh. + #[test] + fn refresh_trigger_none_on_unrelated_diff() { + let diff = DMNStateDiff { + registered_height: Some(42), + ..empty_diff() + }; + assert!(!diff_triggers_validator_refresh(&diff)); + } } diff --git a/packages/rs-drive-abci/src/platform_types/masternode/accessors.rs b/packages/rs-drive-abci/src/platform_types/masternode/accessors.rs index 8ebe7985692..34de504b13e 100644 --- a/packages/rs-drive-abci/src/platform_types/masternode/accessors.rs +++ b/packages/rs-drive-abci/src/platform_types/masternode/accessors.rs @@ -7,36 +7,42 @@ impl MasternodeAccessorsV0 for Masternode { fn node_type(&self) -> MasternodeType { match self { Masternode::V0(v0) => v0.node_type.clone(), //todo(copy) + Masternode::V1(v1) => v1.node_type.clone(), } } fn pro_tx_hash(&self) -> ProTxHash { match self { Masternode::V0(v0) => v0.pro_tx_hash, + Masternode::V1(v1) => v1.pro_tx_hash, } } fn collateral_hash(&self) -> Txid { match self { Masternode::V0(v0) => v0.collateral_hash, + Masternode::V1(v1) => v1.collateral_hash, } } fn collateral_index(&self) -> u32 { match self { Masternode::V0(v0) => v0.collateral_index, + Masternode::V1(v1) => v1.collateral_index, } } fn collateral_address(&self) -> [u8; 20] { match self { Masternode::V0(v0) => v0.collateral_address, + Masternode::V1(v1) => v1.collateral_address, } } fn operator_reward(&self) -> f32 { match self { Masternode::V0(v0) => v0.operator_reward, + Masternode::V1(v1) => v1.operator_reward, } } } diff --git a/packages/rs-drive-abci/src/platform_types/masternode/mod.rs b/packages/rs-drive-abci/src/platform_types/masternode/mod.rs index 0b2b94b0586..35a67fbfcf7 100644 --- a/packages/rs-drive-abci/src/platform_types/masternode/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/masternode/mod.rs @@ -1,6 +1,7 @@ use crate::error::execution::ExecutionError; use crate::error::Error; use crate::platform_types::masternode::v0::MasternodeV0; +use crate::platform_types::masternode::v1::MasternodeV1; use bincode::{Decode, Encode}; use dpp::dashcore_rpc::json::MasternodeListItem; use dpp::version::{PlatformVersion, TryFromPlatformVersioned}; @@ -8,26 +9,35 @@ use dpp::version::{PlatformVersion, TryFromPlatformVersioned}; mod accessors; /// Version 0 pub mod v0; +/// Version 1 +pub mod v1; /// `Masternode` represents a masternode on the network. #[derive(Clone, PartialEq, Debug, Encode, Decode)] pub enum Masternode { /// Version 0 V0(MasternodeV0), + /// Version 1 — persists the Core 23 platform host (split platform/core host support). + V1(MasternodeV1), } impl TryFromPlatformVersioned for Masternode { type Error = Error; + // Only the WRITE direction is gated: protocol v12 writes `V1`, earlier versions write + // `V0` (byte-identical to what they always wrote). The read direction is variant-tag + // driven (see `From for MasternodeListItem`) so any binary decodes either + // variant regardless of protocol version — do NOT add a gate there. fn try_from_platform_versioned( value: MasternodeListItem, platform_version: &PlatformVersion, ) -> Result { match platform_version.drive_abci.structs.masternode { 0 => Ok(Self::V0(value.into())), + 1 => Ok(Self::V1(value.into())), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "Masternode::try_from_platform_versioned(MasternodeListItem)".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } @@ -38,6 +48,7 @@ impl From for MasternodeListItem { fn from(value: Masternode) -> Self { match value { Masternode::V0(v0) => v0.into(), + Masternode::V1(v1) => v1.into(), } } } diff --git a/packages/rs-drive-abci/src/platform_types/masternode/v1/mod.rs b/packages/rs-drive-abci/src/platform_types/masternode/v1/mod.rs new file mode 100644 index 00000000000..a37d1c86064 --- /dev/null +++ b/packages/rs-drive-abci/src/platform_types/masternode/v1/mod.rs @@ -0,0 +1,374 @@ +use dpp::bincode::{Decode, Encode}; +use dpp::dashcore_rpc::dashcore_rpc_json::{DMNState, MasternodeAddresses, MasternodeType}; +use dpp::dashcore_rpc::json::MasternodeListItem; +use std::fmt::{Debug, Formatter}; + +use dpp::dashcore::{ProTxHash, Txid}; + +use std::net::SocketAddr; + +/// `MasternodeV1` represents a masternode on the network. It differs from +/// [`super::v0::MasternodeV0`] only in its `state` ([`MasternodeStateV1`]), which +/// persists the Core 23 platform host so a split platform/core host survives a restart. +#[derive(Clone, PartialEq, Encode, Decode)] +pub struct MasternodeV1 { + /// The type of masternode (e.g., full, partial). + pub node_type: MasternodeType, + /// A unique hash representing the masternode's registration transaction. + #[bincode(with_serde)] + pub pro_tx_hash: ProTxHash, + /// A unique hash representing the collateral transaction. + #[bincode(with_serde)] + pub collateral_hash: Txid, + /// The index of the collateral transaction output. + pub collateral_index: u32, + /// The address where the collateral is stored. + pub collateral_address: [u8; 20], + /// The amount of the operator's reward for running the masternode. + pub operator_reward: f32, + /// The current state of the masternode (e.g., enabled, pre-enabled, banned). + pub state: MasternodeStateV1, +} + +impl Debug for MasternodeV1 { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MasternodeV1") + .field("node_type", &self.node_type) + .field("pro_tx_hash", &self.pro_tx_hash.to_string()) + .field("collateral_hash", &self.collateral_hash) + .field("collateral_index", &self.collateral_index) + .field("collateral_address", &self.collateral_address) + .field("operator_reward", &self.operator_reward) + .field("state", &self.state) + .finish() + } +} + +impl From for MasternodeV1 { + fn from(value: MasternodeListItem) -> Self { + let MasternodeListItem { + node_type, + pro_tx_hash, + collateral_hash, + collateral_index, + collateral_address, + operator_reward, + state, + } = value; + + Self { + node_type, + pro_tx_hash, + collateral_hash, + collateral_index, + collateral_address, + operator_reward, + state: state.into(), + } + } +} + +impl From for MasternodeListItem { + fn from(value: MasternodeV1) -> Self { + let MasternodeV1 { + node_type, + pro_tx_hash, + collateral_hash, + collateral_index, + collateral_address, + operator_reward, + state, + } = value; + + Self { + node_type, + pro_tx_hash, + collateral_hash, + collateral_index, + collateral_address, + operator_reward, + state: state.into(), + } + } +} + +/// A `MasternodeStateV1` contains information about a masternode's state. It extends +/// [`super::v0::MasternodeStateV0`] with `platform_host`: Core 23 (`DEPLOYMENT_V24`) +/// decouples the platform endpoints from the core service address, so the resolved +/// platform host can differ from `service.ip()`. V0 stored only the ports and so lost +/// the host across a restart. +#[derive(Clone, PartialEq, Eq, Debug, Encode, Decode)] +pub struct MasternodeStateV1 { + /// Masternode's network service address. + #[bincode(with_serde)] + pub service: SocketAddr, + + /// Block height when the masternode was registered. + pub registered_height: u32, + + /// Block height when the masternode last revived from a Proof-of-Service ban. + pub pose_revived_height: Option, + + /// Block height when the masternode was banned due to a failed Proof-of-Service. + pub pose_ban_height: Option, + + /// Reason for the masternode's revocation (encoded as an integer). + pub revocation_reason: u32, + + /// The masternode owner's public address. + pub owner_address: [u8; 20], + + /// The masternode voting public address. + pub voting_address: [u8; 20], + + /// The masternode payout public address. + pub payout_address: [u8; 20], + + /// The masternode operator's public key. + pub pub_key_operator: Vec, + + /// Optional masternode operator's payout public address. + pub operator_payout_address: Option<[u8; 20]>, + + /// Platform-specific node ID for the masternode. + pub platform_node_id: Option<[u8; 20]>, + + /// Optional platform-specific P2P port for the masternode. + pub platform_p2p_port: Option, + + /// Optional platform-specific HTTP port for the masternode. + pub platform_http_port: Option, + + /// The host the platform endpoints resolve to (Core 23 nested `addresses`). For a + /// Core 22 / legacy node it equals the core service IP; `None` when no platform p2p + /// address resolves (non-HPMN), in which case the reverse conversion falls back to + /// `service.ip()`. + pub platform_host: Option, +} + +impl From for MasternodeStateV1 { + // Resolve the platform ports via DMNState's accessors (Core 23 nested `addresses` + // preferred, legacy flat fields as fallback) and capture the platform p2p host. + // Unlike V0, the host is retained so a Core 23 node whose platform host differs from + // its core service IP keeps advertising the correct endpoint after a restart. + fn from(value: DMNState) -> Self { + let platform_p2p = value.platform_p2p_address(); + let platform_http_port = value.platform_http_address().map(|(_host, port)| port); + let platform_host = platform_p2p.as_ref().map(|(host, _port)| host.clone()); + let platform_p2p_port = platform_p2p.map(|(_host, port)| port); + let DMNState { + service, + registered_height, + pose_revived_height, + pose_ban_height, + revocation_reason, + owner_address, + voting_address, + payout_address, + pub_key_operator, + operator_payout_address, + platform_node_id, + .. + } = value; + + Self { + service, + registered_height, + pose_revived_height, + pose_ban_height, + revocation_reason, + owner_address, + voting_address, + payout_address, + pub_key_operator, + operator_payout_address, + platform_node_id, + platform_p2p_port, + platform_http_port, + platform_host, + } + } +} + +impl From for DMNState { + // Reverse of the conversion above (used by the persisted-state round-trip). + // Reconstruct the Core 23 nested `addresses`, pairing each stored port with the + // retained `platform_host` (falling back to `service.ip()` for a Core 22 / legacy + // node that has no distinct host — identical to the V0 behavior and to the host the + // live validator path would advertise for such a node). Build `addresses` only when + // a platform port is present, and leave `legacy_*` ports `None` so a later + // `addresses: Some(None)` clear diff actually drops the platform endpoint instead of + // being masked by a stale legacy port. + #[allow(deprecated)] + fn from(value: MasternodeStateV1) -> Self { + let MasternodeStateV1 { + service, + registered_height, + pose_revived_height, + pose_ban_height, + revocation_reason, + owner_address, + voting_address, + payout_address, + pub_key_operator, + operator_payout_address, + platform_node_id, + platform_p2p_port, + platform_http_port, + platform_host, + } = value; + + let host = platform_host.unwrap_or_else(|| service.ip().to_string()); + let addresses = (platform_p2p_port.is_some() || platform_http_port.is_some()).then(|| { + MasternodeAddresses { + core_p2p: vec![], + platform_p2p: platform_p2p_port + .map(|port| format!("{host}:{port}")) + .into_iter() + .collect(), + platform_https: platform_http_port + .map(|port| format!("{host}:{port}")) + .into_iter() + .collect(), + } + }); + + Self { + service, + registered_height, + pose_revived_height, + pose_ban_height, + revocation_reason, + owner_address, + voting_address, + payout_address, + pub_key_operator, + operator_payout_address, + platform_node_id, + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + #[allow(deprecated)] + fn base_dmn_state() -> DMNState { + DMNState { + service: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 2)), 19999), + registered_height: 1, + pose_revived_height: None, + pose_ban_height: None, + revocation_reason: 0, + owner_address: [0u8; 20], + voting_address: [0u8; 20], + payout_address: [0u8; 20], + pub_key_operator: vec![1u8; 48], + operator_payout_address: None, + platform_node_id: Some([7u8; 20]), + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses: None, + } + } + + // A Core 23 masternode whose platform host differs from its core service IP must + // survive the persisted round-trip with the platform host intact. V0 collapses it to + // the service IP (the bug this version fixes); V1 preserves it. The contrast is the + // red→green proof: the same input loses the host through V0 and keeps it through V1. + #[test] + #[allow(deprecated)] + fn v1_round_trip_preserves_distinct_platform_host() { + use crate::platform_types::masternode::v0::MasternodeStateV0; + + let dmn = DMNState { + legacy_platform_p2p_port: Some(0), + legacy_platform_http_port: Some(0), + addresses: Some(MasternodeAddresses { + core_p2p: vec![], + platform_p2p: vec!["203.0.113.7:36656".to_string()], + platform_https: vec!["203.0.113.7:443".to_string()], + }), + ..base_dmn_state() // service IP is 192.0.2.2 — deliberately different + }; + + // V0 loses the platform host: the reverse conversion pairs the port with the + // core service IP. + let v0_back: DMNState = MasternodeStateV0::from(dmn.clone()).into(); + assert_eq!(v0_back.platform_p2p_address().expect("port resolves").0, "192.0.2.2"); + + // V1 captures and restores the platform host. + let state = MasternodeStateV1::from(dmn.clone()); + assert_eq!(state.platform_host.as_deref(), Some("203.0.113.7")); + assert_eq!(state.platform_p2p_port, Some(36656)); + assert_eq!(state.platform_http_port, Some(443)); + + let v1_back: DMNState = state.into(); + let (host, port) = v1_back.platform_p2p_address().expect("port resolves"); + assert_eq!(host, "203.0.113.7"); + assert_eq!(port, 36656); + assert_eq!(v1_back.platform_http_address().expect("http resolves").0, "203.0.113.7"); + } + + // A Core 22 / legacy node has no distinct platform host: the conversion captures + // `platform_host = None` and the reverse falls back to the core service IP, matching + // V0 behavior exactly. + #[test] + #[allow(deprecated)] + fn v1_legacy_node_falls_back_to_service_ip() { + let dmn = DMNState { + legacy_platform_p2p_port: Some(26656), + legacy_platform_http_port: Some(8443), + ..base_dmn_state() + }; + + let state = MasternodeStateV1::from(dmn); + // The legacy accessor pairs the port with the service IP, so the captured host is + // the service IP — not `None`. + assert_eq!(state.platform_host.as_deref(), Some("192.0.2.2")); + assert_eq!(state.platform_p2p_port, Some(26656)); + + let back: DMNState = state.into(); + let (host, port) = back.platform_p2p_address().expect("port resolves"); + assert_eq!(host, "192.0.2.2"); + assert_eq!(port, 26656); + } + + // The full save/load path goes MasternodeListItem -> MasternodeV1 -> MasternodeListItem; + // the outer conversions are pure field moves, so the platform host survives end-to-end. + #[test] + #[allow(deprecated)] + fn v1_masternode_list_item_round_trip_preserves_host() { + let dmn = DMNState { + addresses: Some(MasternodeAddresses { + core_p2p: vec![], + platform_p2p: vec!["203.0.113.7:36656".to_string()], + platform_https: vec!["203.0.113.7:443".to_string()], + }), + ..base_dmn_state() + }; + let item = MasternodeListItem { + node_type: MasternodeType::Evo, + pro_tx_hash: ProTxHash::from([0u8; 32]), + collateral_hash: Txid::from([0u8; 32]), + collateral_index: 0, + collateral_address: [0u8; 20], + operator_reward: 0.0, + state: dmn, + }; + + let persisted: MasternodeV1 = item.into(); + assert_eq!(persisted.state.platform_host.as_deref(), Some("203.0.113.7")); + + let restored: MasternodeListItem = persisted.into(); + assert_eq!( + restored.state.platform_p2p_address().expect("port resolves").0, + "203.0.113.7" + ); + } +} diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_structure_versions/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_structure_versions/mod.rs index d00c9187ae9..99ac8915b08 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_structure_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_structure_versions/mod.rs @@ -1,4 +1,5 @@ pub mod v1; +pub mod v2; use versioned_feature_core::FeatureVersion; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_structure_versions/v2.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_structure_versions/v2.rs new file mode 100644 index 00000000000..98376b4356e --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_structure_versions/v2.rs @@ -0,0 +1,14 @@ +use crate::version::drive_abci_versions::drive_abci_structure_versions::DriveAbciStructureVersions; + +/// Identical to [`super::v1::DRIVE_ABCI_STRUCTURE_VERSIONS_V1`] except `masternode: 1`, +/// which makes the persisted masternode state serialize as `Masternode::V1` — the variant +/// that retains the Core 23 platform host so a split platform/core host survives a restart. +pub const DRIVE_ABCI_STRUCTURE_VERSIONS_V2: DriveAbciStructureVersions = + DriveAbciStructureVersions { + platform_state_structure: 0, + platform_state_for_saving_structure_default: 0, + state_transition_execution_context: 0, + commit: 0, + masternode: 1, + signature_verification_quorum_set: 0, + }; diff --git a/packages/rs-platform-version/src/version/v12.rs b/packages/rs-platform-version/src/version/v12.rs index 2d334b1fd7d..ea8cef256e6 100644 --- a/packages/rs-platform-version/src/version/v12.rs +++ b/packages/rs-platform-version/src/version/v12.rs @@ -17,7 +17,7 @@ use crate::version::dpp_versions::DPPVersion; use crate::version::drive_abci_versions::drive_abci_checkpoint_parameters::v1::DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1; use crate::version::drive_abci_versions::drive_abci_method_versions::v8::DRIVE_ABCI_METHOD_VERSIONS_V8; use crate::version::drive_abci_versions::drive_abci_query_versions::v1::DRIVE_ABCI_QUERY_VERSIONS_V1; -use crate::version::drive_abci_versions::drive_abci_structure_versions::v1::DRIVE_ABCI_STRUCTURE_VERSIONS_V1; +use crate::version::drive_abci_versions::drive_abci_structure_versions::v2::DRIVE_ABCI_STRUCTURE_VERSIONS_V2; use crate::version::drive_abci_versions::drive_abci_validation_versions::v8::DRIVE_ABCI_VALIDATION_VERSIONS_V8; use crate::version::drive_abci_versions::drive_abci_withdrawal_constants::v2::DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V2; use crate::version::drive_abci_versions::DriveAbciVersion; @@ -35,7 +35,9 @@ pub const PLATFORM_V12: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_12, drive: DRIVE_VERSION_V7, // changed: shielded pool (commitment tree, nullifiers, anchors, address funds, sinsemilla hashing) drive_abci: DriveAbciVersion { - structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V1, + // V2 == V1 plus masternode struct version 1: persist the Core 23 platform host so a + // split platform/core host survives a restart (Masternode::V1). + structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, // V8 == V7 plus the four shielded-pool block-processing methods, which // are valid only from v12 onward (the `[52, "M"]` subtree exists here). methods: DRIVE_ABCI_METHOD_VERSIONS_V8, From 073d68c11a7e05d415b61ad67773d7009e2caf9a Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 19 Jun 2026 17:30:28 +0700 Subject: [PATCH 10/13] refactor(drive-abci): make validator reconcile a function of state Address review notes on update_state_masternode_list: fold the apply-or-remove decision into a single function parameterized by the post-apply_diff masternode state rather than a pre-built ValidatorRefresh. Validator reconciliation is now `refresh_validator_in_sets_from_state(pro_tx_hash, &DMNState, validator_sets)`, which resolves the validity predicate internally and either rewrites the cached validator's advertised fields or removes it when the node is no longer a valid HPMN platform validator. The caller clones the updated state so the hpmn-list borrow ends before the validator-set borrow. No behavior change; masternode/validator/update_state unit tests stay green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../update_state_masternode_list/v0/mod.rs | 60 +++++++++---------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs index a48cdcb4f1d..f8083bd80c0 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs @@ -40,17 +40,25 @@ where }); } - /// Apply a [`ValidatorRefresh`] (derived from the post-`apply_diff` full state) to - /// the matching validator in every validator set. Deriving every field from the - /// full state — rather than patching each field from the raw diff — keeps the - /// validity decision and the written values consistent: a partial Core 23 - /// `addresses` diff overwrites the whole nested object, so the diff alone is not a - /// reliable source for the unchanged axis. - fn apply_validator_refresh( + /// Reconcile the cached validator for `pro_tx_hash` in every validator set against a + /// masternode's post-`apply_diff` full state: rewrite its advertised fields if the node + /// is still a valid HPMN platform validator (`validator_refresh_from_state` resolves), + /// or remove it if it no longer is. Deriving everything from the full state — rather + /// than patching each field from the raw diff — keeps the validity decision and the + /// written values consistent: a partial Core 23 `addresses` diff overwrites the whole + /// nested object, so the diff alone is not a reliable source for the unchanged axis. + fn refresh_validator_in_sets_from_state( pro_tx_hash: &ProTxHash, - refresh: &ValidatorRefresh, + state: &DMNState, validator_sets: &mut IndexMap, ) { + let Some(refresh) = validator_refresh_from_state(state) else { + // Platform endpoint disappeared (Core 23 `addresses` cleared, a zeroed legacy + // port with no addresses, or a missing node id) → no longer a valid HPMN + // validator. Drop the stale entry so we stop advertising a dead endpoint. + Self::remove_masternode_in_validator_sets(pro_tx_hash, validator_sets); + return; + }; validator_sets .iter_mut() .for_each(|(_quorum_hash, validator_set)| { @@ -123,32 +131,18 @@ where if let Some(hpmn_list_item) = state.hpmn_masternode_list_mut().get_mut(pro_tx_hash) { hpmn_list_item.state.apply_diff(state_diff.clone()); - // Derive the validator's fields from the post-`apply_diff` full - // state — the single source of truth (the same accessors as - // `new_validator_if_masternode_in_state`). `Some` means the node is - // still a valid HPMN platform validator (both ports + a node id); - // `None` means it no longer is. Computed before the validator-set - // borrow so the `hpmn_list_item` borrow ends first. - let refresh = validator_refresh_from_state(&hpmn_list_item.state); + // Only reconcile when a field the validator advertises can have + // changed (see `diff_triggers_validator_refresh`). Clone the updated + // full state so the `hpmn_list_item` borrow ends before the + // validator-set borrow, then refresh (or drop) the cached validator + // from it. if diff_triggers_validator_refresh(state_diff) { - match &refresh { - // Still a valid platform validator → rewrite its fields - // from the full state. - Some(refresh) => Self::apply_validator_refresh( - pro_tx_hash, - refresh, - state.validator_sets_mut(), - ), - // Platform endpoint disappeared (Core 23 `addresses` - // cleared, a zeroed legacy port with no addresses, or a - // missing node id) → the node is no longer a valid HPMN - // validator. Drop the stale cached entry so we stop - // advertising a dead platform endpoint. - None => Self::remove_masternode_in_validator_sets( - pro_tx_hash, - state.validator_sets_mut(), - ), - } + let updated_state = hpmn_list_item.state.clone(); + Self::refresh_validator_in_sets_from_state( + pro_tx_hash, + &updated_state, + state.validator_sets_mut(), + ); } } } From fc60f5bf19c58cfbf0e490d2f807c7752d16f9d5 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 19 Jun 2026 17:35:57 +0700 Subject: [PATCH 11/13] fix(drive-abci): preserve platform host for http-only masternode entries CodeRabbit finding on MasternodeStateV1: derive `platform_host` from the http address when only it resolves, so an http-only entry round-trips its host instead of collapsing to service.ip() on the reverse conversion. The http host is never advertised (validators use the p2p host, and an http-only entry is not a valid platform validator), but this keeps the V1 round-trip lossless. Pinned by `v1_http_only_node_preserves_host`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/platform_types/masternode/v1/mod.rs | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/rs-drive-abci/src/platform_types/masternode/v1/mod.rs b/packages/rs-drive-abci/src/platform_types/masternode/v1/mod.rs index a37d1c86064..c23ca73cd05 100644 --- a/packages/rs-drive-abci/src/platform_types/masternode/v1/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/masternode/v1/mod.rs @@ -153,8 +153,15 @@ impl From for MasternodeStateV1 { // its core service IP keeps advertising the correct endpoint after a restart. fn from(value: DMNState) -> Self { let platform_p2p = value.platform_p2p_address(); - let platform_http_port = value.platform_http_address().map(|(_host, port)| port); - let platform_host = platform_p2p.as_ref().map(|(host, _port)| host.clone()); + let platform_http = value.platform_http_address(); + let platform_http_port = platform_http.as_ref().map(|(_host, port)| *port); + // Prefer the p2p host (what the validator advertises); fall back to the http host + // so an http-only entry still round-trips its host instead of collapsing to + // service.ip() on the reverse conversion. + let platform_host = platform_p2p + .as_ref() + .map(|(host, _port)| host.clone()) + .or_else(|| platform_http.as_ref().map(|(host, _port)| host.clone())); let platform_p2p_port = platform_p2p.map(|(_host, port)| port); let DMNState { service, @@ -339,6 +346,30 @@ mod tests { assert_eq!(port, 26656); } + // An http-only entry (empty platform_p2p) still round-trips its host: `platform_host` + // falls back to the http host instead of collapsing to service.ip() on reverse. + #[test] + #[allow(deprecated)] + fn v1_http_only_node_preserves_host() { + let dmn = DMNState { + addresses: Some(MasternodeAddresses { + core_p2p: vec![], + platform_p2p: vec![], + platform_https: vec!["203.0.113.7:443".to_string()], + }), + ..base_dmn_state() // service IP is 192.0.2.2 + }; + + let state = MasternodeStateV1::from(dmn); + assert_eq!(state.platform_host.as_deref(), Some("203.0.113.7")); + assert_eq!(state.platform_p2p_port, None); + assert_eq!(state.platform_http_port, Some(443)); + + let back: DMNState = state.into(); + // Host preserved (would be 192.0.2.2 without the http fallback). + assert_eq!(back.platform_http_address().expect("http resolves").0, "203.0.113.7"); + } + // The full save/load path goes MasternodeListItem -> MasternodeV1 -> MasternodeListItem; // the outer conversions are pure field moves, so the platform host survives end-to-end. #[test] From 2979a26613668da25e43529233eb9a7524ac1c5c Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 19 Jun 2026 17:50:54 +0700 Subject: [PATCH 12/13] fix(drive-abci): bracket IPv6 platform hosts in reconstructed Core 23 addresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit thepastaclaw/codex finding: the reverse MasternodeState{V0,V1} -> DMNState conversions rebuilt platform_p2p / platform_https via `format!("{host}:{port}")`, which for an IPv6 host emits an unparsable `2001:db8::1:443`. The upstream nested-address parser rejects unbracketed multi-colon hosts, so `platform_p2p_address()` returned None after a save/load round-trip and the HPMN was dropped from validator sets until the next Core addresses diff — breaking the persisted round-trip for IPv6 HPMN operators. Add a shared `format_platform_address` helper that brackets an unbracketed IPv6 host (IPv4 and hostnames are emitted as-is; already-bracketed hosts are left alone) and route both v0 and v1 reverse conversions through it. Tests (red->green): `format_tests::brackets_only_unbracketed_ipv6`, and IPv6 round-trips `reverse_from_brackets_ipv6_host` (v0) / `v1_ipv6_host_round_trips` (v1) — the reconstructed address re-parses where the unbracketed form would resolve to None. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/platform_types/masternode/mod.rs | 28 +++++++++++++ .../src/platform_types/masternode/v0/mod.rs | 40 +++++++++++++++++-- .../src/platform_types/masternode/v1/mod.rs | 28 ++++++++++++- 3 files changed, 91 insertions(+), 5 deletions(-) diff --git a/packages/rs-drive-abci/src/platform_types/masternode/mod.rs b/packages/rs-drive-abci/src/platform_types/masternode/mod.rs index 35a67fbfcf7..e9dbc9b2f0b 100644 --- a/packages/rs-drive-abci/src/platform_types/masternode/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/masternode/mod.rs @@ -52,3 +52,31 @@ impl From for MasternodeListItem { } } } + +/// Format a platform `host:port` entry for a reconstructed Core 23 `addresses` object, +/// bracketing an IPv6 host so the upstream nested-address parser accepts it. A bare +/// `format!("{host}:{port}")` emits an unparsable `2001:db8::1:443` for an IPv6 host, which +/// would make `DMNState::platform_p2p_address()` return `None` after a save/load round-trip +/// and drop the HPMN from validator sets. An IPv4 address or a hostname is emitted as-is; an +/// already-bracketed host is left alone. +pub(super) fn format_platform_address(host: &str, port: u32) -> String { + if host.contains(':') && !host.starts_with('[') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + +#[cfg(test)] +mod format_tests { + use super::format_platform_address; + + #[test] + fn brackets_only_unbracketed_ipv6() { + assert_eq!(format_platform_address("192.0.2.2", 443), "192.0.2.2:443"); + assert_eq!(format_platform_address("node.example.com", 443), "node.example.com:443"); + assert_eq!(format_platform_address("2001:db8::1", 36656), "[2001:db8::1]:36656"); + // Already bracketed → not double-bracketed. + assert_eq!(format_platform_address("[2001:db8::1]", 36656), "[2001:db8::1]:36656"); + } +} diff --git a/packages/rs-drive-abci/src/platform_types/masternode/v0/mod.rs b/packages/rs-drive-abci/src/platform_types/masternode/v0/mod.rs index dea677bd1fd..d0265efd3aa 100644 --- a/packages/rs-drive-abci/src/platform_types/masternode/v0/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/masternode/v0/mod.rs @@ -208,16 +208,16 @@ impl From for DMNState { platform_http_port, } = value; - let host = service.ip(); + let host = service.ip().to_string(); let addresses = (platform_p2p_port.is_some() || platform_http_port.is_some()).then(|| { MasternodeAddresses { core_p2p: vec![], platform_p2p: platform_p2p_port - .map(|port| format!("{host}:{port}")) + .map(|port| super::format_platform_address(&host, port)) .into_iter() .collect(), platform_https: platform_http_port - .map(|port| format!("{host}:{port}")) + .map(|port| super::format_platform_address(&host, port)) .into_iter() .collect(), } @@ -364,4 +364,38 @@ mod tests { assert!(dmn.platform_p2p_address().is_none()); assert!(dmn.platform_http_address().is_none()); } + + // An IPv6 service must round-trip: the reverse conversion brackets the host so the + // upstream parser re-accepts it. Without bracketing the reconstructed string is + // `2001:db8::1:36656`, which `platform_p2p_address()` rejects → the HPMN is dropped. + #[test] + #[allow(deprecated)] + fn reverse_from_brackets_ipv6_host() { + use std::net::Ipv6Addr; + + let stored = MasternodeStateV0 { + service: SocketAddr::new( + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)), + 19999, + ), + registered_height: 1, + pose_revived_height: None, + pose_ban_height: None, + revocation_reason: 0, + owner_address: [0u8; 20], + voting_address: [0u8; 20], + payout_address: [0u8; 20], + pub_key_operator: vec![1u8; 48], + operator_payout_address: None, + platform_node_id: Some([7u8; 20]), + platform_p2p_port: Some(36656), + platform_http_port: Some(443), + }; + + let dmn: DMNState = stored.into(); + let (host, port) = dmn.platform_p2p_address().expect("ipv6 p2p resolves"); + assert_eq!(port, 36656); + assert!(host.contains("2001:db8::1"), "unexpected host: {host}"); + assert!(dmn.platform_http_address().is_some()); + } } diff --git a/packages/rs-drive-abci/src/platform_types/masternode/v1/mod.rs b/packages/rs-drive-abci/src/platform_types/masternode/v1/mod.rs index c23ca73cd05..083d00b1c03 100644 --- a/packages/rs-drive-abci/src/platform_types/masternode/v1/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/masternode/v1/mod.rs @@ -230,11 +230,11 @@ impl From for DMNState { MasternodeAddresses { core_p2p: vec![], platform_p2p: platform_p2p_port - .map(|port| format!("{host}:{port}")) + .map(|port| super::format_platform_address(&host, port)) .into_iter() .collect(), platform_https: platform_http_port - .map(|port| format!("{host}:{port}")) + .map(|port| super::format_platform_address(&host, port)) .into_iter() .collect(), } @@ -370,6 +370,30 @@ mod tests { assert_eq!(back.platform_http_address().expect("http resolves").0, "203.0.113.7"); } + // An IPv6 platform host must survive the round-trip: the reverse conversion brackets it + // so the upstream parser re-accepts it. Without bracketing the reconstructed string is + // `2001:db8::1:36656` → `platform_p2p_address()` returns None → validator dropped. + #[test] + #[allow(deprecated)] + fn v1_ipv6_host_round_trips() { + use std::net::Ipv6Addr; + let dmn = DMNState { + service: SocketAddr::new( + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)), + 19999, + ), + legacy_platform_p2p_port: Some(36656), + legacy_platform_http_port: Some(443), + ..base_dmn_state() + }; + + let back: DMNState = MasternodeStateV1::from(dmn).into(); + let (host, port) = back.platform_p2p_address().expect("ipv6 p2p resolves"); + assert_eq!(port, 36656); + assert!(host.contains("2001:db8::1"), "unexpected host: {host}"); + assert!(back.platform_http_address().is_some()); + } + // The full save/load path goes MasternodeListItem -> MasternodeV1 -> MasternodeListItem; // the outer conversions are pure field moves, so the platform host survives end-to-end. #[test] From 7641204b9ce925f3e661f301e9d2e59e528fe75d Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 19 Jun 2026 17:55:41 +0700 Subject: [PATCH 13/13] style(drive-abci): rustfmt wrap long assertions in masternode tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical `cargo fmt` — the new IPv6/host test assertions exceeded the line width. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/platform_types/masternode/mod.rs | 15 ++++++++--- .../src/platform_types/masternode/v1/mod.rs | 26 +++++++++++++++---- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/rs-drive-abci/src/platform_types/masternode/mod.rs b/packages/rs-drive-abci/src/platform_types/masternode/mod.rs index e9dbc9b2f0b..8f1b9753ada 100644 --- a/packages/rs-drive-abci/src/platform_types/masternode/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/masternode/mod.rs @@ -74,9 +74,18 @@ mod format_tests { #[test] fn brackets_only_unbracketed_ipv6() { assert_eq!(format_platform_address("192.0.2.2", 443), "192.0.2.2:443"); - assert_eq!(format_platform_address("node.example.com", 443), "node.example.com:443"); - assert_eq!(format_platform_address("2001:db8::1", 36656), "[2001:db8::1]:36656"); + assert_eq!( + format_platform_address("node.example.com", 443), + "node.example.com:443" + ); + assert_eq!( + format_platform_address("2001:db8::1", 36656), + "[2001:db8::1]:36656" + ); // Already bracketed → not double-bracketed. - assert_eq!(format_platform_address("[2001:db8::1]", 36656), "[2001:db8::1]:36656"); + assert_eq!( + format_platform_address("[2001:db8::1]", 36656), + "[2001:db8::1]:36656" + ); } } diff --git a/packages/rs-drive-abci/src/platform_types/masternode/v1/mod.rs b/packages/rs-drive-abci/src/platform_types/masternode/v1/mod.rs index 083d00b1c03..a611c95bcf8 100644 --- a/packages/rs-drive-abci/src/platform_types/masternode/v1/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/masternode/v1/mod.rs @@ -307,7 +307,10 @@ mod tests { // V0 loses the platform host: the reverse conversion pairs the port with the // core service IP. let v0_back: DMNState = MasternodeStateV0::from(dmn.clone()).into(); - assert_eq!(v0_back.platform_p2p_address().expect("port resolves").0, "192.0.2.2"); + assert_eq!( + v0_back.platform_p2p_address().expect("port resolves").0, + "192.0.2.2" + ); // V1 captures and restores the platform host. let state = MasternodeStateV1::from(dmn.clone()); @@ -319,7 +322,10 @@ mod tests { let (host, port) = v1_back.platform_p2p_address().expect("port resolves"); assert_eq!(host, "203.0.113.7"); assert_eq!(port, 36656); - assert_eq!(v1_back.platform_http_address().expect("http resolves").0, "203.0.113.7"); + assert_eq!( + v1_back.platform_http_address().expect("http resolves").0, + "203.0.113.7" + ); } // A Core 22 / legacy node has no distinct platform host: the conversion captures @@ -367,7 +373,10 @@ mod tests { let back: DMNState = state.into(); // Host preserved (would be 192.0.2.2 without the http fallback). - assert_eq!(back.platform_http_address().expect("http resolves").0, "203.0.113.7"); + assert_eq!( + back.platform_http_address().expect("http resolves").0, + "203.0.113.7" + ); } // An IPv6 platform host must survive the round-trip: the reverse conversion brackets it @@ -418,11 +427,18 @@ mod tests { }; let persisted: MasternodeV1 = item.into(); - assert_eq!(persisted.state.platform_host.as_deref(), Some("203.0.113.7")); + assert_eq!( + persisted.state.platform_host.as_deref(), + Some("203.0.113.7") + ); let restored: MasternodeListItem = persisted.into(); assert_eq!( - restored.state.platform_p2p_address().expect("port resolves").0, + restored + .state + .platform_p2p_address() + .expect("port resolves") + .0, "203.0.113.7" ); }