Skip to main content

nym_api_requests/models/described/
v3.rs

1// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
2// SPDX-License-Identifier: GPL-3.0-only
3
4use crate::models::described::type_translation::LewesProtocolDetailsV1;
5use crate::models::described::v1::NymNodeDescriptionV1;
6use crate::models::described::v2::{
7    AnnouncePortsV2, AuthenticatorDetailsV2, DeclaredRolesV2, DescribedNodeTypeV2,
8    HostInformationV2, HostKeysV2, IpPacketRouterDetailsV2, NetworkRequesterDetailsV2,
9    NymNodeAuxiliaryDetailsV2, NymNodeDataV2, NymNodeDescriptionV2, SphinxKeyV2,
10    VersionedNoiseKeyV2, WebSocketsV2, WireguardDetailsV2,
11};
12use crate::models::{BinaryBuildInformationOwned, OffsetDateTimeJsonSchemaWrapper};
13use crate::nym_nodes::{
14    BasicEntryInformation, NodeRole, SemiSkimmedNodeV1, SemiSkimmedNodeV3, SkimmedNodeV1,
15};
16use celes::Country;
17use nym_crypto::asymmetric::{ed25519, x25519};
18use nym_mixnet_contract_common::reward_params::Performance;
19use nym_mixnet_contract_common::NodeId;
20use nym_network_defaults::{DEFAULT_MIX_LISTENING_PORT, DEFAULT_VERLOC_LISTENING_PORT};
21use serde::{Deserialize, Serialize};
22use tracing::warn;
23use utoipa::ToSchema;
24
25// no changes for the following types
26pub type AnnouncePortsV3 = AnnouncePortsV2;
27pub type HostInformationV3 = HostInformationV2;
28pub type DeclaredRolesV3 = DeclaredRolesV2;
29pub type NetworkRequesterDetailsV3 = NetworkRequesterDetailsV2;
30pub type IpPacketRouterDetailsV3 = IpPacketRouterDetailsV2;
31pub type AuthenticatorDetailsV3 = AuthenticatorDetailsV2;
32pub type WireguardDetailsV3 = WireguardDetailsV2;
33pub type WebSocketsV3 = WebSocketsV2;
34pub type DescribedNodeTypeV3 = DescribedNodeTypeV2;
35pub type HostKeysV3 = HostKeysV2;
36pub type SphinxKeyV3 = SphinxKeyV2;
37pub type VersionedNoiseKeyV3 = VersionedNoiseKeyV2;
38pub type LewesProtocolDetailsV3 = LewesProtocolDetailsV1;
39
40#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)]
41pub struct NymNodeDescriptionV3 {
42    #[schema(value_type = u32)]
43    pub node_id: NodeId,
44    pub contract_node_type: DescribedNodeTypeV3,
45    pub description: NymNodeDataV3,
46}
47
48impl NymNodeDescriptionV3 {
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
160impl From<NymNodeDescriptionV3> for NymNodeDescriptionV2 {
161    fn from(value: NymNodeDescriptionV3) -> Self {
162        NymNodeDescriptionV2 {
163            node_id: value.node_id,
164            contract_node_type: value.contract_node_type,
165            description: value.description.into(),
166        }
167    }
168}
169
170impl From<NymNodeDescriptionV3> for NymNodeDescriptionV1 {
171    fn from(value: NymNodeDescriptionV3) -> Self {
172        NymNodeDescriptionV1 {
173            node_id: value.node_id,
174            contract_node_type: value.contract_node_type,
175            description: NymNodeDataV2::from(value.description).into(),
176        }
177    }
178}
179
180#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)]
181pub struct NymNodeDataV3 {
182    #[serde(default)]
183    pub last_polled: OffsetDateTimeJsonSchemaWrapper,
184
185    pub host_information: HostInformationV3,
186
187    #[serde(default)]
188    pub declared_role: DeclaredRolesV3,
189
190    #[serde(default)]
191    pub auxiliary_details: NymNodeAuxiliaryDetailsV3,
192
193    // TODO: do we really care about ALL build info or just the version?
194    pub build_information: BinaryBuildInformationOwned,
195
196    #[serde(default)]
197    pub network_requester: Option<NetworkRequesterDetailsV3>,
198
199    #[serde(default)]
200    pub ip_packet_router: Option<IpPacketRouterDetailsV3>,
201
202    #[serde(default)]
203    pub authenticator: Option<AuthenticatorDetailsV3>,
204
205    #[serde(default)]
206    pub wireguard: Option<WireguardDetailsV3>,
207
208    // for now we only care about their ws/wss situation, nothing more
209    pub mixnet_websockets: WebSocketsV3,
210
211    #[serde(default)]
212    pub lewes_protocol: Option<LewesProtocolDetailsV3>,
213}
214
215impl NymNodeDataV3 {
216    pub fn mix_port(&self) -> u16 {
217        self.auxiliary_details
218            .announce_ports
219            .mix_port
220            .unwrap_or(DEFAULT_MIX_LISTENING_PORT)
221    }
222
223    pub fn verloc_port(&self) -> u16 {
224        self.auxiliary_details
225            .announce_ports
226            .verloc_port
227            .unwrap_or(DEFAULT_VERLOC_LISTENING_PORT)
228    }
229}
230
231impl From<NymNodeDataV3> for NymNodeDataV2 {
232    fn from(data: NymNodeDataV3) -> Self {
233        NymNodeDataV2 {
234            last_polled: data.last_polled,
235            host_information: data.host_information,
236            declared_role: data.declared_role,
237            auxiliary_details: data.auxiliary_details.into(),
238            build_information: data.build_information,
239            network_requester: data.network_requester,
240            ip_packet_router: data.ip_packet_router,
241            authenticator: data.authenticator,
242            wireguard: data.wireguard,
243            mixnet_websockets: data.mixnet_websockets,
244            lewes_protocol: data.lewes_protocol,
245        }
246    }
247}
248
249#[derive(
250    Clone, Debug, Default, Serialize, Deserialize, schemars::JsonSchema, ToSchema, PartialEq,
251)]
252pub struct NymNodeAuxiliaryDetailsV3 {
253    /// Optional ISO 3166 alpha-2 two-letter country code of the node's **physical** location
254    #[schema(example = "PL", value_type = Option<String>)]
255    #[schemars(with = "Option<String>")]
256    #[schemars(length(equal = 2))]
257    pub location: Option<Country>,
258
259    /// On-chain address of this node
260    #[serde(default)]
261    pub address: Option<String>,
262
263    #[serde(default)]
264    pub announce_ports: AnnouncePortsV3,
265
266    /// Specifies whether this node operator has agreed to the terms and conditions
267    /// as defined at <https://nymtech.net/terms-and-conditions/operators/v1.0.0>
268    // make sure to include the default deserialisation as this field hasn't existed when the struct was first created
269    #[serde(default)]
270    pub accepted_operator_terms_and_conditions: bool,
271}
272
273impl From<NymNodeAuxiliaryDetailsV3> for NymNodeAuxiliaryDetailsV2 {
274    fn from(value: NymNodeAuxiliaryDetailsV3) -> Self {
275        NymNodeAuxiliaryDetailsV2 {
276            location: value.location,
277            announce_ports: value.announce_ports,
278            accepted_operator_terms_and_conditions: value.accepted_operator_terms_and_conditions,
279        }
280    }
281}
282
283#[cfg(any(test, feature = "mock-fixtures"))]
284pub fn mock_nym_node_description(seed: u64) -> NymNodeDescriptionV3 {
285    use nym_node_requests::api::v1::lewes_protocol::models::{LPHashFunction, LPKEM};
286    use nym_test_utils::helpers::{u64_seeded_rng, RngCore};
287
288    let mut rng = u64_seeded_rng(seed);
289
290    let ed25519 = nym_crypto::asymmetric::ed25519::KeyPair::new(&mut rng);
291
292    // just reuse the same x25519 key for everything - this is just a data mock
293    let x25519 = nym_crypto::asymmetric::x25519::KeyPair::new(&mut rng);
294
295    let mut dummy_kems = std::collections::BTreeMap::new();
296    for kem in [LPKEM::McEliece, LPKEM::McEliece] {
297        let mut kem_digests = std::collections::BTreeMap::new();
298        for (i, sf) in [
299            LPHashFunction::Blake3,
300            LPHashFunction::Shake128,
301            LPHashFunction::Shake256,
302            LPHashFunction::Sha256,
303        ]
304        .iter()
305        .enumerate()
306        {
307            kem_digests.insert(*sf, hex::encode([((seed + i as u64) % 256) as u8; 32]));
308        }
309        dummy_kems.insert(kem, kem_digests);
310    }
311
312    // make sure the serialisation stays the same and signature is still valid
313    let dummy_lp = nym_node_requests::api::v1::lewes_protocol::models::LewesProtocol {
314        enabled: false,
315        control_port: 123,
316        data_port: 345,
317        x25519: (*x25519.public_key()).into(),
318        kem_keys: dummy_kems,
319    };
320    let dummy_signed_lp =
321        nym_node_requests::api::SignedLewesProtocol::new(dummy_lp, ed25519.private_key()).unwrap();
322
323    NymNodeDescriptionV3 {
324        node_id: rng.next_u32(),
325        contract_node_type: DescribedNodeTypeV3::NymNode,
326        description: NymNodeDataV3 {
327            last_polled: time::OffsetDateTime::from_unix_timestamp(1767225600)
328                .unwrap()
329                .into(),
330            host_information: HostInformationV3 {
331                ip_address: vec![
332                    std::net::IpAddr::V4(std::net::Ipv4Addr::new(1, 2, 3, (seed % 255) as u8)),
333                ],
334                hostname: Some(format!("my-awesome-node-{seed}.com")),
335                keys: HostKeysV3 {
336                    ed25519: *ed25519.public_key(),
337                    x25519: *x25519.public_key(),
338                    current_x25519_sphinx_key: SphinxKeyV3 {
339                        rotation_id: 123,
340                        public_key: *x25519.public_key(),
341                    },
342                    pre_announced_x25519_sphinx_key: None,
343                    x25519_versioned_noise: Some(VersionedNoiseKeyV3 {
344                        supported_version: nym_noise_keys::NoiseVersion::V1,
345                        x25519_pubkey: *x25519.public_key(),
346                    }),
347                },
348            },
349            declared_role: DeclaredRolesV3 {
350                mixnode: false,
351                entry: true,
352                exit_nr: true,
353                exit_ipr: true,
354            },
355            auxiliary_details: NymNodeAuxiliaryDetailsV3 {
356                location: Some(celes::Country::switzerland()),
357                address: Some("n1jw6mp7d5xqc7w6xm79lha27glmd0vdt3l9artf".to_string()),
358                announce_ports: Default::default(),
359                accepted_operator_terms_and_conditions: true,
360            },
361            build_information: BinaryBuildInformationOwned {
362                binary_name: "dummy-node".to_string(),
363                build_timestamp: "2021-02-23T20:14:46.558472672+00:00".to_string(),
364                build_version: "0.1.0-9-g46f83e1".to_string(),
365                commit_sha: "46f83e112520533338245862d366f6a02cef07d4".to_string(),
366                commit_timestamp: "2021-02-23T08:08:02-05:00".to_string(),
367                commit_branch: "master".to_string(),
368                rustc_version: "1.52.0-nightly".to_string(),
369                rustc_channel: "nightly".to_string(),
370                cargo_profile: "release".to_string(),
371                cargo_triple: "wasm32-unknown-unknown".to_string(),
372            },
373            network_requester: Some(NetworkRequesterDetailsV3 {
374                address: "FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve".to_string(),
375                uses_exit_policy: true,
376            }),
377            ip_packet_router: Some(IpPacketRouterDetailsV3 {
378                address: "FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve".to_string(),
379            }),
380            authenticator: Some(AuthenticatorDetailsV3 {
381                address: "FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve".to_string(),
382            }),
383            wireguard: Some(WireguardDetailsV3 {
384                port: 123,
385                tunnel_port: 234,
386                metadata_port: 456,
387                public_key: x25519.public_key().to_base58_string(),
388            }),
389            lewes_protocol: Some(dummy_signed_lp.into()),
390            mixnet_websockets: WebSocketsV3 {
391                ws_port: 9000,
392                wss_port: None,
393            },
394        },
395    }
396}