decdn_incentive/capability.rs
1//! EIP-712 spending capabilities for the `PaymentPool` contract.
2//!
3//! A capability is an owner-signed grant that delegates spend on a pool to a
4//! `signer` key up to a `spending_cap`, until `expiry`. The pool owner (the
5//! funder of the on-chain deposit) signs it; the node registers it and then
6//! accepts [`crate::voucher::Voucher`]s from that `signer` up to the cap
7//! (ADR 003 §Capability delegation).
8//!
9//! The capability shares the voucher's EIP-712 domain (see
10//! [`crate::voucher::voucher_domain`]) — `PaymentPool` recovers both against
11//! the same `_hashTypedDataV4` domain separator. Only the type-string differs:
12//!
13//! ```text
14//! Capability(address signer,uint256 spendingCap,bytes32 poolId,uint64 expiry)
15//! ```
16
17use alloy::dyn_abi::Eip712Domain;
18use alloy::primitives::{Address, B256, Signature, U256};
19use alloy::signers::SignerSync;
20use alloy::sol_types::SolStruct;
21
22pub use crate::voucher::voucher_domain;
23
24// Solidity struct mirroring `PaymentPool.CAPABILITY_TYPEHASH`. The `sol!`
25// macro uses the Rust struct name as the on-chain type name in the EIP-712
26// type-string, so it must be `Capability` verbatim; the wrapping module avoids
27// clashing with the public Rust [`Capability`] below.
28mod sol_types {
29 alloy::sol! {
30 #[allow(non_snake_case, missing_debug_implementations)]
31 struct Capability {
32 address signer;
33 uint256 spendingCap;
34 bytes32 poolId;
35 uint64 expiry;
36 }
37 }
38}
39
40use sol_types::Capability as CapabilitySol;
41
42/// A pool owner's spending grant in its unsigned form.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Capability {
45 /// The address the owner delegates spend to — the voucher-signing key.
46 pub signer: Address,
47 /// Maximum cumulative amount (token base units) the `signer` may spend
48 /// against `pool_id` under this grant. A `u64` to match the
49 /// `PaymentPool.spendingCap` storage/calldata width exactly; the EIP-712
50 /// type-string still hashes it as a `uint256` word (the signing hash widens
51 /// it with `U256::from`).
52 pub spending_cap: u64,
53 /// The pool this capability draws from — the on-chain `PaymentPool`
54 /// deposit id.
55 pub pool_id: B256,
56 /// Unix-seconds expiry. After it, vouchers under this capability are no
57 /// longer redeemable and the node stops accepting them.
58 pub expiry: u64,
59}
60
61impl Capability {
62 fn to_sol(&self) -> CapabilitySol {
63 CapabilitySol {
64 signer: self.signer,
65 // The contract hashes its `uint64 spendingCap` as a zero-padded
66 // `uint256` word (the type-string says `uint256`), so widening the
67 // `u64` here reproduces the contract's EIP-712 digest byte-for-byte.
68 spendingCap: U256::from(self.spending_cap),
69 poolId: self.pool_id,
70 expiry: self.expiry,
71 }
72 }
73
74 /// EIP-712 signing hash bound to `domain` — the 32-byte digest the contract
75 /// recovers the owner signature against.
76 #[must_use]
77 pub fn signing_hash(&self, domain: &Eip712Domain) -> B256 {
78 self.to_sol().eip712_signing_hash(domain)
79 }
80
81 /// Sign the capability with the pool owner's `signer` for the given EIP-712
82 /// `domain`.
83 ///
84 /// # Errors
85 ///
86 /// Propagates any error returned by the underlying signer (key locked,
87 /// remote signer offline, etc.).
88 pub fn sign<S: SignerSync>(
89 self,
90 signer: &S,
91 domain: &Eip712Domain,
92 ) -> Result<SignedCapability, alloy::signers::Error> {
93 let hash = self.signing_hash(domain);
94 let signature = signer.sign_hash_sync(&hash)?;
95 Ok(SignedCapability {
96 capability: self,
97 signature,
98 })
99 }
100}
101
102/// A capability together with its EIP-712 owner signature.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct SignedCapability {
105 /// The delegation itself.
106 pub capability: Capability,
107 /// The pool owner's EIP-712 signature over it.
108 pub signature: Signature,
109}
110
111impl SignedCapability {
112 /// Recover the owner address that signed this capability under `domain`.
113 ///
114 /// # Errors
115 ///
116 /// Returns [`CapabilityError::InvalidSignature`] if the signature is
117 /// malformed (non-canonical high-`s`, invalid recovery id, etc.) —
118 /// rejecting high-`s` up front so the off-chain accept-set matches the
119 /// on-chain verifiable-set (#836), exactly as voucher recovery does.
120 pub fn recover_owner(&self, domain: &Eip712Domain) -> Result<Address, CapabilityError> {
121 if crate::sig_canon::is_high_s(&self.signature) {
122 return Err(CapabilityError::InvalidSignature);
123 }
124 let hash = self.capability.signing_hash(domain);
125 self.signature
126 .recover_address_from_prehash(&hash)
127 .map_err(|_| CapabilityError::InvalidSignature)
128 }
129
130 /// Verify the capability was signed by `expected` (the pool owner) for
131 /// `domain`.
132 ///
133 /// # Errors
134 ///
135 /// - [`CapabilityError::InvalidSignature`] — signature is malformed.
136 /// - [`CapabilityError::WrongOwner`] — recovered address differs from
137 /// `expected`.
138 pub fn verify_owner(
139 &self,
140 expected: Address,
141 domain: &Eip712Domain,
142 ) -> Result<(), CapabilityError> {
143 let recovered = self.recover_owner(domain)?;
144 if recovered == expected {
145 Ok(())
146 } else {
147 Err(CapabilityError::WrongOwner {
148 expected,
149 recovered,
150 })
151 }
152 }
153}
154
155/// Failure modes for capability verification.
156#[derive(Debug, thiserror::Error, PartialEq, Eq)]
157pub enum CapabilityError {
158 /// The signature is malformed — non-canonical `s`, invalid recovery id, or
159 /// a corrupted byte. The capability is unusable as-is.
160 #[error("capability signature is malformed")]
161 InvalidSignature,
162 /// The signature is well-formed but recovers to an address that does not
163 /// match the expected pool owner.
164 #[error("capability signed by {recovered}, expected owner {expected}")]
165 WrongOwner {
166 /// The pool owner the capability had to be signed by.
167 expected: Address,
168 /// The address the signature actually recovers to.
169 recovered: Address,
170 },
171}
172
173#[cfg(test)]
174#[allow(clippy::similar_names)] // signer/signed pair up clearly here
175mod tests;