decdn_protocol/client.rs
1//! Wire message payload types for the `cdn/client/v1` paid-delivery protocol.
2//!
3//! `cdn/client/v1` is the paid byte-transfer path (ADR 005 §`cdn/client/v1`):
4//! a payer opens a bidirectional QUIC stream, sends a [`StreamRequest`], and
5//! the delivering node answers with a [`StreamResponse`] followed by a loop of
6//! [`ChunkData`] interleaved with cumulative payment [`Voucher`]s, terminating
7//! in [`ClientMessage::StreamEnd`]. Acceptance of a voucher is implicit —
8//! delivery simply continues; only rejection is signalled, via
9//! [`ClientMessage::StreamError`]. A delivery or payment fault is signalled by
10//! [`StreamError`].
11//!
12//! Like [`crate::message`] this is a leaf crate with **no crypto dependency**.
13//! The two signed artifacts on this protocol are produced/verified by
14//! `decdn_incentive`:
15//! - `StreamResponse.slash_sig` — an EIP-712 secp256k1 signature over the
16//! signed body fields `{hash, ok, rate_per_mb, total_bytes, pool_id,
17//! timestamp_us}` (ADR 014 §1), produced by the stream-response slash
18//! signer in `decdn_incentive` (analogous to its `ProbeSlashData`).
19//! `error` is unsigned.
20//! - `Voucher.signature` — an EIP-712 secp256k1 voucher signature; the wire
21//! carries `{signature, amount}` and the receiver reconstructs the full typed
22//! data `{poolId, signer, provider, amount, bytesDelivered}` from stream
23//! context (ADR 005 §Voucher wire format, `decdn_incentive::Voucher`).
24//!
25//! # Signed-field freezing (ADR 013 §Signed Field Freezing)
26//!
27//! The signed set on [`StreamResponseBody`] is the `cdn/client/v1` v1 baseline.
28//! Any change to it is a Tier-3 ALPN bump. New unsigned fields go on
29//! [`StreamResponse`] as optional fields, following the `total_bytes` exemplar
30//! on [`crate::message::ProbeResponse`].
31//!
32//! # Variant order
33//!
34//! [`ClientMessage`], [`StreamError`], and [`VoucherRejectReason`] all have
35//! **frozen** variant order — postcard encodes each variant as its
36//! declaration-order index. Reordering is a wire-breaking change; the
37//! `*_discriminant_is_*` / `*_variant_order` tests lock the assignments.
38
39use serde::{Deserialize, Serialize};
40
41use crate::message::{MAX_RATE_PER_MB, MessageValidationError, SLASH_SIG_LEN};
42
43/// One megabyte in bytes (ADR 003: 1 MB = 1,048,576 bytes, exactly). The unit
44/// of `rate_per_mb` and, by the identity below, of [`CHUNK_BYTES`].
45pub const MB_BYTES: u64 = 1_048_576;
46
47/// The payment quantum: one chunk of delivery, 1 MiB (ADR 003 §Chunk Cadence).
48///
49/// A protocol constant, never negotiated. No message carries it, no node
50/// advertises it, and governance does not move it — a chunk is the unit one
51/// hash-chain tick pays for, so payer and node disagreeing on it would make one
52/// released preimage worth two different amounts.
53///
54/// `CHUNK_BYTES == MB_BYTES` by identity, which is what makes a chunk cost
55/// exactly the advertised `rate_per_mb` with no rounding at any rate.
56pub const CHUNK_BYTES: u64 = MB_BYTES;
57
58/// The highest chain index: the hash chain's incremental range over its anchor
59/// (ADR 003 §Chain length and rollover).
60///
61/// The index space is the `u8` domain `0..=255` — 256 slots, exactly one byte.
62/// Index 0 names `chain_root` and resolves to the voucher's own `amount`, so
63/// the base voucher is payable with no chain at all; it adds no increment,
64/// which is why it never travels the wire. Indices `1..=255` each release one
65/// preimage and add one `chunk_price` over that anchor, so a chain adds up to
66/// 255 MiB at [`CHUNK_BYTES`]. 256 slots = one payable base + 255 increments.
67pub const MAX_CHAIN_LENGTH: u8 = 255;
68
69/// Exact byte length of an EOA secp256k1 voucher signature (`r‖s‖v`, 32+32+1).
70/// Mirrors [`SLASH_SIG_LEN`]; both are the EOA off-chain signing form (ADR 024
71/// §Off-Chain Signature Verification — EOA Recovery Only). Carried as a `Vec<u8>` on the wire (serde
72/// derives array impls only up to `[T; 32]`), with the length pinned by
73/// [`Voucher::validate`].
74pub const VOUCHER_SIG_LEN: usize = 65;
75
76/// Exact byte length of a `BindNodeId` client-binding signature (`r‖s‖v`,
77/// 32+32+1). The same EOA off-chain EIP-712 signing form as [`SLASH_SIG_LEN`] /
78/// [`VOUCHER_SIG_LEN`] (ADR 024 §Off-Chain Signature Verification — EOA Recovery Only); pinned by
79/// [`ClientBinding::validate`].
80pub const BINDING_SIG_LEN: usize = 65;
81
82/// Top-level protocol enum for `cdn/client/v1`. Variant order is frozen per
83/// ADR 013 — new variants MUST be appended at the end.
84///
85/// ⚠️ **VARIANT ORDER FROZEN — ADR 013 §Protocol Enums**
86/// Postcard encodes each variant as its declaration-order index. Reordering,
87/// inserting, or removing a variant is a wire-breaking change requiring an ALPN
88/// version bump (`cdn/client/v2`). The discriminant assignments are locked by
89/// the `client_message_*_discriminant_is_*` tests.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub enum ClientMessage {
92 /// discriminant 0 — payer → node, opens a delivery.
93 StreamRequest(StreamRequest),
94 /// discriminant 1 — node → payer, answers a [`StreamRequest`].
95 StreamResponse(StreamResponse),
96 /// discriminant 2 — node → payer, one sequential blob chunk.
97 ChunkData(ChunkData),
98 /// discriminant 3 — payer → node, cumulative payment voucher. Acceptance
99 /// is implicit — delivery simply continues; only rejection is signalled,
100 /// via [`Self::StreamError`].
101 Voucher(Voucher),
102 /// discriminant 4 — payer → node, one released hash-chain preimage,
103 /// advancing the lane's claim by one chunk without a signature (ADR 003
104 /// §Hash-chain metering (`PayWord`)). Acceptance is implicit, exactly as for
105 /// [`Self::Voucher`].
106 ChunkPreimage(ChunkPreimage),
107 /// discriminant 5 — node → payer: the node delivered the whole requested
108 /// range, holds payment for every byte of it, and ends the stream.
109 StreamEnd,
110 /// discriminant 6 — node → payer, mid-stream failure (carries
111 /// [`StreamError::VoucherRejected`]); delivery-side errors instead ride in
112 /// [`StreamResponseExt::error`].
113 StreamError(StreamError),
114}
115
116impl crate::framing::TopLevelEnum for ClientMessage {
117 /// `StreamRequest` (0) … `StreamError` (6). Pinned by
118 /// `client_message_variant_count_matches_discriminants`.
119 const VARIANT_COUNT: u32 = 7;
120}
121
122impl ClientMessage {
123 /// Validate the payload's requester-side invariants, dispatching to the
124 /// per-variant `validate()`. This is the single decode-then-validate seam a
125 /// receive-path handler should call after [`crate::decode_message`] so the
126 /// zero-rate / signature-length / `ok`-`error` checks can't be silently
127 /// skipped (they are not enforced at decode time — see
128 /// [`StreamResponse::validate`]).
129 ///
130 /// The `ok`/`error` agreement is NOT among them: `error` lives in the trailing
131 /// [`StreamResponseExt`], which this method cannot see. Neither extension is
132 /// reachable from here — validate [`StreamRequestExt`] via its own `validate`
133 /// after [`parse_stream_request_ext`], and [`StreamResponseExt`] via
134 /// [`StreamResponseExt::validate`] with `body.ok` after
135 /// [`parse_stream_response_ext`].
136 /// Variants with no value invariants (`StreamEnd`, `StreamError`) return
137 /// `Ok(())`.
138 ///
139 /// [`ChunkData`] is dispatched here for TOTALITY over the enum, and for nothing more
140 /// (#1145 review). Two facts about this method are easy to misread, and either mistake
141 /// leads the next reader to rely on a check that is not here:
142 ///
143 /// - No receive loop calls this. `read_client_message` decodes and returns; only
144 /// `StreamResponse::validate` runs on the receive path, explicitly, via
145 /// `verify_response`. This aggregate is a convenience, not a load-bearing seam.
146 /// - The `ChunkData` arm cannot fail. Its field is private behind `#[serde(try_from)]`,
147 /// which makes `ChunkData::validate` total — its own doc says so ("always `Ok` for a
148 /// frame that exists").
149 ///
150 /// The empty-frame floor #1088 needs is enforced by the DECODE GATE, not by this
151 /// dispatch: an empty frame cannot be constructed *or* deserialized, so no receive loop
152 /// has to remember anything. That is what makes the floor structural rather than a
153 /// convention.
154 ///
155 /// # Errors
156 ///
157 /// Propagates the [`MessageValidationError`] from the wrapped payload's
158 /// `validate()`.
159 pub const fn validate(&self) -> Result<(), MessageValidationError> {
160 match self {
161 Self::StreamResponse(resp) => resp.validate(),
162 Self::Voucher(voucher) => voucher.validate(),
163 Self::ChunkData(chunk) => chunk.validate(),
164 Self::ChunkPreimage(preimage) => preimage.validate(),
165 Self::StreamRequest(_) | Self::StreamEnd | Self::StreamError(_) => Ok(()),
166 }
167 }
168}
169
170/// Payer → node request opening a paid delivery (ADR 005 §`cdn/client/v1`).
171///
172/// `timestamp_us` is a requester-generated microsecond timestamp echoed back in
173/// [`StreamResponseBody::timestamp_us`]; it both correlates the response and
174/// drives rate-manipulation slashing (ADR 005, ADR 014). `byte_offset` supports
175/// seek/resume on failover — the requester reconnects to another node and
176/// resumes from the last BLAKE3-verified byte.
177///
178/// This struct holds **only the frozen base fields** (ADR 013 §Tier 1). The
179/// optional [`StreamRequestExt`] is carried as **separate trailing bytes** after
180/// this message in the frame, parsed via [`parse_stream_request_ext`] — *not* as
181/// an embedded field. This is the ADR 005 §Client identity binding two-phase
182/// deserialization pattern: keeping the base frozen and parsing extension bytes
183/// separately means a future field added to [`StreamRequestExt`] stays
184/// backward-compatible (old senders simply emit fewer trailing bytes; new
185/// optional fields are appended and old receivers skip them). Embedding `ext` as
186/// a nested struct field instead would break old `Some(...)` senders the moment
187/// a field is added (postcard would hit EOF inside the nested struct). Use
188/// [`encode_stream_request`] to build the wire payload with an optional `ext`.
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190pub struct StreamRequest {
191 /// BLAKE3 hash of the requested blob (iroh `Hash`, 32 bytes).
192 pub hash: [u8; 32],
193 /// Namespace the content is published under, as a big-endian `uint256`
194 /// (ADR 002 §Retrieval by namespace, ADR 005 §Namespace routing). All-zero
195 /// = no namespace: the node serves best-effort from local cache / DHT only,
196 /// with no authorized origins. A non-zero value routes cache-miss origin
197 /// pulls to that namespace's `OriginAssignment` set. It is a **routing hint,
198 /// not a trust anchor** — returned bytes are verified against `hash`
199 /// independently, so a wrong/hostile value can only fail the fetch, never
200 /// corrupt delivery. The node layer converts this to an alloy `U256`;
201 /// keeping it `[u8; 32]` here (like `hash`/`pool_id`) leaves `protocol`
202 /// alloy-free. Billing-agnostic but load-bearing for routing, so it lives in
203 /// the frozen base — every node reads it for origin routing (a node with no
204 /// chain origin directory configured resolves nothing from it).
205 pub namespace_id: [u8; 32],
206 /// `poolId = keccak256(owner, poolNonce)` (ADR 005).
207 pub pool_id: [u8; 32],
208 /// Resume position in bytes; `0` for a full-blob fetch.
209 pub byte_offset: u64,
210 /// Upper bound on the requested range: the request covers the half-open span
211 /// `[byte_offset, byte_offset + byte_len)`. `0` means "to end-of-blob" (the
212 /// whole-tail default, so an unset value preserves the prior behavior). A
213 /// non-zero `byte_len` lets a node scope a cache-miss origin fetch to exactly
214 /// the requested bytes and meters payment over the range (ADR 005 §Bounded
215 /// byte ranges, ADR 037 §Origin-tier pull-through). Billing-relevant, so it
216 /// lives in the frozen base — every node must understand it.
217 pub byte_len: u64,
218 /// Requester-generated microseconds since the Unix epoch, echoed back.
219 pub timestamp_us: u64,
220}
221
222/// The reserved "no namespace" [`StreamRequest::namespace_id`] value (all-zero
223/// big-endian `uint256`): content published without a namespace, served
224/// best-effort from cache / DHT with no authorized origins (ADR 002 §Namespace 0).
225pub const NO_NAMESPACE: [u8; 32] = [0u8; 32];
226
227/// Optional [`StreamRequest`] extension fields (ADR 005 §Client identity
228/// binding), carried as trailing bytes after the `StreamRequest` message via the
229/// two-phase pattern (see [`encode_stream_request`] / [`parse_stream_request_ext`]).
230#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
231pub struct StreamRequestExt {
232 /// Off-chain client identity binding (address + attesting signature). Grouped
233 /// so a half-populated state (address without signature, or vice versa) is
234 /// unrepresentable; absent ⇒ a registered/on-chain client.
235 pub binding: Option<ClientBinding>,
236 /// The pool owner's spending capability for this stream's `signer`,
237 /// attached at session start so the delivering node can register the
238 /// signer on that signer's first on-chain redemption (ADR 003 §Capability
239 /// delegation). Node-agnostic: the same capability is valid at every node
240 /// the client streams from, since it grants spend against the pool, not
241 /// against a specific delivering node. Absent ⇒ the node already has the
242 /// signer registered (or the client is relying on a capability it sent on
243 /// an earlier stream to a different node this session).
244 pub capability: Option<WireCapability>,
245}
246
247/// Off-chain ephemeral client identity binding (ADR 003 §Off-Chain Ephemeral
248/// Binding), carried inside [`StreamRequestExt`]. The address and its attesting
249/// signature are paired so neither can appear without the other.
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251pub struct ClientBinding {
252 /// Ephemeral client Ethereum address (20 bytes) issuing vouchers for an
253 /// off-chain (unregistered) client.
254 pub ethereum_address: [u8; 20],
255 /// EIP-712 `BindNodeId(nodeId, nonce=0)` signature attesting the
256 /// `NodeId`↔`ethereum_address` mapping for the connection's lifetime. Exactly
257 /// [`BINDING_SIG_LEN`] bytes; verified by `decdn_incentive` via `ecrecover`.
258 pub binding_signature: Vec<u8>,
259}
260
261impl ClientBinding {
262 /// Validate the wire-level `binding_signature` length ([`BINDING_SIG_LEN`]).
263 /// The cryptographic attestation check (`ecrecover` over the `BindNodeId`
264 /// typed data) happens in `decdn_incentive`.
265 ///
266 /// # Errors
267 ///
268 /// [`MessageValidationError::InvalidBindingSigLen`] if the signature is not
269 /// exactly [`BINDING_SIG_LEN`] bytes.
270 pub const fn validate(&self) -> Result<(), MessageValidationError> {
271 if self.binding_signature.len() != BINDING_SIG_LEN {
272 return Err(MessageValidationError::InvalidBindingSigLen {
273 len: self.binding_signature.len(),
274 });
275 }
276 Ok(())
277 }
278}
279
280/// A pool owner's spending capability, carried inside [`StreamRequestExt`]
281/// (ADR 003 §Capability delegation). `signer` and `pool_id` are not on the
282/// wire — they are derived from stream context, exactly like a [`Voucher`]'s
283/// implicit fields: `signer` is the request's bound Ethereum address
284/// ([`ClientBinding::ethereum_address`], or the registered on-chain signer
285/// when `binding` is absent) and `pool_id` is [`StreamRequest::pool_id`].
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287pub struct WireCapability {
288 /// Maximum cumulative amount the signer may spend against the pool under
289 /// this grant. A `u64`, matching the `PaymentPool.spendingCap` on-chain
290 /// storage/calldata width exactly — the node zero-extends it to a `uint256`
291 /// word when it reconstructs the EIP-712 capability digest (just as it does
292 /// [`Voucher::amount`]).
293 pub spending_cap: u64,
294 /// Unix-seconds expiry. After it, vouchers under this capability are no
295 /// longer redeemable.
296 pub expiry: u64,
297 /// EIP-712 owner signature over the capability (`r‖s‖v`, 65 bytes for an
298 /// EOA owner; may be longer for an ERC-1271 contract owner, so unlike
299 /// [`VOUCHER_SIG_LEN`]-pinned signatures this is only checked non-empty at
300 /// the wire boundary — [`WireCapability::validate`]). The cryptographic
301 /// recovery/verification happens in `decdn_incentive`.
302 pub owner_signature: Vec<u8>,
303}
304
305impl WireCapability {
306 /// Validate the wire-level `owner_signature` non-emptiness. Unlike
307 /// [`ClientBinding::validate`] / [`Voucher::validate`] this cannot pin an
308 /// exact length — an ERC-1271 contract signature may be longer than the
309 /// 65-byte EOA form — so this is a floor, not a full shape check.
310 ///
311 /// # Errors
312 ///
313 /// [`MessageValidationError::EmptyCapabilitySignature`] if `owner_signature`
314 /// is empty.
315 pub const fn validate(&self) -> Result<(), MessageValidationError> {
316 if self.owner_signature.is_empty() {
317 return Err(MessageValidationError::EmptyCapabilitySignature);
318 }
319 Ok(())
320 }
321}
322
323impl StreamRequestExt {
324 /// Validate the (if present) client binding and capability. Called by the
325 /// node on the receive path after [`parse_stream_request_ext`]; kept
326 /// separate from parsing so forward-compatible trailing bytes don't couple
327 /// to value checks.
328 ///
329 /// # Errors
330 ///
331 /// [`MessageValidationError::InvalidBindingSigLen`] if a present `binding`
332 /// has a wrong-length signature; [`MessageValidationError::EmptyCapabilitySignature`]
333 /// if a present `capability` has an empty `owner_signature`.
334 pub const fn validate(&self) -> Result<(), MessageValidationError> {
335 if let Some(binding) = &self.binding {
336 // `?` is not yet stable in `const fn`; match-return instead.
337 match binding.validate() {
338 Ok(()) => {}
339 Err(e) => return Err(e),
340 }
341 }
342 if let Some(capability) = &self.capability {
343 match capability.validate() {
344 Ok(()) => {}
345 Err(e) => return Err(e),
346 }
347 }
348 Ok(())
349 }
350}
351
352/// Encode a `cdn/client/v1` `StreamRequest` frame payload with an optional
353/// trailing [`StreamRequestExt`] (ADR 005 §Client identity binding, two-phase).
354///
355/// The result is the full frame payload to pass to [`crate::write_frame`]: the
356/// postcard-encoded `ClientMessage::StreamRequest(req)` followed, when `ext` is
357/// `Some`, by the postcard-encoded extension. The receiver recovers `req` from
358/// [`crate::decode_message`] and the extension from that call's returned
359/// remainder via [`parse_stream_request_ext`].
360///
361/// # Errors
362///
363/// Propagates a [`postcard::Error`] if serialization fails.
364pub fn encode_stream_request(
365 req: &StreamRequest,
366 ext: Option<&StreamRequestExt>,
367) -> Result<Vec<u8>, postcard::Error> {
368 let mut buf = postcard::to_allocvec(&ClientMessage::StreamRequest(req.clone()))?;
369 if let Some(ext) = ext {
370 buf.extend_from_slice(&postcard::to_allocvec(ext)?);
371 }
372 Ok(buf)
373}
374
375/// Parse the trailing [`StreamRequestExt`] bytes returned as the remainder by
376/// [`crate::decode_message`] after a `ClientMessage::StreamRequest`.
377///
378/// An empty remainder ⇒ [`StreamRequestExt::default`] (no client binding, no
379/// capability). Trailing bytes beyond the known fields are tolerated for
380/// forward compatibility (ADR 013 §Tier 1): a future optional field appended to
381/// [`StreamRequestExt`] is read by new receivers and skipped by old ones.
382///
383/// # Errors
384///
385/// Returns a [`postcard::Error`] if a non-empty remainder is not a valid
386/// `StreamRequestExt` prefix.
387pub fn parse_stream_request_ext(remainder: &[u8]) -> Result<StreamRequestExt, postcard::Error> {
388 if remainder.is_empty() {
389 Ok(StreamRequestExt::default())
390 } else {
391 Ok(postcard::take_from_bytes::<StreamRequestExt>(remainder)?.0)
392 }
393}
394
395/// Node → payer response to a [`StreamRequest`] (ADR 005 §`cdn/client/v1`).
396///
397/// **This struct holds only the frozen base** (ADR 013 §Tier 1): the signed
398/// [`StreamResponseBody`] and the `slash_sig` covering it. Unsigned fields live
399/// in [`StreamResponseExt`], which travels as separate trailing bytes — see
400/// [`encode_stream_response`] / [`parse_stream_response_ext`].
401///
402/// `slash_sig` is mandatory and non-empty (exactly [`SLASH_SIG_LEN`] bytes);
403/// requesters MUST reject missing/zero-length or zero-`rate_per_mb` responses
404/// (enforced via [`StreamResponse::validate`]).
405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
406pub struct StreamResponse {
407 /// Signed body. Its wire layout is frozen per ADR 013.
408 pub body: StreamResponseBody,
409 /// EIP-712 secp256k1 signature over `body`'s signed fields (ADR 014 §1;
410 /// produced by the `decdn_incentive` stream slash signer, analogous to its
411 /// `ProbeSlashData`). Always exactly [`SLASH_SIG_LEN`] bytes — *not* a
412 /// signature over postcard bytes.
413 pub slash_sig: Vec<u8>,
414}
415
416/// Optional [`StreamResponse`] extension fields, carried as trailing bytes after
417/// the `StreamResponse` message via the two-phase pattern (ADR 013 §Tier 1; see
418/// [`encode_stream_response`] / [`parse_stream_response_ext`]).
419///
420/// Nothing here is covered by `slash_sig`. New fields are appended to the END
421/// and MUST be `Option<T>` or have a meaningful `Default`; insertions and
422/// reordering are Tier-3.
423#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
424pub struct StreamResponseExt {
425 /// Open-time refusal class when `body.ok == false` (`NotFound`,
426 /// `Declined`, `Unfunded`).
427 /// Unsigned and informational. `VoucherRejected` never rides here — it is
428 /// delivered mid-stream via [`ClientMessage::StreamError`].
429 ///
430 /// Its agreement with the signed `body.ok` is a cross-half invariant, so it
431 /// is checked by [`StreamResponseExt::validate`], which takes `ok` — the
432 /// base-only [`StreamResponse::validate`] cannot see this field.
433 pub error: Option<StreamError>,
434}
435
436impl StreamResponseExt {
437 /// Check the unsigned half against the signed `ok` flag (ADR 005
438 /// §`cdn/client/v1`): `ok == true` ⇒ no `error`; `ok == false` ⇒ exactly one
439 /// delivery-side `error`, never the mid-stream-only
440 /// [`StreamError::VoucherRejected`].
441 ///
442 /// Separate from parsing so forward-compatible trailing bytes do not couple
443 /// to value checks, and separate from [`StreamResponse::validate`] because
444 /// the rule spans the frozen base and the extension — a receiver holds both
445 /// and must call each.
446 ///
447 /// # Errors
448 ///
449 /// [`MessageValidationError::StreamErrorWithOk`],
450 /// [`MessageValidationError::MissingStreamError`], or
451 /// [`MessageValidationError::VoucherRejectedInResponse`].
452 pub const fn validate(&self, ok: bool) -> Result<(), MessageValidationError> {
453 match (ok, &self.error) {
454 (true, Some(_)) => Err(MessageValidationError::StreamErrorWithOk),
455 (false, None) => Err(MessageValidationError::MissingStreamError),
456 (false, Some(code)) if !code.is_delivery_side() => {
457 Err(MessageValidationError::VoucherRejectedInResponse)
458 }
459 _ => Ok(()),
460 }
461 }
462}
463
464/// Encode a [`StreamResponse`] with its optional trailing [`StreamResponseExt`]
465/// (ADR 013 §Tier 1, two-phase). See [`encode_stream_request`] for why the two
466/// values are encoded separately.
467///
468/// # Errors
469///
470/// Propagates a [`postcard::Error`] if serialization fails.
471pub fn encode_stream_response(
472 resp: &StreamResponse,
473 ext: Option<&StreamResponseExt>,
474) -> Result<Vec<u8>, postcard::Error> {
475 let mut buf = postcard::to_allocvec(&ClientMessage::StreamResponse(resp.clone()))?;
476 if let Some(ext) = ext {
477 buf.extend_from_slice(&postcard::to_allocvec(ext)?);
478 }
479 Ok(buf)
480}
481
482/// Parse the trailing [`StreamResponseExt`] bytes returned as the remainder by
483/// [`crate::decode_message`] after a `ClientMessage::StreamResponse`.
484///
485/// An empty remainder ⇒ [`StreamResponseExt::default`] (no error code, which is
486/// only valid alongside `ok == true`). Trailing bytes beyond the known fields
487/// are tolerated for forward compatibility (ADR 013 §Tier 1).
488///
489/// # Errors
490///
491/// Returns a [`postcard::Error`] if a non-empty remainder is not a valid
492/// `StreamResponseExt` prefix.
493pub fn parse_stream_response_ext(remainder: &[u8]) -> Result<StreamResponseExt, postcard::Error> {
494 if remainder.is_empty() {
495 Ok(StreamResponseExt::default())
496 } else {
497 Ok(postcard::take_from_bytes::<StreamResponseExt>(remainder)?.0)
498 }
499}
500
501/// Signed fields of a [`StreamResponse`] (ADR 014 §1). Layout is frozen per
502/// ADR 013 — future additions go on [`StreamResponse`] as optional unsigned
503/// fields, not here.
504///
505/// `rate_per_mb` is bounded by [`MAX_RATE_PER_MB`] at the wire boundary via the
506/// shared `deserialize_rate_per_mb` hook (#378), keeping the bound bilateral
507/// with the node's config layer.
508#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
509pub struct StreamResponseBody {
510 /// BLAKE3 hash this response answers for, echoed from the request.
511 pub hash: [u8; 32],
512 /// Whether the node will serve the blob. A node MUST NOT sign `true` unless
513 /// it can deliver within the slashing window.
514 pub ok: bool,
515 /// Quoted rate in token base units per MB. Bounded by [`MAX_RATE_PER_MB`].
516 #[serde(deserialize_with = "crate::message::deserialize_rate_per_mb")]
517 pub rate_per_mb: u64,
518 /// Total blob size in bytes, as the signing node claims it. A pulling node
519 /// does not enforce its `max_blob_size` against this claim: it enforces the
520 /// ceiling on the bytes it receives (ADR 005 §`max_blob_size` enforcement).
521 pub total_bytes: u64,
522 /// `pool_id` echoed from the [`StreamRequest`] (signed, so a node cannot
523 /// silently re-bind the response to a different pool).
524 pub pool_id: [u8; 32],
525 /// Requester-generated microsecond timestamp from the [`StreamRequest`],
526 /// echoed back unchanged.
527 pub timestamp_us: u64,
528}
529
530impl StreamResponse {
531 /// Validate requester-side invariants (ADR 005, ADR 014 §1, #252):
532 ///
533 /// - `rate_per_mb` within `(0, MAX_RATE_PER_MB]` (the decode path already
534 /// enforces the upper bound via `deserialize_rate_per_mb`; this adds the
535 /// zero-rate rule),
536 /// - `slash_sig` exactly [`SLASH_SIG_LEN`] bytes.
537 ///
538 /// The `ok`/`error` consistency rule is NOT here: `error` lives in
539 /// [`StreamResponseExt`], which this method cannot see. A receiver must also
540 /// call [`StreamResponseExt::validate`] with `body.ok`.
541 ///
542 /// Exposed so requesters re-check on receive and construction sites assert
543 /// validity before signing.
544 pub const fn validate(&self) -> Result<(), MessageValidationError> {
545 if self.body.rate_per_mb > MAX_RATE_PER_MB {
546 return Err(MessageValidationError::RateTooLarge {
547 rate: self.body.rate_per_mb,
548 });
549 }
550 // #252: requesters MUST reject a zero rate on receive — same rule as
551 // ProbeResponse. Shared `RateIsZero` so both paths fail identically.
552 if self.body.rate_per_mb == 0 {
553 return Err(MessageValidationError::RateIsZero);
554 }
555 if self.slash_sig.len() != SLASH_SIG_LEN {
556 return Err(MessageValidationError::InvalidSlashSigLen {
557 len: self.slash_sig.len(),
558 });
559 }
560 Ok(())
561 }
562}
563
564/// Node → payer chunk of blob bytes. The payload carries at least one byte; its
565/// length is the sender's choice, bounded above by the framing layer's
566/// [`MAX_MESSAGE_SIZE`](crate::framing::MAX_MESSAGE_SIZE); ADR 005
567/// §`cdn/client/v1` additionally requires a sender not to cross a `CHUNK_BYTES`
568/// payment boundary, so in practice a frame runs to at most one interval. Frames need not be uniform, and the frame before
569/// [`ClientMessage::StreamEnd`] is commonly shorter than the ones before it.
570///
571/// Frame size is independent of both verification and payment. The bao codec is
572/// the sole verifier and resolves chunk-group boundaries itself (ADR 038), and
573/// the payment meter ticks on cumulative bytes crossing [`CHUNK_BYTES`] rather
574/// than on frames. A sender therefore picks whatever size suits it; nothing is
575/// negotiated and no message carries the choice.
576///
577/// The lower bound is load-bearing, not cosmetic (#1088). A shorter final frame is
578/// permitted, never an *empty* one: an empty frame carries no
579/// payload, so it advances neither the receiver's cumulative byte count nor its
580/// voucher accounting. An unbounded run of them therefore drives the receive
581/// loops without making application-level progress, and the `cumulative >
582/// expected_wire` overrun guard — which only ever trips on bytes — never fires.
583/// An empty frame cannot be obtained at all — [`ChunkData::new`], the `try_from` decode
584/// gate, and every `encode_chunk_*` helper ([`encode_chunk_frame`],
585/// [`encode_chunk_frame_headers`], and the crate-private one the latter builds on)
586/// each reject one, and between them they are every route to a frame body. That is the invariant the pull
587/// paths' inactivity deadline rests on: with empty frames banned, "a frame arrived" and
588/// "bytes made progress" are the same statement, so a peer cannot refresh the deadline
589/// with padding.
590///
591/// # The bounds are enforced by construction
592///
593/// The field is private and [`ChunkData::new`] is the only constructor, so an invalid
594/// frame cannot be built — and `#[serde(try_from)]` routes decoding through the same
595/// check, so it cannot be *decoded* either. A receive loop therefore holds a valid frame
596/// by having one at all, and a serve path cannot emit an empty frame even by accident.
597///
598/// The invariant lives on the type, not in a convention, because a convention here is
599/// silently skippable. Were the floor an advisory `validate()`, it would ride on every
600/// receive loop remembering to call it and every emitter avoiding an empty frame — and
601/// the serve side avoids one only *incidentally*, differently on each path:
602///
603/// each serve path cuts its byte stream into frames and emits whatever has
604/// accumulated, so an empty frame is avoided only because an exhausted stream yields
605/// nothing to emit. The empty blob (whose bao encoding is zero bytes — see
606/// `decdn_bao_range::align_range`) reaches [`ClientMessage::StreamEnd`] the same way,
607/// with no frame sent first (#1054).
608///
609/// That is a fact about unrelated code, and it could change without anyone noticing
610/// which invariant they had just removed — while the requester's receive loop depends on it
611/// (#1797): an empty frame advances neither the cumulative delivered bytes nor the voucher
612/// accounting, so a run of them would drive that loop without delivering any content. The
613/// requester's throughput floor counts bytes off the wire, envelopes included, so it is this
614/// floor — not that counter — that keeps wire progress equal to content progress.
615/// That is too much weight for a convention, so the type carries the floor instead: the field
616/// is private, both `ChunkData` doors reject an empty payload, and the
617/// `encode_chunk_*` helpers — which build no `ChunkData`, and among them
618/// [`encode_chunk_frame_headers`] is the door both serve paths use — restate the same
619/// check on `payload_len`. Every route is closed.
620#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
621#[serde(try_from = "ChunkDataWire")]
622pub struct ChunkData {
623 /// Sequential blob bytes, at least one. Private: see the type's docs — this is
624 /// what keeps every frame a unit of delivered content (#1797).
625 bytes: Vec<u8>,
626}
627
628/// Decode shape for [`ChunkData`], which cannot deserialize directly without giving up
629/// its private field and with it the invariant. Structurally identical, so the wire
630/// format is unchanged; it exists only to be validated on the way in.
631#[derive(Deserialize)]
632struct ChunkDataWire {
633 bytes: Vec<u8>,
634}
635
636impl TryFrom<ChunkDataWire> for ChunkData {
637 type Error = MessageValidationError;
638
639 fn try_from(wire: ChunkDataWire) -> Result<Self, Self::Error> {
640 Self::new(wire.bytes)
641 }
642}
643
644impl ChunkData {
645 /// The only constructor. Enforces the payload floor: non-empty.
646 ///
647 /// There is no ceiling here. An oversized frame is refused by the framing
648 /// layer against [`MAX_MESSAGE_SIZE`](crate::framing::MAX_MESSAGE_SIZE),
649 /// which is the bound that runs before the receiver allocates.
650 ///
651 /// # Errors
652 ///
653 /// [`MessageValidationError::EmptyChunk`] for a zero-length payload.
654 pub fn new(bytes: Vec<u8>) -> Result<Self, MessageValidationError> {
655 if bytes.is_empty() {
656 return Err(MessageValidationError::EmptyChunk);
657 }
658 Ok(Self { bytes })
659 }
660
661 /// The payload. Non-empty by construction.
662 #[must_use]
663 pub fn bytes(&self) -> &[u8] {
664 &self.bytes
665 }
666
667 /// Consume the frame for its payload, avoiding a copy on the receive path.
668 #[must_use]
669 pub fn into_bytes(self) -> Vec<u8> {
670 self.bytes
671 }
672
673 /// Re-check the payload bounds.
674 ///
675 /// Always `Ok` for a frame that exists — [`Self::new`] and the `try_from` decode gate
676 /// are the only ways to obtain one. Retained because [`ClientMessage::validate`]
677 /// dispatches to it, so the aggregate validator stays total over the message enum
678 /// rather than silently skipping this variant.
679 ///
680 /// `pub(crate)`, deliberately: it is total by construction, and a public function that
681 /// cannot fail is a check the API advertises and does not have (#1145 review). Leaving it
682 /// on the committed surface would invite a caller to depend on it and make removing it a
683 /// breaking change — for a validator whose only honest answer is `Ok`. The dispatcher is
684 /// in this crate, so `pub(crate)` costs nothing and says the truth.
685 ///
686 /// # Errors
687 ///
688 /// [`MessageValidationError::EmptyChunk`], which a constructed frame cannot produce.
689 pub(crate) const fn validate(&self) -> Result<(), MessageValidationError> {
690 if self.bytes.is_empty() {
691 return Err(MessageValidationError::EmptyChunk);
692 }
693 Ok(())
694 }
695}
696
697/// The `ClientMessage::ChunkData` discriminant, as postcard writes it: the
698/// declaration index, varint-encoded, which is one byte for indices 0..=127.
699/// Pinned by `client_message_discriminants_are_frozen`.
700const CHUNK_DATA_DISCRIMINANT: u8 = 2;
701
702/// Stack-buffer size for a `ChunkData` header (discriminant + varint payload
703/// length). Sized to the unconditioned `u32` varint width — 1 + 5 — so the buffer
704/// needs no reasoning about [`crate::framing::MAX_MESSAGE_SIZE`]. That cap keeps
705/// the bytes actually written to at most 5.
706pub(crate) const CHUNK_DATA_HEADER_MAX: usize = 6;
707
708/// Stack-buffer size for the full framing + `ChunkData` header: varint
709/// `postcard_len` + the one-byte `ChunkData` discriminant + varint `payload_len`.
710/// Sized to
711/// the unconditioned `u32` varint width — 5 + 1 + 5 — for the same reason as the
712/// `ChunkData` header buffer; the cap keeps the bytes actually written to at most 9.
713pub const CHUNK_FRAME_HEADERS_MAX: usize = 11;
714
715/// Encode one [`ClientMessage::ChunkData`] frame body directly from its payload.
716///
717/// Byte-identical to `encode_message(&ClientMessage::ChunkData(ChunkData::new(payload)?))`
718/// — pinned by `encode_chunk_frame_matches_the_generic_encoder` — but it copies the
719/// payload once instead of twice. The generic path owns the bytes to build the frame
720/// (`Vec<u8>`) and postcard copies them again into its output buffer.
721///
722/// This is the single-buffer door, for callers that need the whole frame as one
723/// `Vec<u8>`. The serve paths do not use it: they encode headers only
724/// ([`encode_chunk_frame_headers`]) and send the payload uncopied beside them.
725///
726/// The non-empty floor is checked here rather than inherited from [`ChunkData::new`],
727/// since no [`ChunkData`] is built. That keeps the ADR 005 §Non-empty chunk invariant
728/// true of every frame this crate can emit, by either door.
729///
730/// # Errors
731///
732/// [`MessageValidationError::EmptyChunk`] for a zero-length payload, or
733/// [`MessageValidationError::ChunkTooLarge`] if the frame would exceed
734/// [`crate::framing::MAX_MESSAGE_SIZE`].
735pub fn encode_chunk_frame(payload: &[u8]) -> Result<Vec<u8>, MessageValidationError> {
736 if payload.is_empty() {
737 return Err(MessageValidationError::EmptyChunk);
738 }
739 // Postcard payload length `1 + varint(payload_len) + payload_len` must fit.
740 let postcard_len = chunk_frame_postcard_len(payload.len());
741 if postcard_len > crate::framing::MAX_MESSAGE_SIZE as usize {
742 return Err(MessageValidationError::ChunkTooLarge {
743 frame_len: postcard_len,
744 });
745 }
746 // Discriminant, then the `Vec<u8>` field's postcard length prefix, then the
747 // bytes. Postcard writes a sequence length as a LEB128 varint: seven bits per
748 // byte, low group first, high bit set on every byte but the last.
749 let mut out = Vec::with_capacity(1 + 5 + payload.len());
750 out.push(CHUNK_DATA_DISCRIMINANT);
751 let mut len = payload.len();
752 loop {
753 let mut byte = u8::try_from(len & 0x7F).unwrap_or(0);
754 len >>= 7;
755 if len != 0 {
756 byte |= 0x80;
757 }
758 out.push(byte);
759 if len == 0 {
760 break;
761 }
762 }
763 out.extend_from_slice(payload);
764 Ok(out)
765}
766
767/// Encode the bare `ChunkData` postcard prefix (discriminant + varint payload
768/// length) into `out`, returning the number of bytes written.
769///
770/// There is no framing length in front of it, so this alone is not a valid frame
771/// and must never be written to a stream. [`encode_chunk_frame_headers`] builds on
772/// it and is what the serve paths send.
773///
774/// # Errors
775///
776/// [`MessageValidationError::EmptyChunk`] for `payload_len == 0`, or
777/// [`MessageValidationError::ChunkTooLarge`] if the frame would exceed
778/// [`crate::framing::MAX_MESSAGE_SIZE`].
779pub(crate) fn encode_chunk_data_header(
780 payload_len: usize,
781 out: &mut [u8; CHUNK_DATA_HEADER_MAX],
782) -> Result<usize, MessageValidationError> {
783 if payload_len == 0 {
784 return Err(MessageValidationError::EmptyChunk);
785 }
786 // Postcard payload length `1 + varint(payload_len) + payload_len` must fit.
787 let postcard_len = chunk_frame_postcard_len(payload_len);
788 if postcard_len > crate::framing::MAX_MESSAGE_SIZE as usize {
789 return Err(MessageValidationError::ChunkTooLarge {
790 frame_len: postcard_len,
791 });
792 }
793 // The cap above bounds `payload_len` well inside `u32`, so this conversion and
794 // the `get_mut` guards below cannot fail. They are `indexing_slicing` lint
795 // appeasement; each still errors rather than writing a short header, so a future
796 // cap change cannot turn one into a wrong length prefix.
797 let payload_len_u32 =
798 u32::try_from(payload_len).map_err(|_| MessageValidationError::ChunkTooLarge {
799 frame_len: postcard_len,
800 })?;
801 let Some(slot) = out.get_mut(0) else {
802 return Err(MessageValidationError::ChunkTooLarge {
803 frame_len: postcard_len,
804 });
805 };
806 *slot = CHUNK_DATA_DISCRIMINANT;
807 let mut varint_buf = [0u8; 5];
808 let varint_len = crate::framing::encode_varint_u32(payload_len_u32, &mut varint_buf);
809 let total = 1usize.saturating_add(varint_len);
810 let (Some(dst), Some(src)) = (out.get_mut(1..total), varint_buf.get(..varint_len)) else {
811 return Err(MessageValidationError::ChunkTooLarge {
812 frame_len: postcard_len,
813 });
814 };
815 dst.copy_from_slice(src);
816 Ok(total)
817}
818
819/// Encode the full frame headers for a `ChunkData` payload of `payload_len`
820/// bytes: the framing varint `postcard_len` followed by the `ChunkData` header
821/// (discriminant + varint `payload_len`). The payload itself is **not**
822/// included — the caller sends it as `Bytes` alongside this header via a
823/// vectored write.
824///
825/// Returns the number of header bytes written into `out` (≤
826/// [`CHUNK_FRAME_HEADERS_MAX`]).
827///
828/// # Errors
829///
830/// [`MessageValidationError::EmptyChunk`] for `payload_len == 0`, or
831/// [`MessageValidationError::ChunkTooLarge`] if the frame would exceed
832/// [`crate::framing::MAX_MESSAGE_SIZE`].
833pub fn encode_chunk_frame_headers(
834 payload_len: usize,
835 out: &mut [u8; CHUNK_FRAME_HEADERS_MAX],
836) -> Result<usize, MessageValidationError> {
837 if payload_len == 0 {
838 return Err(MessageValidationError::EmptyChunk);
839 }
840 // The `ChunkData` header first, into a temporary, so the framing varint can
841 // carry its measured length rather than a second guess at it.
842 let mut hdr = [0u8; CHUNK_DATA_HEADER_MAX];
843 let hdr_len = encode_chunk_data_header(payload_len, &mut hdr)?;
844 let postcard_len = hdr_len.saturating_add(payload_len);
845 if postcard_len > crate::framing::MAX_MESSAGE_SIZE as usize {
846 return Err(MessageValidationError::ChunkTooLarge {
847 frame_len: postcard_len,
848 });
849 }
850 // As in `encode_chunk_data_header`: the cap bounds this inside `u32` and the
851 // slice guards below cannot fail, but each errors rather than writing a short
852 // header so a future cap change cannot turn one into a wrong length prefix.
853 let postcard_len_u32 =
854 u32::try_from(postcard_len).map_err(|_| MessageValidationError::ChunkTooLarge {
855 frame_len: postcard_len,
856 })?;
857 let mut framing_buf = [0u8; 5];
858 let framing_len = crate::framing::encode_varint_u32(postcard_len_u32, &mut framing_buf);
859 let total = framing_len.saturating_add(hdr_len);
860 let (Some(dst), Some(src)) = (out.get_mut(..framing_len), framing_buf.get(..framing_len))
861 else {
862 return Err(MessageValidationError::ChunkTooLarge {
863 frame_len: postcard_len,
864 });
865 };
866 dst.copy_from_slice(src);
867 let (Some(dst), Some(src)) = (out.get_mut(framing_len..total), hdr.get(..hdr_len)) else {
868 return Err(MessageValidationError::ChunkTooLarge {
869 frame_len: postcard_len,
870 });
871 };
872 dst.copy_from_slice(src);
873 Ok(total)
874}
875
876/// Length of the postcard payload (`CHUNK_DATA_DISCRIMINANT` + varint
877/// `payload_len` + payload) for a given `payload_len`. This is the size gate the
878/// encoders admit on; `encode_chunk_frame_headers` sizes its framing varint from the
879/// header it measured rather than from a second guess at the same number.
880///
881/// Every caller rejects `payload_len == 0` before reaching here, but the answer for
882/// it is still right: postcard writes a zero-length sequence as a single `0x00`
883/// varint, so an empty `ChunkData` body is 2 bytes.
884#[must_use]
885pub(crate) const fn chunk_frame_postcard_len(payload_len: usize) -> usize {
886 let mut len = payload_len;
887 let mut varint = 0usize;
888 loop {
889 varint = varint.saturating_add(1);
890 len >>= 7;
891 if len == 0 {
892 break;
893 }
894 }
895 1usize.saturating_add(varint).saturating_add(payload_len)
896}
897
898/// Payer → node cumulative payment voucher (ADR 005 §Voucher wire format).
899///
900/// The wire carries `{signature, amount, bytes_delivered, chain_root,
901/// chunk_price}`; the receiver reconstructs the full EIP-712 typed data
902/// `{poolId, signer, provider, amount, bytesDelivered, chainRoot, chunkPrice}`
903/// from stream context (`poolId`/`signer`/`provider` fixed for the stream) plus
904/// the self-described fields. There is no nonce: `amount` is the sole ordering
905/// and replay key — a voucher whose `amount` is no greater than the highest
906/// accepted is stale. `bytes_delivered` is an additional signed, monotone
907/// cumulative that must not regress below the highest accepted; it is what the
908/// node verifies against (rather than reconstructing) so same-lane vouchers
909/// settle independent of arrival order. Both are `u64` cumulative totals
910/// matching the contract's on-chain `uint64` `Lane`/`LaneVoucher` storage; the
911/// node zero-extends them to `uint256` to reconstruct the EIP-712 signature.
912///
913/// `amount` is the **settlement anchor**, and `chain_root` heads the optional
914/// hash chain that advances it between signatures (ADR 003 §Hash-chain metering
915/// (`PayWord`)). Redemption resolves both with one formula:
916/// `claimed = amount + chain_index × chunk_price`.
917#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
918pub struct Voucher {
919 /// EOA secp256k1 EIP-712 signature (`r‖s‖v`, exactly [`VOUCHER_SIG_LEN`]).
920 pub signature: Vec<u8>,
921 /// Cumulative payment in token base units.
922 pub amount: u64,
923 /// Cumulative bytes delivered. Signed by the client and transmitted so
924 /// the node verifies against exactly what was signed, independent of
925 /// same-lane stream ordering.
926 pub bytes_delivered: u64,
927 /// Head of the hash chain this voucher opens: `keccak^MAX_CHAIN_LENGTH` of
928 /// the payer's per-lane seed. All-zero **seals** the voucher at exactly
929 /// `amount` — no value that hashes to zero is findable, so no index above 0
930 /// can redeem against it (ADR 003 §The sealed voucher (zero-hash root)).
931 pub chain_root: [u8; 32],
932 /// The price one chunk of delivery adds over `amount`, in token base units.
933 /// Signed so the claim arithmetic is fixed at signing time. A metering
934 /// voucher MUST carry the node's quoted `rate_per_mb` (`CHUNK_BYTES ==
935 /// MB_BYTES`, so a chunk costs exactly one MB); a sealed voucher meters no
936 /// chunk and MUST carry `0`. The node rejects otherwise with
937 /// [`VoucherRejectReason::ChunkPriceMismatch`] (ADR 003 §Chunk Cadence).
938 pub chunk_price: u64,
939}
940
941impl Voucher {
942 /// Validate the wire-level `signature` length ([`VOUCHER_SIG_LEN`]). The
943 /// cryptographic checks (recovery, monotonicity, deposit bound) happen in
944 /// `decdn_incentive` once the typed data is reconstructed.
945 pub const fn validate(&self) -> Result<(), MessageValidationError> {
946 if self.signature.len() != VOUCHER_SIG_LEN {
947 return Err(MessageValidationError::InvalidVoucherSigLen {
948 len: self.signature.len(),
949 });
950 }
951 Ok(())
952 }
953}
954
955/// Payer → node released hash-chain preimage, advancing the lane's claim by one
956/// chunk with no signature (ADR 003 §Hash-chain metering (`PayWord`), ADR 005
957/// §Payment quantum and credit window).
958///
959/// Released after the payer has received and verified the chunk it pays for, so
960/// the payer's exposure stays at zero. Preimage resistance makes the value
961/// self-proving: nobody derives `keccak^(N−k−1)(s)` from `keccak^(N−k)(s)`
962/// without the seed, so a deeper preimage **is** the receipt for every chunk
963/// below it.
964///
965/// # Wire encoding
966///
967/// `preimage ‖ index`, 33 bytes flat — postcard writes a `[u8; 32]` raw and a
968/// `u8` as one byte, so there is no length prefix and no varint. The byte IS
969/// the index, with no offset on send and no increment on receipt.
970///
971/// # Index domain
972///
973/// Releasable indices are `1..=`[`MAX_CHAIN_LENGTH`]. `index == 0` is a
974/// protocol error — index 0 names `chain_root` and is the settlement case at
975/// redemption, so it proves nothing the voucher does not already say. It is
976/// rejected **in band** by the delivery handler as
977/// [`VoucherRejectReason::ChainIndexZero`] rather than at decode, so the
978/// payer learns why instead of seeing an opaque stream close. An index above
979/// [`MAX_CHAIN_LENGTH`] cannot be encoded at all: the walk is bounded at 255
980/// hashes by the type, which is stronger than a runtime comparison.
981#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
982pub struct ChunkPreimage {
983 /// The released value, `keccak^(MAX_CHAIN_LENGTH − index)` of the payer's
984 /// per-lane seed.
985 pub preimage: [u8; 32],
986 /// Depth of `preimage` in the chain. Verified as
987 /// `keccak^(index − verified)(preimage) == tip` against the receiving
988 /// stream's anchor.
989 pub index: u8,
990}
991
992impl ChunkPreimage {
993 /// Always `Ok`. Both fields are fixed-width, so a decoded `ChunkPreimage`
994 /// has no shape to check — and the one value invariant (`index != 0`) is
995 /// deliberately **not** enforced here: it must surface as an in-band
996 /// [`VoucherRejectReason::ChainIndexZero`] from the delivery handler,
997 /// which holds the stream it has to answer on. Validating it here would
998 /// collapse that reason into a decode failure and close the stream mute.
999 ///
1000 /// Present so [`ClientMessage::validate`] stays total over the enum, for
1001 /// the same reason `ChunkData::validate` is.
1002 ///
1003 /// # Errors
1004 ///
1005 /// Never.
1006 pub const fn validate(&self) -> Result<(), MessageValidationError> {
1007 Ok(())
1008 }
1009}
1010
1011/// The node's true watermark for a pool capability, echoed back on a gated
1012/// [`StreamError::VoucherRejected`] so a wallet-less client can self-heal
1013/// (issue #1481). `amount`/`bytes_delivered` mirror the seller-side
1014/// `PoolState::last_*` fields as `u64` cumulative totals, for the same reason
1015/// as [`Voucher`] — matching the contract's on-chain `uint64` storage.
1016/// `last_signature` is the node's stored last-accepted **client** signature
1017/// (`r‖s‖v`, exactly [`VOUCHER_SIG_LEN`]) — not a node signature over this
1018/// bundle — so the client can confirm which of its own vouchers the node
1019/// holds.
1020#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1021pub struct WatermarkBundle {
1022 /// Cumulative amount of the node's last-accepted voucher.
1023 pub amount: u64,
1024 /// Cumulative bytes delivered as of the node's last-accepted voucher.
1025 pub bytes_delivered: u64,
1026 /// Root the lane is currently metering against; all-zero when the lane
1027 /// holds no live chain.
1028 pub chain_root: [u8; 32],
1029 /// Deepest chain index the node has verified under `chain_root`.
1030 pub verified_index: u8,
1031 /// The preimage bytes at `verified_index`; all-zero when the node has
1032 /// verified none. Echoed so a re-seeding signer folds
1033 /// `verified_index × chunk_price` back into the amount it re-signs rather
1034 /// than dropping the frontier (ADR 005 §Watermark bundle).
1035 pub tip: [u8; 32],
1036 /// The `chunk_price` the node's last-accepted voucher was signed at.
1037 pub chunk_price: u64,
1038 /// The client's own signature (`r‖s‖v`, exactly [`VOUCHER_SIG_LEN`]) on the
1039 /// node's last-accepted voucher. `Vec<u8>` rather than a fixed array,
1040 /// mirroring [`Voucher::signature`] — postcard/serde signature fields on
1041 /// this wire are length-prefixed `Vec<u8>`, not `[u8; N]` (serde's array
1042 /// impls top out at N=32).
1043 pub last_signature: Vec<u8>,
1044}
1045
1046impl WatermarkBundle {
1047 /// Validate the wire-level `last_signature` length ([`VOUCHER_SIG_LEN`]) —
1048 /// the same client-voucher-signature length [`Voucher::validate`] checks,
1049 /// since this IS a client voucher signature (issue #1481). A caller MUST
1050 /// call this before trusting a bundle enough to `reseed` a ledger from it:
1051 /// the bundle rides inside an application-level `StreamError`, not signed
1052 /// itself, so this is a shape check, not an authentication check — it stops
1053 /// a malformed/truncated bundle from reaching a fixed-length conversion
1054 /// downstream, nothing more.
1055 pub const fn validate(&self) -> Result<(), MessageValidationError> {
1056 if self.last_signature.len() != VOUCHER_SIG_LEN {
1057 return Err(MessageValidationError::InvalidVoucherSigLen {
1058 len: self.last_signature.len(),
1059 });
1060 }
1061 Ok(())
1062 }
1063}
1064
1065/// A stream failure code (ADR 005 §Open-time refusal classes).
1066///
1067/// `NotFound`, `Declined`, and `Unfunded` are the open-time refusal classes and
1068/// ride in [`StreamResponseExt::error`] alongside `ok: false`.
1069/// `VoucherRejected` is the only variant delivered mid-stream, inside a
1070/// [`ClientMessage::StreamError`]. All codes are unsigned and informational,
1071/// never on-chain evidence. A node keeps its finer refusal reason in its own
1072/// per-reason metrics. The wire carries only the class, because a requester
1073/// acts on the class alone.
1074///
1075/// Every refusal scopes to the node that sent it, for the current fetch. A
1076/// requester never ends a fetch on the word of one node.
1077///
1078/// Postcard encodes a struct variant's payload positionally, in declaration
1079/// order, so `VoucherRejected`'s `bundle` field sits after `reason` and any
1080/// further field appends after `bundle`.
1081#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1082pub enum StreamError {
1083 /// "No": this node cannot serve the request now. The requester tries
1084 /// other nodes. This node can answer differently later.
1085 NotFound,
1086 /// "No, and stop asking": this node will not serve this hash or request
1087 /// during this fetch. The requester removes it from the candidates for the
1088 /// hash for the rest of the fetch.
1089 Declined,
1090 /// "No at the current funding": this node will not serve this request at
1091 /// the requester's current pool deposit or signer capability (ADR 003
1092 /// §Funding recovery). The requester parks it at the current funding for
1093 /// this fetch, and asks it again only after a funding recovery step.
1094 ///
1095 /// Spoken ONLY to a requester that passed the lane-ownership proof or holds
1096 /// a verified signer binding. Any other requester gets [`Self::NotFound`]
1097 /// for the same state, so pool and signer state stay unmappable off the
1098 /// wire.
1099 Unfunded,
1100 /// Mid-stream payment-voucher rejection carried in a
1101 /// [`ClientMessage::StreamError`] message (never in the initial
1102 /// [`StreamResponse`]).
1103 VoucherRejected {
1104 /// The specific validation failure.
1105 reason: VoucherRejectReason,
1106 /// The node's true watermark plus the client's own last-accepted
1107 /// signature, attached ONLY on the watermark-gated reasons
1108 /// ([`VoucherRejectReason::is_watermark_gated`]) and ONLY
1109 /// when the rejected voucher's signature recovers to the
1110 /// capability's pinned `voucher_signer` (issue #1481 §5 security
1111 /// property — otherwise anyone who guessed the chain-derivable
1112 /// `pool_id` could pull the node's watermark). A wallet-less client
1113 /// cannot reconstruct its watermark from chain (the claim watermark
1114 /// is `0` until settlement), so this lets it self-heal: re-seed the
1115 /// ledger's PAYMENT BASELINE to `bytes_delivered` (a pool-cumulative
1116 /// counter, NOT a blob `byte_offset`) and re-sign from the new
1117 /// baseline. `None` for every reason that is not watermark-gated and
1118 /// whenever the signer does not recover to `voucher_signer`.
1119 bundle: Option<WatermarkBundle>,
1120 },
1121}
1122
1123impl StreamError {
1124 /// `true` for the delivery-side codes that ride in [`StreamResponseExt::error`]
1125 /// alongside `ok: false` — everything except `VoucherRejected`. Expresses
1126 /// the enum's domain split in code rather than only in prose, and backs the
1127 /// [`StreamResponseExt::validate`] mid-stream-only exclusion.
1128 pub const fn is_delivery_side(&self) -> bool {
1129 !self.is_mid_stream()
1130 }
1131
1132 /// `true` for the mid-stream-only [`StreamError::VoucherRejected`], which
1133 /// rides exclusively in [`ClientMessage::StreamError`].
1134 pub const fn is_mid_stream(&self) -> bool {
1135 matches!(self, Self::VoucherRejected { .. })
1136 }
1137}
1138
1139/// Why a [`Voucher`] was rejected (ADR 005 §`VoucherRejected` semantics).
1140///
1141/// The first seven variants, [`Self::BadPreimage`], and [`Self::UnderFold`]
1142/// mirror `decdn_incentive::PoolError` ∪ `VoucherError` one-to-one; the
1143/// handler-side conversion `voucher_reject_reason` matches those exhaustively
1144/// so a new `PoolError` variant fails to compile until this enum is extended
1145/// (ADR 005 §Mirror obligation). The other variants are emitted directly by the
1146/// `cdn/client/v1` handler: [`Self::CapabilityExpired`] fires when the signer's
1147/// capability has passed its expiry; [`Self::PoolExhausted`] fires when the
1148/// pool's remaining deposit can no longer fund further credit;
1149/// [`Self::SignerCapExhausted`] fires when the signer's shared cap headroom can
1150/// no longer cover a serve floor; the three remaining hash-chain reasons
1151/// ([`Self::ChainIndexZero`], [`Self::UnanchoredPreimage`],
1152/// [`Self::ChunkPriceMismatch`]) are raised where the handler holds the
1153/// per-stream chain anchor or quoted rate a validation enum cannot see; and
1154/// [`Self::Underpaid`] fires when the span a voucher adds over the lane's
1155/// accepted watermark pays below the quoted `rate_per_mb`.
1156/// Variant order is the postcard wire discriminant, so a reorder is a
1157/// wire-breaking change.
1158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1159pub enum VoucherRejectReason {
1160 /// Signature malformed (corrupted bytes, non-canonical `s`, invalid
1161 /// recovery id). `VoucherError::InvalidSignature`.
1162 BadSignature,
1163 /// Signature well-formed but recovers to the wrong signer.
1164 /// `VoucherError::WrongSigner`.
1165 WrongSigner,
1166 /// `voucher.pool_id` mismatch (also: unknown pool). `PoolError::WrongPool`.
1167 WrongPool,
1168 /// The voucher names a provider node other than the one receiving it —
1169 /// a capability voucher scoped to one node redeemed against another.
1170 /// `PoolError::WrongProvider`.
1171 WrongProvider,
1172 /// Cumulative amount regressed. `PoolError::AmountRegression`.
1173 AmountRegression,
1174 /// Cumulative bytes delivered regressed. `PoolError::BytesRegression`.
1175 BytesRegression,
1176 /// The signer's remaining spending cap is exhausted — the voucher amount
1177 /// exceeds what the capability has left to spend (`cap − spent`).
1178 /// Recovery: the pool **owner** raises this signer's cap or delegates a new
1179 /// capability. `PoolError::CapExceeded`.
1180 SpendingCapExhausted,
1181 /// The signer's capability has passed its `expiry`, or lies within the node's
1182 /// expiry margin of it. The node's serve-side check compares its LOCAL wall
1183 /// clock (operator-settable) plus that margin against `expiry`, refuses the
1184 /// voucher or preimage, and stops serving the lane. The margin is one redeem
1185 /// interval plus a landing slack, so the lane's claim is final when the
1186 /// margin starts, and the node's redeem sweep one second later has time to
1187 /// land before the expiry (ADR 003 §Revocation). A lane below the redemption
1188 /// floor can still wait past it. On-chain settlement separately gates redemption on
1189 /// `block.timestamp`. Already-earned vouchers stay redeemable until expiry at
1190 /// settlement. Recovery: the owner mints a **fresh capability** with a new expiry —
1191 /// a watermark resync or top-up does not help. Emitted directly by the
1192 /// `cdn/client/v1` handler (the node holds the clock), not via the `PoolError`
1193 /// bridge.
1194 CapabilityExpired,
1195 /// The pool's on-chain remaining deposit, minus the refundable floor `M` and
1196 /// the pool's already-committed concurrent floor credit, can no longer fund
1197 /// further credit for this stream (ADR 003 §Pool solvency). A pool-wide
1198 /// condition, not this signer's. Recovery: the pool **owner tops up the
1199 /// deposit**. Emitted mid-stream by the serve loop after the client has proved
1200 /// capability ownership; the open-time equivalent is [`StreamError::Unfunded`],
1201 /// spoken only to a proven requester. Not watermark-gated.
1202 PoolExhausted,
1203 /// The signer's shared on-chain `cap − spent` headroom, tracked across every
1204 /// provider, can no longer cover a serve floor — the signer has drained its
1205 /// `cap` at OTHER nodes since this stream was admitted, so further vouchers
1206 /// here would redeem `min(desired, cap − spent) ≈ 0` and the node would eat the
1207 /// delivered bytes (ADR 003 §Pool solvency). A per-SIGNER condition, distinct
1208 /// from [`Self::PoolExhausted`]: the pool's `remaining` can be healthy on other
1209 /// signers' budgets while this one signer's cap is spent. Recovery: the pool
1210 /// **owner** raises this signer's cap or delegates a fresh capability. Emitted
1211 /// mid-stream after the client has proved capability ownership, so naming the
1212 /// condition is post-auth and leaks nothing an open-time refusal must hide (the
1213 /// admit-time equivalent is [`StreamError::Unfunded`], spoken only to a proven
1214 /// requester). Not watermark-gated.
1215 SignerCapExhausted,
1216 /// A released [`ChunkPreimage`] does not hash to the stream's deepest
1217 /// verified preimage in `index − verified` steps (ADR 003 §Concurrent
1218 /// Streams, Rule 2). A payer bug — a wrong seed, a wrong chain, or a
1219 /// mis-derived index. On-chain the same mismatch reverts `BadPreimage`,
1220 /// which is caller error and not transient state, so this is terminal and
1221 /// carries **no** watermark bundle: a preimage has no signature of its own,
1222 /// and a payment watermark cannot repair a hash-chain mismatch.
1223 BadPreimage,
1224 /// A [`ChunkPreimage`] arrived with `index == 0`, which never travels the
1225 /// wire — index 0 names `chain_root` and is the settlement case at
1226 /// redemption. A payer bug; do not retry.
1227 ///
1228 /// Zero is the whole of it. There is no over-large index to report: the wire
1229 /// index is a `u8` and [`MAX_CHAIN_LENGTH`] is 255, so an index past the end
1230 /// of the chain cannot be encoded. A chain that has run out of indices is
1231 /// not this reason either — the payer rolls to a fresh `chain_root` first.
1232 ChainIndexZero,
1233 /// A [`ChunkPreimage`] arrived on a stream that holds no chain anchor, so
1234 /// the node cannot name the chain the reveal belongs to (ADR 003
1235 /// §Concurrent Streams, Rule 1). Per-stream and therefore decidable, which
1236 /// a lane-wide reading would not be. **Not fatal**: the payer sends the
1237 /// current epoch's `chain_root` voucher on this stream and resends the
1238 /// preimage. The resend is free — an at-or-below-watermark voucher is
1239 /// already-satisfied rather than rejected.
1240 UnanchoredPreimage,
1241 /// The voucher's `chunk_price` is not the node's quoted `rate_per_mb` on a
1242 /// metering voucher, or is non-zero on a sealed one (`chain_root == 0`).
1243 ///
1244 /// `chunk_price` is signed by the *payer* and a preimage carries no price
1245 /// of its own, so a voucher signed at the governance floor against a node
1246 /// quoting ten times that would meter every later chunk at a tenth of the
1247 /// quote, with no per-tick moment revealing it. The node therefore checks
1248 /// the price it is being paid before it meters against it (ADR 003 §Chunk
1249 /// Cadence). A payer bug: re-read `rate_per_mb` from the `StreamResponse`
1250 /// and re-sign; do not retry with the same price.
1251 ChunkPriceMismatch,
1252 /// The voucher advances the lane, but the span it adds over the node's
1253 /// last-accepted watermark pays below the quoted `rate_per_mb` (ADR 003
1254 /// §Voucher withholding). The node stops delivery and credits nothing.
1255 ///
1256 /// An honest payer reaches this only when its local watermark has diverged
1257 /// from the node's: its cumulative carries bytes the node never accepted a
1258 /// voucher for, so every later span looks short. The reject therefore carries
1259 /// the node's [`WatermarkBundle`]. Recovery: the payer verifies the bundle
1260 /// against its own key, rebases its lane to it once, and retries (ADR 005
1261 /// §`VoucherRejected` semantics).
1262 Underpaid,
1263 /// The voucher is at or above the signed anchor and names a `chain_root`
1264 /// other than the live one, which retires the lane's live chain, but its
1265 /// `amount` or `bytes_delivered` does not fold the frontier that chain
1266 /// proved: the signed anchor plus `verified_index` chunks (ADR 003 §Chain
1267 /// length and rollover). At exactly the anchor, only a voucher that opens a
1268 /// chain qualifies; a sealed one there is [`Self::AmountRegression`].
1269 /// Accepting it would discard chunks the node holds preimages for, so the
1270 /// node adopts nothing.
1271 ///
1272 /// A payer reaches this when it resumes from a watermark that trails the
1273 /// node's verified frontier. The reject carries the node's
1274 /// [`WatermarkBundle`]. Recovery: the payer verifies the bundle against its
1275 /// own key, reseeds to it with the chain frontier folded in, and retries.
1276 /// Concurrent streams on the lane get the same bundle; one that finds its
1277 /// ledger already at the bundle retries without a reseed. A voucher that
1278 /// already covers the claim's `amount` but falls short on
1279 /// `bytes_delivered` cannot heal this way — the bundle does not advance the
1280 /// payer's amount — so the payer surfaces it as terminal.
1281 /// `PoolError::UnderFold`.
1282 UnderFold,
1283}
1284
1285impl VoucherRejectReason {
1286 /// Whether a [`StreamError::VoucherRejected`] carrying this reason is
1287 /// eligible for a [`WatermarkBundle`] (issue #1481 §5): exactly five
1288 /// reasons. The three regression/exhaustion reasons are ones a wallet-less
1289 /// client cannot distinguish from chain, since its local watermark is the
1290 /// only thing that could be wrong. [`Self::Underpaid`] and
1291 /// [`Self::UnderFold`] are the other two: a payer reaches them only through
1292 /// that same watermark divergence, and the bundle states the watermark it
1293 /// has to rebase or fold to.
1294 /// Every other reason (`CapabilityExpired`, `PoolExhausted`,
1295 /// `SignerCapExhausted`, the chain reasons) and the signer/pool/provider
1296 /// mismatches are never eligible — a bundle would not help there, since the
1297 /// fix is not "resync the watermark".
1298 ///
1299 /// Single source of truth for the gate: the node checks this before
1300 /// attaching a bundle (`crates/node/src/handlers/client/voucher.rs`) and
1301 /// the client checks it again before trusting one enough to self-heal
1302 /// (`crates/client/src/lib.rs`) — both call this rather than each
1303 /// keeping their own copy of the match.
1304 #[must_use]
1305 pub const fn is_watermark_gated(self) -> bool {
1306 matches!(
1307 self,
1308 Self::SpendingCapExhausted
1309 | Self::AmountRegression
1310 | Self::BytesRegression
1311 | Self::Underpaid
1312 | Self::UnderFold
1313 )
1314 }
1315}
1316
1317#[cfg(test)]
1318mod tests;