decdn_incentive/client_bridge.rs
1//! Bridge between `cdn/client/v1` wire vouchers and the incentive voucher
2//! state machine.
3//!
4//! The protocol crate ([`decdn_protocol::Voucher`]) is crypto-free: a wire
5//! voucher carries `{signature, amount, bytes_delivered, chain_root,
6//! chunk_price}`, with `amount` and
7//! `bytes_delivered` as `u64` cumulative totals matching the contract's
8//! on-chain `uint64` storage. Validating it against a lane requires the full
9//! EIP-712 typed data `{pool_id, signer, provider, amount, bytes_delivered,
10//! chain_root, chunk_price}`
11//! — and `pool_id`, `signer`, and `provider` are **not** on the wire (ADR 005
12//! §Voucher wire format). They come from stream context: `pool_id` from the
13//! signed `StreamRequest`, `signer` the capability key, and `provider` this
14//! node. [`wire_voucher_to_signed`] reconstructs the [`SignedVoucher`] from a
15//! wire voucher plus that context, widening `u64 → U256` (infallible);
16//! [`signed_to_wire_voucher`] is the requester-side inverse, narrowing
17//! `U256 → u64` (fallible — a value above the on-chain `uint64` cap is
18//! refused, never truncated).
19//!
20//! [`voucher_reject_reason`] maps an off-chain [`PoolError`] to the wire
21//! [`VoucherRejectReason`] a node returns mid-stream. It is exhaustive with no
22//! wildcard so that adding a `PoolError` variant fails to compile until the wire
23//! enum and ADR 005 §Mirror obligation are updated.
24
25use alloy::primitives::{Address, B256, Signature, U256};
26
27use decdn_protocol::client::{Voucher as WireVoucher, VoucherRejectReason};
28
29use crate::lane::PoolError;
30use crate::voucher::{SignedVoucher, Voucher, VoucherError};
31
32/// Reconstruct a [`SignedVoucher`] from a wire voucher plus lane context.
33///
34/// `pool_id`, `signer`, and `provider` are not carried on the wire — the node
35/// supplies `pool_id` from the originating `StreamRequest`, `signer` from the
36/// registered capability, and `provider` from its own identity. `amount`,
37/// `bytes_delivered`, `chain_root` and `chunk_price` ride the wire.
38///
39/// # Errors
40///
41/// Returns [`WireVoucherError::BadSignature`] if `wire.signature` is not a
42/// well-formed 65-byte (`r‖s‖v`) secp256k1 signature.
43pub fn wire_voucher_to_signed(
44 wire: &WireVoucher,
45 pool_id: B256,
46 signer: Address,
47 provider: Address,
48) -> Result<SignedVoucher, WireVoucherError> {
49 // Length is checked by the protocol's own `Voucher::validate` (it pins
50 // `VOUCHER_SIG_LEN`); `Signature::from_raw` then enforces well-formedness.
51 wire.validate()
52 .map_err(|_| WireVoucherError::BadSignature)?;
53 let signature =
54 Signature::from_raw(&wire.signature).map_err(|_| WireVoucherError::BadSignature)?;
55 Ok(SignedVoucher {
56 voucher: Voucher {
57 pool_id,
58 signer,
59 provider,
60 amount: U256::from(wire.amount),
61 bytes_delivered: U256::from(wire.bytes_delivered),
62 chain_root: B256::from(wire.chain_root),
63 chunk_price: U256::from(wire.chunk_price),
64 },
65 signature,
66 })
67}
68
69/// Encode a [`SignedVoucher`] to its wire form (requester side). The
70/// lane-context fields (`pool_id`, `signer`, `provider`) are dropped — the
71/// receiver reconstructs them.
72///
73/// # Errors
74///
75/// Returns [`WireVoucherError::ValueExceedsWireWidth`] if `amount`,
76/// `bytes_delivered` or `chunk_price` exceeds `u64::MAX` — the on-chain pool
77/// caps all three at `uint64` (`chunk_price` rides inside the packed
78/// `chainMeter` word), so such a voucher is unredeemable; it is refused rather
79/// than truncated.
80pub fn signed_to_wire_voucher(signed: &SignedVoucher) -> Result<WireVoucher, WireVoucherError> {
81 let amount = u64::try_from(signed.voucher.amount)
82 .map_err(|_| WireVoucherError::ValueExceedsWireWidth)?;
83 let bytes_delivered = u64::try_from(signed.voucher.bytes_delivered)
84 .map_err(|_| WireVoucherError::ValueExceedsWireWidth)?;
85 let chunk_price = u64::try_from(signed.voucher.chunk_price)
86 .map_err(|_| WireVoucherError::ValueExceedsWireWidth)?;
87 Ok(WireVoucher {
88 signature: signed.signature.as_bytes().to_vec(),
89 amount,
90 bytes_delivered,
91 chain_root: signed.voucher.chain_root.into(),
92 chunk_price,
93 })
94}
95
96/// Map an off-chain [`PoolError`] to the wire [`VoucherRejectReason`] a node
97/// returns mid-stream, or [`RetrySignal`] when the failure is transient.
98///
99/// **Exhaustive, no wildcard (ADR 005 §Mirror obligation).** A new `PoolError`
100/// variant breaks this match at compile time, forcing a coordinated update of
101/// [`VoucherRejectReason`] and the retry-semantics table.
102///
103/// `BadPreimage` bridges too — the hash-chain walk is a lane-state check like
104/// the monotonicity guards, so it belongs in the same taxonomy. The other three
105/// chain reasons have no `PoolError` counterpart by design:
106/// `ChainIndexZero` and `UnanchoredPreimage` are decided against the
107/// *per-stream* anchor, and `ChunkPriceMismatch` against the node's own quoted
108/// rate — none of which the lane-scoped validation enum can see, so the
109/// `cdn/client/v1` handler raises those directly.
110///
111/// [`PoolError::Store`] is the only transient failure: in-memory state did not
112/// advance, so it returns [`RetrySignal`] rather than a permanent reason. The
113/// `cdn/client/v1` handler surfaces that signal by aborting the stream (no
114/// in-band wire reason), so the client resends the **same** voucher on a
115/// fresh stream (ADR 003 §Off-chain voucher state persistence). Every other
116/// variant is a permanent rejection.
117pub const fn voucher_reject_reason(err: &PoolError) -> Result<VoucherRejectReason, RetrySignal> {
118 match err {
119 PoolError::WrongPool { .. } => Ok(VoucherRejectReason::WrongPool),
120 PoolError::WrongProvider { .. } => Ok(VoucherRejectReason::WrongProvider),
121 PoolError::AmountRegression { .. } => Ok(VoucherRejectReason::AmountRegression),
122 PoolError::UnderFold { .. } => Ok(VoucherRejectReason::UnderFold),
123 PoolError::BytesRegression { .. } => Ok(VoucherRejectReason::BytesRegression),
124 PoolError::CapExceeded { .. } => Ok(VoucherRejectReason::SpendingCapExhausted),
125 PoolError::BadPreimage { .. } => Ok(VoucherRejectReason::BadPreimage),
126 PoolError::Signature(VoucherError::InvalidSignature) => {
127 Ok(VoucherRejectReason::BadSignature)
128 }
129 PoolError::Signature(VoucherError::WrongSigner { .. }) => {
130 Ok(VoucherRejectReason::WrongSigner)
131 }
132 // Transient — in-memory state unchanged. The handler aborts the stream
133 // rather than emitting an in-band wire reason; the client resends the
134 // same voucher (ADR 003 §Off-chain voucher state persistence).
135 PoolError::Store(_) => Err(RetrySignal),
136 }
137}
138
139/// Failure mode for [`wire_voucher_to_signed`].
140#[derive(Debug, thiserror::Error, PartialEq, Eq)]
141pub enum WireVoucherError {
142 /// `wire.signature` is not a well-formed 65-byte secp256k1 signature.
143 #[error("wire voucher signature is malformed or wrong length")]
144 BadSignature,
145 /// A cumulative amount or byte count exceeds the `u64` wire width. The
146 /// on-chain pool caps both at `uint64`, so such a voucher is unredeemable;
147 /// it is refused rather than truncated.
148 #[error("voucher amount or bytes_delivered exceeds the u64 wire width")]
149 ValueExceedsWireWidth,
150}
151
152/// Signals that a [`PoolError`] was transient ([`PoolError::Store`]): in-memory
153/// state did not advance, so the caller aborts the stream (no in-band wire
154/// reason) and the client resends the **same** voucher on a fresh stream (ADR
155/// 003) — distinguishable from a permanent rejection.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub struct RetrySignal;
158
159#[cfg(test)]
160#[allow(clippy::similar_names)] // signer/signed pair up clearly here
161mod tests;
162
163/// Property-based tests for the wire bridge (#740). The bridge is where the
164/// `U256` money field crosses to the `u64` wire form and back — the exact
165/// spot a truncation would corrupt a payment. These sweep the full `u64`
166/// keyspace and confirm the round-trip is lossless and that malformed wire
167/// input never panics. The narrowing failure path (`U256` above `u64::MAX`)
168/// is covered separately by `amount_above_u64_max_is_refused`.
169#[cfg(test)]
170#[allow(
171 clippy::unwrap_used,
172 clippy::expect_used,
173 clippy::panic,
174 clippy::indexing_slicing
175)]
176mod prop_tests;