Skip to main content

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;