Skip to main content

nym_api_requests/models/described/
v2.rs

1// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::models::{
5    AuthenticatorDetailsV1, AuxiliaryDetailsV1, BinaryBuildInformationOwned, DeclaredRolesV1,
6    DescribedNodeTypeV1, HostInformationV1, HostKeysV1, IpPacketRouterDetailsV1,
7    LewesProtocolDetailsV1, NetworkRequesterDetailsV1, NymNodeDataV1, NymNodeDescriptionV1,
8    OffsetDateTimeJsonSchemaWrapper, SphinxKeyV1, WebSocketsV1, WireguardDetailsV1,
9};
10use crate::nym_nodes::{
11    BasicEntryInformation, NodeRole, SemiSkimmedNodeV1, SemiSkimmedNodeV3, SkimmedNodeV1,
12};
13use nym_crypto::asymmetric::{ed25519, x25519};
14use nym_mixnet_contract_common::reward_params::Performance;
15use nym_mixnet_contract_common::NodeId;
16use nym_network_defaults::{DEFAULT_MIX_LISTENING_PORT, DEFAULT_VERLOC_LISTENING_PORT};
17use nym_noise_keys::VersionedNoiseKeyV1;
18use serde::{Deserialize, Serialize};
19use tracing::warn;
20use utoipa::ToSchema;
21
22// no changes for the following types
23pub type HostInformationV2 = HostInformationV1;
24pub type DeclaredRolesV2 = DeclaredRolesV1;
25pub type AuxiliaryDetailsV2 = AuxiliaryDetailsV1;
26pub type NetworkRequesterDetailsV2 = NetworkRequesterDetailsV1;
27pub type IpPacketRouterDetailsV2 = IpPacketRouterDetailsV1;
28pub type AuthenticatorDetailsV2 = AuthenticatorDetailsV1;
29pub type WireguardDetailsV2 = WireguardDetailsV1;
30pub type WebSocketsV2 = WebSocketsV1;
31pub type DescribedNodeTypeV2 = DescribedNodeTypeV1;
32pub type HostKeysV2 = HostKeysV1;
33pub type SphinxKeyV2 = SphinxKeyV1;
34pub type VersionedNoiseKeyV2 = VersionedNoiseKeyV1;
35
36// to whoever is thinking of modifying this struct.
37// you MUST NOT change its structure in any way - adding, removing or changing fields
38// otherwise, it will break old clients as bincode serialisation is not backwards compatible
39// even if you put `#[serde(default)]` all over the place
40#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)]
41pub struct NymNodeDescriptionV2 {
42    #[schema(value_type = u32)]
43    pub node_id: NodeId,
44    pub contract_node_type: DescribedNodeTypeV2,
45    pub description: NymNodeDataV2,
46}
47
48impl NymNodeDescriptionV2 {
49    pub fn version(&self) -> &str {
50        &self.description.build_information.build_version
51    }
52
53    pub fn entry_information(&self) -> BasicEntryInformation {
54        BasicEntryInformation {
55            hostname: self.description.host_information.hostname.clone(),
56            ws_port: self.description.mixnet_websockets.ws_port,
57            wss_port: self.description.mixnet_websockets.wss_port,
58        }
59    }
60
61    pub fn ed25519_identity_key(&self) -> ed25519::PublicKey {
62        self.description.host_information.keys.ed25519
63    }
64
65    pub fn current_sphinx_key(&self, current_rotation_id: u32) -> x25519::PublicKey {
66        let keys = &self.description.host_information.keys;
67
68        if keys.current_x25519_sphinx_key.rotation_id == u32::MAX {
69            // legacy case (i.e. node doesn't support rotation)
70            return keys.current_x25519_sphinx_key.public_key;
71        }
72
73        if current_rotation_id == keys.current_x25519_sphinx_key.rotation_id {
74            // it's the 'current' key
75            return keys.current_x25519_sphinx_key.public_key;
76        }
77
78        if let Some(pre_announced) = &keys.pre_announced_x25519_sphinx_key {
79            if pre_announced.rotation_id == current_rotation_id {
80                return pre_announced.public_key;
81            }
82        }
83
84        warn!(
85            "unexpected key rotation {current_rotation_id} for node {}",
86            self.node_id
87        );
88        // this should never be reached, but just in case, return the fallback option
89        keys.current_x25519_sphinx_key.public_key
90    }
91
92    pub fn to_skimmed_node(
93        &self,
94        current_rotation_id: u32,
95        role: NodeRole,
96        performance: Performance,
97    ) -> SkimmedNodeV1 {
98        let keys = &self.description.host_information.keys;
99        let entry = if self.description.declared_role.entry {
100            Some(self.entry_information())
101        } else {
102            None
103        };
104
105        SkimmedNodeV1 {
106            node_id: self.node_id,
107            ed25519_identity_pubkey: keys.ed25519,
108            ip_addresses: self.description.host_information.ip_address.clone(),
109            mix_port: self.description.mix_port(),
110            x25519_sphinx_pubkey: self.current_sphinx_key(current_rotation_id),
111            // we can't use the declared roles, we have to take whatever was provided in the contract.
112            // why? say this node COULD operate as an exit, but it might be the case the contract decided
113            // to assign it an ENTRY role only. we have to use that one instead.
114            role,
115            supported_roles: self.description.declared_role,
116            entry,
117            performance,
118        }
119    }
120
121    pub fn to_semi_skimmed_node(
122        &self,
123        current_rotation_id: u32,
124        role: NodeRole,
125        performance: Performance,
126    ) -> SemiSkimmedNodeV1 {
127        let skimmed_node = self.to_skimmed_node(current_rotation_id, role, performance);
128
129        SemiSkimmedNodeV1 {
130            basic: skimmed_node,
131            x25519_noise_versioned_key: self
132                .description
133                .host_information
134                .keys
135                .x25519_versioned_noise,
136        }
137    }
138
139    pub fn to_semi_skimmed_node_v3(
140        &self,
141        current_rotation_id: u32,
142        role: NodeRole,
143        performance: Performance,
144    ) -> SemiSkimmedNodeV3 {
145        let skimmed_node = self.to_skimmed_node(current_rotation_id, role, performance);
146
147        SemiSkimmedNodeV3 {
148            basic: skimmed_node,
149            noise_key: self
150                .description
151                .host_information
152                .keys
153                .x25519_versioned_noise,
154            build_version: self.description.build_information.build_version.clone(),
155            lp: self.description.lewes_protocol.clone(),
156        }
157    }
158}
159
160// to whoever is thinking of modifying this struct.
161// you MUST NOT change its structure in any way - adding, removing or changing fields
162// otherwise, it will break old clients as bincode serialisation is not backwards compatible
163// even if you put `#[serde(default)]` all over the place
164#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)]
165pub struct NymNodeDataV2 {
166    #[serde(default)]
167    pub last_polled: OffsetDateTimeJsonSchemaWrapper,
168
169    pub host_information: HostInformationV2,
170
171    #[serde(default)]
172    pub declared_role: DeclaredRolesV2,
173
174    #[serde(default)]
175    pub auxiliary_details: AuxiliaryDetailsV2,
176
177    // TODO: do we really care about ALL build info or just the version?
178    pub build_information: BinaryBuildInformationOwned,
179
180    #[serde(default)]
181    pub network_requester: Option<NetworkRequesterDetailsV2>,
182
183    #[serde(default)]
184    pub ip_packet_router: Option<IpPacketRouterDetailsV2>,
185
186    #[serde(default)]
187    pub authenticator: Option<AuthenticatorDetailsV2>,
188
189    #[serde(default)]
190    pub wireguard: Option<WireguardDetailsV2>,
191
192    // for now we only care about their ws/wss situation, nothing more
193    pub mixnet_websockets: WebSocketsV2,
194
195    #[serde(default)]
196    pub lewes_protocol: Option<LewesProtocolDetailsV1>,
197}
198
199impl NymNodeDataV2 {
200    pub fn mix_port(&self) -> u16 {
201        self.auxiliary_details
202            .announce_ports
203            .mix_port
204            .unwrap_or(DEFAULT_MIX_LISTENING_PORT)
205    }
206
207    pub fn verloc_port(&self) -> u16 {
208        self.auxiliary_details
209            .announce_ports
210            .verloc_port
211            .unwrap_or(DEFAULT_VERLOC_LISTENING_PORT)
212    }
213}
214
215impl From<NymNodeDataV2> for NymNodeDataV1 {
216    fn from(data: NymNodeDataV2) -> Self {
217        NymNodeDataV1 {
218            last_polled: data.last_polled,
219            host_information: data.host_information,
220            declared_role: data.declared_role,
221            auxiliary_details: data.auxiliary_details,
222            build_information: data.build_information,
223            network_requester: data.network_requester,
224            ip_packet_router: data.ip_packet_router,
225            authenticator: data.authenticator,
226            wireguard: data.wireguard,
227            mixnet_websockets: data.mixnet_websockets,
228        }
229    }
230}
231
232impl From<NymNodeDataV1> for NymNodeDataV2 {
233    fn from(data: NymNodeDataV1) -> Self {
234        NymNodeDataV2 {
235            last_polled: data.last_polled,
236            host_information: data.host_information,
237            declared_role: data.declared_role,
238            auxiliary_details: data.auxiliary_details,
239            build_information: data.build_information,
240            network_requester: data.network_requester,
241            ip_packet_router: data.ip_packet_router,
242            authenticator: data.authenticator,
243            wireguard: data.wireguard,
244            mixnet_websockets: data.mixnet_websockets,
245            lewes_protocol: Default::default(),
246        }
247    }
248}
249
250impl From<NymNodeDescriptionV2> for NymNodeDescriptionV1 {
251    fn from(value: NymNodeDescriptionV2) -> Self {
252        NymNodeDescriptionV1 {
253            node_id: value.node_id,
254            contract_node_type: value.contract_node_type,
255            description: value.description.into(),
256        }
257    }
258}
259
260impl From<NymNodeDescriptionV1> for NymNodeDescriptionV2 {
261    fn from(value: NymNodeDescriptionV1) -> Self {
262        NymNodeDescriptionV2 {
263            node_id: value.node_id,
264            contract_node_type: value.contract_node_type,
265            description: value.description.into(),
266        }
267    }
268}
269
270#[cfg(any(test, feature = "mock-fixtures"))]
271pub fn mock_nym_node_description(seed: u64) -> NymNodeDescriptionV2 {
272    use nym_node_requests::api::v1::lewes_protocol::models::{LPHashFunction, LPKEM};
273    use nym_test_utils::helpers::{u64_seeded_rng, RngCore};
274
275    let mut rng = u64_seeded_rng(seed);
276
277    let ed25519 = ed25519::KeyPair::new(&mut rng);
278
279    // just reuse the same x25519 key for everything - this is just a data mock
280    let x25519 = x25519::KeyPair::new(&mut rng);
281
282    let mut dummy_kems = std::collections::BTreeMap::new();
283    for kem in [LPKEM::McEliece, LPKEM::McEliece] {
284        let mut kem_digests = std::collections::BTreeMap::new();
285        for (i, sf) in [
286            LPHashFunction::Blake3,
287            LPHashFunction::Shake128,
288            LPHashFunction::Shake256,
289            LPHashFunction::Sha256,
290        ]
291        .iter()
292        .enumerate()
293        {
294            kem_digests.insert(*sf, hex::encode([((seed + i as u64) % 256) as u8; 32]));
295        }
296        dummy_kems.insert(kem, kem_digests);
297    }
298
299    // make sure the serialisation stays the same and signature is still valid
300    let dummy_lp = nym_node_requests::api::v1::lewes_protocol::models::LewesProtocol {
301        enabled: false,
302        control_port: 123,
303        data_port: 345,
304        x25519: (*x25519.public_key()).into(),
305        kem_keys: dummy_kems,
306    };
307    let dummy_signed_lp =
308        nym_node_requests::api::SignedLewesProtocol::new(dummy_lp, ed25519.private_key()).unwrap();
309
310    NymNodeDescriptionV2 {
311        node_id: rng.next_u32(),
312        contract_node_type: DescribedNodeTypeV1::NymNode,
313        description: NymNodeDataV2 {
314            last_polled: time::OffsetDateTime::from_unix_timestamp(1767225600)
315                .unwrap()
316                .into(),
317            host_information: HostInformationV2 {
318                ip_address: vec![
319                    std::net::IpAddr::V4(std::net::Ipv4Addr::new(1, 2, 3, (seed % 255) as u8)),
320                ],
321                hostname: Some(format!("my-awesome-node-{seed}.com")),
322                keys: HostKeysV2 {
323                    ed25519: *ed25519.public_key(),
324                    x25519: *x25519.public_key(),
325                    current_x25519_sphinx_key: SphinxKeyV2 {
326                        rotation_id: 123,
327                        public_key: *x25519.public_key(),
328                    },
329                    pre_announced_x25519_sphinx_key: None,
330                    x25519_versioned_noise: Some(VersionedNoiseKeyV2 {
331                        supported_version: nym_noise_keys::NoiseVersion::V1,
332                        x25519_pubkey: *x25519.public_key(),
333                    }),
334                },
335            },
336            declared_role: DeclaredRolesV2 {
337                mixnode: false,
338                entry: true,
339                exit_nr: true,
340                exit_ipr: true,
341            },
342            auxiliary_details: AuxiliaryDetailsV2 {
343                location: Some(celes::Country::switzerland()),
344                announce_ports: Default::default(),
345                accepted_operator_terms_and_conditions: true,
346            },
347            build_information: BinaryBuildInformationOwned {
348                binary_name: "dummy-node".to_string(),
349                build_timestamp: "2021-02-23T20:14:46.558472672+00:00".to_string(),
350                build_version: "0.1.0-9-g46f83e1".to_string(),
351                commit_sha: "46f83e112520533338245862d366f6a02cef07d4".to_string(),
352                commit_timestamp: "2021-02-23T08:08:02-05:00".to_string(),
353                commit_branch: "master".to_string(),
354                rustc_version: "1.52.0-nightly".to_string(),
355                rustc_channel: "nightly".to_string(),
356                cargo_profile: "release".to_string(),
357                cargo_triple: "wasm32-unknown-unknown".to_string(),
358            },
359            network_requester: Some(NetworkRequesterDetailsV2 {
360                address: "FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve".to_string(),
361                uses_exit_policy: true,
362            }),
363            ip_packet_router: Some(IpPacketRouterDetailsV2 {
364                address: "FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve".to_string(),
365            }),
366            authenticator: Some(AuthenticatorDetailsV2 {
367                address: "FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve".to_string(),
368            }),
369            wireguard: Some(WireguardDetailsV2 {
370                port: 123,
371                tunnel_port: 234,
372                metadata_port: 456,
373                public_key: x25519.public_key().to_base58_string(),
374            }),
375            lewes_protocol: Some(dummy_signed_lp.into()),
376            mixnet_websockets: WebSocketsV2 {
377                ws_port: 9000,
378                wss_port: None,
379            },
380        },
381    }
382}