Skip to main content

decdn_incentive/
chain.rs

1//! `PayWord` hash chains: the per-lane meter that advances a voucher's
2//! settlement anchor without a signature (ADR 003 §Hash-chain metering
3//! (`PayWord`)).
4//!
5//! # The primitive
6//!
7//! The payer draws a random seed `s` for a lane and commits
8//! `chain_root = keccak^N(s)`, where `N` is [`MAX_CHAIN_LENGTH`]. It signs that
9//! root into a voucher. After the node delivers chunk `k` — and after the payer
10//! has verified those bytes against the BLAKE3 root — the payer releases
11//! `keccak^(N−k)(s)`. The node hashes the released value forward until it
12//! reaches a value it already trusts.
13//!
14//! Preimage resistance makes a release self-proving: nobody computes
15//! `keccak^(N−k−1)(s)` from `keccak^(N−k)(s)` without the seed, so a deeper
16//! preimage **is** the receipt for every chunk below it. Accepting one costs a
17//! single keccak — no signature, no round trip, and nothing to persist before
18//! the node sends the next chunk.
19//!
20//! Redemption resolves the two axes with one formula:
21//! `claimed = amount + chain_index × chunk_price`.
22//!
23//! # Index convention
24//!
25//! `chain_root` sits at index `0` and index `k` reveals `keccak^(N−k)(s)`,
26//! which redemption checks as `keccak^k(preimage) == chain_root`. The wire byte
27//! carries the index as itself — no offset on send, no increment on receipt —
28//! and the same byte is the low byte of the on-chain packed `chainMeter` word,
29//! so no off-by-one is possible anywhere along the path.
30//!
31//! # One chain per lane
32//!
33//! A released preimage is a **bearer proof**: it names no payee, and its
34//! binding to one comes entirely from the voucher whose `chain_root` it
35//! satisfies. So a payer MUST NOT commit one root on two lanes — reuse lets a
36//! second node claim chunks it never delivered, and the payer pays twice for
37//! one tick (ADR 003 §Cross-lane preimage spend).
38//!
39//! [`random_seed`] is that defence, and it keeps no state to get wrong: every
40//! chain draws 32 fresh bytes from the OS, so two chains share a root only with
41//! probability 2⁻²⁵⁶ — on one lane or across every lane a payer holds. Reuse
42//! stops being a rule to enforce and becomes an outcome the draw does not
43//! produce.
44//!
45//! A seed lives in memory for the life of its chain and is never written down,
46//! never derived from the signing key, and never reproduced. Nothing needs to
47//! reproduce one: a chain is only ever extended by the process that drew it,
48//! because crossing a process or device boundary FOLDS the frontier the node
49//! proved into a fresh signed amount and opens a fresh chain (ADR 003
50//! §Resumption folds).
51//!
52//! There is deliberately **no node-side check** for root reuse: the reuse pays
53//! the node, so a rejection rule protects nobody who would choose to run it,
54//! and enforcing it would need an unbounded, never-expiring set of every root
55//! the node has ever seen.
56
57use alloy::primitives::{B256, U256, keccak256};
58use rand::Rng;
59
60pub use decdn_protocol::client::{CHUNK_BYTES, MAX_CHAIN_LENGTH};
61
62/// Hash `value` forward exactly `steps` times.
63///
64/// `steps` is a `u8`, so the walk is bounded at [`MAX_CHAIN_LENGTH`] by the
65/// type rather than by a runtime comparison — the same bound the one-byte wire
66/// index and the on-chain `uint8` extraction carry.
67#[must_use]
68fn hash_forward(value: B256, steps: u8) -> B256 {
69    let mut acc = value;
70    for _ in 0..steps {
71        acc = keccak256(acc);
72    }
73    acc
74}
75
76/// The chain head committed in a voucher: `keccak^MAX_CHAIN_LENGTH(seed)`,
77/// the value at index 0.
78///
79/// A real root is a keccak output, so it is all-zero only with probability
80/// 2⁻²⁵⁶; a payer that draws such a seed redraws, which is what makes all-zero
81/// a safe sentinel for the sealed voucher (ADR 003 §The sealed voucher).
82#[must_use]
83pub fn root_from_seed(seed: B256) -> B256 {
84    hash_forward(seed, MAX_CHAIN_LENGTH)
85}
86
87/// The preimage to release at `index`: `keccak^(MAX_CHAIN_LENGTH − index)(seed)`.
88///
89/// At `index == 0` this returns the root itself, which is the value a payer
90/// submits when it settles a real-root voucher at exactly its `amount` without
91/// walking anything.
92#[must_use]
93pub fn preimage_at(seed: B256, index: u8) -> B256 {
94    hash_forward(seed, MAX_CHAIN_LENGTH - index)
95}
96
97/// Verify a released preimage against a value the receiver already trusts:
98/// `keccak^steps(preimage) == tip`.
99///
100/// `tip` is the deepest preimage the receiver has verified on this chain (or
101/// the `chain_root` itself when it has verified none), and `steps` is
102/// `index − verified`. Hashing forward from the *submitted* value — never from
103/// a stored intermediate — is what lets both the node and the contract keep no
104/// chain state at all.
105#[must_use]
106pub fn verify_forward(preimage: B256, steps: u8, tip: B256) -> bool {
107    hash_forward(preimage, steps) == tip
108}
109
110/// Pack `chunk_price` and `chain_index` into the single `chainMeter` word the
111/// contract's `LaneVoucher` carries (ADR 003 §Voucher signatures are compact).
112///
113/// ```text
114///  byte  0                     22 23            30 31
115///       +------------------------+----------------+--+
116///       |  reserved — MUST be 0  |   chunkPrice   |ci|
117///       +------------------------+----------------+--+
118///          23 bytes                 8 bytes (u64)  1 byte (u8)
119/// ```
120///
121/// Packing two fields into one word is what keeps `LaneVoucher` at 8 static
122/// words instead of 9. The reserved span stays zero so a later field can claim
123/// those bits without any voucher signed today becoming reinterpretable — the
124/// contract rejects a non-zero reserved span rather than masking it away.
125///
126/// # Errors
127///
128/// [`ChainMeterError::PriceExceedsWireWidth`] when `chunk_price` does not fit
129/// the 8 bytes the word gives it. The on-chain pool caps every USDC counter at
130/// `uint64`, so such a price is unredeemable; it is refused rather than
131/// truncated into a *different, cheaper* price the node would then be paid at.
132pub fn pack_chain_meter(chunk_price: U256, chain_index: u8) -> Result<U256, ChainMeterError> {
133    let price = u64::try_from(chunk_price).map_err(|_| ChainMeterError::PriceExceedsWireWidth)?;
134    Ok((U256::from(price) << 8) | U256::from(chain_index))
135}
136
137/// Why [`pack_chain_meter`] refused.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
139pub enum ChainMeterError {
140    /// `chunk_price` exceeds the `uint64` the packed word gives it.
141    #[error("chunk_price exceeds the uint64 width of the packed chainMeter word")]
142    PriceExceedsWireWidth,
143}
144
145/// Draw the seed for one chain: 32 fresh bytes from the OS CSPRNG.
146///
147/// A seed is drawn when the chain opens, held in memory for as long as the
148/// chain meters, and dropped with it. Two draws collide only with probability
149/// 2⁻²⁵⁶, so the one-root-per-lane rule the scheme rests on holds by the draw
150/// rather than by any state kept correct between draws — across a payer's
151/// lanes, and across the successive chains of one lane (ADR 003 §One chain per
152/// lane).
153///
154/// Nothing reproduces a seed, and nothing needs to. Re-opening a chain across a
155/// process or device boundary is never how a payer resumes: it folds the
156/// frontier the node proved into a signed amount and opens a fresh chain
157/// instead, which needs the signing key and no secret at rest at all (ADR 003
158/// §Resumption folds).
159#[must_use]
160pub fn random_seed() -> B256 {
161    let mut seed = [0u8; 32];
162    rand::rng().fill_bytes(&mut seed);
163    B256::from(seed)
164}
165
166#[cfg(test)]
167#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] // tests
168mod tests;