Skip to main content

tempo_alloy/rpc/
request.rs

1use alloy_consensus::{Signed, TxEip1559, TxEip2930, TxEip7702, TxLegacy, error::ValueError};
2use alloy_contract::{CallBuilder, CallDecoder};
3use alloy_eips::Typed2718;
4use alloy_primitives::{Address, Bytes, U256};
5use alloy_provider::Provider;
6use alloy_rpc_types_eth::{Transaction, TransactionRequest, TransactionTrait};
7use core::num::NonZeroU64;
8use serde::{Deserialize, Serialize};
9use tempo_primitives::{
10    AASigned, SignatureType, TempoTransaction, TempoTxEnvelope,
11    transaction::{
12        Call, SignedKeyAuthorization, TempoSignedAuthorization, TempoTypedTransaction,
13        key_authorization::serde_nonzero_quantity_opt,
14    },
15};
16
17use crate::TempoNetwork;
18
19/// An Ethereum [`TransactionRequest`] extended with Tempo-specific fields.
20#[derive(
21    Clone,
22    Debug,
23    Default,
24    PartialEq,
25    Eq,
26    Hash,
27    Serialize,
28    Deserialize,
29    derive_more::Deref,
30    derive_more::DerefMut,
31)]
32#[serde(rename_all = "camelCase")]
33pub struct TempoTransactionRequest {
34    /// Inner [`TransactionRequest`]
35    #[serde(flatten)]
36    #[deref]
37    #[deref_mut]
38    pub inner: TransactionRequest,
39
40    /// Optional fee token preference
41    #[serde(default)]
42    pub fee_token: Option<Address>,
43
44    /// Optional nonce key for a 2D [`TempoTransaction`].
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub nonce_key: Option<U256>,
47
48    /// Optional calls array, for Tempo transactions.
49    #[serde(default)]
50    pub calls: Vec<Call>,
51
52    /// Optional key type for gas estimation of Tempo transactions.
53    /// Specifies the signature verification algorithm to calculate accurate gas costs.
54    #[serde(default)]
55    pub key_type: Option<SignatureType>,
56
57    /// Optional key-specific data for gas estimation (e.g., webauthn authenticator data).
58    /// Required when key_type is WebAuthn to calculate calldata gas costs.
59    #[serde(default)]
60    pub key_data: Option<Bytes>,
61
62    /// Optional access key ID for gas estimation.
63    /// When provided, indicates the transaction uses a Keychain (access key) signature.
64    /// This enables accurate gas estimation for:
65    /// - Keychain signature validation overhead (+3,000 gas)
66    /// - Spending limits enforcement during execution
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub key_id: Option<Address>,
69
70    /// Optional authorization list for Tempo transactions (supports multiple signature types)
71    #[serde(
72        default,
73        skip_serializing_if = "Vec::is_empty",
74        rename = "aaAuthorizationList"
75    )]
76    pub tempo_authorization_list: Vec<TempoSignedAuthorization>,
77
78    /// Key authorization for provisioning an access key (for gas estimation).
79    /// Provide a signed KeyAuthorization when the transaction provisions an access key.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub key_authorization: Option<SignedKeyAuthorization>,
82
83    /// Transaction valid before timestamp in seconds (for expiring nonces, [TIP-1009]).
84    /// Transaction can only be included in a block before this timestamp.
85    ///
86    /// [TIP-1009]: <https://docs.tempo.xyz/protocol/tips/tip-1009>
87    #[serde(
88        default,
89        skip_serializing_if = "Option::is_none",
90        with = "serde_nonzero_quantity_opt"
91    )]
92    pub valid_before: Option<NonZeroU64>,
93
94    /// Transaction valid after timestamp in seconds (for expiring nonces, [TIP-1009]).
95    /// Transaction can only be included in a block after this timestamp.
96    ///
97    /// [TIP-1009]: <https://docs.tempo.xyz/protocol/tips/tip-1009>
98    #[serde(
99        default,
100        skip_serializing_if = "Option::is_none",
101        with = "serde_nonzero_quantity_opt"
102    )]
103    pub valid_after: Option<NonZeroU64>,
104
105    /// Fee payer signature for sponsored transactions.
106    /// The sponsor signs fee_payer_signature_hash(sender) to commit to paying gas.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub fee_payer_signature: Option<alloy_primitives::Signature>,
109}
110
111impl TempoTransactionRequest {
112    /// Set the fee token for the [`TempoTransaction`] transaction.
113    pub fn set_fee_token(&mut self, fee_token: Address) {
114        self.fee_token = Some(fee_token);
115    }
116
117    /// Builder-pattern method for setting the fee token.
118    pub fn with_fee_token(mut self, fee_token: Address) -> Self {
119        self.fee_token = Some(fee_token);
120        self
121    }
122
123    /// Set the 2D nonce key for the [`TempoTransaction`] transaction.
124    pub fn set_nonce_key(&mut self, nonce_key: U256) {
125        self.nonce_key = Some(nonce_key);
126    }
127
128    /// Builder-pattern method for setting a 2D nonce key for a [`TempoTransaction`].
129    pub fn with_nonce_key(mut self, nonce_key: U256) -> Self {
130        self.nonce_key = Some(nonce_key);
131        self
132    }
133
134    /// Replace the Tempo call list for this transaction.
135    pub fn set_calls(&mut self, calls: Vec<Call>) {
136        self.calls = calls;
137    }
138
139    /// Builder-pattern method for replacing the Tempo call list.
140    pub fn with_calls(mut self, calls: Vec<Call>) -> Self {
141        self.calls = calls;
142        self
143    }
144
145    /// Append one call to the Tempo call list.
146    pub fn push_call(&mut self, call: Call) {
147        self.calls.push(call);
148    }
149
150    /// Set the access-key signature type used for gas estimation.
151    pub fn set_key_type(&mut self, key_type: SignatureType) {
152        self.key_type = Some(key_type);
153    }
154
155    /// Builder-pattern method for setting the access-key signature type.
156    pub fn with_key_type(mut self, key_type: SignatureType) -> Self {
157        self.key_type = Some(key_type);
158        self
159    }
160
161    /// Set key-specific signature data used for gas estimation.
162    pub fn set_key_data(&mut self, key_data: impl Into<Bytes>) {
163        self.key_data = Some(key_data.into());
164    }
165
166    /// Builder-pattern method for setting key-specific signature data.
167    pub fn with_key_data(mut self, key_data: impl Into<Bytes>) -> Self {
168        self.key_data = Some(key_data.into());
169        self
170    }
171
172    /// Set the access-key ID used for gas estimation.
173    pub fn set_key_id(&mut self, key_id: Address) {
174        self.key_id = Some(key_id);
175    }
176
177    /// Builder-pattern method for setting the access-key ID.
178    pub fn with_key_id(mut self, key_id: Address) -> Self {
179        self.key_id = Some(key_id);
180        self
181    }
182
183    /// Set the key authorization attached to this transaction.
184    pub fn set_key_authorization(&mut self, key_authorization: SignedKeyAuthorization) {
185        self.key_authorization = Some(key_authorization);
186    }
187
188    /// Builder-pattern method for setting the key authorization.
189    pub fn with_key_authorization(mut self, key_authorization: SignedKeyAuthorization) -> Self {
190        self.key_authorization = Some(key_authorization);
191        self
192    }
193
194    /// Set the valid_before timestamp for expiring nonces ([TIP-1009]).
195    ///
196    /// [TIP-1009]: <https://docs.tempo.xyz/protocol/tips/tip-1009>
197    pub fn set_valid_before(&mut self, valid_before: NonZeroU64) {
198        self.valid_before = Some(valid_before);
199    }
200
201    /// Builder-pattern method for setting valid_before timestamp.
202    pub fn with_valid_before(mut self, valid_before: NonZeroU64) -> Self {
203        self.valid_before = Some(valid_before);
204        self
205    }
206
207    /// Set the valid_after timestamp for expiring nonces ([TIP-1009]).
208    ///
209    /// [TIP-1009]: <https://docs.tempo.xyz/protocol/tips/tip-1009>
210    pub fn set_valid_after(&mut self, valid_after: NonZeroU64) {
211        self.valid_after = Some(valid_after);
212    }
213
214    /// Builder-pattern method for setting valid_after timestamp.
215    pub fn with_valid_after(mut self, valid_after: NonZeroU64) -> Self {
216        self.valid_after = Some(valid_after);
217        self
218    }
219
220    /// Set the fee payer signature for sponsored transactions.
221    pub fn set_fee_payer_signature(&mut self, signature: alloy_primitives::Signature) {
222        self.fee_payer_signature = Some(signature);
223    }
224
225    /// Builder-pattern method for setting fee payer signature.
226    pub fn with_fee_payer_signature(mut self, signature: alloy_primitives::Signature) -> Self {
227        self.fee_payer_signature = Some(signature);
228        self
229    }
230
231    /// Attempts to build a [`TempoTransaction`] with the configured fields.
232    pub fn build_aa(self) -> Result<TempoTransaction, ValueError<Self>> {
233        if self.calls.is_empty() && self.inner.to.is_none() {
234            return Err(ValueError::new(
235                self,
236                "Missing 'calls' or 'to' field for Tempo transaction.",
237            ));
238        }
239
240        let Some(nonce) = self.inner.nonce else {
241            return Err(ValueError::new(
242                self,
243                "Missing 'nonce' field for Tempo transaction.",
244            ));
245        };
246        let Some(gas_limit) = self.inner.gas else {
247            return Err(ValueError::new(
248                self,
249                "Missing 'gas_limit' field for Tempo transaction.",
250            ));
251        };
252        let Some(max_fee_per_gas) = self.inner.max_fee_per_gas else {
253            return Err(ValueError::new(
254                self,
255                "Missing 'max_fee_per_gas' field for Tempo transaction.",
256            ));
257        };
258        let Some(max_priority_fee_per_gas) = self.inner.max_priority_fee_per_gas else {
259            return Err(ValueError::new(
260                self,
261                "Missing 'max_priority_fee_per_gas' field for Tempo transaction.",
262            ));
263        };
264
265        let mut calls = self.calls;
266        if let Some(to) = self.inner.to {
267            calls.push(Call {
268                to,
269                value: self.inner.value.unwrap_or_default(),
270                input: self.inner.input.into_input().unwrap_or_default(),
271            });
272        }
273
274        Ok(TempoTransaction {
275            chain_id: self.inner.chain_id.unwrap_or(4217),
276            nonce,
277            fee_payer_signature: self.fee_payer_signature,
278            valid_before: self.valid_before,
279            valid_after: self.valid_after,
280            gas_limit,
281            max_fee_per_gas,
282            max_priority_fee_per_gas,
283            fee_token: self.fee_token,
284            access_list: self.inner.access_list.unwrap_or_default(),
285            calls,
286            tempo_authorization_list: self.tempo_authorization_list,
287            nonce_key: self.nonce_key.unwrap_or_default(),
288            key_authorization: self.key_authorization,
289        })
290    }
291}
292
293impl AsRef<TransactionRequest> for TempoTransactionRequest {
294    fn as_ref(&self) -> &TransactionRequest {
295        &self.inner
296    }
297}
298
299impl AsMut<TransactionRequest> for TempoTransactionRequest {
300    fn as_mut(&mut self) -> &mut TransactionRequest {
301        &mut self.inner
302    }
303}
304
305impl From<TransactionRequest> for TempoTransactionRequest {
306    fn from(value: TransactionRequest) -> Self {
307        Self {
308            inner: value,
309            fee_token: None,
310            ..Default::default()
311        }
312    }
313}
314
315impl From<TempoTransactionRequest> for TransactionRequest {
316    fn from(value: TempoTransactionRequest) -> Self {
317        value.inner
318    }
319}
320
321impl From<Transaction<TempoTxEnvelope>> for TempoTransactionRequest {
322    fn from(tx: Transaction<TempoTxEnvelope>) -> Self {
323        tx.inner.into_inner().into()
324    }
325}
326
327impl From<TempoTxEnvelope> for TempoTransactionRequest {
328    fn from(value: TempoTxEnvelope) -> Self {
329        match value {
330            TempoTxEnvelope::Legacy(tx) => tx.into(),
331            TempoTxEnvelope::Eip2930(tx) => tx.into(),
332            TempoTxEnvelope::Eip1559(tx) => tx.into(),
333            TempoTxEnvelope::Eip7702(tx) => tx.into(),
334            TempoTxEnvelope::AA(tx) => tx.into(),
335        }
336    }
337}
338
339pub trait FeeToken {
340    fn fee_token(&self) -> Option<Address>;
341}
342
343impl FeeToken for TempoTransaction {
344    fn fee_token(&self) -> Option<Address> {
345        self.fee_token
346    }
347}
348
349impl FeeToken for TxEip7702 {
350    fn fee_token(&self) -> Option<Address> {
351        None
352    }
353}
354
355impl FeeToken for TxEip1559 {
356    fn fee_token(&self) -> Option<Address> {
357        None
358    }
359}
360
361impl FeeToken for TxEip2930 {
362    fn fee_token(&self) -> Option<Address> {
363        None
364    }
365}
366
367impl FeeToken for TxLegacy {
368    fn fee_token(&self) -> Option<Address> {
369        None
370    }
371}
372
373impl<T: TransactionTrait + FeeToken> From<Signed<T>> for TempoTransactionRequest {
374    fn from(value: Signed<T>) -> Self {
375        Self {
376            fee_token: value.tx().fee_token(),
377            inner: TransactionRequest::from_transaction(value),
378            ..Default::default()
379        }
380    }
381}
382
383impl From<TempoTransaction> for TempoTransactionRequest {
384    fn from(tx: TempoTransaction) -> Self {
385        Self {
386            fee_token: tx.fee_token,
387            inner: TransactionRequest {
388                from: None,
389                // AA transactions store their calls in `calls` below.
390                // `to`, `value`, `input` must stay unset to avoid the builder
391                // creating a duplicate call from the envelope fields.
392                to: None,
393                gas: Some(tx.gas_limit()),
394                gas_price: tx.gas_price(),
395                max_fee_per_gas: Some(tx.max_fee_per_gas()),
396                max_priority_fee_per_gas: tx.max_priority_fee_per_gas(),
397                value: None,
398                input: alloy_rpc_types_eth::TransactionInput::default(),
399                nonce: Some(tx.nonce()),
400                chain_id: tx.chain_id(),
401                access_list: tx.access_list().cloned(),
402                max_fee_per_blob_gas: None,
403                blob_versioned_hashes: None,
404                sidecar: None,
405                authorization_list: None,
406                transaction_type: Some(tx.ty()),
407            },
408            calls: tx.calls,
409            tempo_authorization_list: tx.tempo_authorization_list,
410            key_type: None,
411            key_data: None,
412            key_id: None,
413            nonce_key: Some(tx.nonce_key),
414            key_authorization: tx.key_authorization,
415            valid_before: tx.valid_before,
416            valid_after: tx.valid_after,
417            fee_payer_signature: tx.fee_payer_signature,
418        }
419    }
420}
421
422impl From<AASigned> for TempoTransactionRequest {
423    fn from(value: AASigned) -> Self {
424        value.into_parts().0.into()
425    }
426}
427
428impl From<TempoTypedTransaction> for TempoTransactionRequest {
429    fn from(value: TempoTypedTransaction) -> Self {
430        match value {
431            TempoTypedTransaction::Legacy(tx) => Self {
432                inner: tx.into(),
433                fee_token: None,
434                ..Default::default()
435            },
436            TempoTypedTransaction::Eip2930(tx) => Self {
437                inner: tx.into(),
438                fee_token: None,
439                ..Default::default()
440            },
441            TempoTypedTransaction::Eip1559(tx) => Self {
442                inner: tx.into(),
443                fee_token: None,
444                ..Default::default()
445            },
446            TempoTypedTransaction::Eip7702(tx) => Self {
447                inner: tx.into(),
448                fee_token: None,
449                ..Default::default()
450            },
451            TempoTypedTransaction::AA(tx) => tx.into(),
452        }
453    }
454}
455
456/// Extension trait for [`CallBuilder`]
457pub trait TempoCallBuilderExt {
458    /// Sets the `fee_token` field in the [`TempoTransaction`] transaction to the provided value
459    fn fee_token(self, fee_token: Address) -> Self;
460
461    /// Sets the `nonce_key` field in the [`TempoTransaction`] transaction to the provided value
462    fn nonce_key(self, nonce_key: U256) -> Self;
463
464    /// Sets the `valid_before` field in the [`TempoTransaction`] transaction.
465    fn valid_before(self, valid_before: NonZeroU64) -> Self;
466
467    /// Sets the `valid_after` field in the [`TempoTransaction`] transaction.
468    fn valid_after(self, valid_after: NonZeroU64) -> Self;
469
470    /// Sets the `key_id` field in the [`TempoTransaction`] transaction.
471    fn key_id(self, key_id: Address) -> Self;
472
473    /// Sets the `key_type` field in the [`TempoTransaction`] transaction.
474    fn key_type(self, key_type: SignatureType) -> Self;
475
476    /// Sets the `key_data` field in the [`TempoTransaction`] transaction.
477    fn key_data(self, key_data: Bytes) -> Self;
478
479    /// Sets the `key_authorization` field in the [`TempoTransaction`] transaction.
480    fn key_authorization(self, key_authorization: SignedKeyAuthorization) -> Self;
481}
482
483impl<P: Provider<TempoNetwork>, D: CallDecoder> TempoCallBuilderExt
484    for CallBuilder<P, D, TempoNetwork>
485{
486    fn fee_token(self, fee_token: Address) -> Self {
487        self.map(|request| request.with_fee_token(fee_token))
488    }
489
490    fn nonce_key(self, nonce_key: U256) -> Self {
491        self.map(|request| request.with_nonce_key(nonce_key))
492    }
493
494    fn valid_before(self, valid_before: NonZeroU64) -> Self {
495        self.map(|request| request.with_valid_before(valid_before))
496    }
497
498    fn valid_after(self, valid_after: NonZeroU64) -> Self {
499        self.map(|request| request.with_valid_after(valid_after))
500    }
501
502    fn key_id(self, key_id: Address) -> Self {
503        self.map(|request| request.with_key_id(key_id))
504    }
505
506    fn key_type(self, key_type: SignatureType) -> Self {
507        self.map(|request| request.with_key_type(key_type))
508    }
509
510    fn key_data(self, key_data: Bytes) -> Self {
511        self.map(|request| request.with_key_data(key_data))
512    }
513
514    fn key_authorization(self, key_authorization: SignedKeyAuthorization) -> Self {
515        self.map(|request| request.with_key_authorization(key_authorization))
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use alloy_primitives::{Bytes, Signature, address};
523    use tempo_primitives::transaction::{
524        Call, KeyAuthorization, PrimitiveSignature, TEMPO_EXPIRING_NONCE_KEY,
525    };
526
527    fn nz(value: u64) -> NonZeroU64 {
528        NonZeroU64::new(value).expect("test timestamp must be non-zero")
529    }
530
531    #[test]
532    fn test_set_valid_before() {
533        let mut request = TempoTransactionRequest::default();
534        assert!(request.valid_before.is_none());
535
536        request.set_valid_before(nz(1234567890));
537        assert_eq!(request.valid_before, Some(nz(1234567890)));
538    }
539
540    #[test]
541    fn test_set_valid_after() {
542        let mut request = TempoTransactionRequest::default();
543        assert!(request.valid_after.is_none());
544
545        request.set_valid_after(nz(1234567800));
546        assert_eq!(request.valid_after, Some(nz(1234567800)));
547    }
548
549    #[test]
550    fn test_with_valid_before() {
551        let request = TempoTransactionRequest::default().with_valid_before(nz(1234567890));
552        assert_eq!(request.valid_before, Some(nz(1234567890)));
553    }
554
555    #[test]
556    fn test_with_valid_after() {
557        let request = TempoTransactionRequest::default().with_valid_after(nz(1234567800));
558        assert_eq!(request.valid_after, Some(nz(1234567800)));
559    }
560
561    #[test]
562    fn test_build_aa_with_validity_window() {
563        let request = TempoTransactionRequest::default()
564            .with_nonce_key(TEMPO_EXPIRING_NONCE_KEY)
565            .with_valid_before(nz(1234567890))
566            .with_valid_after(nz(1234567800));
567
568        // Set required fields for build_aa
569        let mut request = request;
570        request.inner.nonce = Some(0);
571        request.inner.gas = Some(21000);
572        request.inner.max_fee_per_gas = Some(1000000000);
573        request.inner.max_priority_fee_per_gas = Some(1000000);
574        request.inner.to = Some(address!("0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D").into());
575
576        let tx = request.build_aa().expect("should build transaction");
577        assert_eq!(tx.valid_before, Some(nz(1234567890)));
578        assert_eq!(tx.valid_after, Some(nz(1234567800)));
579        assert_eq!(tx.nonce_key, TEMPO_EXPIRING_NONCE_KEY);
580        assert_eq!(tx.nonce, 0);
581    }
582
583    #[test]
584    fn test_deserialize_rejects_zero_validity_window_bounds() {
585        let err = serde_json::from_str::<TempoTransactionRequest>(r#"{"validBefore":"0x0"}"#)
586            .expect_err("zero valid_before must be rejected during deserialization");
587        assert!(err.to_string().contains("expected non-zero quantity"));
588
589        let err = serde_json::from_str::<TempoTransactionRequest>(r#"{"validAfter":"0x0"}"#)
590            .expect_err("zero valid_after must be rejected during deserialization");
591        assert!(err.to_string().contains("expected non-zero quantity"));
592    }
593
594    #[test]
595    fn test_from_tempo_transaction_preserves_validity_window() {
596        let tx = TempoTransaction {
597            chain_id: 1,
598            nonce: 0,
599            fee_payer_signature: None,
600            valid_before: Some(NonZeroU64::new(1234567890).unwrap()),
601            valid_after: Some(NonZeroU64::new(1234567800).unwrap()),
602            gas_limit: 21000,
603            max_fee_per_gas: 1000000000,
604            max_priority_fee_per_gas: 1000000,
605            fee_token: None,
606            access_list: Default::default(),
607            calls: vec![Call {
608                to: address!("0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D").into(),
609                value: Default::default(),
610                input: Default::default(),
611            }],
612            tempo_authorization_list: vec![],
613            nonce_key: TEMPO_EXPIRING_NONCE_KEY,
614            key_authorization: None,
615        };
616
617        let request: TempoTransactionRequest = tx.into();
618        assert_eq!(request.valid_before, Some(nz(1234567890)));
619        assert_eq!(request.valid_after, Some(nz(1234567800)));
620        assert_eq!(request.nonce_key, Some(TEMPO_EXPIRING_NONCE_KEY));
621    }
622
623    #[test]
624    fn test_expiring_nonce_builder_chain() {
625        let request = TempoTransactionRequest::default()
626            .with_nonce_key(TEMPO_EXPIRING_NONCE_KEY)
627            .with_valid_before(nz(1234567890))
628            .with_valid_after(nz(1234567800))
629            .with_fee_token(address!("0x20c0000000000000000000000000000000000000"));
630
631        assert_eq!(request.nonce_key, Some(TEMPO_EXPIRING_NONCE_KEY));
632        assert_eq!(request.valid_before, Some(nz(1234567890)));
633        assert_eq!(request.valid_after, Some(nz(1234567800)));
634        assert_eq!(
635            request.fee_token,
636            Some(address!("0x20c0000000000000000000000000000000000000"))
637        );
638    }
639
640    #[test]
641    fn test_set_fee_payer_signature() {
642        let mut request = TempoTransactionRequest::default();
643        assert!(request.fee_payer_signature.is_none());
644
645        let sig = Signature::test_signature();
646        request.set_fee_payer_signature(sig);
647        assert!(request.fee_payer_signature.is_some());
648    }
649
650    #[test]
651    fn test_with_fee_payer_signature() {
652        let sig = Signature::test_signature();
653        let request = TempoTransactionRequest::default().with_fee_payer_signature(sig);
654        assert!(request.fee_payer_signature.is_some());
655    }
656
657    #[test]
658    fn test_build_aa_with_fee_payer_signature() {
659        let sig = Signature::test_signature();
660        let mut request = TempoTransactionRequest::default().with_fee_payer_signature(sig);
661
662        request.inner.nonce = Some(0);
663        request.inner.gas = Some(21000);
664        request.inner.max_fee_per_gas = Some(1000000000);
665        request.inner.max_priority_fee_per_gas = Some(1000000);
666        request.inner.to = Some(address!("0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D").into());
667
668        let tx = request.build_aa().expect("should build transaction");
669        assert_eq!(tx.fee_payer_signature, Some(sig));
670    }
671
672    #[test]
673    fn test_from_tempo_transaction_preserves_fee_payer_signature() {
674        let sig = Signature::test_signature();
675        let tx = TempoTransaction {
676            chain_id: 1,
677            nonce: 0,
678            fee_payer_signature: Some(sig),
679            valid_before: None,
680            valid_after: None,
681            gas_limit: 21000,
682            max_fee_per_gas: 1000000000,
683            max_priority_fee_per_gas: 1000000,
684            fee_token: None,
685            access_list: Default::default(),
686            calls: vec![Call {
687                to: address!("0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D").into(),
688                value: Default::default(),
689                input: Default::default(),
690            }],
691            tempo_authorization_list: vec![],
692            nonce_key: Default::default(),
693            key_authorization: None,
694        };
695
696        let request: TempoTransactionRequest = tx.into();
697        assert_eq!(request.fee_payer_signature, Some(sig));
698    }
699
700    #[test]
701    fn test_build_aa_preserves_key_authorization() {
702        let key_auth = KeyAuthorization::unrestricted(
703            4217,
704            SignatureType::Secp256k1,
705            address!("0x1111111111111111111111111111111111111111"),
706        )
707        .into_signed(PrimitiveSignature::default());
708
709        let mut request = TempoTransactionRequest {
710            key_authorization: Some(key_auth.clone()),
711            ..Default::default()
712        };
713        request.inner.nonce = Some(0);
714        request.inner.gas = Some(21000);
715        request.inner.max_fee_per_gas = Some(1000000000);
716        request.inner.max_priority_fee_per_gas = Some(1000000);
717        request.inner.to = Some(address!("0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D").into());
718
719        let tx = request.build_aa().expect("should build transaction");
720        assert_eq!(
721            tx.key_authorization,
722            Some(key_auth),
723            "build_aa must preserve key_authorization from the request"
724        );
725    }
726
727    #[test]
728    fn test_set_calls_and_push_call() {
729        let call = Call {
730            to: address!("0x1111111111111111111111111111111111111111").into(),
731            value: U256::ZERO,
732            input: Bytes::from(vec![0xaa]),
733        };
734
735        let mut request = TempoTransactionRequest::default();
736        request.set_calls(vec![call.clone()]);
737        request.push_call(call.clone());
738
739        assert_eq!(request.calls, vec![call.clone(), call]);
740    }
741
742    #[test]
743    fn test_keychain_builder_helpers() {
744        let key_auth = KeyAuthorization::unrestricted(
745            4217,
746            SignatureType::Secp256k1,
747            address!("0x1111111111111111111111111111111111111111"),
748        )
749        .into_signed(PrimitiveSignature::default());
750
751        let request = TempoTransactionRequest::default()
752            .with_key_id(address!("0x2222222222222222222222222222222222222222"))
753            .with_key_type(SignatureType::WebAuthn)
754            .with_key_data(Bytes::from_static(b"auth-data"))
755            .with_key_authorization(key_auth.clone());
756
757        assert_eq!(
758            request.key_id,
759            Some(address!("0x2222222222222222222222222222222222222222"))
760        );
761        assert_eq!(request.key_type, Some(SignatureType::WebAuthn));
762        assert_eq!(request.key_data, Some(Bytes::from_static(b"auth-data")));
763        assert_eq!(request.key_authorization, Some(key_auth));
764    }
765
766    #[test]
767    fn test_aa_roundtrip_preserves_count() {
768        let base = TempoTransaction {
769            chain_id: 4217,
770            nonce: 1,
771            gas_limit: 100_000,
772            max_fee_per_gas: 1_000_000_000,
773            max_priority_fee_per_gas: 1_000_000,
774            calls: vec![],
775            ..Default::default()
776        };
777
778        // Regression: single-call AA round-trip must not duplicate the call + preserve.
779        let call = vec![Call {
780            to: address!("0x1111111111111111111111111111111111111111").into(),
781            value: U256::ZERO,
782            input: Bytes::from(vec![0xaa]),
783        }];
784        let mut original = base.clone();
785        original.calls = call.clone();
786
787        let roundtrip = TempoTransactionRequest::from(original)
788            .build_aa()
789            .expect("build_aa should succeed");
790        assert_eq!(
791            roundtrip.calls, call,
792            "single-call AA must not gain extra calls on round-trip"
793        );
794
795        // Regression: multi-call AA round-trip must preserve exact call list.
796        let batch = vec![
797            Call {
798                to: address!("0x1111111111111111111111111111111111111111").into(),
799                value: U256::ZERO,
800                input: Bytes::from(vec![0xaa]),
801            },
802            Call {
803                to: address!("0x2222222222222222222222222222222222222222").into(),
804                value: U256::ZERO,
805                input: Bytes::from(vec![0xbb]),
806            },
807        ];
808        let mut original = base;
809        original.calls = batch.clone();
810
811        let roundtrip = TempoTransactionRequest::from(original)
812            .build_aa()
813            .expect("build_aa should succeed");
814        assert_eq!(
815            roundtrip.calls, batch,
816            "multi-call AA must not gain phantom calls on round-trip"
817        );
818    }
819}