decdn_incentive/rate.rs
1//! Rate enforcement for incremental voucher payments.
2//!
3//! Each voucher carries cumulative `amount` and `bytes_delivered`. Between
4//! two vouchers on the same lane, the increments must satisfy
5//! `amount_delta / bytes_delta >= rate_per_mb` — i.e. the client paid at
6//! least the advertised rate for the bytes delivered. Underpayment beyond a
7//! tolerance threshold (rounding error, edge-MB padding) indicates a faulty
8//! or hostile client.
9//!
10//! Rate units, all matching ADR 005 / ADR 003:
11//! - `amount` is in token base units (`µUSDC` = 10⁻⁶ `USDC`)
12//! - `bytes_delivered` is in bytes
13//! - `rate_per_mb` is in token base units per **megabyte**, where 1 `MB` =
14//! 1,048,576 bytes (binary `MB`, ADR 005)
15//!
16//! Tolerance is expressed in basis points (bps); 100 bps = 1%. The default
17//! [`DEFAULT_TOLERANCE_BPS`] of 100 leaves headroom for the rounding the
18//! client does when computing `amount = ceil(bytes / 1_048_576) * rate`.
19
20use alloy::primitives::U256;
21
22/// 1 `MB` in bytes per ADR 005 §Probe-Triggered Eviction Hold's `MB`
23/// definition.
24pub const BYTES_PER_MB: u64 = 1_048_576;
25
26/// Basis-points scale: 100% = `10_000` `bps`.
27pub const BPS_SCALE: u64 = 10_000;
28
29/// Default underpayment tolerance — 1%. Calibrated against the client-side
30/// rounding `amount = ceil(bytes / 1_048_576) * rate_per_mb`: at 1 MB
31/// granularity the worst-case underpayment is < 1 byte's-worth of rate, so
32/// 100 bps leaves >>10× headroom.
33pub const DEFAULT_TOLERANCE_BPS: u64 = 100;
34
35/// Verify that paying `amount_delta` for `bytes_delta` bytes meets
36/// `rate_per_mb` within `tolerance_bps`.
37///
38/// # Errors
39///
40/// - [`RateError::ZeroBytes`] — `bytes_delta == 0`. A voucher that adds no
41/// bytes cannot be priced and is silently invalid.
42/// - [`RateError::Underpayment`] — effective rate is below `rate_per_mb` by
43/// more than `tolerance_bps` basis points.
44/// - [`RateError::Overflow`] — `amount_delta * BYTES_PER_MB * BPS_SCALE`
45/// would exceed `U256::MAX`. Will not occur in practice (would require
46/// ~10⁶³ `µUSDC`, far more than total `USDC` supply).
47pub fn verify_rate(
48 amount_delta: U256,
49 bytes_delta: U256,
50 rate_per_mb: u64,
51 tolerance_bps: u64,
52) -> Result<(), RateError> {
53 if bytes_delta.is_zero() {
54 return Err(RateError::ZeroBytes);
55 }
56
57 // Required micro-USDC for `bytes_delta` at exactly `rate_per_mb`:
58 // required = bytes_delta * rate_per_mb / BYTES_PER_MB
59 // Allowed slack:
60 // min_paid = required * (BPS_SCALE - tolerance_bps) / BPS_SCALE
61 //
62 // We avoid the float divide by cross-multiplying:
63 // amount_delta * BYTES_PER_MB * BPS_SCALE
64 // >= bytes_delta * rate_per_mb * (BPS_SCALE - tolerance_bps)
65 //
66 // Both sides fit comfortably in U256 for any plausible network values.
67 let tolerance = tolerance_bps.min(BPS_SCALE);
68 let allowed_bps = BPS_SCALE - tolerance;
69
70 let lhs = amount_delta
71 .checked_mul(U256::from(BYTES_PER_MB))
72 .and_then(|v| v.checked_mul(U256::from(BPS_SCALE)))
73 .ok_or(RateError::Overflow)?;
74 let rhs = bytes_delta
75 .checked_mul(U256::from(rate_per_mb))
76 .and_then(|v| v.checked_mul(U256::from(allowed_bps)))
77 .ok_or(RateError::Overflow)?;
78
79 if lhs >= rhs {
80 Ok(())
81 } else {
82 Err(RateError::Underpayment {
83 paid: amount_delta,
84 bytes: bytes_delta,
85 rate_per_mb,
86 tolerance_bps: tolerance,
87 })
88 }
89}
90
91/// Minimum payment (in token base units) required to cover `bytes` delivered
92/// at `rate_per_mb`, i.e. `ceil(bytes * rate_per_mb / BYTES_PER_MB)`.
93///
94/// This is the exact lower bound [`verify_rate`] enforces at zero tolerance and
95/// the same arithmetic the buyer's voucher signer uses to price an interval
96/// (`bytes * rate / MB`, rounded up). It is intended for pre-flight cost
97/// estimation — e.g. gating a speculative pull on the requesting lane's
98/// remaining deposit covering the worst-case blob cost (#856).
99///
100/// Saturating on the (practically unreachable) multiply overflow: a saturated
101/// `U256::MAX` ceiling makes any finite deposit look insufficient, which is the
102/// safe, conservative direction for a guard.
103#[must_use]
104pub fn min_payment(bytes: u64, rate_per_mb: u64) -> U256 {
105 U256::from(bytes)
106 .saturating_mul(U256::from(rate_per_mb))
107 .div_ceil(U256::from(BYTES_PER_MB))
108}
109
110/// Stateful-B pool solvency: the pool's remaining deposit minus the refundable
111/// floor `M` must cover its already-committed concurrent floor credit plus this
112/// stream's new reservation. Saturating (a `remaining` below `M` yields no
113/// headroom rather than underflowing).
114#[must_use]
115pub fn pool_budget_covers(remaining: U256, m: U256, committed: U256, new_reserve: U256) -> bool {
116 remaining.saturating_sub(m) >= committed.saturating_add(new_reserve)
117}
118
119/// Failure modes for [`verify_rate`].
120#[derive(Debug, thiserror::Error, PartialEq, Eq)]
121pub enum RateError {
122 /// Voucher delta included zero bytes — cannot be priced. The node should
123 /// reject it before it advances lane state.
124 #[error("voucher carries zero bytes_delta — cannot enforce rate")]
125 ZeroBytes,
126 /// Effective rate is below the advertised rate by more than the
127 /// configured tolerance. The node should pause the stream and (per
128 /// ADR 003 §Voucher withholding) refuse further delivery.
129 #[error(
130 "underpayment: paid {paid} for {bytes} bytes against rate {rate_per_mb}/MB \
131 with {tolerance_bps} bps tolerance"
132 )]
133 Underpayment {
134 /// The voucher's amount delta.
135 paid: U256,
136 /// The voucher's bytes delta, priced against that amount.
137 bytes: U256,
138 /// The advertised per-MB rate the payment is judged against.
139 rate_per_mb: u64,
140 /// Allowed shortfall, in basis points of the expected amount.
141 tolerance_bps: u64,
142 },
143 /// Arithmetic would overflow U256. Will not occur for realistic values
144 /// but surfaced as an error rather than panicking, per the workspace
145 /// anti-panic policy.
146 #[error("rate enforcement arithmetic overflowed U256")]
147 Overflow,
148}
149
150#[cfg(test)]
151mod tests;