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