Skip to main content

decdn_incentive/
voucher.rs

1//! EIP-712 payment vouchers for the `PaymentPool` contract.
2//!
3//! Vouchers are off-chain signed messages a client (payer) issues to a node
4//! (provider) as content is delivered. Each voucher carries a cumulative
5//! `amount` of `USDC` base units (`µUSDC`) and a cumulative `bytes_delivered`
6//! count, scoped to a `(pool_id, signer, provider)` lane. The delivering node
7//! holds the latest voucher and redeems it on-chain against the pool.
8//!
9//! The signed payload follows ADR 003 §EIP-712 Voucher Signature exactly so
10//! that an off-chain Rust signature byte-matches what the on-chain
11//! `PaymentPool.redeem` accepts.
12//!
13//! # Domain
14//!
15//! ```text
16//! EIP712Domain {
17//!     name: "PaymentPool",
18//!     version: "1",
19//!     chainId: <L2 chain id>,
20//!     verifyingContract: <PaymentPool deployment address>,
21//! }
22//! ```
23//!
24//! # Voucher type
25//!
26//! ```text
27//! Voucher(bytes32 poolId,address signer,address provider,
28//!         uint256 amount,uint256 bytesDelivered,
29//!         bytes32 chainRoot,uint256 chunkPrice)
30//! ```
31//!
32//! There is no nonce: `amount` is the sole monotone ordering and replay key —
33//! a voucher whose `amount` is no greater than the highest already accepted is
34//! stale (ADR 003 §Voucher ordering).
35//!
36//! `amount` is the settlement anchor and `chain_root` heads the optional
37//! `PayWord` hash chain that advances it between signatures, so redemption
38//! resolves both as `claimed = amount + chain_index × chunk_price` (ADR 003
39//! §Hash-chain metering (`PayWord`); the chain primitive lives in
40//! [`crate::chain`]). Neither `chain_length` nor `chunk_bytes` is signed —
41//! both are protocol constants, so signing them would pay calldata for values
42//! every party already holds.
43
44use alloy::dyn_abi::Eip712Domain;
45use alloy::primitives::{Address, B256, Signature, U256};
46use alloy::signers::SignerSync;
47use alloy::sol_types::{SolStruct, eip712_domain};
48
49/// EIP-712 domain `name` field. Must match the `PaymentPool` contract's
50/// domain exactly — a mismatch produces a different digest and every
51/// signature fails on-chain.
52pub const DOMAIN_NAME: &str = "PaymentPool";
53
54/// EIP-712 domain `version` field.
55pub const DOMAIN_VERSION: &str = "1";
56
57// Solidity struct mirroring ADR 003 §EIP-712 Voucher Signature. Field names
58// and order are part of the signed type — both must match the on-chain
59// contract verbatim, hence the camelCase. The `sol!` macro generates a
60// `SolStruct` impl whose `eip712_signing_hash` produces the same 32-byte
61// digest the contract recovers signatures against.
62//
63// Wrapped in a private module because the `sol!` macro uses the Rust struct
64// name as the on-chain Solidity type name in the EIP-712 type-string. The
65// contract's struct is `Voucher`, so the Rust struct must be `Voucher` too —
66// the wrapping module avoids the name clash with our public Rust [`Voucher`].
67mod sol_types {
68    alloy::sol! {
69        #[allow(non_snake_case, missing_debug_implementations)]
70        struct Voucher {
71            bytes32 poolId;
72            address signer;
73            address provider;
74            uint256 amount;
75            uint256 bytesDelivered;
76            bytes32 chainRoot;
77            uint256 chunkPrice;
78        }
79    }
80}
81
82use sol_types::Voucher as VoucherSol;
83
84/// Construct the EIP-712 domain used to sign vouchers for a given
85/// `PaymentPool` deployment.
86#[must_use]
87pub fn voucher_domain(chain_id: u64, verifying_contract: Address) -> Eip712Domain {
88    eip712_domain! {
89        name: DOMAIN_NAME,
90        version: DOMAIN_VERSION,
91        chain_id: chain_id,
92        verifying_contract: verifying_contract,
93    }
94}
95
96/// A payment voucher in its unsigned form.
97///
98/// All amounts are cumulative across the lane's lifetime — a voucher with
99/// `amount = 100` does not mean "pay 100 more" but "the total claimable on
100/// this lane is 100." `bytes_delivered` is cumulative likewise.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct Voucher {
103    /// The pool this voucher draws from — the on-chain `PaymentPool` deposit id.
104    pub pool_id: B256,
105    /// The address whose EIP-712 signature authorizes this voucher (the
106    /// capability signer the pool owner delegated spend to).
107    pub signer: Address,
108    /// The provider node this voucher pays — a capability voucher is scoped to
109    /// one provider and is invalid if redeemed against another.
110    pub provider: Address,
111    /// Cumulative payment in token base units (`µUSDC` for `USDC`). The
112    /// settlement anchor a hash chain extends.
113    pub amount: U256,
114    /// Cumulative bytes delivered against this lane.
115    pub bytes_delivered: U256,
116    /// Head of the hash chain this voucher opens —
117    /// [`crate::chain::root_from_seed`] of the payer's per-lane seed. Zero
118    /// **seals** the voucher at exactly `amount`: nothing hashes to zero, so no
119    /// index above 0 can redeem against it (ADR 003 §The sealed voucher).
120    pub chain_root: B256,
121    /// What one chunk of delivery adds over `amount`, in token base units.
122    /// Signed so the claim arithmetic is fixed at signing time; the floor clamp
123    /// at redemption still reads the live `deliveryFloor`. Zero on a sealed
124    /// voucher, which meters no chunk.
125    pub chunk_price: U256,
126}
127
128impl Voucher {
129    const fn to_sol(&self) -> VoucherSol {
130        VoucherSol {
131            poolId: self.pool_id,
132            signer: self.signer,
133            provider: self.provider,
134            amount: self.amount,
135            bytesDelivered: self.bytes_delivered,
136            chainRoot: self.chain_root,
137            chunkPrice: self.chunk_price,
138        }
139    }
140
141    /// EIP-712 signing hash bound to `domain`. This is the 32-byte digest
142    /// passed into `ecrecover` on-chain — identical for any signer.
143    #[must_use]
144    pub fn signing_hash(&self, domain: &Eip712Domain) -> B256 {
145        self.to_sol().eip712_signing_hash(domain)
146    }
147
148    /// Sign the voucher with `signer` for the given EIP-712 `domain`.
149    ///
150    /// # Errors
151    ///
152    /// Propagates any error returned by the underlying signer (key locked,
153    /// remote signer offline, etc.).
154    pub fn sign<S: SignerSync>(
155        self,
156        signer: &S,
157        domain: &Eip712Domain,
158    ) -> Result<SignedVoucher, alloy::signers::Error> {
159        let hash = self.signing_hash(domain);
160        let signature = signer.sign_hash_sync(&hash)?;
161        Ok(SignedVoucher {
162            voucher: self,
163            signature,
164        })
165    }
166}
167
168/// A voucher together with its EIP-712 signature.
169///
170/// Holding a `SignedVoucher` is sufficient for a node to redeem against the
171/// pool — the signature recovers the capability signer's address, which the
172/// contract then matches against the registered capability.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct SignedVoucher {
175    /// The cumulative payment claim.
176    pub voucher: Voucher,
177    /// The capability signer's EIP-712 signature over it.
178    pub signature: Signature,
179}
180
181impl SignedVoucher {
182    /// Recover the address that signed this voucher under `domain`.
183    ///
184    /// # Errors
185    ///
186    /// Returns [`VoucherError::InvalidSignature`] if the signature is
187    /// malformed (non-canonical `s`, invalid recovery id, etc.).
188    pub fn recover_signer(&self, domain: &Eip712Domain) -> Result<Address, VoucherError> {
189        // Reject non-canonical high-`s` up front so the off-chain accept-set
190        // matches the on-chain verifiable-set (#836); alloy would otherwise
191        // silently normalize and accept it.
192        if crate::sig_canon::is_high_s(&self.signature) {
193            return Err(VoucherError::InvalidSignature);
194        }
195        let hash = self.voucher.signing_hash(domain);
196        self.signature
197            .recover_address_from_prehash(&hash)
198            .map_err(|_| VoucherError::InvalidSignature)
199    }
200
201    /// Verify the signature was produced by `expected` for `domain`.
202    ///
203    /// # Errors
204    ///
205    /// - [`VoucherError::InvalidSignature`] — signature is malformed.
206    /// - [`VoucherError::WrongSigner`] — recovered address differs from
207    ///   `expected`.
208    pub fn verify_signer(
209        &self,
210        expected: Address,
211        domain: &Eip712Domain,
212    ) -> Result<(), VoucherError> {
213        let recovered = self.recover_signer(domain)?;
214        if recovered == expected {
215            Ok(())
216        } else {
217            Err(VoucherError::WrongSigner {
218                expected,
219                recovered,
220            })
221        }
222    }
223}
224
225/// Failure modes for voucher verification.
226#[derive(Debug, thiserror::Error, PartialEq, Eq)]
227pub enum VoucherError {
228    /// The signature is malformed — non-canonical `s`, invalid recovery id,
229    /// or a corrupted byte. The voucher is unusable as-is.
230    #[error("voucher signature is malformed")]
231    InvalidSignature,
232    /// The signature is well-formed but recovers to an address that does not
233    /// match the expected signer — e.g., the lane's pinned capability signer.
234    #[error("voucher signed by {recovered}, expected {expected}")]
235    WrongSigner {
236        /// The address the voucher had to be signed by.
237        expected: Address,
238        /// The address the signature actually recovers to.
239        recovered: Address,
240    },
241}
242
243#[cfg(test)]
244#[allow(clippy::similar_names)] // signer/signed and address/addr pair up clearly here
245mod tests;
246
247/// Property-based tests for the EIP-712 voucher core (#740). The example-based
248/// `tests` module above pins fixed vectors and single-field tamper cases with
249/// small values; these sweep the full `U256` keyspace for `amount` /
250/// `bytes_delivered` with the dangerous boundaries (`0`, `u64::MAX`,
251/// `U256::MAX`) heavily over-sampled (see [`any_u256`]), to catch any silent
252/// truncation or accept-invalid path before mainnet. We trust `alloy` for the
253/// EIP-712
254/// primitive and assert the invariants the protocol depends on: sign→recover is
255/// total over that range, the digest is deterministic and covers every field,
256/// and the domain is binding.
257#[cfg(test)]
258#[allow(
259    clippy::unwrap_used,
260    clippy::expect_used,
261    clippy::panic,
262    clippy::indexing_slicing
263)]
264mod prop_tests;