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;