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/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_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..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 @@ -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}; @@ -38,43 +40,33 @@ 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( + /// 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, - dmn_state_diff: &DMNStateDiff, + 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)| { 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) = dmn_state_diff.platform_p2p_port { - validator.platform_p2p_port = p2p_port as u16; - } - - if let Some(http_port) = dmn_state_diff.platform_http_port { - 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; } }); } @@ -139,16 +131,16 @@ 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 - if state_diff.pose_ban_height.is_some() - || state_diff.service.is_some() - || state_diff.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( + // 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) { + let updated_state = hpmn_list_item.state.clone(); + Self::refresh_validator_in_sets_from_state( pro_tx_hash, - state_diff, + &updated_state, state.validator_sets_mut(), ); } @@ -181,3 +173,334 @@ 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. +/// +/// 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) + .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 { + diff.platform_http_address() + .map(|(_host, port)| port) + .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, +} + +/// 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(), + }) +} + +/// 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::*; + use dpp::dashcore_rpc::dashcore_rpc_json::MasternodeAddresses; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + #[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); + } + + // 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, + } + } + + // 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. The advertised `node_ip` is + // the Core 23 platform host from `addresses`, NOT the core service IP. + #[test] + #[allow(deprecated)] + 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!["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 + }; + 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, paired with + // the core service IP. + #[test] + #[allow(deprecated)] + 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() + }; + 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; the cached entry must be dropped. + #[test] + #[allow(deprecated)] + fn refresh_none_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!(validator_refresh_from_state(&cleared).is_none()); + + 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!(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()); + } + + // 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/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/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..8f1b9753ada 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,44 @@ impl From for MasternodeListItem { fn from(value: Masternode) -> Self { match value { Masternode::V0(v0) => v0.into(), + Masternode::V1(v1) => v1.into(), } } } + +/// 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 43e9dba0359..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 @@ -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}; @@ -138,7 +138,16 @@ pub struct MasternodeStateV0 { } impl From for MasternodeStateV0 { + // 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, @@ -151,8 +160,7 @@ impl From for MasternodeStateV0 { pub_key_operator, operator_payout_address, platform_node_id, - platform_p2p_port, - platform_http_port, + .. } = value; Self { @@ -174,6 +182,15 @@ impl From for MasternodeStateV0 { } impl From for DMNState { + // 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 { service, @@ -191,6 +208,21 @@ impl From for DMNState { platform_http_port, } = value; + 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| super::format_platform_address(&host, port)) + .into_iter() + .collect(), + platform_https: platform_http_port + .map(|port| super::format_platform_address(&host, port)) + .into_iter() + .collect(), + } + }); + Self { service, registered_height, @@ -203,8 +235,167 @@ impl From for DMNState { pub_key_operator, operator_payout_address, platform_node_id, - platform_p2p_port, - platform_http_port, + legacy_platform_p2p_port: None, + legacy_platform_http_port: None, + addresses, } } } + +#[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)); + } + + // 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()); + } + + // 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 new file mode 100644 index 00000000000..a611c95bcf8 --- /dev/null +++ b/packages/rs-drive-abci/src/platform_types/masternode/v1/mod.rs @@ -0,0 +1,445 @@ +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 = 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, + 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| super::format_platform_address(&host, port)) + .into_iter() + .collect(), + platform_https: platform_http_port + .map(|port| super::format_platform_address(&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); + } + + // 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" + ); + } + + // 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] + #[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-drive-abci/src/platform_types/validator/v0/mod.rs b/packages/rs-drive-abci/src/platform_types/validator/v0/mod.rs index f52b335b54e..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 @@ -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, @@ -22,30 +22,29 @@ 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, - platform_p2p_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. + // 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: service.ip().to_string(), + node_ip: platform_p2p_host, 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: 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(), }) } } 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..53eb650e639 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,13 +536,13 @@ 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() { - *port += 1 + if let Some(port) = hpmn_list_item_b.state.legacy_platform_p2p_port.as_mut() { + *port = port.saturating_add(1) } } if update.http_port { - if let Some(port) = hpmn_list_item_b.state.platform_http_port.as_mut() { - *port += 1 + if let Some(port) = hpmn_list_item_b.state.legacy_platform_http_port.as_mut() { + *port = port.saturating_add(1) } } 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, 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) ); }