Skip to main content

tempo_alloy/rpc/
reth_compat.rs

1use crate::rpc::{TempoHeaderResponse, TempoTransactionRequest};
2use alloy_consensus::{EthereumTxEnvelope, TxEip4844, error::ValueError};
3use alloy_network::{NetworkTransactionBuilder, TxSigner};
4use alloy_primitives::{Address, B256, Bytes, Signature};
5use core::num::NonZeroU64;
6use reth_evm::EvmEnv;
7use reth_primitives_traits::SealedHeader;
8use reth_rpc_convert::{
9    FromConsensusHeader, SignTxRequestError, SignableTxRequest, TryIntoSimTx, TryIntoTxEnv,
10};
11use reth_rpc_eth_types::EthApiError;
12use tempo_chainspec::hardfork::TempoHardfork;
13use tempo_evm::TempoBlockEnv;
14use tempo_primitives::{
15    SignatureType, TempoHeader, TempoSignature, TempoTxEnvelope, TempoTxType,
16    transaction::{Call, RecoveredTempoAuthorization},
17};
18use tempo_revm::{TempoBatchCallEnv, TempoTxEnv};
19
20impl TryIntoSimTx<TempoTxEnvelope> for TempoTransactionRequest {
21    fn try_into_sim_tx(self) -> Result<TempoTxEnvelope, ValueError<Self>> {
22        match self.output_tx_type() {
23            TempoTxType::AA => {
24                let tx = self.build_aa()?;
25
26                // Create an empty signature for the transaction.
27                let signature = TempoSignature::default();
28
29                Ok(tx.into_signed(signature).into())
30            }
31            TempoTxType::Legacy
32            | TempoTxType::Eip2930
33            | TempoTxType::Eip1559
34            | TempoTxType::Eip7702 => {
35                let Self {
36                    inner,
37                    fee_token,
38                    nonce_key,
39                    calls,
40                    key_type,
41                    key_data,
42                    key_id,
43                    tempo_authorization_list,
44                    key_authorization,
45                    valid_before,
46                    valid_after,
47                    fee_payer_signature,
48                } = self;
49                let envelope = match TryIntoSimTx::<EthereumTxEnvelope<TxEip4844>>::try_into_sim_tx(
50                    inner.clone(),
51                ) {
52                    Ok(inner) => inner,
53                    Err(e) => {
54                        return Err(e.map(|inner| Self {
55                            inner,
56                            fee_token,
57                            nonce_key,
58                            calls,
59                            key_type,
60                            key_data,
61                            key_id,
62                            tempo_authorization_list,
63                            key_authorization,
64                            valid_before,
65                            valid_after,
66                            fee_payer_signature,
67                        }));
68                    }
69                };
70
71                Ok(envelope.try_into().map_err(
72                    |e: ValueError<EthereumTxEnvelope<TxEip4844>>| {
73                        e.map(|_inner| Self {
74                            inner,
75                            fee_token,
76                            nonce_key,
77                            calls,
78                            key_type,
79                            key_data,
80                            key_id,
81                            tempo_authorization_list,
82                            key_authorization,
83                            valid_before,
84                            valid_after,
85                            fee_payer_signature,
86                        })
87                    },
88                )?)
89            }
90        }
91    }
92}
93
94impl TryIntoTxEnv<TempoTxEnv, TempoHardfork, TempoBlockEnv> for TempoTransactionRequest {
95    type Err = EthApiError;
96
97    fn try_into_tx_env(
98        self,
99        evm_env: &EvmEnv<TempoHardfork, TempoBlockEnv>,
100    ) -> Result<TempoTxEnv, Self::Err> {
101        let caller_addr = self.inner.from.unwrap_or_default();
102
103        let fee_payer = if self.fee_payer_signature.is_some() {
104            // Try to recover the fee payer address from the signature.
105            // If recovery fails (e.g. dummy signature during gas estimation / fill),
106            // keep it unresolved so estimation/fill can continue with sender-paid semantics.
107            let recovered = self
108                .clone()
109                .build_aa()
110                .ok()
111                .and_then(|tx| tx.recover_fee_payer(caller_addr).ok());
112            Some(recovered)
113        } else {
114            None
115        };
116
117        let Self {
118            inner,
119            fee_token,
120            calls,
121            key_type,
122            key_data,
123            key_id,
124            tempo_authorization_list,
125            nonce_key,
126            key_authorization,
127            valid_before,
128            valid_after,
129            fee_payer_signature: _,
130        } = self;
131
132        Ok(TempoTxEnv {
133            fee_token,
134            is_system_tx: false,
135            fee_payer,
136            tempo_tx_env: if !calls.is_empty()
137                || !tempo_authorization_list.is_empty()
138                || nonce_key.is_some()
139                || key_authorization.is_some()
140                || key_id.is_some()
141                || fee_payer.is_some()
142                || valid_before.is_some()
143                || valid_after.is_some()
144            {
145                // Create mock signature for gas estimation
146                // If key_type is not provided, default to secp256k1
147                // For Keychain signatures, use the caller's address as the root key address
148                let key_type = key_type.unwrap_or(SignatureType::Secp256k1);
149                let mock_signature = create_mock_tempo_sig(
150                    &key_type,
151                    key_data.as_ref(),
152                    key_id,
153                    caller_addr,
154                    evm_env.spec_id().is_t1c(),
155                );
156
157                let mut calls = calls;
158                if let Some(to) = &inner.to {
159                    calls.push(Call {
160                        to: *to,
161                        value: inner.value.unwrap_or_default(),
162                        input: inner.input.clone().into_input().unwrap_or_default(),
163                    });
164                }
165                if calls.is_empty() {
166                    return Err(EthApiError::InvalidParams("empty calls list".to_string()));
167                }
168
169                Some(Box::new(TempoBatchCallEnv {
170                    aa_calls: calls,
171                    signature: mock_signature,
172                    tempo_authorization_list: tempo_authorization_list
173                        .into_iter()
174                        .map(RecoveredTempoAuthorization::new)
175                        .collect(),
176                    nonce_key: nonce_key.unwrap_or_default(),
177                    key_authorization,
178                    signature_hash: B256::ZERO,
179                    tx_hash: B256::ZERO,
180                    // Use a zero sentinel for expiring nonce hash during simulation.
181                    // The real hash is keccak256(encode_for_signing || sender), but we
182                    // don't have a signed transaction here. B256::ZERO lets the handler's
183                    // replay check proceed (it runs against ephemeral simulation state
184                    // that is discarded). Gas accuracy is unaffected — the 13k
185                    // EXPIRING_NONCE_GAS is charged based on nonce_key, not the hash value.
186                    expiring_nonce_hash: Some(B256::ZERO),
187                    valid_before: valid_before.map(NonZeroU64::get),
188                    valid_after: valid_after.map(NonZeroU64::get),
189                    subblock_transaction: false,
190                    override_key_id: key_id,
191                    expiring_nonce_idx: None,
192                }))
193            } else {
194                None
195            },
196            inner: inner.try_into_tx_env(evm_env)?,
197        })
198    }
199}
200
201/// Creates a mock AA signature for gas estimation based on key type hints
202///
203/// - `key_type`: The primitive signature type (secp256k1, P256, WebAuthn)
204/// - `key_data`: Type-specific data (e.g., WebAuthn size)
205/// - `key_id`: If Some, wraps the signature in a Keychain wrapper (+3,000 gas for key validation)
206/// - `caller_addr`: The transaction caller address (used as root key address for Keychain)
207/// - `is_t1c`: Whether T1C is active — determines keychain signature version (V1 pre-T1C, V2 post-T1C)
208fn create_mock_tempo_sig(
209    key_type: &SignatureType,
210    key_data: Option<&Bytes>,
211    key_id: Option<Address>,
212    caller_addr: alloy_primitives::Address,
213    is_t1c: bool,
214) -> TempoSignature {
215    use tempo_primitives::transaction::tt_signature::{KeychainSignature, TempoSignature};
216
217    let inner_sig = create_mock_primitive_signature(key_type, key_data.cloned());
218
219    if key_id.is_some() {
220        // For Keychain signatures, the root_key_address is the caller (account owner).
221        let keychain_sig = if is_t1c {
222            KeychainSignature::new(caller_addr, inner_sig)
223        } else {
224            KeychainSignature::new_v1(caller_addr, inner_sig)
225        };
226        TempoSignature::Keychain(keychain_sig)
227    } else {
228        TempoSignature::Primitive(inner_sig)
229    }
230}
231
232/// Creates a mock primitive signature for gas estimation
233fn create_mock_primitive_signature(
234    sig_type: &SignatureType,
235    key_data: Option<Bytes>,
236) -> tempo_primitives::transaction::tt_signature::PrimitiveSignature {
237    use tempo_primitives::transaction::tt_signature::{
238        P256SignatureWithPreHash, PrimitiveSignature, WebAuthnSignature,
239    };
240
241    match sig_type {
242        SignatureType::Secp256k1 => {
243            // Create a dummy secp256k1 signature (65 bytes)
244            PrimitiveSignature::Secp256k1(Signature::new(
245                alloy_primitives::U256::ZERO,
246                alloy_primitives::U256::ZERO,
247                false,
248            ))
249        }
250        SignatureType::P256 => {
251            // Create a dummy P256 signature
252            PrimitiveSignature::P256(P256SignatureWithPreHash {
253                r: alloy_primitives::B256::ZERO,
254                s: alloy_primitives::B256::ZERO,
255                pub_key_x: alloy_primitives::B256::ZERO,
256                pub_key_y: alloy_primitives::B256::ZERO,
257                pre_hash: false,
258            })
259        }
260        SignatureType::WebAuthn => {
261            // Create a dummy WebAuthn signature with the specified size
262            // key_data contains the total size of webauthn_data (excluding 128 bytes for public keys)
263            // Default: 800 bytes if no key_data provided
264
265            // Base clientDataJSON template (50 bytes): {"type":"webauthn.get","challenge":"","origin":""}
266            // Authenticator data (37 bytes): 32 rpIdHash + 1 flags + 4 signCount
267            // Minimum total: 87 bytes
268            const BASE_CLIENT_JSON: &str = r#"{"type":"webauthn.get","challenge":"","origin":""}"#;
269            const AUTH_DATA_SIZE: usize = 37;
270            const MIN_WEBAUTHN_SIZE: usize = AUTH_DATA_SIZE + BASE_CLIENT_JSON.len(); // 87 bytes
271            const DEFAULT_WEBAUTHN_SIZE: usize = 800; // Default when no key_data provided
272            const MAX_WEBAUTHN_SIZE: usize = 8192; // Maximum realistic WebAuthn signature size
273
274            // Parse size from key_data, or use default
275            let size = if let Some(data) = key_data.as_ref() {
276                match data.len() {
277                    1 => data[0] as usize,
278                    2 => u16::from_be_bytes([data[0], data[1]]) as usize,
279                    4 => u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize,
280                    _ => DEFAULT_WEBAUTHN_SIZE, // Fallback default
281                }
282            } else {
283                DEFAULT_WEBAUTHN_SIZE // Default size when no key_data provided
284            };
285
286            // Clamp size to safe bounds to prevent DoS via unbounded allocation
287            let size = size.clamp(MIN_WEBAUTHN_SIZE, MAX_WEBAUTHN_SIZE);
288
289            // Construct authenticatorData (37 bytes)
290            let mut webauthn_data = vec![0u8; AUTH_DATA_SIZE];
291            webauthn_data[32] = 0x01; // UP flag set
292
293            // Construct clientDataJSON with padding in origin field if needed
294            let additional_bytes = size - MIN_WEBAUTHN_SIZE;
295            let client_json = if additional_bytes > 0 {
296                // Add padding bytes to origin field
297                // {"type":"webauthn.get","challenge":"","origin":"XXXXX"}
298                let padding = "x".repeat(additional_bytes);
299                format!(r#"{{"type":"webauthn.get","challenge":"","origin":"{padding}"}}"#,)
300            } else {
301                BASE_CLIENT_JSON.to_string()
302            };
303
304            webauthn_data.extend_from_slice(client_json.as_bytes());
305            let webauthn_data = Bytes::from(webauthn_data);
306
307            PrimitiveSignature::WebAuthn(WebAuthnSignature {
308                webauthn_data,
309                r: alloy_primitives::B256::ZERO,
310                s: alloy_primitives::B256::ZERO,
311                pub_key_x: alloy_primitives::B256::ZERO,
312                pub_key_y: alloy_primitives::B256::ZERO,
313            })
314        }
315    }
316}
317
318impl SignableTxRequest<TempoTxEnvelope> for TempoTransactionRequest {
319    async fn try_build_and_sign(
320        self,
321        signer: impl TxSigner<Signature> + Send,
322    ) -> Result<TempoTxEnvelope, SignTxRequestError> {
323        if self.output_tx_type() == TempoTxType::AA {
324            let mut tx = self
325                .build_aa()
326                .map_err(|_| SignTxRequestError::InvalidTransactionRequest)?;
327            let signature = signer.sign_transaction(&mut tx).await?;
328            Ok(tx.into_signed(signature.into()).into())
329        } else {
330            SignableTxRequest::<TempoTxEnvelope>::try_build_and_sign(self.inner, signer).await
331        }
332    }
333}
334
335impl FromConsensusHeader<TempoHeader> for TempoHeaderResponse {
336    fn from_consensus_header(header: SealedHeader<TempoHeader>, block_size: usize) -> Self {
337        Self {
338            timestamp_millis: header.timestamp_millis(),
339            inner: FromConsensusHeader::from_consensus_header(header, block_size),
340        }
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use alloy_primitives::{TxKind, U256, address};
348    use alloy_rpc_types_eth::TransactionRequest;
349    use alloy_signer::SignerSync;
350    use alloy_signer_local::PrivateKeySigner;
351    use reth_rpc_convert::TryIntoTxEnv;
352    use tempo_primitives::{
353        TempoTransaction,
354        transaction::{Call, tt_signature::PrimitiveSignature},
355    };
356
357    #[test]
358    fn test_estimate_gas_when_calls_set() {
359        let existing_call = Call {
360            to: TxKind::Call(address!("0x1111111111111111111111111111111111111111")),
361            value: alloy_primitives::U256::from(1),
362            input: Bytes::from(vec![0xaa]),
363        };
364
365        let req = TempoTransactionRequest {
366            inner: TransactionRequest {
367                to: Some(TxKind::Call(address!(
368                    "0x2222222222222222222222222222222222222222"
369                ))),
370                value: Some(alloy_primitives::U256::from(2)),
371                input: alloy_rpc_types_eth::TransactionInput::new(Bytes::from(vec![0xbb])),
372                nonce: Some(0),
373                gas: Some(100_000),
374                max_fee_per_gas: Some(1_000_000_000),
375                max_priority_fee_per_gas: Some(1_000_000),
376                ..Default::default()
377            },
378            calls: vec![existing_call],
379            nonce_key: Some(alloy_primitives::U256::ZERO),
380            ..Default::default()
381        };
382
383        let built_calls = req.clone().build_aa().expect("build_aa").calls;
384
385        let evm_env = EvmEnv::default();
386        let tx_env = req.try_into_tx_env(&evm_env).expect("try_into_tx_env");
387        let estimated_calls = tx_env.tempo_tx_env.expect("tempo_tx_env").aa_calls;
388
389        assert_eq!(estimated_calls, built_calls);
390    }
391
392    #[test]
393    fn test_webauthn_size_clamped_to_max() {
394        // Attempt to create a signature with u32::MAX size (would be ~4GB without fix)
395        let malicious_key_data = Bytes::from(0xFFFFFFFFu32.to_be_bytes().to_vec());
396        let sig =
397            create_mock_primitive_signature(&SignatureType::WebAuthn, Some(malicious_key_data));
398
399        // Extract webauthn_data and verify it's clamped to MAX_WEBAUTHN_SIZE (8192)
400        let PrimitiveSignature::WebAuthn(webauthn_sig) = sig else {
401            panic!("Expected WebAuthn signature");
402        };
403
404        // The webauthn_data should be at most MAX_WEBAUTHN_SIZE bytes
405        assert!(
406            webauthn_sig.webauthn_data.len() <= 8192,
407            "WebAuthn data size {} exceeds maximum 8192",
408            webauthn_sig.webauthn_data.len()
409        );
410    }
411
412    #[test]
413    fn test_webauthn_size_respects_minimum() {
414        // Attempt to create a signature with size 0
415        let key_data = Bytes::from(vec![0u8]);
416        let sig = create_mock_primitive_signature(&SignatureType::WebAuthn, Some(key_data));
417
418        let PrimitiveSignature::WebAuthn(webauthn_sig) = sig else {
419            panic!("Expected WebAuthn signature");
420        };
421
422        // Should be at least MIN_WEBAUTHN_SIZE (87 bytes)
423        assert!(
424            webauthn_sig.webauthn_data.len() >= 87,
425            "WebAuthn data size {} is below minimum 87",
426            webauthn_sig.webauthn_data.len()
427        );
428    }
429
430    #[test]
431    fn test_webauthn_default_size() {
432        // No key_data should use default size (800)
433        let sig = create_mock_primitive_signature(&SignatureType::WebAuthn, None);
434
435        let PrimitiveSignature::WebAuthn(webauthn_sig) = sig else {
436            panic!("Expected WebAuthn signature");
437        };
438
439        // Default is 800 bytes
440        assert_eq!(webauthn_sig.webauthn_data.len(), 800);
441    }
442
443    #[test]
444    fn test_estimate_gas_fee_payer_signature_only_produces_aa_env() {
445        let sponsor = PrivateKeySigner::random();
446        let sender = address!("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
447        let target = address!("0x2222222222222222222222222222222222222222");
448
449        // Build a TempoTransaction so we can compute fee_payer_signature_hash
450        let tx = TempoTransaction {
451            chain_id: 4217,
452            nonce: 0,
453            fee_payer_signature: None,
454            valid_before: None,
455            valid_after: None,
456            gas_limit: 100_000,
457            max_fee_per_gas: 1_000_000_000,
458            max_priority_fee_per_gas: 1_000_000,
459            fee_token: None,
460            access_list: Default::default(),
461            calls: vec![Call {
462                to: target.into(),
463                value: Default::default(),
464                input: Default::default(),
465            }],
466            tempo_authorization_list: vec![],
467            nonce_key: Default::default(),
468            key_authorization: None,
469        };
470        let hash = tx.fee_payer_signature_hash(sender);
471        let fee_payer_sig = sponsor.sign_hash_sync(&hash).expect("sign");
472
473        // Request with ONLY fee_payer_signature as the Tempo-specific field
474        let req = TempoTransactionRequest {
475            inner: TransactionRequest {
476                from: Some(sender),
477                to: Some(TxKind::Call(target)),
478                nonce: Some(0),
479                gas: Some(100_000),
480                max_fee_per_gas: Some(1_000_000_000),
481                max_priority_fee_per_gas: Some(1_000_000),
482                chain_id: Some(4217),
483                ..Default::default()
484            },
485            fee_payer_signature: Some(fee_payer_sig),
486            ..Default::default()
487        };
488
489        let evm_env = EvmEnv::default();
490        let tx_env = req.try_into_tx_env(&evm_env).expect("try_into_tx_env");
491
492        assert!(
493            tx_env.tempo_tx_env.is_some(),
494            "fee_payer_signature alone must produce an AA tx env"
495        );
496        assert_eq!(
497            tx_env.fee_payer,
498            Some(Some(sponsor.address())),
499            "fee_payer should recover sponsor address"
500        );
501    }
502
503    #[test]
504    fn test_aa_roundtrip_via_tx_env() {
505        use alloy_primitives::U256;
506
507        let calls = vec![
508            Call {
509                to: address!("0x1111111111111111111111111111111111111111").into(),
510                value: U256::ZERO,
511                input: Bytes::from(vec![0xaa]),
512            },
513            Call {
514                to: address!("0x2222222222222222222222222222222222222222").into(),
515                value: U256::ZERO,
516                input: Bytes::from(vec![0xbb]),
517            },
518        ];
519
520        let tx = TempoTransaction {
521            chain_id: 4217,
522            nonce: 1,
523            gas_limit: 100_000,
524            max_fee_per_gas: 1_000_000_000,
525            max_priority_fee_per_gas: 1_000_000,
526            calls: calls.clone(),
527            ..Default::default()
528        };
529
530        let req: TempoTransactionRequest = tx.into();
531
532        let evm_env = EvmEnv::default();
533        let tx_env = req.try_into_tx_env(&evm_env).expect("try_into_tx_env");
534        let aa_calls = tx_env.tempo_tx_env.expect("tempo_tx_env").aa_calls;
535
536        assert_eq!(
537            aa_calls, calls,
538            "roundtrip via try_into_tx_env must preserve exact call list"
539        );
540    }
541
542    #[test]
543    fn test_estimate_gas_invalid_fee_payer_signature_keeps_unresolved_fee_payer() {
544        let sender = address!("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
545        let target = address!("0x2222222222222222222222222222222222222222");
546
547        let req = TempoTransactionRequest {
548            inner: TransactionRequest {
549                from: Some(sender),
550                to: Some(TxKind::Call(target)),
551                nonce: Some(0),
552                gas: Some(100_000),
553                max_fee_per_gas: Some(1_000_000_000),
554                max_priority_fee_per_gas: Some(1_000_000),
555                chain_id: Some(4217),
556                ..Default::default()
557            },
558            fee_payer_signature: Some(Signature::new(U256::ZERO, U256::ZERO, false)),
559            ..Default::default()
560        };
561
562        let evm_env = EvmEnv::default();
563        let tx_env = req.try_into_tx_env(&evm_env).expect("try_into_tx_env");
564
565        assert!(
566            tx_env.tempo_tx_env.is_some(),
567            "fee_payer_signature alone must produce an AA tx env"
568        );
569        assert_eq!(
570            tx_env.fee_payer,
571            Some(None),
572            "invalid fee_payer_signature should remain unresolved"
573        );
574    }
575
576    #[tokio::test]
577    async fn test_signable_tx_request_preserves_tempo_fields() {
578        let signer = PrivateKeySigner::random();
579
580        let call = Call {
581            to: alloy_primitives::TxKind::Call(address!(
582                "0x1111111111111111111111111111111111111111"
583            )),
584            value: alloy_primitives::U256::from(1),
585            input: Bytes::from(vec![0xaa]),
586        };
587
588        let fee_token = address!("0x20c0000000000000000000000000000000000000");
589        let nonce_key = alloy_primitives::U256::from(42);
590
591        let req = TempoTransactionRequest {
592            inner: TransactionRequest {
593                nonce: Some(0),
594                gas: Some(100_000),
595                max_fee_per_gas: Some(1_000_000_000),
596                max_priority_fee_per_gas: Some(1_000_000),
597                chain_id: Some(4217),
598                ..Default::default()
599            },
600            calls: vec![call.clone()],
601            fee_token: Some(fee_token),
602            nonce_key: Some(nonce_key),
603            ..Default::default()
604        };
605
606        let envelope = SignableTxRequest::<TempoTxEnvelope>::try_build_and_sign(req, &signer)
607            .await
608            .expect("should build and sign");
609
610        match &envelope {
611            TempoTxEnvelope::AA(signed) => {
612                let tx = signed.tx();
613                assert_eq!(tx.fee_token, Some(fee_token), "fee_token must be preserved");
614                assert_eq!(tx.nonce_key, nonce_key, "nonce_key must be preserved");
615                assert_eq!(tx.calls, vec![call], "calls must be preserved");
616            }
617            other => panic!(
618                "Expected AA envelope for request with Tempo fields, got {:?}",
619                other.tx_type()
620            ),
621        }
622    }
623}