tempo_precompiles/tip_account_registrar/
mod.rs

1pub mod dispatch;
2
3pub use tempo_contracts::precompiles::ITipAccountRegistrar;
4use tempo_precompiles_macros::contract;
5
6use crate::error::Result;
7use alloy::{
8    eips::eip7702::constants::SECP256K1N_HALF,
9    primitives::{Address, B512, U256},
10};
11use revm::{precompile::secp256k1::ecrecover, state::Bytecode};
12use tempo_contracts::{
13    DEFAULT_7702_DELEGATE_ADDRESS,
14    precompiles::{TIP_ACCOUNT_REGISTRAR, TIPAccountRegistrarError},
15};
16
17#[contract(addr = TIP_ACCOUNT_REGISTRAR)]
18pub struct TipAccountRegistrar {}
19
20impl TipAccountRegistrar {
21    /// Initializes the TIP Account Registrar contract
22    ///
23    /// Ensures the [`TipAccountRegistrar`] account isn't empty and prevents state clear.
24    pub fn initialize(&mut self) -> Result<()> {
25        self.__initialize()
26    }
27
28    /// Pre-Moderato: Validates an ECDSA signature against a provided hash.
29    /// **WARNING**: This version is vulnerable to signature forgery and is only
30    /// kept for pre-Moderato compatibility. **Deprecated at Moderato hardfork**.
31    pub fn delegate_to_default_v1(
32        &mut self,
33        call: ITipAccountRegistrar::delegateToDefault_0Call,
34    ) -> Result<Address> {
35        let ITipAccountRegistrar::delegateToDefault_0Call { hash, signature } = call;
36
37        // taken from precompile gas cost
38        // https://github.com/bluealloy/revm/blob/a1fdb9d9e98f9dd14b7577edbad49c139ab53b16/crates/precompile/src/secp256k1.rs#L34
39        self.storage.deduct_gas(3_000)?;
40        let (sig, v) = validate_signature(&signature)?;
41
42        let signer = match ecrecover(&sig, v, &hash) {
43            Ok(recovered_addr) => Address::from_word(recovered_addr),
44            Err(_) => {
45                return Err(TIPAccountRegistrarError::invalid_signature().into());
46            }
47        };
48
49        // EIP-7702 gas cost
50        // can be discussed to lower this down as this cost i think encompasses the bytes of authorization in EIP-7702 tx.
51        let cost = self.storage.with_account_info(signer, |info| {
52            if info.nonce != 0 {
53                Err(TIPAccountRegistrarError::nonce_not_zero().into())
54            } else if !info.is_empty_code_hash() {
55                Err(TIPAccountRegistrarError::code_not_empty().into())
56            } else if info.is_empty() {
57                Ok(revm::primitives::eip7702::PER_EMPTY_ACCOUNT_COST)
58            } else {
59                Ok(revm::primitives::eip7702::PER_AUTH_BASE_COST)
60            }
61        })?;
62        self.storage.deduct_gas(cost)?;
63
64        // Delegate the account to the default 7702 implementation
65        self.storage
66            .set_code(signer, Bytecode::new_eip7702(DEFAULT_7702_DELEGATE_ADDRESS))?;
67
68        Ok(signer)
69    }
70
71    /// Post-Moderato: Validates an ECDSA signature and deploys the default 7702 delegate code
72    /// to the recovered signer's account. The account must have nonce = 0 and empty code.
73    ///
74    /// This version computes the hash internally from arbitrary message bytes to prevent signature forgery.
75    pub fn delegate_to_default_v2(
76        &mut self,
77        call: ITipAccountRegistrar::delegateToDefault_1Call,
78    ) -> Result<Address> {
79        let ITipAccountRegistrar::delegateToDefault_1Call { message, signature } = call;
80
81        // Compute the hash internally from the provided message
82        let hash = alloy::primitives::keccak256(&message);
83
84        // Reuse v1 logic with the computed hash
85        self.delegate_to_default_v1(ITipAccountRegistrar::delegateToDefault_0Call {
86            hash,
87            signature,
88        })
89    }
90}
91
92/// Validates an ECDSA signature according to Ethereum standards.
93/// Accepts recovery values `v ∈ {0, 1, 27, 28}` and enforces EIP-2 low-s requirement.
94fn validate_signature(signature: &[u8]) -> Result<(B512, u8)> {
95    if signature.len() != 65 {
96        return Err(TIPAccountRegistrarError::invalid_signature().into());
97    }
98
99    // Extract signature components
100    // r: bytes 0-31 bytes
101    // s: bytes 32-63 bytes
102    // v: byte 64
103    // SAFETY: This is safe to unwrap because we already validated length == 65
104    let sig: &[u8; 64] = signature[0..64].try_into().unwrap();
105    let mut v = signature[64];
106    // Normalize v and bound-check
107    v = match v {
108        27 | 28 => v - 27,
109        0 | 1 => v,
110        _ => {
111            return Err(TIPAccountRegistrarError::invalid_signature().into());
112        }
113    };
114
115    // Enforce EIP-2 low-s
116    let s = U256::from_be_slice(&sig[32..64]);
117    if s > SECP256K1N_HALF {
118        return Err(TIPAccountRegistrarError::invalid_signature().into());
119    }
120
121    Ok((sig.into(), v))
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::{
128        Precompile,
129        error::TempoPrecompileError,
130        storage::{StorageCtx, hashmap::HashMapStorageProvider},
131    };
132    use alloy::sol_types::{SolCall, SolError};
133    use alloy_signer::SignerSync;
134    use alloy_signer_local::PrivateKeySigner;
135    use tempo_chainspec::hardfork::TempoHardfork;
136    use tempo_contracts::precompiles::{TIPAccountRegistrarError, UnknownFunctionSelector};
137
138    #[test]
139    fn test_delegate_to_default_v1_pre_moderato() -> eyre::Result<()> {
140        let mut storage = HashMapStorageProvider::new(1);
141        StorageCtx::enter(&mut storage, || {
142            // Pre-Moderato: delegateToDefault(bytes32,bytes) should work
143            let signer = PrivateKeySigner::random();
144            let expected_address = signer.address();
145            let hash = alloy::primitives::keccak256(b"test");
146
147            let mut registrar = TipAccountRegistrar::new();
148
149            let signature = signer.sign_hash_sync(&hash).unwrap();
150            let call = ITipAccountRegistrar::delegateToDefault_0Call {
151                hash,
152                signature: signature.as_bytes().into(),
153            };
154
155            let result = registrar.delegate_to_default_v1(call);
156            assert!(result.is_ok());
157
158            let recovered_address = result.unwrap();
159            assert_eq!(recovered_address, expected_address);
160
161            let account_info_after = registrar
162                .storage
163                .get_account_info(expected_address)
164                .expect("Failed to get account info");
165            assert_eq!(
166                account_info_after.code,
167                Some(Bytecode::new_eip7702(DEFAULT_7702_DELEGATE_ADDRESS)),
168            );
169
170            Ok(())
171        })
172    }
173
174    #[test]
175    fn test_delegate_to_default_v1_rejected_post_moderato() -> eyre::Result<()> {
176        let mut storage = HashMapStorageProvider::new(1).with_spec(TempoHardfork::Moderato);
177        StorageCtx::enter(&mut storage, || {
178            // Post-Moderato: delegateToDefault(bytes32,bytes) should be rejected
179            let hash = alloy::primitives::keccak256(b"test");
180            let mut registrar = TipAccountRegistrar::new();
181
182            let signer = PrivateKeySigner::random();
183            let signature = signer.sign_hash_sync(&hash).unwrap();
184
185            // Encode the call using the old signature
186            let call = ITipAccountRegistrar::delegateToDefault_0Call {
187                hash,
188                signature: signature.as_bytes().into(),
189            };
190            let calldata = call.abi_encode();
191
192            // Should fail with UnknownFunctionSelector after Moderato (ABI-encoded error)
193            let result = registrar.call(&calldata, signer.address());
194            assert!(result.is_ok());
195            let output = result.unwrap();
196            assert!(output.reverted);
197
198            // Verify the error can be decoded as UnknownFunctionSelector
199            let decoded_error = UnknownFunctionSelector::abi_decode(&output.bytes);
200            assert!(
201                decoded_error.is_ok(),
202                "Should decode as UnknownFunctionSelector"
203            );
204
205            // Verify it contains the expected selector
206            let error = decoded_error.unwrap();
207            assert_eq!(
208                error.selector.as_slice(),
209                &ITipAccountRegistrar::delegateToDefault_0Call::SELECTOR
210            );
211
212            Ok(())
213        })
214    }
215
216    #[test]
217    fn test_delegate_to_default_v2_post_moderato() -> eyre::Result<()> {
218        let mut storage = HashMapStorageProvider::new(1).with_spec(TempoHardfork::Moderato);
219        StorageCtx::enter(&mut storage, || {
220            // Post-Moderato: delegateToDefault(bytes,bytes) should work
221            let signer = PrivateKeySigner::random();
222            let expected_address = signer.address();
223
224            let mut registrar = TipAccountRegistrar::new();
225
226            let message = b"Hello, Tempo! I want to delegate my account.";
227            let message_hash = alloy::primitives::keccak256(message);
228            let signature = signer.sign_hash_sync(&message_hash).unwrap();
229
230            let call = ITipAccountRegistrar::delegateToDefault_1Call {
231                message: message.to_vec().into(),
232                signature: signature.as_bytes().into(),
233            };
234
235            let result = registrar.delegate_to_default_v2(call);
236            assert!(result.is_ok());
237
238            let recovered_address = result.unwrap();
239            assert_eq!(recovered_address, expected_address);
240
241            let account_info_after = registrar
242                .storage
243                .get_account_info(expected_address)
244                .expect("Failed to get account info");
245            assert_eq!(
246                account_info_after.code,
247                Some(Bytecode::new_eip7702(DEFAULT_7702_DELEGATE_ADDRESS)),
248            );
249
250            Ok(())
251        })
252    }
253
254    #[test]
255    fn test_delegate_to_default_v2_rejected_pre_moderato() -> eyre::Result<()> {
256        let mut storage = HashMapStorageProvider::new(1).with_spec(TempoHardfork::Adagio);
257        StorageCtx::enter(&mut storage, || {
258            // Pre-Moderato: delegateToDefault(bytes,bytes) should be rejected
259            let mut registrar = TipAccountRegistrar::new();
260
261            let signer = PrivateKeySigner::random();
262            let message = b"Hello, Tempo!";
263            let message_hash = alloy::primitives::keccak256(message);
264            let signature = signer.sign_hash_sync(&message_hash).unwrap();
265
266            // Encode the call using the new signature
267            let call = ITipAccountRegistrar::delegateToDefault_1Call {
268                message: message.to_vec().into(),
269                signature: signature.as_bytes().into(),
270            };
271            let calldata = call.abi_encode();
272
273            // Should fail with UnknownFunctionSelector pre-Moderato
274            let result = registrar.call(&calldata, signer.address());
275            assert!(matches!(
276                result,
277                Err(revm::precompile::PrecompileError::Other(ref msg)) if msg.contains("Unknown function selector")
278            ));
279
280            Ok(())
281        })
282    }
283
284    #[test]
285    fn test_malformed_signature_v1() -> eyre::Result<()> {
286        let mut storage = HashMapStorageProvider::new(1);
287        StorageCtx::enter(&mut storage, || {
288            let hash = alloy::primitives::keccak256(b"test");
289            let mut registrar = TipAccountRegistrar::new();
290
291            // Signature too short
292            let call = ITipAccountRegistrar::delegateToDefault_0Call {
293                hash,
294                signature: vec![0u8; 64].into(),
295            };
296
297            let result = registrar.delegate_to_default_v1(call);
298            assert!(result.is_err());
299            assert!(matches!(
300                result.unwrap_err(),
301                TempoPrecompileError::TIPAccountRegistrarError(
302                    TIPAccountRegistrarError::InvalidSignature(_)
303                )
304            ));
305
306            // Signature too long
307            let call = ITipAccountRegistrar::delegateToDefault_0Call {
308                hash,
309                signature: vec![0u8; 66].into(),
310            };
311
312            let result = registrar.delegate_to_default_v1(call);
313            assert!(result.is_err());
314            assert!(matches!(
315                result.unwrap_err(),
316                TempoPrecompileError::TIPAccountRegistrarError(
317                    TIPAccountRegistrarError::InvalidSignature(_)
318                )
319            ));
320
321            Ok(())
322        })
323    }
324
325    #[test]
326    fn test_invalid_signature_v1() -> eyre::Result<()> {
327        let mut storage = HashMapStorageProvider::new(1);
328        StorageCtx::enter(&mut storage, || {
329            let hash = alloy::primitives::keccak256(b"test");
330            let mut registrar = TipAccountRegistrar::new();
331
332            // Create a signature with an invalid recovery value
333            let mut invalid_signature = vec![0u8; 65];
334            invalid_signature[64] = 30;
335
336            let call = ITipAccountRegistrar::delegateToDefault_0Call {
337                hash,
338                signature: invalid_signature.into(),
339            };
340
341            let result = registrar.delegate_to_default_v1(call);
342            assert!(result.is_err());
343            assert!(matches!(
344                result.unwrap_err(),
345                TempoPrecompileError::TIPAccountRegistrarError(
346                    TIPAccountRegistrarError::InvalidSignature(_)
347                )
348            ));
349
350            Ok(())
351        })
352    }
353
354    #[test]
355    fn test_nonce_gt_zero_v1() -> eyre::Result<()> {
356        let mut storage = HashMapStorageProvider::new(1);
357        StorageCtx::enter(&mut storage, || {
358            let signer = PrivateKeySigner::random();
359            let expected_address = signer.address();
360            let hash = alloy::primitives::keccak256(b"test");
361
362            let mut registrar = TipAccountRegistrar::new();
363
364            registrar.storage.set_nonce(expected_address, 1);
365            let signature = signer.sign_hash_sync(&hash).unwrap();
366            let call = ITipAccountRegistrar::delegateToDefault_0Call {
367                hash,
368                signature: signature.as_bytes().into(),
369            };
370
371            let result = registrar.delegate_to_default_v1(call);
372            assert!(matches!(
373                result.unwrap_err(),
374                TempoPrecompileError::TIPAccountRegistrarError(
375                    TIPAccountRegistrarError::NonceNotZero(_)
376                )
377            ));
378
379            let account_info_after = registrar
380                .storage
381                .get_account_info(expected_address)
382                .expect("Failed to get account info");
383            assert!(account_info_after.is_empty_code_hash());
384
385            Ok(())
386        })
387    }
388
389    #[test]
390    fn test_delegate_to_default_v2_different_messages_different_signers() -> eyre::Result<()> {
391        let mut storage = HashMapStorageProvider::new(1).with_spec(TempoHardfork::Moderato);
392        StorageCtx::enter(&mut storage, || {
393            let signer = PrivateKeySigner::random();
394            let expected_address = signer.address();
395
396            let mut registrar = TipAccountRegistrar::new();
397
398            // Sign one message
399            let message1 = b"Message 1";
400            let hash1 = alloy::primitives::keccak256(message1);
401            let signature1 = signer.sign_hash_sync(&hash1).unwrap();
402
403            // Try to reuse the signature for a different message
404            let message2 = b"Message 2";
405
406            let call = ITipAccountRegistrar::delegateToDefault_1Call {
407                message: message2.to_vec().into(),
408                signature: signature1.as_bytes().into(),
409            };
410
411            // The signature was for message1, not message2
412            // ecrecover will succeed but recover a different (random) address
413            let result = registrar.delegate_to_default_v2(call);
414
415            // Should succeed and recover a different address than the actual signer
416            // This demonstrates the signature is valid but for a different message
417            let recovered_addr = result.expect("ecrecover should succeed with valid signature");
418            assert_ne!(
419                recovered_addr, expected_address,
420                "Should recover a different address when signature is for different message"
421            );
422
423            Ok(())
424        })
425    }
426}