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::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}