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::models::OffsetDateTimeJsonSchemaWrapper;
83 use crate::signable::SignedMessage;
84 use std::time::Duration;
85 use time::OffsetDateTime;
86
87 /// Signed envelope posted by a network monitor orchestrator to
88 /// `POST /v3/nym-nodes/stress-testing/batch-submit`.
89 ///
90 /// The signature is checked against the `signer` field of the inner
91 /// [`StressTestBatchSubmissionContent`], which must also match one of the orchestrators
92 /// registered in the network-monitors contract.
93 pub type StressTestBatchSubmission = SignedMessage<StressTestBatchSubmissionContent>;
94
95 /// Confirmation returned to an orchestrator after a successful submission, reporting what
96 /// became of the individual results. The three counts sum to the number of results submitted.
97 ///
98 /// Reporting these matters because an accepted batch can still store nothing: rows deduplicate
99 /// at the database with insert-or-ignore semantics, so without a count the submitter cannot
100 /// distinguish "stored" from "silently discarded" - both are a `200`.
101 ///
102 /// Every field is optional so that a newer orchestrator can still read the empty body returned
103 /// by a nym-api predating these counts. `None` therefore means "not reported", which is a
104 /// different signal from `Some(0)`.
105 #[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
106 pub struct BatchSubmissionResponse {
107 /// Results newly stored by this submission.
108 #[serde(default)]
109 pub accepted: Option<usize>,
110
111 /// Results that were already stored, i.e. this submission re-sent a measurement nym-api had
112 /// seen before. Expected to be non-zero on the orchestrator's at-least-once retry path; a
113 /// persistently non-zero value means measurements are being discarded.
114 #[serde(default)]
115 pub duplicates: Option<usize>,
116
117 /// Results dropped by per-entry validation (an entry whose role the endpoint does not
118 /// accept, or a performance score outside `[0.0, 1.0]`).
119 #[serde(default)]
120 pub rejected: Option<usize>,
121 }
122
123 /// Both batch endpoints report the same three counts, so they share one body rather than
124 /// carrying two structurally identical types that could drift apart.
125 pub type StressTestBatchSubmissionResponse = BatchSubmissionResponse;
126 pub type LivenessTestBatchSubmissionResponse = BatchSubmissionResponse;
127
128 /// Single stress-test measurement for one node, produced by a network monitor orchestrator.
129 #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
130 pub struct StressTestResult {
131 /// Orchestrator-local id of the test run that produced this result. Combined with the
132 /// batch's `signer` it uniquely identifies the measurement, allowing nym-api to dedupe
133 /// retried submissions on the at-least-once delivery path.
134 pub testrun_id: i64,
135
136 /// Contract-assigned id of the node that was tested.
137 pub node_id: NodeId,
138
139 /// Whether the tested node was acting as a mixnode during the measurement.
140 ///
141 /// Included explicitly (rather than inferred from on-chain role) so the API can reject or
142 /// route entries that don't match the expected role without re-querying the contract.
143 pub is_mixnode: bool,
144
145 #[schema(value_type = String)]
146 #[serde(with = "time::serde::rfc3339")]
147 pub test_timestamp: OffsetDateTime,
148
149 /// Measured performance score in the `[0.0, 1.0]` range.
150 pub test_performance: f64,
151
152 /// Whether the node responded at all during testing.
153 ///
154 /// Recorded alongside `test_performance` so that a genuine 0.0 score (node responded but
155 /// dropped everything) can be distinguished from the node being offline entirely.
156 pub was_reachable: bool,
157 }
158
159 /// Body of a stress-test batch submission, signed by a network monitor orchestrator.
160 #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
161 pub struct StressTestBatchSubmissionContent {
162 /// ed25519 identity key of the submitting orchestrator. Must match an entry in the
163 /// network-monitors contract for the batch to be accepted.
164 #[schema(value_type = String)]
165 #[serde(with = "ed25519::bs58_ed25519_pubkey")]
166 pub signer: ed25519::PublicKey,
167
168 /// Time at which this batch was produced. Also used as a monotonic nonce for replay
169 /// protection: the API rejects submissions whose timestamp is not strictly greater than
170 /// the orchestrator's previous accepted submission.
171 #[schema(value_type = String)]
172 #[serde(with = "time::serde::rfc3339")]
173 pub timestamp: OffsetDateTime,
174
175 pub results: Vec<StressTestResult>,
176 }
177
178 impl StressTestBatchSubmissionContent {
179 /// Build a batch submission body stamped with the current UTC time.
180 pub fn new(signer: ed25519::PublicKey, results: Vec<StressTestResult>) -> Self {
181 StressTestBatchSubmissionContent {
182 signer,
183 timestamp: OffsetDateTime::now_utc(),
184 results,
185 }
186 }
187
188 /// Whether this submission is older than `max_age` relative to the current UTC time.
189 ///
190 /// Used server-side to reject submissions that have been sitting around too long, even if
191 /// they are otherwise well-formed and correctly signed.
192 pub fn is_stale(&self, max_age: Duration) -> bool {
193 self.timestamp + max_age < OffsetDateTime::now_utc()
194 }
195 }
196
197 /// Signed envelope posted by a network monitor orchestrator to
198 /// `POST /v3/nym-nodes/liveness-testing/batch-submit`.
199 ///
200 /// Liveness has its OWN endpoint rather than sharing the stress one because nym-api keeps a
201 /// per-signer replay high-water mark per endpoint: two streams signed by the same orchestrator
202 /// and validated against one shared mark would reject each other indefinitely.
203 pub type LivenessTestBatchSubmission = SignedMessage<LivenessTestBatchSubmissionContent>;
204
205 /// Single liveness measurement for one node, produced by a network monitor orchestrator.
206 #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
207 pub struct LivenessTestResult {
208 /// Orchestrator-local id of the test run that produced this result. Combined with the
209 /// batch's `signer` it uniquely identifies the measurement, allowing nym-api to dedupe
210 /// retried submissions on the at-least-once delivery path.
211 pub testrun_id: i64,
212
213 /// Contract-assigned id of the node that was tested.
214 pub node_id: NodeId,
215
216 #[schema(value_type = String)]
217 #[serde(with = "time::serde::rfc3339")]
218 pub test_timestamp: OffsetDateTime,
219
220 /// Score for the run: the average over the fixed set of interfaces its probe exercises,
221 /// with an interface that produced no measurement counted as zero. A mixnode probe
222 /// exercises one interface and a gateway probe two, so this single ratio is the whole
223 /// submission shape for both - identical for every role.
224 ///
225 /// The average is computed orchestrator-side and CANNOT be recomputed here: its
226 /// denominator is the set the run's (kind, role) pairing is expected to produce, which
227 /// this submission deliberately does not carry, so a run missing one of its interfaces
228 /// would average over the wrong denominator on this side. The orchestrator knows the
229 /// expected set from the row it stored, so it averages there.
230 ///
231 /// The per-interface breakdown behind this number stays on the orchestrator, which keeps
232 /// it under the run's row and serves it on its own read surface. It is deliberately NOT
233 /// submitted: nym-api scores on the average alone, and a submission shape carrying figures
234 /// nothing reads would have to be versioned before it could be trusted - the same reason
235 /// the latency distribution is not submitted either. Telling a gateway with a dead
236 /// delivery apart from a uniformly half-lossy one is therefore a lookup against the
237 /// orchestrator, not a question nym-api can answer.
238 pub test_performance: f64,
239
240 /// Whether the node responded at all during testing.
241 ///
242 /// Recorded alongside `test_performance` so that a genuine 0.0 score (node responded but
243 /// dropped everything) can be distinguished from the node being offline entirely.
244 pub was_reachable: bool,
245 }
246
247 /// Body of a liveness batch submission, signed by a network monitor orchestrator.
248 #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
249 pub struct LivenessTestBatchSubmissionContent {
250 /// ed25519 identity key of the submitting orchestrator. Must match an entry in the
251 /// network-monitors contract for the batch to be accepted.
252 #[schema(value_type = String)]
253 #[serde(with = "ed25519::bs58_ed25519_pubkey")]
254 pub signer: ed25519::PublicKey,
255
256 /// Time at which this batch was produced. Also used as a monotonic nonce for replay
257 /// protection: the API rejects submissions whose timestamp is not strictly greater than
258 /// the orchestrator's previous accepted submission ON THIS ENDPOINT.
259 #[schema(value_type = String)]
260 #[serde(with = "time::serde::rfc3339")]
261 pub timestamp: OffsetDateTime,
262
263 pub results: Vec<LivenessTestResult>,
264 }
265
266 impl LivenessTestBatchSubmissionContent {
267 /// Whether this submission is older than `max_age` relative to the current UTC time.
268 ///
269 /// Used server-side to reject submissions that have been sitting around too long, even if
270 /// they are otherwise well-formed and correctly signed.
271 pub fn is_stale(&self, max_age: Duration) -> bool {
272 self.timestamp + max_age < OffsetDateTime::now_utc()
273 }
274 }
275
276 /// Which role or roles a node declares, so a divergence row can be read against the probe
277 /// that produced it. The two liveness probes share no machinery, and a mixnode's divergence
278 /// has nothing in common with a gateway's.
279 ///
280 /// `Both` labels the NODE, not the measurement: a dual-role node's stored liveness score
281 /// already averages its mixnode and gateway runs together, so its divergence cannot be
282 /// attributed to one path or the other.
283 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
284 #[serde(rename_all = "snake_case")]
285 pub enum DivergenceNodeRole {
286 Mixnode,
287 Gateway,
288 Both,
289 }
290
291 /// One node's liveness score set against the v1 monitor's routing score for the same node.
292 #[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, ToSchema)]
293 pub struct NodeLivenessDivergence {
294 #[schema(value_type = u32)]
295 pub node_id: NodeId,
296
297 /// Aggregated v3 liveness score over the configured window, in `[0.0, 1.0]`.
298 pub liveness_score: f64,
299
300 /// The v1 monitor's routing score for the same node, in `[0.0, 1.0]`.
301 pub routing_score: f64,
302
303 /// `liveness_score - routing_score`. NEGATIVE means liveness scores the node WORSE than
304 /// v1 routing does, which is the expected direction while the fleet is mid-upgrade: a node
305 /// that has not ingested its agents' on-chain authorisations, or a gateway not yet
306 /// carrying the monitor-session behaviour, scores zero on liveness for reasons unrelated
307 /// to its forwarding. Reading the sign the other way inverts the whole gauge.
308 pub divergence: f64,
309
310 /// Whether any liveness sample in the window reached the node at all. `false` with a
311 /// `liveness_score` of zero means never measured, which is a different finding from a node
312 /// that answered and delivered nothing.
313 pub was_reachable: bool,
314
315 pub role: DivergenceNodeRole,
316 }
317
318 /// Response body for the liveness divergence gauge.
319 ///
320 /// Every liveness-ELIGIBLE bonded node appears, including those with no sample yet, so that
321 /// coverage is readable off the same response as the comparison: deciding whether to give
322 /// liveness a non-zero weight needs both how much of the fleet has been measured and how the
323 /// measured part compares.
324 #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)]
325 pub struct LivenessDivergenceResponse {
326 /// Oldest of the caches this was assembled from, so the figures' age is visible.
327 pub refreshed_at: OffsetDateTimeJsonSchemaWrapper,
328
329 pub nodes: Vec<NodeLivenessDivergence>,
330 }
331
332 /// Response body for `GET /v3/nym-nodes/stress-testing/known-monitors/{identity_key}`,
333 /// used by orchestrators to check whether this nym-api currently recognises their key.
334 #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
335 pub struct KnownNetworkMonitorResponse {
336 /// The ed25519 identity key that was queried (base58-encoded on the wire).
337 #[serde(with = "bs58_ed25519_pubkey")]
338 #[schema(value_type = String)]
339 pub identity_key: ed25519::PublicKey,
340
341 /// Whether the queried identity key is currently recognised by this nym-api
342 /// as an authorised network monitor permitted to submit stress testing results.
343 pub authorised: bool,
344 }
345
346 #[cfg(test)]
347 mod tests {
348 use super::*;
349 use crate::signable::SignableMessageBody;
350 use nym_test_utils::helpers::deterministic_rng;
351 use time::macros::datetime;
352
353 fn dummy_liveness_results() -> Vec<LivenessTestResult> {
354 // Order-distinguishable entries, as in the stress body: `testrun_id` is the order
355 // witness, so a permuting deserialisation would break the signature check below.
356 vec![
357 // a gateway run, whose score averages its two interfaces - the shape is the same
358 // as a mixnode's, since the interfaces averaged over never reach the wire
359 LivenessTestResult {
360 testrun_id: 1,
361 node_id: 42,
362 test_timestamp: datetime!(2026-06-01 12:34:56.123456789 UTC),
363 test_performance: 0.5,
364 was_reachable: true,
365 },
366 // a mixnode run: one interface, so the score is that interface's ratio
367 LivenessTestResult {
368 testrun_id: 2,
369 node_id: 7,
370 test_timestamp: datetime!(2026-06-01 12:34:56 UTC),
371 test_performance: 0.97,
372 was_reachable: true,
373 },
374 ]
375 }
376
377 // Same hazard as the stress body: if JSON serialisation is not a byte-exact fixed point,
378 // no liveness batch could ever pass nym-api's signature check. Kept separate from the
379 // stress test because the two bodies are independent types on independent endpoints, and
380 // a float or timestamp encoding change would have to break both to be caught by one.
381 #[test]
382 fn signed_liveness_batch_roundtrips_and_is_a_byte_exact_fixed_point() {
383 let mut rng = deterministic_rng();
384 let keys = ed25519::KeyPair::new(&mut rng);
385
386 let body = LivenessTestBatchSubmissionContent {
387 signer: *keys.public_key(),
388 timestamp: datetime!(2026-06-01 12:34:56.123456789 UTC),
389 results: dummy_liveness_results(),
390 };
391
392 let signed_bytes = body.plaintext();
393 let signed = body.clone().sign(keys.private_key());
394
395 let bytes = serde_json::to_vec(&signed).unwrap();
396 let deserialised: LivenessTestBatchSubmission = serde_json::from_slice(&bytes).unwrap();
397 assert!(
398 deserialised.verify_signature(&deserialised.body.signer),
399 "signature failed to verify after a JSON round-trip"
400 );
401
402 // re-serialising the parsed body must reproduce the signed bytes verbatim
403 assert_eq!(signed_bytes, deserialised.body.plaintext());
404
405 // the results keep their order, so the scores stay attached to the right nodes
406 let ids: Vec<_> = deserialised
407 .body
408 .results
409 .iter()
410 .map(|result| (result.testrun_id, result.node_id))
411 .collect();
412 assert_eq!(ids, vec![(1, 42), (2, 7)]);
413 }
414
415 fn dummy_results() -> Vec<StressTestResult> {
416 // Order-distinguishable entries: if deserialisation ever permuted the array, the
417 // re-serialised body would no longer match the signed bytes, and `verify_signature`
418 // would return false. `testrun_id` is the order witness.
419 vec![
420 StressTestResult {
421 testrun_id: 1,
422 node_id: 42,
423 is_mixnode: true,
424 test_timestamp: datetime!(2026-06-01 12:34:56.123456789 UTC),
425 test_performance: 0.6666666666666666,
426 was_reachable: true,
427 },
428 StressTestResult {
429 testrun_id: 2,
430 node_id: 7,
431 is_mixnode: true,
432 test_timestamp: datetime!(2026-06-01 12:34:56 UTC),
433 test_performance: 0.0,
434 was_reachable: false,
435 },
436 StressTestResult {
437 testrun_id: 3,
438 node_id: u32::MAX,
439 is_mixnode: true,
440 test_timestamp: datetime!(2026-06-01 12:34:56.999999999 UTC),
441 test_performance: 1.0,
442 was_reachable: true,
443 },
444 ]
445 }
446
447 // Integrity check on the wire is `serde_json::to_vec(deserialize(serde_json::to_vec(body)))
448 // == serde_json::to_vec(body)`. If JSON serialisation isn't a fixed point, every batch
449 // submission would fail nym-api's signature verification. Cover the timestamp shapes the
450 // orchestrator actually produces, including the `+1ns` bump from the monotonicity safeguard.
451 #[test]
452 fn signed_batch_submission_roundtrips_through_json() {
453 let mut rng = deterministic_rng();
454 let keys = ed25519::KeyPair::new(&mut rng);
455
456 let timestamps = [
457 datetime!(2026-06-01 12:34:56 UTC),
458 datetime!(2026-06-01 12:34:56.000000001 UTC),
459 datetime!(2026-06-01 12:34:56.999999999 UTC),
460 datetime!(2026-06-01 12:34:56.123456789 UTC),
461 OffsetDateTime::now_utc(),
462 OffsetDateTime::now_utc() + time::Duration::NANOSECOND,
463 ];
464
465 for timestamp in timestamps {
466 let body = StressTestBatchSubmissionContent {
467 signer: *keys.public_key(),
468 timestamp,
469 results: dummy_results(),
470 };
471 let signed = body.clone().sign(keys.private_key());
472
473 let bytes = serde_json::to_vec(&signed).unwrap();
474 let deserialised: StressTestBatchSubmission =
475 serde_json::from_slice(&bytes).unwrap();
476
477 // The handler verifies against `body.body.signer` — match that exactly.
478 assert!(
479 deserialised.verify_signature(&deserialised.body.signer),
480 "signature failed to verify after JSON round-trip for timestamp {timestamp}",
481 );
482 assert_eq!(deserialised.body.timestamp, timestamp);
483 }
484 }
485
486 // Every f64 that the orchestrator's `received as f64 / sent as f64` formula can produce
487 // (storage/models.rs) must round-trip byte-exactly through JSON. Exhaustively cover the
488 // range and exercise sent values that produce non-terminating fractions (1/3, 1/7, ...).
489 #[test]
490 fn computed_test_performance_values_roundtrip() {
491 for sent in 1u64..=200 {
492 for received in 0u64..=(sent * 2) {
493 let perf = received as f64 / sent as f64;
494 let s = serde_json::to_string(&perf).unwrap();
495 let perf2: f64 = serde_json::from_str(&s).unwrap();
496 let s2 = serde_json::to_string(&perf2).unwrap();
497 assert_eq!(
498 s, s2,
499 "f64 round-trip mismatch for {received}/{sent} = {perf}: {s} -> {s2}",
500 );
501 }
502 }
503 }
504
505 // serde_json serialises non-finite f64 as `null`. Confirm what the deserialiser does with
506 // `null` for a struct field typed as f64 - if it succeeds with a default value (rather than
507 // erroring), a NaN/Infinity test_performance could silently break signature verification
508 // because the re-serialised body would no longer have `null` at that position.
509 #[test]
510 fn non_finite_test_performance_breaks_loudly_not_silently() {
511 let nan_result = StressTestResult {
512 testrun_id: 1,
513 node_id: 1,
514 is_mixnode: true,
515 test_timestamp: datetime!(2026-06-01 12:34:56 UTC),
516 test_performance: f64::NAN,
517 was_reachable: true,
518 };
519 let json = serde_json::to_string(&nan_result).unwrap();
520 // NaN serialises as `null` - this is the dangerous shape
521 assert!(
522 json.contains(r#""test_performance":null"#),
523 "expected NaN to serialise as null: {json}",
524 );
525 // ...and `null` MUST fail to deserialise rather than silently becoming 0.0 / default;
526 // if this ever changes, NaN would silently corrupt signature verification.
527 let deserialised: Result<StressTestResult, _> = serde_json::from_str(&json);
528 assert!(
529 deserialised.is_err(),
530 "deserialising null into f64 unexpectedly succeeded - signature verification \
531 would silently fail for any submission containing a non-finite test_performance",
532 );
533 }
534
535 // Specifically pin the two hypotheses we want to rule out:
536 // 1. Vec<StressTestResult> serialisation/deserialisation preserves order.
537 // 2. The body bytes serialised standalone (= what gets signed) are byte-identical to
538 // the body sub-object bytes embedded in the outer SignedMessage JSON (= what the
539 // server sees after parsing). Re-serialising the deserialised body must reproduce
540 // the signed bytes verbatim, otherwise no signature could ever verify.
541 #[test]
542 fn batch_body_serialisation_is_a_byte_exact_fixed_point() {
543 let mut rng = deterministic_rng();
544 let keys = ed25519::KeyPair::new(&mut rng);
545
546 let body = StressTestBatchSubmissionContent {
547 signer: *keys.public_key(),
548 timestamp: datetime!(2026-06-01 12:34:56.123456789 UTC),
549 results: dummy_results(),
550 };
551
552 let signed_bytes = body.plaintext();
553 let body_str = std::str::from_utf8(&signed_bytes).unwrap();
554
555 // (1) array order preserved on the wire
556 let pos1 = body_str.find(r#""testrun_id":1"#).unwrap();
557 let pos2 = body_str.find(r#""testrun_id":2"#).unwrap();
558 let pos3 = body_str.find(r#""testrun_id":3"#).unwrap();
559 assert!(pos1 < pos2 && pos2 < pos3, "JSON: {body_str}");
560
561 // (2) round-trip is byte-exact
562 let deserialised: StressTestBatchSubmissionContent =
563 serde_json::from_slice(&signed_bytes).unwrap();
564 let resigned_bytes = deserialised.plaintext();
565 assert_eq!(
566 signed_bytes, resigned_bytes,
567 "deserialise-then-re-serialise was not a fixed point"
568 );
569 }
570
571 // nym-api and the orchestrator are deployed independently, so an orchestrator carrying the
572 // submission counts may talk to a nym-api that predates them and answers a bare `{}`. That
573 // must deserialise rather than error - a hard failure here would break submissions outright,
574 // which is worse than the missing telemetry - and it must land as `None` rather than
575 // `Some(0)`, because the orchestrator warns on a non-zero duplicate count and "not reported"
576 // must not be mistaken for "nothing was stored".
577 #[test]
578 fn submission_response_tolerates_a_body_without_counts() {
579 let old: StressTestBatchSubmissionResponse = serde_json::from_str("{}")
580 .expect("a response body predating the counts must still deserialise");
581 assert_eq!(old.accepted, None);
582 assert_eq!(old.duplicates, None);
583 assert_eq!(old.rejected, None);
584
585 // and a populated body round-trips with its counts intact
586 let reported = StressTestBatchSubmissionResponse {
587 accepted: Some(48),
588 duplicates: Some(1),
589 rejected: Some(1),
590 };
591 let json = serde_json::to_string(&reported).unwrap();
592 let parsed: StressTestBatchSubmissionResponse = serde_json::from_str(&json).unwrap();
593 assert_eq!(parsed.accepted, Some(48));
594 assert_eq!(parsed.duplicates, Some(1));
595 assert_eq!(parsed.rejected, Some(1));
596 }
597
598 // The mirror of the above: a nym-api reporting the counts, answering an orchestrator that
599 // predates them and whose type carried no fields at all. Serde must ignore the unknown keys
600 // rather than error.
601 //
602 // This is the more dangerous of the two directions. The client parses with a plain
603 // `serde_json::from_slice`, so a decode failure surfaces as a failed POST, and the
604 // submission watermark is only advanced after a POST succeeds - an old orchestrator would
605 // therefore treat every batch as failed, resubmit the same rows forever and never make
606 // forward progress, while nym-api quietly stored them on the first attempt.
607 #[test]
608 fn submission_response_counts_are_ignored_by_a_reader_predating_them() {
609 // faithful copy of the previously deployed shape
610 #[derive(Deserialize)]
611 struct OldStressTestBatchSubmissionResponse {}
612
613 let json = serde_json::to_string(&StressTestBatchSubmissionResponse {
614 accepted: Some(48),
615 duplicates: Some(1),
616 rejected: Some(1),
617 })
618 .unwrap();
619
620 let parsed: Result<OldStressTestBatchSubmissionResponse, _> =
621 serde_json::from_str(&json);
622 assert!(
623 parsed.is_ok(),
624 "a reader predating the counts rejected {json}: {:?}",
625 parsed.err(),
626 );
627 }
628 }
629}