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