Skip to main content

decdn_incentive/
capability_grant.rs

1//! `dcap1:` — the transferable encoding of a pool owner's spending
2//! [`crate::capability::SignedCapability`].
3//!
4//! A pool owner delegates spend to a signer off-chain (see
5//! [`crate::capability`]). To hand that grant to a delegated client, the owner
6//! serializes it as a single copy-pasteable token: `dcap1:` followed by the
7//! base64url (no padding) of the postcard-encoded [`CapabilityGrant`]. The
8//! client decodes the token, reconstructs the [`SignedCapability`], and presents
9//! it to the serving node, which registers the signer on its first on-chain
10//! redemption.
11//!
12//! The token is self-contained: it carries the four signed capability fields and
13//! the owner's raw EIP-712 signature, so the client needs nothing else to rebuild
14//! and use the grant. It is NOT secret to the pool — anyone holding it can only
15//! spend as the named `signer`, and only that signer's key can sign vouchers
16//! under it.
17
18use alloy::dyn_abi::Eip712Domain;
19use alloy::primitives::{Address, B256, Signature};
20use base64::Engine;
21use base64::engine::general_purpose::URL_SAFE_NO_PAD;
22use serde::{Deserialize, Serialize};
23
24use crate::capability::{Capability, CapabilityError, SignedCapability};
25
26/// The `dcap1:` token prefix — a version tag, so a future encoding change is a
27/// visible break rather than a silent misdecode.
28const TOKEN_PREFIX: &str = "dcap1:";
29
30/// A pool owner's [`SignedCapability`] in its transferable, self-contained form.
31///
32/// Postcard-encoded and base64url-wrapped by [`Self::to_token`]. The owner
33/// signature is held as raw bytes (65-byte `r‖s‖v`, exactly
34/// [`Signature::as_bytes`]) rather than an `alloy` `Signature` so the wire shape
35/// is a plain byte string that postcard round-trips without a human-readable
36/// branch.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct CapabilityGrant {
39    /// The on-chain pool the grant draws from.
40    pub pool_id: B256,
41    /// The delegate voucher-signing key the owner authorizes.
42    pub signer: Address,
43    /// Cumulative spend ceiling (token base units) for `signer` under this
44    /// grant. A `u64`, matching [`Capability::spending_cap`] and the
45    /// `PaymentPool.spendingCap` on-chain width.
46    pub spending_cap: u64,
47    /// Unix-seconds expiry after which vouchers under this grant stop being
48    /// redeemable.
49    pub expiry: u64,
50    /// The owner's raw EIP-712 signature over the capability (65 bytes,
51    /// `r‖s‖v`).
52    pub owner_signature: Vec<u8>,
53}
54
55/// Failure modes for decoding a `dcap1:` token.
56#[derive(Debug, thiserror::Error, PartialEq, Eq)]
57pub enum GrantError {
58    /// The string does not start with the `dcap1:` prefix — not a delegation
59    /// token (or a future/foreign version).
60    #[error("not a dcap1 capability token (missing the `dcap1:` prefix)")]
61    BadPrefix,
62    /// The body is not valid base64url.
63    #[error("capability token body is not valid base64url")]
64    BadBase64,
65    /// The decoded bytes are not a well-formed postcard `CapabilityGrant`
66    /// (truncated or corrupt).
67    #[error("capability token payload is malformed (truncated or corrupt)")]
68    BadPayload,
69    /// The embedded owner signature is not a well-formed 65-byte ECDSA
70    /// signature, so no owner can be recovered.
71    #[error("capability token owner signature is malformed")]
72    BadSignature,
73}
74
75impl CapabilityGrant {
76    /// Build a grant from an already-signed capability, copying its owner
77    /// signature into the token's raw-bytes field.
78    #[must_use]
79    pub fn from_signed_capability(signed: &SignedCapability) -> Self {
80        Self {
81            pool_id: signed.capability.pool_id,
82            signer: signed.capability.signer,
83            spending_cap: signed.capability.spending_cap,
84            expiry: signed.capability.expiry,
85            owner_signature: signed.signature.as_bytes().to_vec(),
86        }
87    }
88
89    /// Encode the grant as a `dcap1:` token: the prefix followed by the
90    /// base64url (no padding) of its postcard encoding.
91    ///
92    /// # Panics
93    ///
94    /// Never — postcard encoding of a fixed-shape struct into a growable `Vec`
95    /// does not fail, so the `Result` is unwrapped to an empty body on the
96    /// unreachable error rather than propagated.
97    #[must_use]
98    pub fn to_token(&self) -> String {
99        // Postcard serialization of an owned, fixed-shape struct into a `Vec`
100        // has no fallible step; on the unreachable error emit an empty body,
101        // which `from_token` then rejects as `BadPayload` rather than crashing.
102        let bytes = postcard::to_allocvec(self).unwrap_or_default();
103        format!("{TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))
104    }
105
106    /// Parse and validate a `dcap1:` token back into a grant.
107    ///
108    /// # Errors
109    ///
110    /// - [`GrantError::BadPrefix`] — the `dcap1:` prefix is absent.
111    /// - [`GrantError::BadBase64`] — the body is not valid base64url.
112    /// - [`GrantError::BadPayload`] — the decoded bytes are not a postcard
113    ///   `CapabilityGrant`.
114    pub fn from_token(s: &str) -> Result<Self, GrantError> {
115        let body = s.strip_prefix(TOKEN_PREFIX).ok_or(GrantError::BadPrefix)?;
116        let bytes = URL_SAFE_NO_PAD
117            .decode(body.as_bytes())
118            .map_err(|_| GrantError::BadBase64)?;
119        postcard::from_bytes(&bytes).map_err(|_| GrantError::BadPayload)
120    }
121
122    /// Reconstruct the [`SignedCapability`] this grant carries, ready for
123    /// `PoolContext::with_capability`.
124    ///
125    /// # Errors
126    ///
127    /// [`GrantError::BadSignature`] when `owner_signature` is not a well-formed
128    /// 65-byte ECDSA signature.
129    pub fn to_signed_capability(&self) -> Result<SignedCapability, GrantError> {
130        let signature =
131            Signature::from_raw(&self.owner_signature).map_err(|_| GrantError::BadSignature)?;
132        Ok(SignedCapability {
133            capability: Capability {
134                signer: self.signer,
135                spending_cap: self.spending_cap,
136                pool_id: self.pool_id,
137                expiry: self.expiry,
138            },
139            signature,
140        })
141    }
142
143    /// Recover the pool owner that signed this grant under `domain`, for display
144    /// and validation.
145    ///
146    /// # Errors
147    ///
148    /// - [`GrantError::BadSignature`] — the embedded signature is malformed.
149    /// - [`CapabilityError::InvalidSignature`] via [`GrantOwnerError`] — the
150    ///   signature is non-canonical (high-`s`) or unrecoverable.
151    pub fn owner(&self, domain: &Eip712Domain) -> Result<Address, GrantOwnerError> {
152        let signed = self.to_signed_capability()?;
153        signed
154            .recover_owner(domain)
155            .map_err(GrantOwnerError::Recover)
156    }
157}
158
159/// Failure of [`CapabilityGrant::owner`]: either the token's signature bytes are
160/// malformed, or a well-formed signature does not recover an owner.
161#[derive(Debug, thiserror::Error, PartialEq, Eq)]
162pub enum GrantOwnerError {
163    /// The token's raw signature bytes are not a 65-byte ECDSA signature.
164    #[error(transparent)]
165    Decode(#[from] GrantError),
166    /// A well-formed signature failed owner recovery (non-canonical or invalid).
167    #[error(transparent)]
168    Recover(CapabilityError),
169}
170
171#[cfg(test)]
172#[allow(clippy::unwrap_used, clippy::expect_used)]
173mod tests;