Skip to main content

nym_api_requests/models/
network_monitor.rs

1// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::pagination::PaginatedResponse;
5use nym_crypto::asymmetric::ed25519;
6use nym_crypto::asymmetric::ed25519::serde_helpers::bs58_ed25519_pubkey;
7use nym_mixnet_contract_common::NodeId;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use utoipa::ToSchema;
12
13#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, Default, ToSchema)]
14pub struct TestNode {
15    pub node_id: Option<u32>,
16    pub identity_key: Option<String>,
17}
18
19#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)]
20pub struct TestRoute {
21    pub gateway: TestNode,
22    pub layer1: TestNode,
23    pub layer2: TestNode,
24    pub layer3: TestNode,
25}
26
27#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)]
28pub struct PartialTestResult {
29    pub monitor_run_id: i64,
30    pub timestamp: i64,
31    pub overall_reliability_for_all_routes_in_monitor_run: Option<u8>,
32    pub test_routes: TestRoute,
33}
34
35pub type MixnodeTestResultResponse = PaginatedResponse<PartialTestResult>;
36pub type GatewayTestResultResponse = PaginatedResponse<PartialTestResult>;
37
38#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)]
39pub struct NetworkMonitorRunDetailsResponse {
40    pub monitor_run_id: i64,
41    pub network_reliability: f64,
42    pub total_sent: usize,
43    pub total_received: usize,
44
45    // integer score to number of nodes with that score
46    pub mixnode_results: BTreeMap<u8, usize>,
47    pub gateway_results: BTreeMap<u8, usize>,
48}
49
50#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)]
51#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
52#[cfg_attr(
53    feature = "generate-ts",
54    ts(
55        export,
56        export_to = "ts-packages/types/src/types/rust/MixnodeCoreStatusResponse.ts"
57    )
58)]
59pub struct MixnodeCoreStatusResponse {
60    pub mix_id: NodeId,
61    pub count: i64,
62}
63
64#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)]
65#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
66#[cfg_attr(
67    feature = "generate-ts",
68    ts(
69        export,
70        export_to = "ts-packages/types/src/types/rust/GatewayCoreStatusResponse.ts"
71    )
72)]
73pub struct GatewayCoreStatusResponse {
74    pub identity: String,
75    pub count: i64,
76}
77
78/// Request/response types for the v3 network-monitor flow, in which an orchestrator submits
79/// stress testing results to nym-api via signed batches.
80pub mod v3 {
81    use super::*;
82    use crate::signable::SignedMessage;
83    use std::time::Duration;
84    use time::OffsetDateTime;
85
86    /// Signed envelope posted by a network monitor orchestrator to
87    /// `POST /v3/nym-nodes/stress-testing/batch-submit`.
88    ///
89    /// The signature is checked against the `signer` field of the inner
90    /// [`StressTestBatchSubmissionContent`], which must also match one of the orchestrators
91    /// registered in the network-monitors contract.
92    pub type StressTestBatchSubmission = SignedMessage<StressTestBatchSubmissionContent>;
93
94    /// Confirmation returned to an orchestrator after a successful submission.
95    /// Currently empty — exists to give the response an explicit type rather than
96    /// relying on `Json(())`.
97    #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
98    pub struct StressTestBatchSubmissionResponse {}
99
100    /// Single stress-test measurement for one node, produced by a network monitor orchestrator.
101    #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
102    pub struct StressTestResult {
103        /// Orchestrator-local id of the test run that produced this result. Combined with the
104        /// batch's `signer` it uniquely identifies the measurement, allowing nym-api to dedupe
105        /// retried submissions on the at-least-once delivery path.
106        pub testrun_id: i64,
107
108        /// Contract-assigned id of the node that was tested.
109        pub node_id: NodeId,
110
111        /// Whether the tested node was acting as a mixnode during the measurement.
112        ///
113        /// Included explicitly (rather than inferred from on-chain role) so the API can reject or
114        /// route entries that don't match the expected role without re-querying the contract.
115        pub is_mixnode: bool,
116
117        #[schema(value_type = String)]
118        #[serde(with = "time::serde::rfc3339")]
119        pub test_timestamp: OffsetDateTime,
120
121        /// Measured performance score in the `[0.0, 1.0]` range.
122        pub test_performance: f64,
123
124        /// Whether the node responded at all during testing.
125        ///
126        /// Recorded alongside `test_performance` so that a genuine 0.0 score (node responded but
127        /// dropped everything) can be distinguished from the node being offline entirely.
128        pub was_reachable: bool,
129    }
130
131    /// Body of a stress-test batch submission, signed by a network monitor orchestrator.
132    #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
133    pub struct StressTestBatchSubmissionContent {
134        /// ed25519 identity key of the submitting orchestrator. Must match an entry in the
135        /// network-monitors contract for the batch to be accepted.
136        #[schema(value_type = String)]
137        #[serde(with = "ed25519::bs58_ed25519_pubkey")]
138        pub signer: ed25519::PublicKey,
139
140        /// Time at which this batch was produced. Also used as a monotonic nonce for replay
141        /// protection: the API rejects submissions whose timestamp is not strictly greater than
142        /// the orchestrator's previous accepted submission.
143        #[schema(value_type = String)]
144        #[serde(with = "time::serde::rfc3339")]
145        pub timestamp: OffsetDateTime,
146
147        pub results: Vec<StressTestResult>,
148    }
149
150    impl StressTestBatchSubmissionContent {
151        /// Build a batch submission body stamped with the current UTC time.
152        pub fn new(signer: ed25519::PublicKey, results: Vec<StressTestResult>) -> Self {
153            StressTestBatchSubmissionContent {
154                signer,
155                timestamp: OffsetDateTime::now_utc(),
156                results,
157            }
158        }
159
160        /// Whether this submission is older than `max_age` relative to the current UTC time.
161        ///
162        /// Used server-side to reject submissions that have been sitting around too long, even if
163        /// they are otherwise well-formed and correctly signed.
164        pub fn is_stale(&self, max_age: Duration) -> bool {
165            self.timestamp + max_age < OffsetDateTime::now_utc()
166        }
167    }
168
169    /// Response body for `GET /v3/nym-nodes/stress-testing/known-monitors/{identity_key}`,
170    /// used by orchestrators to check whether this nym-api currently recognises their key.
171    #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
172    pub struct KnownNetworkMonitorResponse {
173        /// The ed25519 identity key that was queried (base58-encoded on the wire).
174        #[serde(with = "bs58_ed25519_pubkey")]
175        #[schema(value_type = String)]
176        pub identity_key: ed25519::PublicKey,
177
178        /// Whether the queried identity key is currently recognised by this nym-api
179        /// as an authorised network monitor permitted to submit stress testing results.
180        pub authorised: bool,
181    }
182
183    #[cfg(test)]
184    mod tests {
185        use super::*;
186        use crate::signable::SignableMessageBody;
187        use nym_test_utils::helpers::deterministic_rng;
188        use time::macros::datetime;
189
190        fn dummy_results() -> Vec<StressTestResult> {
191            // Order-distinguishable entries: if deserialisation ever permuted the array, the
192            // re-serialised body would no longer match the signed bytes, and `verify_signature`
193            // would return false. `testrun_id` is the order witness.
194            vec![
195                StressTestResult {
196                    testrun_id: 1,
197                    node_id: 42,
198                    is_mixnode: true,
199                    test_timestamp: datetime!(2026-06-01 12:34:56.123456789 UTC),
200                    test_performance: 0.6666666666666666,
201                    was_reachable: true,
202                },
203                StressTestResult {
204                    testrun_id: 2,
205                    node_id: 7,
206                    is_mixnode: true,
207                    test_timestamp: datetime!(2026-06-01 12:34:56 UTC),
208                    test_performance: 0.0,
209                    was_reachable: false,
210                },
211                StressTestResult {
212                    testrun_id: 3,
213                    node_id: u32::MAX,
214                    is_mixnode: true,
215                    test_timestamp: datetime!(2026-06-01 12:34:56.999999999 UTC),
216                    test_performance: 1.0,
217                    was_reachable: true,
218                },
219            ]
220        }
221
222        // Integrity check on the wire is `serde_json::to_vec(deserialize(serde_json::to_vec(body)))
223        // == serde_json::to_vec(body)`. If JSON serialisation isn't a fixed point, every batch
224        // submission would fail nym-api's signature verification. Cover the timestamp shapes the
225        // orchestrator actually produces, including the `+1ns` bump from the monotonicity safeguard.
226        #[test]
227        fn signed_batch_submission_roundtrips_through_json() {
228            let mut rng = deterministic_rng();
229            let keys = ed25519::KeyPair::new(&mut rng);
230
231            let timestamps = [
232                datetime!(2026-06-01 12:34:56 UTC),
233                datetime!(2026-06-01 12:34:56.000000001 UTC),
234                datetime!(2026-06-01 12:34:56.999999999 UTC),
235                datetime!(2026-06-01 12:34:56.123456789 UTC),
236                OffsetDateTime::now_utc(),
237                OffsetDateTime::now_utc() + time::Duration::NANOSECOND,
238            ];
239
240            for timestamp in timestamps {
241                let body = StressTestBatchSubmissionContent {
242                    signer: *keys.public_key(),
243                    timestamp,
244                    results: dummy_results(),
245                };
246                let signed = body.clone().sign(keys.private_key());
247
248                let bytes = serde_json::to_vec(&signed).unwrap();
249                let deserialised: StressTestBatchSubmission =
250                    serde_json::from_slice(&bytes).unwrap();
251
252                // The handler verifies against `body.body.signer` — match that exactly.
253                assert!(
254                    deserialised.verify_signature(&deserialised.body.signer),
255                    "signature failed to verify after JSON round-trip for timestamp {timestamp}",
256                );
257                assert_eq!(deserialised.body.timestamp, timestamp);
258            }
259        }
260
261        // Every f64 that the orchestrator's `received as f64 / sent as f64` formula can produce
262        // (storage/models.rs) must round-trip byte-exactly through JSON. Exhaustively cover the
263        // range and exercise sent values that produce non-terminating fractions (1/3, 1/7, ...).
264        #[test]
265        fn computed_test_performance_values_roundtrip() {
266            for sent in 1u64..=200 {
267                for received in 0u64..=(sent * 2) {
268                    let perf = received as f64 / sent as f64;
269                    let s = serde_json::to_string(&perf).unwrap();
270                    let perf2: f64 = serde_json::from_str(&s).unwrap();
271                    let s2 = serde_json::to_string(&perf2).unwrap();
272                    assert_eq!(
273                        s, s2,
274                        "f64 round-trip mismatch for {received}/{sent} = {perf}: {s} -> {s2}",
275                    );
276                }
277            }
278        }
279
280        // serde_json serialises non-finite f64 as `null`. Confirm what the deserialiser does with
281        // `null` for a struct field typed as f64 - if it succeeds with a default value (rather than
282        // erroring), a NaN/Infinity test_performance could silently break signature verification
283        // because the re-serialised body would no longer have `null` at that position.
284        #[test]
285        fn non_finite_test_performance_breaks_loudly_not_silently() {
286            let nan_result = StressTestResult {
287                testrun_id: 1,
288                node_id: 1,
289                is_mixnode: true,
290                test_timestamp: datetime!(2026-06-01 12:34:56 UTC),
291                test_performance: f64::NAN,
292                was_reachable: true,
293            };
294            let json = serde_json::to_string(&nan_result).unwrap();
295            // NaN serialises as `null` - this is the dangerous shape
296            assert!(
297                json.contains(r#""test_performance":null"#),
298                "expected NaN to serialise as null: {json}",
299            );
300            // ...and `null` MUST fail to deserialise rather than silently becoming 0.0 / default;
301            // if this ever changes, NaN would silently corrupt signature verification.
302            let deserialised: Result<StressTestResult, _> = serde_json::from_str(&json);
303            assert!(
304                deserialised.is_err(),
305                "deserialising null into f64 unexpectedly succeeded - signature verification \
306                 would silently fail for any submission containing a non-finite test_performance",
307            );
308        }
309
310        // Specifically pin the two hypotheses we want to rule out:
311        //   1. Vec<StressTestResult> serialisation/deserialisation preserves order.
312        //   2. The body bytes serialised standalone (= what gets signed) are byte-identical to
313        //      the body sub-object bytes embedded in the outer SignedMessage JSON (= what the
314        //      server sees after parsing). Re-serialising the deserialised body must reproduce
315        //      the signed bytes verbatim, otherwise no signature could ever verify.
316        #[test]
317        fn batch_body_serialisation_is_a_byte_exact_fixed_point() {
318            let mut rng = deterministic_rng();
319            let keys = ed25519::KeyPair::new(&mut rng);
320
321            let body = StressTestBatchSubmissionContent {
322                signer: *keys.public_key(),
323                timestamp: datetime!(2026-06-01 12:34:56.123456789 UTC),
324                results: dummy_results(),
325            };
326
327            let signed_bytes = body.plaintext();
328            let body_str = std::str::from_utf8(&signed_bytes).unwrap();
329
330            // (1) array order preserved on the wire
331            let pos1 = body_str.find(r#""testrun_id":1"#).unwrap();
332            let pos2 = body_str.find(r#""testrun_id":2"#).unwrap();
333            let pos3 = body_str.find(r#""testrun_id":3"#).unwrap();
334            assert!(pos1 < pos2 && pos2 < pos3, "JSON: {body_str}");
335
336            // (2) round-trip is byte-exact
337            let deserialised: StressTestBatchSubmissionContent =
338                serde_json::from_slice(&signed_bytes).unwrap();
339            let resigned_bytes = deserialised.plaintext();
340            assert_eq!(
341                signed_bytes, resigned_bytes,
342                "deserialise-then-re-serialise was not a fixed point"
343            );
344        }
345    }
346}