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;