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}