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