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, reporting what
95    /// became of the individual results. The three counts sum to the number of results submitted.
96    ///
97    /// Reporting these matters because an accepted batch can still store nothing: rows deduplicate
98    /// at the database with insert-or-ignore semantics, so without a count the submitter cannot
99    /// distinguish "stored" from "silently discarded" - both are a `200`.
100    ///
101    /// Every field is optional so that a newer orchestrator can still read the empty body returned
102    /// by a nym-api predating these counts. `None` therefore means "not reported", which is a
103    /// different signal from `Some(0)`.
104    #[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
105    pub struct StressTestBatchSubmissionResponse {
106        /// Results newly stored by this submission.
107        #[serde(default)]
108        pub accepted: Option<usize>,
109
110        /// Results that were already stored, i.e. this submission re-sent a measurement nym-api had
111        /// seen before. Expected to be non-zero on the orchestrator's at-least-once retry path; a
112        /// persistently non-zero value means measurements are being discarded.
113        #[serde(default)]
114        pub duplicates: Option<usize>,
115
116        /// Results dropped by per-entry validation (a non-mixnode entry, or a performance score
117        /// outside `[0.0, 1.0]`).
118        #[serde(default)]
119        pub rejected: Option<usize>,
120    }
121
122    /// Single stress-test measurement for one node, produced by a network monitor orchestrator.
123    #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
124    pub struct StressTestResult {
125        /// Orchestrator-local id of the test run that produced this result. Combined with the
126        /// batch's `signer` it uniquely identifies the measurement, allowing nym-api to dedupe
127        /// retried submissions on the at-least-once delivery path.
128        pub testrun_id: i64,
129
130        /// Contract-assigned id of the node that was tested.
131        pub node_id: NodeId,
132
133        /// Whether the tested node was acting as a mixnode during the measurement.
134        ///
135        /// Included explicitly (rather than inferred from on-chain role) so the API can reject or
136        /// route entries that don't match the expected role without re-querying the contract.
137        pub is_mixnode: bool,
138
139        #[schema(value_type = String)]
140        #[serde(with = "time::serde::rfc3339")]
141        pub test_timestamp: OffsetDateTime,
142
143        /// Measured performance score in the `[0.0, 1.0]` range.
144        pub test_performance: f64,
145
146        /// Whether the node responded at all during testing.
147        ///
148        /// Recorded alongside `test_performance` so that a genuine 0.0 score (node responded but
149        /// dropped everything) can be distinguished from the node being offline entirely.
150        pub was_reachable: bool,
151    }
152
153    /// Body of a stress-test batch submission, signed by a network monitor orchestrator.
154    #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
155    pub struct StressTestBatchSubmissionContent {
156        /// ed25519 identity key of the submitting orchestrator. Must match an entry in the
157        /// network-monitors contract for the batch to be accepted.
158        #[schema(value_type = String)]
159        #[serde(with = "ed25519::bs58_ed25519_pubkey")]
160        pub signer: ed25519::PublicKey,
161
162        /// Time at which this batch was produced. Also used as a monotonic nonce for replay
163        /// protection: the API rejects submissions whose timestamp is not strictly greater than
164        /// the orchestrator's previous accepted submission.
165        #[schema(value_type = String)]
166        #[serde(with = "time::serde::rfc3339")]
167        pub timestamp: OffsetDateTime,
168
169        pub results: Vec<StressTestResult>,
170    }
171
172    impl StressTestBatchSubmissionContent {
173        /// Build a batch submission body stamped with the current UTC time.
174        pub fn new(signer: ed25519::PublicKey, results: Vec<StressTestResult>) -> Self {
175            StressTestBatchSubmissionContent {
176                signer,
177                timestamp: OffsetDateTime::now_utc(),
178                results,
179            }
180        }
181
182        /// Whether this submission is older than `max_age` relative to the current UTC time.
183        ///
184        /// Used server-side to reject submissions that have been sitting around too long, even if
185        /// they are otherwise well-formed and correctly signed.
186        pub fn is_stale(&self, max_age: Duration) -> bool {
187            self.timestamp + max_age < OffsetDateTime::now_utc()
188        }
189    }
190
191    /// Response body for `GET /v3/nym-nodes/stress-testing/known-monitors/{identity_key}`,
192    /// used by orchestrators to check whether this nym-api currently recognises their key.
193    #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
194    pub struct KnownNetworkMonitorResponse {
195        /// The ed25519 identity key that was queried (base58-encoded on the wire).
196        #[serde(with = "bs58_ed25519_pubkey")]
197        #[schema(value_type = String)]
198        pub identity_key: ed25519::PublicKey,
199
200        /// Whether the queried identity key is currently recognised by this nym-api
201        /// as an authorised network monitor permitted to submit stress testing results.
202        pub authorised: bool,
203    }
204
205    #[cfg(test)]
206    mod tests {
207        use super::*;
208        use crate::signable::SignableMessageBody;
209        use nym_test_utils::helpers::deterministic_rng;
210        use time::macros::datetime;
211
212        fn dummy_results() -> Vec<StressTestResult> {
213            // Order-distinguishable entries: if deserialisation ever permuted the array, the
214            // re-serialised body would no longer match the signed bytes, and `verify_signature`
215            // would return false. `testrun_id` is the order witness.
216            vec![
217                StressTestResult {
218                    testrun_id: 1,
219                    node_id: 42,
220                    is_mixnode: true,
221                    test_timestamp: datetime!(2026-06-01 12:34:56.123456789 UTC),
222                    test_performance: 0.6666666666666666,
223                    was_reachable: true,
224                },
225                StressTestResult {
226                    testrun_id: 2,
227                    node_id: 7,
228                    is_mixnode: true,
229                    test_timestamp: datetime!(2026-06-01 12:34:56 UTC),
230                    test_performance: 0.0,
231                    was_reachable: false,
232                },
233                StressTestResult {
234                    testrun_id: 3,
235                    node_id: u32::MAX,
236                    is_mixnode: true,
237                    test_timestamp: datetime!(2026-06-01 12:34:56.999999999 UTC),
238                    test_performance: 1.0,
239                    was_reachable: true,
240                },
241            ]
242        }
243
244        // Integrity check on the wire is `serde_json::to_vec(deserialize(serde_json::to_vec(body)))
245        // == serde_json::to_vec(body)`. If JSON serialisation isn't a fixed point, every batch
246        // submission would fail nym-api's signature verification. Cover the timestamp shapes the
247        // orchestrator actually produces, including the `+1ns` bump from the monotonicity safeguard.
248        #[test]
249        fn signed_batch_submission_roundtrips_through_json() {
250            let mut rng = deterministic_rng();
251            let keys = ed25519::KeyPair::new(&mut rng);
252
253            let timestamps = [
254                datetime!(2026-06-01 12:34:56 UTC),
255                datetime!(2026-06-01 12:34:56.000000001 UTC),
256                datetime!(2026-06-01 12:34:56.999999999 UTC),
257                datetime!(2026-06-01 12:34:56.123456789 UTC),
258                OffsetDateTime::now_utc(),
259                OffsetDateTime::now_utc() + time::Duration::NANOSECOND,
260            ];
261
262            for timestamp in timestamps {
263                let body = StressTestBatchSubmissionContent {
264                    signer: *keys.public_key(),
265                    timestamp,
266                    results: dummy_results(),
267                };
268                let signed = body.clone().sign(keys.private_key());
269
270                let bytes = serde_json::to_vec(&signed).unwrap();
271                let deserialised: StressTestBatchSubmission =
272                    serde_json::from_slice(&bytes).unwrap();
273
274                // The handler verifies against `body.body.signer` — match that exactly.
275                assert!(
276                    deserialised.verify_signature(&deserialised.body.signer),
277                    "signature failed to verify after JSON round-trip for timestamp {timestamp}",
278                );
279                assert_eq!(deserialised.body.timestamp, timestamp);
280            }
281        }
282
283        // Every f64 that the orchestrator's `received as f64 / sent as f64` formula can produce
284        // (storage/models.rs) must round-trip byte-exactly through JSON. Exhaustively cover the
285        // range and exercise sent values that produce non-terminating fractions (1/3, 1/7, ...).
286        #[test]
287        fn computed_test_performance_values_roundtrip() {
288            for sent in 1u64..=200 {
289                for received in 0u64..=(sent * 2) {
290                    let perf = received as f64 / sent as f64;
291                    let s = serde_json::to_string(&perf).unwrap();
292                    let perf2: f64 = serde_json::from_str(&s).unwrap();
293                    let s2 = serde_json::to_string(&perf2).unwrap();
294                    assert_eq!(
295                        s, s2,
296                        "f64 round-trip mismatch for {received}/{sent} = {perf}: {s} -> {s2}",
297                    );
298                }
299            }
300        }
301
302        // serde_json serialises non-finite f64 as `null`. Confirm what the deserialiser does with
303        // `null` for a struct field typed as f64 - if it succeeds with a default value (rather than
304        // erroring), a NaN/Infinity test_performance could silently break signature verification
305        // because the re-serialised body would no longer have `null` at that position.
306        #[test]
307        fn non_finite_test_performance_breaks_loudly_not_silently() {
308            let nan_result = StressTestResult {
309                testrun_id: 1,
310                node_id: 1,
311                is_mixnode: true,
312                test_timestamp: datetime!(2026-06-01 12:34:56 UTC),
313                test_performance: f64::NAN,
314                was_reachable: true,
315            };
316            let json = serde_json::to_string(&nan_result).unwrap();
317            // NaN serialises as `null` - this is the dangerous shape
318            assert!(
319                json.contains(r#""test_performance":null"#),
320                "expected NaN to serialise as null: {json}",
321            );
322            // ...and `null` MUST fail to deserialise rather than silently becoming 0.0 / default;
323            // if this ever changes, NaN would silently corrupt signature verification.
324            let deserialised: Result<StressTestResult, _> = serde_json::from_str(&json);
325            assert!(
326                deserialised.is_err(),
327                "deserialising null into f64 unexpectedly succeeded - signature verification \
328                 would silently fail for any submission containing a non-finite test_performance",
329            );
330        }
331
332        // Specifically pin the two hypotheses we want to rule out:
333        //   1. Vec<StressTestResult> serialisation/deserialisation preserves order.
334        //   2. The body bytes serialised standalone (= what gets signed) are byte-identical to
335        //      the body sub-object bytes embedded in the outer SignedMessage JSON (= what the
336        //      server sees after parsing). Re-serialising the deserialised body must reproduce
337        //      the signed bytes verbatim, otherwise no signature could ever verify.
338        #[test]
339        fn batch_body_serialisation_is_a_byte_exact_fixed_point() {
340            let mut rng = deterministic_rng();
341            let keys = ed25519::KeyPair::new(&mut rng);
342
343            let body = StressTestBatchSubmissionContent {
344                signer: *keys.public_key(),
345                timestamp: datetime!(2026-06-01 12:34:56.123456789 UTC),
346                results: dummy_results(),
347            };
348
349            let signed_bytes = body.plaintext();
350            let body_str = std::str::from_utf8(&signed_bytes).unwrap();
351
352            // (1) array order preserved on the wire
353            let pos1 = body_str.find(r#""testrun_id":1"#).unwrap();
354            let pos2 = body_str.find(r#""testrun_id":2"#).unwrap();
355            let pos3 = body_str.find(r#""testrun_id":3"#).unwrap();
356            assert!(pos1 < pos2 && pos2 < pos3, "JSON: {body_str}");
357
358            // (2) round-trip is byte-exact
359            let deserialised: StressTestBatchSubmissionContent =
360                serde_json::from_slice(&signed_bytes).unwrap();
361            let resigned_bytes = deserialised.plaintext();
362            assert_eq!(
363                signed_bytes, resigned_bytes,
364                "deserialise-then-re-serialise was not a fixed point"
365            );
366        }
367
368        // nym-api and the orchestrator are deployed independently, so an orchestrator carrying the
369        // submission counts may talk to a nym-api that predates them and answers a bare `{}`. That
370        // must deserialise rather than error - a hard failure here would break submissions outright,
371        // which is worse than the missing telemetry - and it must land as `None` rather than
372        // `Some(0)`, because the orchestrator warns on a non-zero duplicate count and "not reported"
373        // must not be mistaken for "nothing was stored".
374        #[test]
375        fn submission_response_tolerates_a_body_without_counts() {
376            let old: StressTestBatchSubmissionResponse = serde_json::from_str("{}")
377                .expect("a response body predating the counts must still deserialise");
378            assert_eq!(old.accepted, None);
379            assert_eq!(old.duplicates, None);
380            assert_eq!(old.rejected, None);
381
382            // and a populated body round-trips with its counts intact
383            let reported = StressTestBatchSubmissionResponse {
384                accepted: Some(48),
385                duplicates: Some(1),
386                rejected: Some(1),
387            };
388            let json = serde_json::to_string(&reported).unwrap();
389            let parsed: StressTestBatchSubmissionResponse = serde_json::from_str(&json).unwrap();
390            assert_eq!(parsed.accepted, Some(48));
391            assert_eq!(parsed.duplicates, Some(1));
392            assert_eq!(parsed.rejected, Some(1));
393        }
394
395        // The mirror of the above: a nym-api reporting the counts, answering an orchestrator that
396        // predates them and whose type carried no fields at all. Serde must ignore the unknown keys
397        // rather than error.
398        //
399        // This is the more dangerous of the two directions. The client parses with a plain
400        // `serde_json::from_slice`, so a decode failure surfaces as a failed POST, and the
401        // submission watermark is only advanced after a POST succeeds - an old orchestrator would
402        // therefore treat every batch as failed, resubmit the same rows forever and never make
403        // forward progress, while nym-api quietly stored them on the first attempt.
404        #[test]
405        fn submission_response_counts_are_ignored_by_a_reader_predating_them() {
406            // faithful copy of the previously deployed shape
407            #[derive(Deserialize)]
408            struct OldStressTestBatchSubmissionResponse {}
409
410            let json = serde_json::to_string(&StressTestBatchSubmissionResponse {
411                accepted: Some(48),
412                duplicates: Some(1),
413                rejected: Some(1),
414            })
415            .unwrap();
416
417            let parsed: Result<OldStressTestBatchSubmissionResponse, _> =
418                serde_json::from_str(&json);
419            assert!(
420                parsed.is_ok(),
421                "a reader predating the counts rejected {json}: {:?}",
422                parsed.err(),
423            );
424        }
425    }
426}