decdn_protocol/message.rs
1//! Wire message payload types for deCDN ALPN protocols.
2//!
3//! Every ALPN defines a top-level enum (e.g. [`ProbeMessage`]) whose variants
4//! wrap the per-message structs. The enum is serialized as the outermost
5//! postcard value inside a length-prefixed frame (see [`crate::framing`]).
6//!
7//! # Signed-field freezing (ADR 013 §Signed Field Freezing)
8//!
9//! [`ProbeResponse`] is exactly a signed [`ProbeResponseBody`] plus its
10//! `slash_sig`; every unsigned field trails in [`ProbeResponseExt`], encoded
11//! separately and decoded two-phase (ADR 013 §Tier 1). Unlike an Ed25519
12//! signature over postcard bytes, `slash_sig` is **not** computed over a
13//! postcard serialization: it is an EIP-712
14//! secp256k1 signature over the *typed-data hash of the body fields*
15//! `{hash, has_blob, rate_per_mb, timestamp_us}`, exactly as defined in ADR
16//! 014 §EIP-712 Type Definitions and implemented in
17//! `decdn_incentive::ProbeSlashData`. There is intentionally no
18//! `signing_bytes()` helper here — postcard bytes are *not* the signing
19//! input, and exposing one would invite incompatible verifier
20//! implementations. The `#[serde(deserialize_with)]` validation on
21//! `rate_per_mb` lives on the body field so the protocol-boundary bound on
22//! [`MAX_RATE_PER_MB`] continues to apply (issue #378).
23//!
24//! This establishes the `cdn/probe/v1` signed baseline — it is **not** an
25//! ALPN bump. ADR 013 freezes a signed field set at the protocol version that
26//! introduces it; `cdn/probe/v1` had no prior signed `ProbeResponse`, so the
27//! set `{hash, has_blob, rate_per_mb, timestamp_us}` is the v1 baseline (ADR
28//! 005 §`cdn/probe/v1`, ADR 014 §1). `total_bytes` rides in
29//! [`ProbeResponseExt`] as the ADR 013 Tier-1 unsigned-evolution exemplar (ADR
30//! 005 §`cdn/probe/v1`) and is deliberately NOT covered by `slash_sig`. The
31//! `ProbeMessage` variant order is unchanged.
32//!
33//! This crate deliberately does not depend on any crypto library. The caller
34//! (`decdn_incentive::ProbeSlashData` and the probe handler/CLI) computes and
35//! verifies the EIP-712 `slash_sig`.
36
37use serde::de::{self, Deserializer};
38use serde::{Deserialize, Serialize};
39
40/// Maximum permitted value for [`ProbeResponseBody::rate_per_mb`] (issue #378).
41///
42/// 1000 µUSDC/MB (USDC at 6 decimals: ~$1/GB), a realistic ceiling about 100×
43/// market price (~10 µUSDC/MB ≈ $0.01/GB). It bounds two things at once. It is
44/// the largest `rate_per_mb` the wire decodes, and it caps the governance
45/// `deliveryFloor` on-chain (ADR 003 §Rate-floor enforcement); the floor can
46/// never exceed the wire price. A cap far above market lets a governance floor
47/// near the ceiling drive credited bytes toward zero and suppress ADR-036
48/// vote-weight accrual, so the cap stays close to real prices.
49///
50/// The value also participates in the client selection score
51/// `rate_per_mb × rtt_ms × scale / reputation²` (issue #322, ADR 001 §Node
52/// Selection Algorithm); a peer-controlled `u64::MAX` would overflow the
53/// multiplication and could silently let a malicious node win the selection.
54/// Selection-formula callers SHOULD still use saturating arithmetic as
55/// defense-in-depth — the bound is the protocol-boundary check, not a
56/// substitute for safe math.
57pub const MAX_RATE_PER_MB: u64 = 1000;
58
59/// Frame ceiling for `cdn/probe/v1`, in payload bytes (ADR 013 § Wire Framing).
60///
61/// Every probe read passes this to [`crate::read_frame`]. The largest legitimate
62/// message is a [`ProbeResponse`] with its [`ProbeResponseExt`] at full width:
63/// 325 bytes, of which the encoded coverage is up to 202 (a 200-byte bitmap and
64/// its 2-byte length). A [`ProbeRequest`] is
65/// at most 43 bytes. The ceiling leaves headroom for Tier-1 extension fields
66/// and refuses a larger frame before the reader buffers it. The
67/// `probe_frame_ceiling_holds_the_largest_message` test pins the margin.
68pub const MAX_PROBE_FRAME: u32 = 1024;
69
70/// Length in bytes of an EOA secp256k1 EIP-712 signature (`r‖s‖v`, 32+32+1).
71///
72/// ADR 014 §1 mandates `slash_sig` is **non-empty** and that requesters MUST
73/// reject missing/zero-length signatures — it does not itself fix a byte
74/// length. Off-chain `slash_sig` producers are EOA-only — ADR 024 §Off-Chain
75/// ERC-1271 Verification: "Every signature deCDN produces or verifies
76/// off-chain … is the fixed 65-byte secp256k1 `r‖s‖v` form" — which is always
77/// exactly this length. Variable-length
78/// **ERC-1271** smart-account signatures are verified on-chain by
79/// `SlashJudge` via `SignatureChecker.isValidSignatureNow` (ADR 014
80/// §On-Chain Verification); enforcing exactly this length off-chain is the
81/// correct, intentionally-strict bound for the EOA producer set. When
82/// off-chain ERC-1271 handling lands this constant becomes a lower bound.
83pub const SLASH_SIG_LEN: usize = 65;
84
85/// Errors produced when validating wire-decoded protocol messages.
86///
87/// `RateTooLarge` surfaces from the [`Deserialize`] impl on
88/// [`ProbeResponseBody`] (postcard wraps it as [`postcard::Error`] which the
89/// framing layer maps to [`crate::FrameError::Decode`]).
90/// `InvalidSlashSigLen` is a requester-side obligation enforced via
91/// [`ProbeResponse::validate`] — it is intentionally NOT enforced at decode
92/// time so the handler can build the response before signing it.
93#[derive(Debug, thiserror::Error, PartialEq, Eq)]
94pub enum MessageValidationError {
95 /// `rate_per_mb` exceeds [`MAX_RATE_PER_MB`].
96 #[error("rate_per_mb {rate} exceeds MAX_RATE_PER_MB ({max})", max = MAX_RATE_PER_MB)]
97 RateTooLarge {
98 /// The rate that was offered.
99 rate: u64,
100 },
101 /// `rate_per_mb` is zero. A zero rate trivially wins client selection
102 /// (score `rate × rtt × scale / reputation²` → 0, sorting to the top)
103 /// while earning the node nothing — an obvious misconfiguration. Nodes
104 /// reject it at config resolution; requesters MUST also reject it on
105 /// receive (#252, ADR 005 §Rate bounds validation). Enforced via
106 /// [`ProbeResponse::validate`] / `StreamResponse::validate`, not at decode
107 /// time, so a handler can still build a response before signing it.
108 #[error("rate_per_mb is zero (requesters must reject; #252)")]
109 RateIsZero,
110 /// `slash_sig` is missing or not [`SLASH_SIG_LEN`] bytes. ADR 014 §1
111 /// mandates a non-empty signature on every `ProbeResponse`; the
112 /// EOA-only off-chain signing path (ADR 024 §Off-Chain Signature
113 /// Verification — EOA Recovery Only) makes that exactly [`SLASH_SIG_LEN`],
114 /// which requesters MUST reject deviations from.
115 #[error(
116 "slash_sig has invalid length {len} \
117 (ADR 014 §1: mandatory non-empty; EOA form is {expected} bytes)",
118 expected = SLASH_SIG_LEN
119 )]
120 InvalidSlashSigLen {
121 /// The signature length that was offered.
122 len: usize,
123 },
124 /// A wire [`crate::client::Voucher`]'s `signature` is not
125 /// [`crate::client::VOUCHER_SIG_LEN`] bytes. The EOA off-chain voucher
126 /// signing form (ADR 024 §Off-Chain Signature Verification — EOA Recovery Only) is exactly that
127 /// length; receivers reject deviations before reconstructing the EIP-712
128 /// typed data.
129 #[error("Voucher.signature has invalid length {len} (EOA form is 65 bytes)")]
130 InvalidVoucherSigLen {
131 /// The signature length that was offered.
132 len: usize,
133 },
134 /// A wire [`crate::client::ClientBinding`]'s `binding_signature` is not
135 /// [`crate::client::BINDING_SIG_LEN`] bytes. The `BindNodeId` attestation
136 /// is the same EOA off-chain signing form (ADR 024 §Off-Chain ERC-1271
137 /// Verification); receivers reject deviations before `decdn_incentive`
138 /// recovers the bound address.
139 #[error("ClientBinding.binding_signature has invalid length {len} (EOA form is 65 bytes)")]
140 InvalidBindingSigLen {
141 /// The signature length that was offered.
142 len: usize,
143 },
144 /// A [`crate::client::StreamResponse`] carries `body.ok == true` yet also an
145 /// `error`. A node MUST NOT both promise to serve and report a failure
146 /// (ADR 005 §`cdn/client/v1`). Enforced via
147 /// [`crate::client::StreamResponseExt::validate`].
148 #[error("StreamResponse has ok=true but also carries an error")]
149 StreamErrorWithOk,
150 /// A [`crate::client::StreamResponse`] carries `body.ok == false` but no
151 /// `error` code. A refusal MUST name its reason (ADR 005
152 /// §`cdn/client/v1`). Enforced via [`crate::client::StreamResponseExt::validate`].
153 #[error("StreamResponse has ok=false but no error code")]
154 MissingStreamError,
155 /// A [`crate::client::StreamResponse`] carries a mid-stream-only
156 /// [`crate::client::StreamError::VoucherRejected`] in its `error` field.
157 /// That variant rides exclusively in [`crate::client::ClientMessage::StreamError`]
158 /// (ADR 005 §`VoucherRejected` semantics). Enforced via
159 /// [`crate::client::StreamResponseExt::validate`].
160 #[error("StreamResponse.error carries VoucherRejected (a mid-stream-only code)")]
161 VoucherRejectedInResponse,
162 /// A [`crate::client::ChunkData`] carries a zero-length payload. ADR 005
163 /// §Frame size permits a *shorter* final frame, never an *empty*
164 /// one: an empty frame advances neither the receiver's cumulative byte count
165 /// nor its voucher accounting, so an unbounded run of them drives the receive
166 /// loop without application-level progress (#1088). Enforced by
167 /// [`crate::client::ChunkData::new`], the `try_from` decode gate, and every
168 /// `encode_chunk_*` helper ([`crate::client::encode_chunk_frame`],
169 /// [`crate::client::encode_chunk_frame_headers`], and the crate-private one the
170 /// latter builds on) — every route to a frame body.
171 #[error(
172 "ChunkData carries a zero-length payload (ADR 005: a chunk must carry at least 1 byte)"
173 )]
174 EmptyChunk,
175 /// The postcard message body of a [`crate::client::ChunkData`] frame —
176 /// discriminant + length prefix + payload, which is the length the framing prefix
177 /// announces — exceeds [`crate::client::MAX_CHUNK_FRAME`] (ADR 013 §Wire
178 /// Framing).
179 ///
180 /// The field is named for what it holds: this is always larger than the
181 /// `payload_len` the caller passed in, so comparing it against that is a category
182 /// error.
183 #[error("ChunkData frame length {frame_len} exceeds MAX_CHUNK_FRAME ({max})", max = crate::client::MAX_CHUNK_FRAME)]
184 ChunkTooLarge {
185 /// The refused frame-body length, in bytes.
186 frame_len: usize,
187 },
188 /// A wire [`crate::client::WireCapability`]'s `owner_signature` is empty.
189 /// The EOA form is exactly [`crate::client::VOUCHER_SIG_LEN`] bytes but an
190 /// ERC-1271 contract-signer form may be longer, so only the non-empty
191 /// floor is a wire-level check; the rest is `decdn_incentive`'s job
192 /// (ADR 024 §Off-Chain Signature Verification — EOA Recovery Only).
193 #[error("WireCapability.owner_signature is empty")]
194 EmptyCapabilitySignature,
195}
196
197/// Top-level protocol enum for `cdn/probe/v1`. Variant order is frozen per
198/// ADR 013 — new variants MUST be appended at the end.
199///
200/// ⚠️ **VARIANT ORDER FROZEN — ADR 013 §Protocol Enums**
201/// Postcard encodes each variant as its declaration-order index. Reordering,
202/// inserting, or removing a variant is a wire-breaking change requiring an
203/// ALPN version bump (`cdn/probe/v2`). The discriminant assignments are
204/// locked in by the tests `probe_message_request_discriminant_is_zero` and
205/// `probe_message_response_discriminant_is_one` — if you change this enum,
206/// those tests will fail and tell you why.
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208pub enum ProbeMessage {
209 /// discriminant 0 — asserted by `probe_message_request_discriminant_is_zero`
210 Request(ProbeRequest),
211 /// discriminant 1 — asserted by `probe_message_response_discriminant_is_one`
212 Response(ProbeResponse),
213}
214
215impl crate::framing::TopLevelEnum for ProbeMessage {
216 /// `Request` (0), `Response` (1). Pinned by
217 /// `probe_message_variant_count_matches_discriminants`.
218 const VARIANT_COUNT: u32 = 2;
219}
220
221/// Client → node request on `cdn/probe/v1` (ADR 005 §`cdn/probe/v1`).
222///
223/// A probe is unauthenticated and unpaid: it asks a candidate node whether it
224/// holds `hash` and at what rate it will serve it, so the client can rank
225/// candidates on both availability and price in a single round-trip.
226/// `timestamp_us` is a requester-generated microsecond timestamp echoed back
227/// in [`ProbeResponseBody::timestamp_us`]; it serves both response
228/// correlation and RTT measurement (RTT = `receive_time` − `timestamp_us`)
229/// and is
230/// part of the EIP-712 signed set so the slashing window can be computed
231/// on-chain from a single requester clock (ADR 014 §1).
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
233pub struct ProbeRequest {
234 /// BLAKE3 hash of the blob being queried (iroh `Hash`, 32 bytes).
235 pub hash: [u8; 32],
236 /// Requester-generated microseconds since Unix epoch, echoed back.
237 pub timestamp_us: u64,
238}
239
240/// Node → client response on `cdn/probe/v1` (ADR 005 §`cdn/probe/v1`).
241///
242/// **This struct holds only the frozen base** (ADR 013 §Tier 1): the signed
243/// [`ProbeResponseBody`] and the `slash_sig` covering it. Unsigned fields live
244/// in [`ProbeResponseExt`], which travels as separate trailing bytes — see
245/// [`encode_probe_response`] / [`parse_probe_response_ext`].
246///
247/// The split is what keeps a future unsigned field out of Tier 3. Postcard is
248/// positional and fills no defaults for absent trailing fields, so an
249/// `Option<T>` appended to *this* struct would fail to decode against an older
250/// sender that never wrote it; appended to the separately-decoded extension it
251/// costs no coordination at all.
252///
253/// `slash_sig` is mandatory and non-empty on the wire; requesters MUST reject
254/// missing/zero-length signatures (enforced via [`ProbeResponse::validate`] and
255/// the requester's [`SLASH_SIG_LEN`] check).
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
257pub struct ProbeResponse {
258 /// Signed body. Its wire layout is frozen per ADR 013.
259 pub body: ProbeResponseBody,
260 /// EIP-712 secp256k1 signature over the typed-data hash of `body`'s
261 /// fields `{hash, has_blob, rate_per_mb, timestamp_us}` (ADR 014 §1; see
262 /// `decdn_incentive::ProbeSlashData`). Always exactly [`SLASH_SIG_LEN`]
263 /// bytes — *not* a signature over postcard bytes. The verify path
264 /// rejects any other length.
265 pub slash_sig: Vec<u8>,
266}
267
268/// Optional [`ProbeResponse`] extension fields, carried as trailing bytes after
269/// the `ProbeResponse` message via the two-phase pattern (ADR 013 §Tier 1; see
270/// [`encode_probe_response`] / [`parse_probe_response_ext`]).
271///
272/// Nothing here is covered by `slash_sig`, and nothing here may become
273/// load-bearing for payment, slashing, or any decision a lying node profits
274/// from: an unsigned field is a hint, and the sender is the party with the
275/// motive to shade it.
276///
277/// New fields are appended to the END of this struct and MUST be `Option<T>` or
278/// have a meaningful `Default`. Insertions and reordering are Tier-3.
279#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
280pub struct ProbeResponseExt {
281 /// Blob size in bytes when known. Nodes SHOULD include it when the size is
282 /// known so requesters can estimate cost before opening a payment pool.
283 /// Unsigned: treat it as a sizing hint, never as a commitment.
284 pub total_bytes: Option<u64>,
285 /// Which discovery blocks (`decdn_protocol::coverage`) this node will
286 /// serve. Unsigned: treat it as a hint, never as a commitment. A
287 /// requester MUST check it against the signed body (`hash` and
288 /// `has_blob`) with [`ProbeResponseExt::consistent_with`] and drop a
289 /// response where they disagree rather than trust either field alone.
290 pub coverage: crate::Coverage,
291}
292
293/// The BLAKE3 hash of zero bytes: the root of the empty blob.
294///
295/// The empty blob spans zero discovery blocks, so it is the one hash whose
296/// coverage is empty on every honest holder. For it,
297/// [`ProbeResponseExt::consistent_with`] only requires an empty coverage, and
298/// the requester reads `has_blob` alone to learn whether the node serves it.
299pub const EMPTY_ROOT: [u8; 32] = [
300 0xaf, 0x13, 0x49, 0xb9, 0xf5, 0xf9, 0xa1, 0xa6, 0xa0, 0x40, 0x4d, 0xea, 0x36, 0xdc, 0xc9, 0x49,
301 0x9b, 0xcb, 0x25, 0xc9, 0xad, 0xc1, 0x12, 0xb7, 0xcc, 0x9a, 0x93, 0xca, 0xe4, 0x1f, 0x32, 0x62,
302];
303
304impl ProbeResponseExt {
305 /// Does this ext's `coverage` agree with the signed `body` (ADR 013 §Tier
306 /// 1; ADR 022 § Consistency rule; #1506)?
307 ///
308 /// For every blob but the empty one, `body.has_blob` and
309 /// `coverage.is_empty()` are a biconditional by construction: the probe
310 /// handler derives `has_blob` FROM `coverage`, never the other way round,
311 /// so any disagreement means a malformed or dishonest response.
312 ///
313 /// The empty blob ([`EMPTY_ROOT`]) spans zero blocks, so its coverage is
314 /// empty on a holder and a non-holder alike. For that hash the coverage
315 /// must be empty and `has_blob` alone says whether the node serves it
316 /// (#2471). The rule keys on `body.hash`, which the signature covers,
317 /// never on the unsigned `total_bytes` hint.
318 ///
319 /// Neither `coverage` nor `total_bytes` is in the signed set, so a mismatch
320 /// has no attributable author to slash: a requester finding `false` here
321 /// MUST drop the candidate rather than admit it, and must NOT score it to
322 /// local reputation — the same treatment an unrecovered `slash_sig` gets.
323 #[must_use]
324 pub fn consistent_with(&self, body: &ProbeResponseBody) -> bool {
325 if body.hash == EMPTY_ROOT {
326 self.coverage.is_empty()
327 } else {
328 body.has_blob != self.coverage.is_empty()
329 }
330 }
331}
332
333/// Signed fields of a [`ProbeResponse`]. Layout is frozen per ADR 013 — future
334/// additions go on [`ProbeResponse`] as optional unsigned fields, not here.
335///
336/// `rate_per_mb` is bounded by [`MAX_RATE_PER_MB`] at the wire boundary — the
337/// field-level `deserialize_rate_per_mb` hook rejects oversize values so a
338/// malicious node cannot poison the client selection score with an
339/// overflow-inducing rate (issue #378). Server-side construction is
340/// unconstrained at the type level; the node's config layer
341/// (`resolve_payment`) enforces the same ceiling at startup and on hot
342/// reload, keeping the bound bilateral.
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344pub struct ProbeResponseBody {
345 /// BLAKE3 hash this response answers for, echoed from the request.
346 pub hash: [u8; 32],
347 /// Whether the node currently holds the blob and will serve it. A node
348 /// MUST NOT sign `true` unless it can guarantee delivery within the
349 /// slashing window (ADR 005 §Probe-triggered eviction hold).
350 pub has_blob: bool,
351 /// The node's current quoted rate in payment-token base units per MB. The
352 /// token is USDC, fixed at contract deployment (ADR 003), so the wire
353 /// carries no token identifier and the units need no normalization.
354 /// Bounded by [`MAX_RATE_PER_MB`].
355 #[serde(deserialize_with = "deserialize_rate_per_mb")]
356 pub rate_per_mb: u64,
357 /// The requester-generated microsecond timestamp from the corresponding
358 /// [`ProbeRequest`], echoed back unchanged.
359 pub timestamp_us: u64,
360}
361
362/// Encode a [`ProbeResponse`] with its optional trailing [`ProbeResponseExt`]
363/// (ADR 013 §Tier 1, two-phase).
364///
365/// The base message and the extension are encoded as two adjacent postcard
366/// values in one frame. Encoding them separately is what makes a future field
367/// appended to [`ProbeResponseExt`] invisible to an older receiver, which stops
368/// consuming at the end of the base and discards the rest.
369///
370/// # Errors
371///
372/// Propagates a [`postcard::Error`] if serialization fails.
373pub fn encode_probe_response(
374 resp: &ProbeResponse,
375 ext: Option<&ProbeResponseExt>,
376) -> Result<Vec<u8>, postcard::Error> {
377 let mut buf = postcard::to_allocvec(&ProbeMessage::Response(resp.clone()))?;
378 if let Some(ext) = ext {
379 buf.extend_from_slice(&postcard::to_allocvec(ext)?);
380 }
381 Ok(buf)
382}
383
384/// Parse the trailing [`ProbeResponseExt`] bytes returned as the remainder by
385/// [`crate::decode_message`] after a `ProbeMessage::Response`.
386///
387/// An empty remainder ⇒ [`ProbeResponseExt::default`] (no size hint). Trailing
388/// bytes beyond the known fields are tolerated for forward compatibility (ADR
389/// 013 §Tier 1): a future optional field appended to [`ProbeResponseExt`] is
390/// read by new receivers and skipped by old ones.
391///
392/// # Errors
393///
394/// Returns a [`postcard::Error`] if a non-empty remainder is not a valid
395/// `ProbeResponseExt` prefix.
396pub fn parse_probe_response_ext(remainder: &[u8]) -> Result<ProbeResponseExt, postcard::Error> {
397 if remainder.is_empty() {
398 Ok(ProbeResponseExt::default())
399 } else {
400 Ok(postcard::take_from_bytes::<ProbeResponseExt>(remainder)?.0)
401 }
402}
403
404impl ProbeResponse {
405 /// Validate field invariants. The wire-decode path enforces the
406 /// `rate_per_mb` bound automatically via `deserialize_rate_per_mb`; this
407 /// method additionally enforces the requester-side mandatory-`slash_sig`
408 /// rule (ADR 014 §1) and is exposed so construction sites can re-check
409 /// before sending and tests can assert validity without a full
410 /// encode/decode roundtrip.
411 pub const fn validate(&self) -> Result<(), MessageValidationError> {
412 if self.body.rate_per_mb > MAX_RATE_PER_MB {
413 return Err(MessageValidationError::RateTooLarge {
414 rate: self.body.rate_per_mb,
415 });
416 }
417 // #252: requesters MUST reject a zero rate on receive. The decode hook
418 // only bounds the upper end (a peer-controlled overflow source, #378);
419 // zero is a legitimate `u64` the wire accepts but a requester must not.
420 if self.body.rate_per_mb == 0 {
421 return Err(MessageValidationError::RateIsZero);
422 }
423 if self.slash_sig.len() != SLASH_SIG_LEN {
424 return Err(MessageValidationError::InvalidSlashSigLen {
425 len: self.slash_sig.len(),
426 });
427 }
428 Ok(())
429 }
430}
431
432// Field-level deserialize hook so the protocol boundary rejects oversize
433// `rate_per_mb` (issue #378). Using `#[serde(deserialize_with)]` rather
434// than a hand-written `impl Deserialize for ProbeResponseBody` keeps the
435// struct's field order and codec in lockstep with the derive — there is
436// no mirror struct to drift out of sync. The wire bytes are byte-identical
437// to what a fully-derived `Deserialize` would have read, so postcard's
438// positional layout is preserved.
439pub(crate) fn deserialize_rate_per_mb<'de, D>(deserializer: D) -> Result<u64, D::Error>
440where
441 D: Deserializer<'de>,
442{
443 let rate = u64::deserialize(deserializer)?;
444 if rate > MAX_RATE_PER_MB {
445 return Err(de::Error::custom(MessageValidationError::RateTooLarge {
446 rate,
447 }));
448 }
449 Ok(rate)
450}
451
452#[cfg(test)]
453mod tests;