Skip to main content

nox_core/models/
topology.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
4pub struct RelayerNode {
5    pub address: String,
6    pub sphinx_key: String,
7    /// P2P multiaddr (e.g., /ip4/1.2.3.4/tcp/9000)
8    pub url: String,
9    /// String to preserve U256 precision
10    pub stake: String,
11    pub last_seen: u64,
12    pub is_privileged: bool,
13    /// 0=Entry, 1=Mix, 2=Exit
14    pub layer: u8,
15    /// 1=Relay, 2=Exit, 3=Full. Defaults to 3 for backward compat.
16    #[serde(default = "default_role")]
17    pub role: u8,
18    /// HTTPS ingress URL for client SDK packet submission. Separate from `url` (P2P multiaddr).
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub ingress_url: Option<String>,
21    /// Extended metadata JSON URL for version, region, capabilities, etc.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub metadata_url: Option<String>,
24}
25
26fn default_role() -> u8 {
27    3
28}
29
30/// Maps on-chain role to allowed topology layers.
31/// Role 1 (Relay) -> [0,1]; Role 2 (Exit) / 3 (Full) -> [0,1,2].
32#[must_use]
33pub fn layers_for_role(role: u8) -> &'static [u8] {
34    match role {
35        1 => &[0, 1],
36        _ => &[0, 1, 2],
37    }
38}
39
40impl RelayerNode {
41    #[must_use]
42    pub fn new(address: String, sphinx_key: String, url: String, stake: String, role: u8) -> Self {
43        let is_privileged = Self::parse_stake(&stake).is_some_and(|s| s == 0);
44        Self {
45            address,
46            sphinx_key,
47            url,
48            stake,
49            last_seen: 0,
50            is_privileged,
51            layer: 0,
52            role,
53            ingress_url: None,
54            metadata_url: None,
55        }
56    }
57
58    #[must_use]
59    pub fn with_ingress_url(mut self, ingress_url: String) -> Self {
60        self.ingress_url = Some(ingress_url);
61        self
62    }
63
64    /// Parse stake string to u128 for numeric comparison.
65    #[must_use]
66    pub fn parse_stake(stake_str: &str) -> Option<u128> {
67        stake_str.parse::<u128>().ok()
68    }
69
70    #[allow(clippy::must_use_candidate)]
71    pub fn stake_value(&self) -> u128 {
72        Self::parse_stake(&self.stake).unwrap_or(0)
73    }
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77pub struct TopologySnapshot {
78    pub nodes: Vec<RelayerNode>,
79    /// XOR(keccak256(addr) for each node), hex-encoded
80    pub fingerprint: String,
81    pub timestamp: u64,
82    pub block_number: u64,
83    /// `PoW` difficulty required by the network. Clients use this instead of guessing.
84    #[serde(default)]
85    pub pow_difficulty: u32,
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    fn make_node(addr: &str, stake: &str, role: u8) -> RelayerNode {
93        RelayerNode::new(
94            addr.to_string(),
95            "0xdead".to_string(),
96            "/ip4/127.0.0.1/tcp/9000".to_string(),
97            stake.to_string(),
98            role,
99        )
100    }
101
102    #[test]
103    fn test_new_sets_defaults() {
104        let node = make_node("0xabc", "1000", 3);
105        assert_eq!(node.address, "0xabc");
106        assert_eq!(node.sphinx_key, "0xdead");
107        assert_eq!(node.stake, "1000");
108        assert_eq!(node.role, 3);
109        assert_eq!(node.layer, 0);
110        assert_eq!(node.last_seen, 0);
111        assert!(!node.is_privileged);
112        assert!(node.ingress_url.is_none());
113    }
114
115    #[test]
116    fn test_new_privileged_when_stake_zero() {
117        let node = make_node("0xabc", "0", 1);
118        assert!(
119            node.is_privileged,
120            "Zero-stake nodes are privileged (admin)"
121        );
122    }
123
124    #[test]
125    fn test_new_not_privileged_when_stake_nonzero() {
126        let node = make_node("0xabc", "100", 2);
127        assert!(!node.is_privileged);
128    }
129
130    #[test]
131    fn test_new_not_privileged_when_stake_unparseable() {
132        let node = make_node("0xabc", "not_a_number", 1);
133        assert!(!node.is_privileged, "Unparseable stake is not privileged");
134    }
135
136    #[test]
137    fn test_with_ingress_url() {
138        let node =
139            make_node("0xabc", "1000", 3).with_ingress_url("http://1.2.3.4:8080".to_string());
140        assert_eq!(node.ingress_url.as_deref(), Some("http://1.2.3.4:8080"));
141    }
142
143    #[test]
144    fn test_parse_stake_valid() {
145        assert_eq!(RelayerNode::parse_stake("42"), Some(42));
146        assert_eq!(RelayerNode::parse_stake("0"), Some(0));
147        assert_eq!(
148            RelayerNode::parse_stake("340282366920938463463374607431768211455"),
149            Some(u128::MAX),
150        );
151    }
152
153    #[test]
154    fn test_parse_stake_invalid() {
155        assert_eq!(RelayerNode::parse_stake(""), None);
156        assert_eq!(RelayerNode::parse_stake("abc"), None);
157        assert_eq!(RelayerNode::parse_stake("-1"), None);
158    }
159
160    #[test]
161    fn test_stake_value_returns_parsed() {
162        let node = make_node("0xabc", "9999", 1);
163        assert_eq!(node.stake_value(), 9999);
164    }
165
166    #[test]
167    fn test_stake_value_returns_zero_on_failure() {
168        let node = make_node("0xabc", "garbage", 1);
169        assert_eq!(node.stake_value(), 0);
170    }
171
172    #[test]
173    fn test_relayer_node_json_roundtrip() {
174        let node =
175            make_node("0xabc", "500", 2).with_ingress_url("http://localhost:8080".to_string());
176        let json = serde_json::to_string(&node).expect("serialize");
177        let back: RelayerNode = serde_json::from_str(&json).expect("deserialize");
178        assert_eq!(node, back);
179    }
180
181    #[test]
182    fn test_relayer_node_deserialize_missing_role_defaults_to_full() {
183        // Nodes registered before role support omit `role` field.
184        let json = r#"{
185            "address": "0xabc",
186            "sphinx_key": "0xdead",
187            "url": "/ip4/127.0.0.1/tcp/9000",
188            "stake": "1000",
189            "last_seen": 0,
190            "is_privileged": false,
191            "layer": 0
192        }"#;
193        let node: RelayerNode = serde_json::from_str(json).expect("deserialize");
194        assert_eq!(node.role, 3, "Missing role should default to 3 (Full)");
195    }
196
197    #[test]
198    fn test_relayer_node_deserialize_missing_ingress_url_defaults_to_none() {
199        let json = r#"{
200            "address": "0xabc",
201            "sphinx_key": "0xdead",
202            "url": "/ip4/127.0.0.1/tcp/9000",
203            "stake": "1000",
204            "last_seen": 0,
205            "is_privileged": false,
206            "layer": 0,
207            "role": 2
208        }"#;
209        let node: RelayerNode = serde_json::from_str(json).expect("deserialize");
210        assert!(node.ingress_url.is_none());
211    }
212
213    #[test]
214    fn test_topology_snapshot_json_roundtrip() {
215        let snapshot = TopologySnapshot {
216            nodes: vec![make_node("0x1", "100", 1), make_node("0x2", "200", 2)],
217            fingerprint: "abcdef1234567890".to_string(),
218            timestamp: 1700000000,
219            block_number: 42,
220            pow_difficulty: 0,
221        };
222        let json = serde_json::to_string(&snapshot).expect("serialize");
223        let back: TopologySnapshot = serde_json::from_str(&json).expect("deserialize");
224        assert_eq!(snapshot, back);
225    }
226
227    #[test]
228    fn test_layers_for_role_relay() {
229        assert_eq!(layers_for_role(1), &[0, 1]);
230    }
231
232    #[test]
233    fn test_layers_for_role_exit() {
234        assert_eq!(layers_for_role(2), &[0, 1, 2]);
235    }
236
237    #[test]
238    fn test_layers_for_role_full() {
239        assert_eq!(layers_for_role(3), &[0, 1, 2]);
240    }
241
242    #[test]
243    fn test_layers_for_role_unknown_defaults_to_all() {
244        assert_eq!(layers_for_role(0), &[0, 1, 2]);
245        assert_eq!(layers_for_role(255), &[0, 1, 2]);
246    }
247
248    #[test]
249    fn test_ingress_url_not_serialized_when_none() {
250        let node = make_node("0xabc", "100", 1);
251        let json = serde_json::to_string(&node).expect("serialize");
252        assert!(
253            !json.contains("ingress_url"),
254            "ingress_url should be skipped when None"
255        );
256    }
257}