diff --git a/rpc-json/src/lib.rs b/rpc-json/src/lib.rs index 6416f2857..5d4e7015e 100644 --- a/rpc-json/src/lib.rs +++ b/rpc-json/src/lib.rs @@ -2052,6 +2052,51 @@ pub struct GetMasternodePaymentsResult { pub masternodes: Vec, } +/// Nested `addresses` object on Core 23+ masternode entries. +/// +/// Each purpose maps to an array of `"host:port"` strings. Dash Core 23 moved the +/// platform ports here, away from the deprecated top-level `platformP2PPort`/ +/// `platformHTTPPort` keys. Unknown purposes are ignored. +#[derive(Clone, PartialEq, Eq, Debug, Default, Deserialize, Serialize)] +pub struct MasternodeAddresses { + #[serde(default)] + pub core_p2p: Vec, + #[serde(default)] + pub platform_p2p: Vec, + #[serde(default)] + pub platform_https: Vec, +} + +impl MasternodeAddresses { + /// First valid `(host, port)` from a `"host:port"` array, if any. + /// + /// "Valid" means parseable and with a non-zero in-range port; zero ports are + /// skipped because Dash Core uses `0` as a "not set" sentinel. + fn first_valid_host_port(addrs: &[String]) -> Option<(String, u32)> { + addrs.iter().filter_map(|a| parse_host_port(a)).find(|(_, p)| *p != 0) + } +} + +/// Splits a `"host:port"` string into `(host, port)` with a non-fabricated host. +/// +/// Supports IPv4 (`1.2.3.4:9999`) and bracketed IPv6 (`[2001:db8::1]:9999`). The +/// final `:port` segment is parsed through [`u16`] then widened to `u32`, rejecting +/// values outside the TCP/UDP port range (e.g. `"host:70000"`). Unbracketed +/// multi-colon hosts (bare IPv6) are rejected rather than silently mangled. An empty +/// host (e.g. `":36656"`) is also rejected. Returns `None` when no colon is present, +/// the host is empty or ambiguous, or the suffix is not a valid `u16`. +fn parse_host_port(addr: &str) -> Option<(String, u32)> { + let (host, port) = addr.rsplit_once(':')?; + if host.is_empty() { + return None; + } + if host.contains(':') && !(host.starts_with('[') && host.ends_with(']')) { + return None; + } + let port = port.parse::().ok()?; + Some((host.to_string(), u32::from(port))) +} + #[serde_as] #[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] @@ -2080,10 +2125,54 @@ pub struct DMNState { rename = "platformNodeID" )] pub platform_node_id: Option<[u8; 20]>, + #[deprecated(note = "Core 23+ nested addresses.platform_p2p should be used instead")] #[serde(default, rename = "platformP2PPort")] - pub platform_p2p_port: Option, + pub legacy_platform_p2p_port: Option, + #[deprecated(note = "Core 23+ nested addresses.platform_https should be used instead")] #[serde(default, rename = "platformHTTPPort")] - pub platform_http_port: Option, + pub legacy_platform_http_port: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub addresses: Option, +} + +impl DMNState { + /// Resolved platform P2P `(host, port)`. + /// + /// Prefers the Core 23+ nested `addresses.platform_p2p` entry, returning its + /// host and port verbatim. Falls back to the deprecated top-level + /// `platformP2PPort`, pairing it with the node IP from [`service`](Self::service) + /// because Dash deploys platform services on the masternode's core IP. Returns + /// `None` when no source yields a non-zero in-range port. + #[allow(deprecated)] + pub fn platform_p2p_address(&self) -> Option<(String, u32)> { + self.addresses + .as_ref() + .and_then(|a| MasternodeAddresses::first_valid_host_port(&a.platform_p2p)) + .or_else(|| self.legacy_platform_address(self.legacy_platform_p2p_port)) + } + + /// Resolved platform HTTPS `(host, port)`. + /// + /// Prefers the Core 23+ nested `addresses.platform_https` entry, returning its + /// host and port verbatim. Falls back to the deprecated top-level + /// `platformHTTPPort`, pairing it with the node IP from [`service`](Self::service) + /// because Dash deploys platform services on the masternode's core IP. Returns + /// `None` when no source yields a non-zero in-range port. + #[allow(deprecated)] + pub fn platform_http_address(&self) -> Option<(String, u32)> { + self.addresses + .as_ref() + .and_then(|a| MasternodeAddresses::first_valid_host_port(&a.platform_https)) + .or_else(|| self.legacy_platform_address(self.legacy_platform_http_port)) + } + + /// Pairs a legacy platform port with the node IP, dropping zero, absent, and + /// out-of-`u16`-range ports so the result honors the TCP/UDP port range. + fn legacy_platform_address(&self, port: Option) -> Option<(String, u32)> { + port.and_then(|p| u16::try_from(p).ok()) + .filter(|&p| p != 0) + .map(|p| (self.service.ip().to_string(), u32::from(p))) + } } #[derive(Clone, PartialEq, Eq, Debug, Deserialize)] @@ -2103,13 +2192,19 @@ pub struct DMNStateDiff { pub pub_key_operator: Option>, pub operator_payout_address: Option>, pub platform_node_id: Option<[u8; 20]>, - pub platform_p2p_port: Option, - pub platform_http_port: Option, + #[deprecated(note = "Core 23+ nested addresses.platform_p2p should be used instead")] + pub legacy_platform_p2p_port: Option, + #[deprecated(note = "Core 23+ nested addresses.platform_https should be used instead")] + pub legacy_platform_http_port: Option, + /// Three-state nested addresses: `None` = unchanged, `Some(None)` = cleared, + /// `Some(Some(_))` = set. Mirrors [`pose_ban_height`](Self::pose_ban_height). + pub addresses: Option>, } impl TryFrom for DMNStateDiff { type Error = encode::Error; + #[allow(deprecated)] fn try_from(value: DMNStateDiffIntermediate) -> Result { let DMNStateDiffIntermediate { service, @@ -2123,10 +2218,11 @@ impl TryFrom for DMNStateDiff { owner_address, voting_address, platform_node_id, - platform_p2p_port, - platform_http_port, + legacy_platform_p2p_port, + legacy_platform_http_port, payout_address, pub_key_operator, + addresses, } = value; let owner_address = owner_address @@ -2188,12 +2284,43 @@ impl TryFrom for DMNStateDiff { pub_key_operator, operator_payout_address, platform_node_id, - platform_p2p_port, - platform_http_port, + #[allow(deprecated)] + legacy_platform_p2p_port, + #[allow(deprecated)] + legacy_platform_http_port, + addresses, }) } } +impl DMNStateDiff { + /// Resolved platform P2P `(host, port)` carried by this diff, if any. + /// + /// Returns the host and port from the Core 23+ nested `addresses.platform_p2p` + /// entry. The legacy top-level `platformP2PPort` is not resolved here: a diff + /// carries no node IP to pair it with, so a host would have to be fabricated. + /// Read the legacy port via [`legacy_platform_p2p_port`](Self::legacy_platform_p2p_port). + pub fn platform_p2p_address(&self) -> Option<(String, u32)> { + self.addresses + .as_ref() + .and_then(|a| a.as_ref()) + .and_then(|a| MasternodeAddresses::first_valid_host_port(&a.platform_p2p)) + } + + /// Resolved platform HTTPS `(host, port)` carried by this diff, if any. + /// + /// Returns the host and port from the Core 23+ nested `addresses.platform_https` + /// entry. The legacy top-level `platformHTTPPort` is not resolved here: a diff + /// carries no node IP to pair it with, so a host would have to be fabricated. + /// Read the legacy port via [`legacy_platform_http_port`](Self::legacy_platform_http_port). + pub fn platform_http_address(&self) -> Option<(String, u32)> { + self.addresses + .as_ref() + .and_then(|a| a.as_ref()) + .and_then(|a| MasternodeAddresses::first_valid_host_port(&a.platform_https)) + } +} + impl DMNState { pub fn compare_to_older_dmn_state(&self, older: &DMNState) -> Option { older.compare_to_newer_dmn_state(self) @@ -2272,15 +2399,27 @@ impl DMNState { } else { None }, - platform_p2p_port: if self.platform_p2p_port != newer.platform_p2p_port { + #[allow(deprecated)] + legacy_platform_p2p_port: if self.legacy_platform_p2p_port + != newer.legacy_platform_p2p_port + { + has_diff = true; + newer.legacy_platform_p2p_port + } else { + None + }, + #[allow(deprecated)] + legacy_platform_http_port: if self.legacy_platform_http_port + != newer.legacy_platform_http_port + { has_diff = true; - newer.platform_p2p_port + newer.legacy_platform_http_port } else { None }, - platform_http_port: if self.platform_http_port != newer.platform_http_port { + addresses: if self.addresses != newer.addresses { has_diff = true; - newer.platform_http_port + Some(newer.addresses.clone()) } else { None }, @@ -2304,8 +2443,11 @@ impl DMNState { pub_key_operator, operator_payout_address, platform_node_id, - platform_p2p_port, - platform_http_port, + #[allow(deprecated)] + legacy_platform_p2p_port, + #[allow(deprecated)] + legacy_platform_http_port, + addresses, .. } = diff; self.pose_revived_height = pose_revived_height; @@ -2338,12 +2480,18 @@ impl DMNState { self.platform_node_id = Some(platform_node_id); } - if let Some(platform_p2p_port) = platform_p2p_port { - self.platform_p2p_port = Some(platform_p2p_port); + #[allow(deprecated)] + if let Some(legacy_platform_p2p_port) = legacy_platform_p2p_port { + self.legacy_platform_p2p_port = Some(legacy_platform_p2p_port); + } + + #[allow(deprecated)] + if let Some(legacy_platform_http_port) = legacy_platform_http_port { + self.legacy_platform_http_port = Some(legacy_platform_http_port); } - if let Some(platform_http_port) = platform_http_port { - self.platform_http_port = Some(platform_http_port); + if let Some(addresses) = addresses { + self.addresses = addresses; } } } @@ -2885,14 +3033,19 @@ pub struct DMNStateDiffIntermediate { pub voting_address: Option, #[serde(default, rename = "platformNodeID")] pub platform_node_id: Option, + #[deprecated(note = "Core 23+ nested addresses.platform_p2p should be used instead")] #[serde(default, rename = "platformP2PPort")] - pub platform_p2p_port: Option, + pub legacy_platform_p2p_port: Option, + #[deprecated(note = "Core 23+ nested addresses.platform_https should be used instead")] #[serde(default, rename = "platformHTTPPort")] - pub platform_http_port: Option, + pub legacy_platform_http_port: Option, #[serde(default)] pub payout_address: Option, #[serde(default, deserialize_with = "deserialize_hex_opt")] pub pub_key_operator: Option>, + // Three-state: missing field = None, `null` = Some(None), object = Some(Some(_)). + #[serde(default, deserialize_with = "deserialize_addresses_2opt")] + pub addresses: Option>, } #[derive(Clone, PartialEq, Debug, Deserialize)] @@ -3251,6 +3404,20 @@ where Ok(Some(Some(val as u32))) } +/// Deserializes the present-but-clearable nested `addresses` object. +/// +/// Paired with `#[serde(default)]`, the field absence yields `None` (unchanged), +/// a JSON `null` yields `Some(None)` (cleared), and an object yields +/// `Some(Some(_))` (set) — the canonical serde double-`Option` pattern. +fn deserialize_addresses_2opt<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + Ok(Some(Option::::deserialize(deserializer)?)) +} + #[cfg(test)] mod tests { use dashcore::hashes::Hash; @@ -3258,7 +3425,8 @@ mod tests { use serde_json::json; use crate::{ - ExtendedQuorumListResult, MasternodeListDiff, MnSyncStatus, QuorumType, deserialize_u32_opt, + DMNState, DMNStateDiff, ExtendedQuorumListResult, MasternodeAddresses, MasternodeListDiff, + MnSyncStatus, QuorumType, deserialize_u32_opt, parse_host_port, }; #[test] @@ -3435,6 +3603,313 @@ mod tests { ); } + #[test] + #[allow(deprecated)] + fn dmn_state_core23_addresses_resolve_platform_ports() { + // Core 23 entry: legacy platformP2PPort/platformHTTPPort absent, ports live + // in the nested `addresses` object. Raw fields stay None; accessors resolve. + let json = r#"{ + "service": "192.0.2.1:9999", + "registeredHeight": 123456, + "revocationReason": 0, + "ownerAddress": "yPBWCdMRY5PsS3hJzs7csbdWQVRR85yxUz", + "votingAddress": "ySM11LUD65Bi4p1gm68XLkdWc65TBKRzvQ", + "payoutAddress": "yX4Ve7Q8Y4jscV4LZJD8HVCHKyePzR3MhA", + "pubKeyOperator": "8ed3f0c208efbcfc815cbfb94490dc68cf2e29d44dd9f8a91e20e06057aa110d7062c8ab7ccc85a9ff0c88760157f563", + "platformNodeID": "f2dbd9b0a1f541a7c44d34a58674d0262f5feca5", + "addresses": { + "core_p2p": ["192.0.2.1:9999"], + "platform_p2p": ["192.0.2.2:36656"], + "platform_https": ["192.0.2.2:443"] + } + }"#; + let state: DMNState = serde_json::from_str(json).expect("expected to deserialize json"); + assert_eq!(state.legacy_platform_p2p_port, None, "raw legacy field deserialized as-is"); + assert_eq!(state.legacy_platform_http_port, None, "raw legacy field deserialized as-is"); + assert_eq!( + state.platform_p2p_address(), + Some(("192.0.2.2".to_string(), 36656)), + "p2p resolved from addresses" + ); + assert_eq!( + state.platform_http_address(), + Some(("192.0.2.2".to_string(), 443)), + "http resolved from addresses" + ); + } + + #[test] + #[allow(deprecated)] + fn dmn_state_diff_core23_addresses_resolve_platform_ports() { + // updatedMNs entry carrying only the new `addresses` object. + let json = r#"{ + "addresses": { + "platform_p2p": ["192.0.2.2:36656"], + "platform_https": ["192.0.2.2:443"] + } + }"#; + let diff: DMNStateDiff = serde_json::from_str(json).expect("expected to deserialize json"); + assert_eq!(diff.legacy_platform_p2p_port, None, "raw legacy diff field deserialized as-is"); + assert_eq!( + diff.legacy_platform_http_port, None, + "raw legacy diff field deserialized as-is" + ); + assert_eq!( + diff.platform_p2p_address(), + Some(("192.0.2.2".to_string(), 36656)), + "diff p2p resolved from addresses" + ); + assert_eq!( + diff.platform_http_address(), + Some(("192.0.2.2".to_string(), 443)), + "diff http resolved from addresses" + ); + } + + #[test] + fn dmn_state_legacy_platform_ports_resolve_to_node_ip() { + // Pre-23 entry: legacy top-level keys, no `addresses`. Accessors fall back to + // the legacy port paired with the node IP from `service`. + let json = r#"{ + "service": "192.0.2.1:9999", + "registeredHeight": 123456, + "revocationReason": 0, + "ownerAddress": "yPBWCdMRY5PsS3hJzs7csbdWQVRR85yxUz", + "votingAddress": "ySM11LUD65Bi4p1gm68XLkdWc65TBKRzvQ", + "payoutAddress": "yX4Ve7Q8Y4jscV4LZJD8HVCHKyePzR3MhA", + "pubKeyOperator": "8ed3f0c208efbcfc815cbfb94490dc68cf2e29d44dd9f8a91e20e06057aa110d7062c8ab7ccc85a9ff0c88760157f563", + "platformNodeID": "f2dbd9b0a1f541a7c44d34a58674d0262f5feca5", + "platformP2PPort": 26656, + "platformHTTPPort": 443 + }"#; + let state: DMNState = serde_json::from_str(json).expect("expected to deserialize json"); + assert!(state.addresses.is_none(), "no addresses object present"); + assert_eq!( + state.platform_p2p_address(), + Some(("192.0.2.1".to_string(), 26656)), + "p2p resolved from legacy paired with node IP" + ); + assert_eq!( + state.platform_http_address(), + Some(("192.0.2.1".to_string(), 443)), + "http resolved from legacy paired with node IP" + ); + } + + #[test] + #[allow(deprecated)] + fn dmn_state_zero_legacy_port_resolves_to_addresses() { + // Transitional entry: legacy port present but zero -> addresses wins (new-first). + let json = r#"{ + "service": "192.0.2.1:9999", + "registeredHeight": 123456, + "revocationReason": 0, + "ownerAddress": "yPBWCdMRY5PsS3hJzs7csbdWQVRR85yxUz", + "votingAddress": "ySM11LUD65Bi4p1gm68XLkdWc65TBKRzvQ", + "payoutAddress": "yX4Ve7Q8Y4jscV4LZJD8HVCHKyePzR3MhA", + "pubKeyOperator": "8ed3f0c208efbcfc815cbfb94490dc68cf2e29d44dd9f8a91e20e06057aa110d7062c8ab7ccc85a9ff0c88760157f563", + "platformNodeID": "f2dbd9b0a1f541a7c44d34a58674d0262f5feca5", + "platformP2PPort": 0, + "addresses": { + "platform_p2p": ["192.0.2.2:36656"] + } + }"#; + let state: DMNState = serde_json::from_str(json).expect("expected to deserialize json"); + assert_eq!(state.legacy_platform_p2p_port, Some(0), "raw legacy zero deserialized as-is"); + assert_eq!( + state.platform_p2p_address(), + Some(("192.0.2.2".to_string(), 36656)), + "zero legacy yields to addresses" + ); + } + + #[test] + #[allow(deprecated)] + fn dmn_state_zero_legacy_port_no_addresses_resolves_to_none() { + // Legacy port present but zero and no `addresses` -> accessor returns None, + // never `(host, 0)`. + let json = r#"{ + "service": "192.0.2.1:9999", + "registeredHeight": 123456, + "revocationReason": 0, + "ownerAddress": "yPBWCdMRY5PsS3hJzs7csbdWQVRR85yxUz", + "votingAddress": "ySM11LUD65Bi4p1gm68XLkdWc65TBKRzvQ", + "payoutAddress": "yX4Ve7Q8Y4jscV4LZJD8HVCHKyePzR3MhA", + "pubKeyOperator": "8ed3f0c208efbcfc815cbfb94490dc68cf2e29d44dd9f8a91e20e06057aa110d7062c8ab7ccc85a9ff0c88760157f563", + "platformNodeID": "f2dbd9b0a1f541a7c44d34a58674d0262f5feca5", + "platformP2PPort": 0 + }"#; + let state: DMNState = serde_json::from_str(json).expect("expected to deserialize json"); + assert_eq!(state.legacy_platform_p2p_port, Some(0), "raw legacy zero deserialized as-is"); + assert_eq!(state.platform_p2p_address(), None, "zero legacy with no addresses -> None"); + } + + #[test] + fn dmn_state_out_of_range_port_rejected() { + // A port above the u16 range must be rejected rather than truncated/accepted. + let json = r#"{ + "service": "192.0.2.1:9999", + "registeredHeight": 123456, + "revocationReason": 0, + "ownerAddress": "yPBWCdMRY5PsS3hJzs7csbdWQVRR85yxUz", + "votingAddress": "ySM11LUD65Bi4p1gm68XLkdWc65TBKRzvQ", + "payoutAddress": "yX4Ve7Q8Y4jscV4LZJD8HVCHKyePzR3MhA", + "pubKeyOperator": "8ed3f0c208efbcfc815cbfb94490dc68cf2e29d44dd9f8a91e20e06057aa110d7062c8ab7ccc85a9ff0c88760157f563", + "platformNodeID": "f2dbd9b0a1f541a7c44d34a58674d0262f5feca5", + "addresses": { + "platform_p2p": ["192.0.2.2:70000"] + } + }"#; + let state: DMNState = serde_json::from_str(json).expect("expected to deserialize json"); + assert_eq!(state.platform_p2p_address(), None, "out-of-range port rejected"); + } + + #[test] + fn dmn_state_legacy_out_of_range_port_rejected() { + // A legacy port above the u16 range must be rejected, matching the addresses + // path, so the accessor honors its documented in-range invariant. + let json = r#"{ + "service": "192.0.2.1:9999", + "registeredHeight": 123456, + "revocationReason": 0, + "ownerAddress": "yPBWCdMRY5PsS3hJzs7csbdWQVRR85yxUz", + "votingAddress": "ySM11LUD65Bi4p1gm68XLkdWc65TBKRzvQ", + "payoutAddress": "yX4Ve7Q8Y4jscV4LZJD8HVCHKyePzR3MhA", + "pubKeyOperator": "8ed3f0c208efbcfc815cbfb94490dc68cf2e29d44dd9f8a91e20e06057aa110d7062c8ab7ccc85a9ff0c88760157f563", + "platformNodeID": "f2dbd9b0a1f541a7c44d34a58674d0262f5feca5", + "platformP2PPort": 70000 + }"#; + let state: DMNState = serde_json::from_str(json).expect("expected to deserialize json"); + assert_eq!(state.platform_p2p_address(), None, "out-of-range legacy port rejected"); + } + + #[test] + fn dmn_state_no_ports_resolve_to_none() { + // No addresses and no legacy ports -> accessors return None. + let json = r#"{ + "service": "192.0.2.1:9999", + "registeredHeight": 123456, + "revocationReason": 0, + "ownerAddress": "yPBWCdMRY5PsS3hJzs7csbdWQVRR85yxUz", + "votingAddress": "ySM11LUD65Bi4p1gm68XLkdWc65TBKRzvQ", + "payoutAddress": "yX4Ve7Q8Y4jscV4LZJD8HVCHKyePzR3MhA", + "pubKeyOperator": "8ed3f0c208efbcfc815cbfb94490dc68cf2e29d44dd9f8a91e20e06057aa110d7062c8ab7ccc85a9ff0c88760157f563", + "platformNodeID": "f2dbd9b0a1f541a7c44d34a58674d0262f5feca5" + }"#; + let state: DMNState = serde_json::from_str(json).expect("expected to deserialize json"); + assert_eq!(state.platform_p2p_address(), None, "no source -> None"); + assert_eq!(state.platform_http_address(), None, "no source -> None"); + } + + fn dmn_state_with_legacy_p2p_zero() -> DMNState { + let json = r#"{ + "service": "192.0.2.1:9999", + "registeredHeight": 123456, + "revocationReason": 0, + "ownerAddress": "yPBWCdMRY5PsS3hJzs7csbdWQVRR85yxUz", + "votingAddress": "ySM11LUD65Bi4p1gm68XLkdWc65TBKRzvQ", + "payoutAddress": "yX4Ve7Q8Y4jscV4LZJD8HVCHKyePzR3MhA", + "pubKeyOperator": "8ed3f0c208efbcfc815cbfb94490dc68cf2e29d44dd9f8a91e20e06057aa110d7062c8ab7ccc85a9ff0c88760157f563", + "platformNodeID": "f2dbd9b0a1f541a7c44d34a58674d0262f5feca5", + "platformP2PPort": 0 + }"#; + serde_json::from_str(json).expect("expected to deserialize json") + } + + #[test] + fn dmn_state_apply_diff_propagates_addresses() { + // Stored entry has a zero legacy port and no addresses; a diff carrying a + // nested `addresses` object must make the merged state resolvable. + let mut state = dmn_state_with_legacy_p2p_zero(); + assert_eq!(state.platform_p2p_address(), None, "unresolvable before diff"); + + let diff = 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, + #[allow(deprecated)] + legacy_platform_p2p_port: None, + #[allow(deprecated)] + legacy_platform_http_port: None, + addresses: Some(Some(MasternodeAddresses { + core_p2p: vec![], + platform_p2p: vec!["192.0.2.2:36656".to_string()], + platform_https: vec![], + })), + }; + + state.apply_diff(diff); + assert_eq!( + state.platform_p2p_address(), + Some(("192.0.2.2".to_string(), 36656)), + "diff addresses propagated and resolvable" + ); + } + + #[test] + fn dmn_state_diff_clears_addresses() { + // A Some -> None transition must survive the compare/apply round-trip: the + // diff carries `Some(None)` and applying it clears the stored addresses. + let mut newer = dmn_state_with_legacy_p2p_zero(); + newer.addresses = Some(MasternodeAddresses { + core_p2p: vec![], + platform_p2p: vec!["192.0.2.2:36656".to_string()], + platform_https: vec![], + }); + let older = dmn_state_with_legacy_p2p_zero(); + + let diff = + newer.compare_to_newer_dmn_state(&older).expect("addresses change yields a diff"); + assert_eq!(diff.addresses, Some(None), "clear is encoded as Some(None)"); + + let mut applied = newer; + applied.apply_diff(diff); + assert!(applied.addresses.is_none(), "Some(None) diff clears stored addresses"); + } + + #[test] + fn dmn_state_diff_addresses_null_wire_clears() { + // Wire-level three-state: `null` -> Some(None) (clear), absent -> None + // (unchanged). Exercises `deserialize_addresses_2opt` through the intermediate. + let diff: DMNStateDiff = + serde_json::from_str(r#"{"addresses": null}"#).expect("expected to deserialize json"); + assert_eq!(diff.addresses, Some(None), "null wire -> Some(None) (clear)"); + + let diff: DMNStateDiff = + serde_json::from_str(r#"{}"#).expect("expected to deserialize json"); + assert_eq!(diff.addresses, None, "absent wire -> None (unchanged)"); + } + + #[test] + fn parse_host_port_ipv6() { + // Bracketed IPv6 keeps host intact; unbracketed (ambiguous) is rejected. + assert_eq!( + parse_host_port("[2001:db8::1]:9999"), + Some(("[2001:db8::1]".to_string(), 9999)), + "bracketed IPv6 parses host + port" + ); + assert_eq!(parse_host_port("2001:db8::1"), None, "unbracketed IPv6 rejected"); + assert_eq!( + parse_host_port("192.0.2.1:9999"), + Some(("192.0.2.1".to_string(), 9999)), + "IPv4 still parses" + ); + // Empty host must be rejected. + assert_eq!(parse_host_port(":36656"), None, "empty host rejected"); + assert_eq!(parse_host_port(":443"), None, "empty host rejected"); + } + #[test] fn deserialize_mnsync_status() { let json_value = json!({