Skip to main content

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;