Skip to main content

tempo_primitives/transaction/
key_authorization.rs

1use super::SignatureType;
2use crate::transaction::PrimitiveSignature;
3use alloc::vec::Vec;
4use alloy_consensus::crypto::RecoveryError;
5use alloy_primitives::{Address, B256, U256, keccak256};
6use alloy_rlp::Encodable;
7
8/// Token spending limit for access keys
9///
10/// Defines a per-token spending limit for an access key provisioned via key_authorization.
11/// This limit is enforced by the AccountKeychain precompile when the key is used.
12#[derive(Clone, Debug, PartialEq, Eq, Hash, alloy_rlp::RlpEncodable, alloy_rlp::RlpDecodable)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
15#[cfg_attr(any(test, feature = "arbitrary"), derive(arbitrary::Arbitrary))]
16#[cfg_attr(feature = "reth-codec", derive(reth_codecs::Compact))]
17#[cfg_attr(test, reth_codecs::add_arbitrary_tests(compact, rlp))]
18pub struct TokenLimit {
19    /// TIP20 token address
20    pub token: Address,
21
22    /// Maximum spending amount for this token (enforced over the key's lifetime)
23    pub limit: U256,
24}
25
26/// Key authorization for provisioning access keys
27///
28/// Used in TempoTransaction to add a new key to the AccountKeychain precompile.
29/// The transaction must be signed by the root key to authorize adding this access key.
30///
31/// RLP encoding: `[key_type, key_id, expiry?, limits?]`
32/// - Non-optional fields come first, followed by optional (trailing) fields
33/// - `expiry`: `None` (omitted or 0x80) = key never expires, `Some(timestamp)` = expires at timestamp
34/// - `limits`: `None` (omitted or 0x80) = unlimited spending, `Some([])` = no spending, `Some([...])` = specific limits
35#[derive(Clone, Debug, PartialEq, Eq, Hash, alloy_rlp::RlpEncodable, alloy_rlp::RlpDecodable)]
36#[rlp(trailing)]
37#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
38#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
39#[cfg_attr(test, reth_codecs::add_arbitrary_tests(rlp))]
40pub struct KeyAuthorization {
41    /// Chain ID for replay protection.
42    /// Pre-T1C: 0 = valid on any chain (wildcard). T1C+: must match current chain.
43    #[cfg_attr(feature = "serde", serde(with = "alloy_serde::quantity"))]
44    pub chain_id: u64,
45
46    /// Type of key being authorized (Secp256k1, P256, or WebAuthn)
47    pub key_type: SignatureType,
48
49    /// Key identifier, is the address derived from the public key of the key type.
50    pub key_id: Address,
51
52    /// Unix timestamp when key expires.
53    /// - `None` (RLP 0x80) = key never expires (stored as u64::MAX in precompile)
54    /// - `Some(timestamp)` = key expires at this timestamp
55    #[cfg_attr(feature = "serde", serde(with = "alloy_serde::quantity::opt"))]
56    pub expiry: Option<u64>,
57
58    /// TIP20 spending limits for this key.
59    /// - `None` (RLP 0x80) = unlimited spending (no limits enforced)
60    /// - `Some([])` = no spending allowed (enforce_limits=true but no tokens allowed)
61    /// - `Some([TokenLimit{...}])` = specific limits enforced
62    pub limits: Option<Vec<TokenLimit>>,
63}
64
65impl KeyAuthorization {
66    /// Computes the authorization message hash for this key authorization.
67    pub fn signature_hash(&self) -> B256 {
68        let mut buf = Vec::new();
69        self.encode(&mut buf);
70        keccak256(&buf)
71    }
72
73    /// Returns whether this key has unlimited spending (limits is None)
74    pub fn has_unlimited_spending(&self) -> bool {
75        self.limits.is_none()
76    }
77
78    /// Returns whether this key never expires (expiry is None)
79    pub fn never_expires(&self) -> bool {
80        self.expiry.is_none()
81    }
82
83    /// Convert the key authorization into a [`SignedKeyAuthorization`] with a signature.
84    pub fn into_signed(self, signature: PrimitiveSignature) -> SignedKeyAuthorization {
85        SignedKeyAuthorization {
86            authorization: self,
87            signature,
88        }
89    }
90
91    /// Validates that this key authorization's `chain_id` is compatible with `expected_chain_id`.
92    ///
93    /// - Post-T1C: `chain_id` must exactly match (wildcard `0` is no longer allowed).
94    /// - Pre-T1C: `chain_id == 0` is a wildcard (valid on any chain), otherwise must match.
95    pub fn validate_chain_id(
96        &self,
97        expected_chain_id: u64,
98        is_t1c: bool,
99    ) -> Result<(), KeyAuthorizationChainIdError> {
100        if is_t1c {
101            if self.chain_id != expected_chain_id {
102                return Err(KeyAuthorizationChainIdError {
103                    expected: expected_chain_id,
104                    got: self.chain_id,
105                });
106            }
107        } else if self.chain_id != 0 && self.chain_id != expected_chain_id {
108            return Err(KeyAuthorizationChainIdError {
109                expected: expected_chain_id,
110                got: self.chain_id,
111            });
112        }
113        Ok(())
114    }
115
116    /// Calculates a heuristic for the in-memory size of the key authorization
117    pub fn size(&self) -> usize {
118        size_of::<Self>()
119            + self
120                .limits
121                .as_ref()
122                .map_or(0, |limits| limits.capacity() * size_of::<TokenLimit>())
123    }
124}
125
126/// Error returned when a [`KeyAuthorization`]'s `chain_id` does not match the expected value.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub struct KeyAuthorizationChainIdError {
129    /// The expected chain ID (current chain).
130    pub expected: u64,
131    /// The chain ID from the KeyAuthorization.
132    pub got: u64,
133}
134
135/// Signed key authorization that can be attached to a transaction.
136#[derive(
137    Clone,
138    Debug,
139    PartialEq,
140    Eq,
141    Hash,
142    alloy_rlp::RlpEncodable,
143    alloy_rlp::RlpDecodable,
144    derive_more::Deref,
145)]
146#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
147#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
148#[cfg_attr(any(test, feature = "arbitrary"), derive(arbitrary::Arbitrary))]
149#[rlp(trailing)]
150#[cfg_attr(test, reth_codecs::add_arbitrary_tests(compact, rlp))]
151pub struct SignedKeyAuthorization {
152    /// Key authorization for provisioning access keys
153    #[cfg_attr(feature = "serde", serde(flatten))]
154    #[deref]
155    pub authorization: KeyAuthorization,
156
157    /// Signature authorizing this key (signed by root key)
158    pub signature: PrimitiveSignature,
159}
160
161impl SignedKeyAuthorization {
162    /// Recover the signer of the [`KeyAuthorization`].
163    pub fn recover_signer(&self) -> Result<Address, RecoveryError> {
164        self.signature
165            .recover_signer(&self.authorization.signature_hash())
166    }
167
168    /// Calculates a heuristic for the in-memory size of the signed key authorization
169    pub fn size(&self) -> usize {
170        self.authorization.size() + self.signature.size()
171    }
172}
173
174#[cfg(feature = "reth-codec")]
175impl reth_codecs::Compact for SignedKeyAuthorization {
176    fn to_compact<B>(&self, buf: &mut B) -> usize
177    where
178        B: alloy_rlp::BufMut + AsMut<[u8]>,
179    {
180        // Use RLP encoding for compact representation
181        self.encode(buf);
182        self.length()
183    }
184
185    fn from_compact(mut buf: &[u8], _len: usize) -> (Self, &[u8]) {
186        let item = alloy_rlp::Decodable::decode(&mut buf)
187            .expect("Failed to decode KeyAuthorization from compact");
188        (item, buf)
189    }
190}
191
192#[cfg(any(test, feature = "arbitrary"))]
193impl<'a> arbitrary::Arbitrary<'a> for KeyAuthorization {
194    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
195        Ok(Self {
196            chain_id: u.arbitrary()?,
197            key_type: u.arbitrary()?,
198            key_id: u.arbitrary()?,
199            // Ensure that Some(0) is not generated as it's becoming `None` after RLP roundtrip.
200            expiry: u.arbitrary::<Option<u64>>()?.filter(|v| *v != 0),
201            limits: u.arbitrary()?,
202        })
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::transaction::{
210        TempoSignature,
211        tt_authorization::tests::{generate_secp256k1_keypair, sign_hash},
212    };
213
214    fn make_auth(expiry: Option<u64>, limits: Option<Vec<TokenLimit>>) -> KeyAuthorization {
215        KeyAuthorization {
216            chain_id: 1,
217            key_type: SignatureType::Secp256k1,
218            key_id: Address::random(),
219            expiry,
220            limits,
221        }
222    }
223
224    #[test]
225    fn test_signature_hash_and_recover_signer() {
226        let (signing_key, expected_address) = generate_secp256k1_keypair();
227
228        let auth = make_auth(Some(1000), None);
229
230        // Hash determinism
231        let hash1 = auth.signature_hash();
232        let hash2 = auth.signature_hash();
233        assert_eq!(hash1, hash2, "signature_hash should be deterministic");
234        assert_ne!(hash1, B256::ZERO);
235
236        // Different auth produces different hash
237        let auth2 = make_auth(Some(2000), None);
238        assert_ne!(auth.signature_hash(), auth2.signature_hash());
239
240        // Sign and recover
241        let signature = sign_hash(&signing_key, &auth.signature_hash());
242        let inner_sig = match signature {
243            TempoSignature::Primitive(p) => p,
244            _ => panic!("Expected primitive signature"),
245        };
246        let signed = auth.clone().into_signed(inner_sig);
247
248        // Recovery should succeed with correct address
249        let recovered = signed.recover_signer();
250        assert!(recovered.is_ok());
251        assert_eq!(recovered.unwrap(), expected_address);
252
253        // Wrong signature hash yields wrong address
254        let wrong_sig = sign_hash(&signing_key, &B256::random());
255        let wrong_inner = match wrong_sig {
256            TempoSignature::Primitive(p) => p,
257            _ => panic!("Expected primitive signature"),
258        };
259        let bad_signed = auth.into_signed(wrong_inner);
260        let bad_recovered = bad_signed.recover_signer();
261        assert!(bad_recovered.is_ok());
262        assert_ne!(bad_recovered.unwrap(), expected_address);
263    }
264
265    #[test]
266    fn test_spending_expiry_and_size() {
267        // has_unlimited_spending: None = true, Some = false
268        assert!(make_auth(None, None).has_unlimited_spending());
269        assert!(!make_auth(None, Some(vec![])).has_unlimited_spending());
270        assert!(
271            !make_auth(
272                None,
273                Some(vec![TokenLimit {
274                    token: Address::ZERO,
275                    limit: U256::from(100),
276                }])
277            )
278            .has_unlimited_spending()
279        );
280
281        // never_expires: None = true, Some = false
282        assert!(make_auth(None, None).never_expires());
283        assert!(!make_auth(Some(1000), None).never_expires());
284        assert!(!make_auth(Some(0), None).never_expires()); // 0 is still Some
285    }
286
287    fn make_auth_with_chain_id(chain_id: u64) -> KeyAuthorization {
288        KeyAuthorization {
289            chain_id,
290            key_type: SignatureType::Secp256k1,
291            key_id: Address::random(),
292            expiry: None,
293            limits: None,
294        }
295    }
296
297    #[test]
298    fn test_validate_chain_id_pre_t1c() {
299        let expected = 42431;
300
301        // Matching chain_id → ok
302        assert!(
303            make_auth_with_chain_id(expected)
304                .validate_chain_id(expected, false)
305                .is_ok()
306        );
307
308        // Wildcard chain_id=0 → ok pre-T1C
309        assert!(
310            make_auth_with_chain_id(0)
311                .validate_chain_id(expected, false)
312                .is_ok()
313        );
314
315        // Wrong chain_id → err
316        let err = make_auth_with_chain_id(999)
317            .validate_chain_id(expected, false)
318            .unwrap_err();
319        assert_eq!(err.expected, expected);
320        assert_eq!(err.got, 999);
321    }
322
323    #[test]
324    fn test_validate_chain_id_post_t1c() {
325        let expected = 42431;
326
327        // Matching chain_id → ok
328        assert!(
329            make_auth_with_chain_id(expected)
330                .validate_chain_id(expected, true)
331                .is_ok()
332        );
333
334        // Wildcard chain_id=0 → rejected post-T1C
335        let err = make_auth_with_chain_id(0)
336            .validate_chain_id(expected, true)
337            .unwrap_err();
338        assert_eq!(err.expected, expected);
339        assert_eq!(err.got, 0);
340
341        // Wrong chain_id → rejected
342        let err = make_auth_with_chain_id(999)
343            .validate_chain_id(expected, true)
344            .unwrap_err();
345        assert_eq!(err.expected, expected);
346        assert_eq!(err.got, 999);
347    }
348}