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::{TransactionRequest, TransactionTrait};
7use serde::{Deserialize, Serialize};
8use tempo_primitives::{
9 AASigned, SignatureType, TempoTransaction, TempoTxEnvelope,
10 transaction::{Call, SignedKeyAuthorization, TempoSignedAuthorization, TempoTypedTransaction},
11};
12
13use crate::TempoNetwork;
14
15#[derive(
17 Clone,
18 Debug,
19 Default,
20 PartialEq,
21 Eq,
22 Hash,
23 Serialize,
24 Deserialize,
25 derive_more::Deref,
26 derive_more::DerefMut,
27)]
28#[serde(rename_all = "camelCase")]
29pub struct TempoTransactionRequest {
30 #[serde(flatten)]
32 #[deref]
33 #[deref_mut]
34 pub inner: TransactionRequest,
35
36 pub fee_token: Option<Address>,
38
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub nonce_key: Option<U256>,
42
43 #[serde(default)]
45 pub calls: Vec<Call>,
46
47 pub key_type: Option<SignatureType>,
50
51 pub key_data: Option<Bytes>,
54
55 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub key_id: Option<Address>,
62
63 #[serde(
65 default,
66 skip_serializing_if = "Vec::is_empty",
67 rename = "aaAuthorizationList"
68 )]
69 pub tempo_authorization_list: Vec<TempoSignedAuthorization>,
70
71 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub key_authorization: Option<SignedKeyAuthorization>,
75
76 #[serde(
81 default,
82 skip_serializing_if = "Option::is_none",
83 with = "alloy_serde::quantity::opt"
84 )]
85 pub valid_before: Option<u64>,
86
87 #[serde(
92 default,
93 skip_serializing_if = "Option::is_none",
94 with = "alloy_serde::quantity::opt"
95 )]
96 pub valid_after: Option<u64>,
97
98 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub fee_payer_signature: Option<alloy_primitives::Signature>,
102}
103
104impl TempoTransactionRequest {
105 pub fn set_fee_token(&mut self, fee_token: Address) {
107 self.fee_token = Some(fee_token);
108 }
109
110 pub fn with_fee_token(mut self, fee_token: Address) -> Self {
112 self.fee_token = Some(fee_token);
113 self
114 }
115
116 pub fn set_nonce_key(&mut self, nonce_key: U256) {
118 self.nonce_key = Some(nonce_key);
119 }
120
121 pub fn with_nonce_key(mut self, nonce_key: U256) -> Self {
123 self.nonce_key = Some(nonce_key);
124 self
125 }
126
127 pub fn set_valid_before(&mut self, valid_before: u64) {
131 self.valid_before = Some(valid_before);
132 }
133
134 pub fn with_valid_before(mut self, valid_before: u64) -> Self {
136 self.valid_before = Some(valid_before);
137 self
138 }
139
140 pub fn set_valid_after(&mut self, valid_after: u64) {
144 self.valid_after = Some(valid_after);
145 }
146
147 pub fn with_valid_after(mut self, valid_after: u64) -> Self {
149 self.valid_after = Some(valid_after);
150 self
151 }
152
153 pub fn set_fee_payer_signature(&mut self, signature: alloy_primitives::Signature) {
155 self.fee_payer_signature = Some(signature);
156 }
157
158 pub fn with_fee_payer_signature(mut self, signature: alloy_primitives::Signature) -> Self {
160 self.fee_payer_signature = Some(signature);
161 self
162 }
163
164 pub fn build_aa(self) -> Result<TempoTransaction, ValueError<Self>> {
166 if self.calls.is_empty() && self.inner.to.is_none() {
167 return Err(ValueError::new(
168 self,
169 "Missing 'calls' or 'to' field for Tempo transaction.",
170 ));
171 }
172
173 let Some(nonce) = self.inner.nonce else {
174 return Err(ValueError::new(
175 self,
176 "Missing 'nonce' field for Tempo transaction.",
177 ));
178 };
179 let Some(gas_limit) = self.inner.gas else {
180 return Err(ValueError::new(
181 self,
182 "Missing 'gas_limit' field for Tempo transaction.",
183 ));
184 };
185 let Some(max_fee_per_gas) = self.inner.max_fee_per_gas else {
186 return Err(ValueError::new(
187 self,
188 "Missing 'max_fee_per_gas' field for Tempo transaction.",
189 ));
190 };
191 let Some(max_priority_fee_per_gas) = self.inner.max_priority_fee_per_gas else {
192 return Err(ValueError::new(
193 self,
194 "Missing 'max_priority_fee_per_gas' field for Tempo transaction.",
195 ));
196 };
197
198 let mut calls = self.calls;
199 if let Some(to) = self.inner.to {
200 calls.push(Call {
201 to,
202 value: self.inner.value.unwrap_or_default(),
203 input: self.inner.input.into_input().unwrap_or_default(),
204 });
205 }
206
207 Ok(TempoTransaction {
208 chain_id: self.inner.chain_id.unwrap_or(4217),
209 nonce,
210 fee_payer_signature: self.fee_payer_signature,
211 valid_before: self.valid_before,
212 valid_after: self.valid_after,
213 gas_limit,
214 max_fee_per_gas,
215 max_priority_fee_per_gas,
216 fee_token: self.fee_token,
217 access_list: self.inner.access_list.unwrap_or_default(),
218 calls,
219 tempo_authorization_list: self.tempo_authorization_list,
220 nonce_key: self.nonce_key.unwrap_or_default(),
221 key_authorization: self.key_authorization,
222 })
223 }
224}
225
226impl AsRef<TransactionRequest> for TempoTransactionRequest {
227 fn as_ref(&self) -> &TransactionRequest {
228 &self.inner
229 }
230}
231
232impl AsMut<TransactionRequest> for TempoTransactionRequest {
233 fn as_mut(&mut self) -> &mut TransactionRequest {
234 &mut self.inner
235 }
236}
237
238impl From<TransactionRequest> for TempoTransactionRequest {
239 fn from(value: TransactionRequest) -> Self {
240 Self {
241 inner: value,
242 fee_token: None,
243 ..Default::default()
244 }
245 }
246}
247
248impl From<TempoTransactionRequest> for TransactionRequest {
249 fn from(value: TempoTransactionRequest) -> Self {
250 value.inner
251 }
252}
253
254impl From<TempoTxEnvelope> for TempoTransactionRequest {
255 fn from(value: TempoTxEnvelope) -> Self {
256 match value {
257 TempoTxEnvelope::Legacy(tx) => tx.into(),
258 TempoTxEnvelope::Eip2930(tx) => tx.into(),
259 TempoTxEnvelope::Eip1559(tx) => tx.into(),
260 TempoTxEnvelope::Eip7702(tx) => tx.into(),
261 TempoTxEnvelope::AA(tx) => tx.into(),
262 }
263 }
264}
265
266pub trait FeeToken {
267 fn fee_token(&self) -> Option<Address>;
268}
269
270impl FeeToken for TempoTransaction {
271 fn fee_token(&self) -> Option<Address> {
272 self.fee_token
273 }
274}
275
276impl FeeToken for TxEip7702 {
277 fn fee_token(&self) -> Option<Address> {
278 None
279 }
280}
281
282impl FeeToken for TxEip1559 {
283 fn fee_token(&self) -> Option<Address> {
284 None
285 }
286}
287
288impl FeeToken for TxEip2930 {
289 fn fee_token(&self) -> Option<Address> {
290 None
291 }
292}
293
294impl FeeToken for TxLegacy {
295 fn fee_token(&self) -> Option<Address> {
296 None
297 }
298}
299
300impl<T: TransactionTrait + FeeToken> From<Signed<T>> for TempoTransactionRequest {
301 fn from(value: Signed<T>) -> Self {
302 Self {
303 fee_token: value.tx().fee_token(),
304 inner: TransactionRequest::from_transaction(value),
305 ..Default::default()
306 }
307 }
308}
309
310impl From<TempoTransaction> for TempoTransactionRequest {
311 fn from(tx: TempoTransaction) -> Self {
312 Self {
313 fee_token: tx.fee_token,
314 inner: TransactionRequest {
315 from: None,
316 to: None,
320 gas: Some(tx.gas_limit()),
321 gas_price: tx.gas_price(),
322 max_fee_per_gas: Some(tx.max_fee_per_gas()),
323 max_priority_fee_per_gas: tx.max_priority_fee_per_gas(),
324 value: None,
325 input: alloy_rpc_types_eth::TransactionInput::default(),
326 nonce: Some(tx.nonce()),
327 chain_id: tx.chain_id(),
328 access_list: tx.access_list().cloned(),
329 max_fee_per_blob_gas: None,
330 blob_versioned_hashes: None,
331 sidecar: None,
332 authorization_list: None,
333 transaction_type: Some(tx.ty()),
334 },
335 calls: tx.calls,
336 tempo_authorization_list: tx.tempo_authorization_list,
337 key_type: None,
338 key_data: None,
339 key_id: None,
340 nonce_key: Some(tx.nonce_key),
341 key_authorization: tx.key_authorization,
342 valid_before: tx.valid_before,
343 valid_after: tx.valid_after,
344 fee_payer_signature: tx.fee_payer_signature,
345 }
346 }
347}
348
349impl From<AASigned> for TempoTransactionRequest {
350 fn from(value: AASigned) -> Self {
351 value.into_parts().0.into()
352 }
353}
354
355impl From<TempoTypedTransaction> for TempoTransactionRequest {
356 fn from(value: TempoTypedTransaction) -> Self {
357 match value {
358 TempoTypedTransaction::Legacy(tx) => Self {
359 inner: tx.into(),
360 fee_token: None,
361 ..Default::default()
362 },
363 TempoTypedTransaction::Eip2930(tx) => Self {
364 inner: tx.into(),
365 fee_token: None,
366 ..Default::default()
367 },
368 TempoTypedTransaction::Eip1559(tx) => Self {
369 inner: tx.into(),
370 fee_token: None,
371 ..Default::default()
372 },
373 TempoTypedTransaction::Eip7702(tx) => Self {
374 inner: tx.into(),
375 fee_token: None,
376 ..Default::default()
377 },
378 TempoTypedTransaction::AA(tx) => tx.into(),
379 }
380 }
381}
382
383pub trait TempoCallBuilderExt {
385 fn fee_token(self, fee_token: Address) -> Self;
387
388 fn nonce_key(self, nonce_key: U256) -> Self;
390}
391
392impl<P: Provider<TempoNetwork>, D: CallDecoder> TempoCallBuilderExt
393 for CallBuilder<P, D, TempoNetwork>
394{
395 fn fee_token(self, fee_token: Address) -> Self {
396 self.map(|request| request.with_fee_token(fee_token))
397 }
398
399 fn nonce_key(self, nonce_key: U256) -> Self {
400 self.map(|request| request.with_nonce_key(nonce_key))
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use alloy_primitives::{Bytes, address};
408 use tempo_primitives::transaction::{Call, TEMPO_EXPIRING_NONCE_KEY};
409
410 #[test]
411 fn test_set_valid_before() {
412 let mut request = TempoTransactionRequest::default();
413 assert!(request.valid_before.is_none());
414
415 request.set_valid_before(1234567890);
416 assert_eq!(request.valid_before, Some(1234567890));
417 }
418
419 #[test]
420 fn test_set_valid_after() {
421 let mut request = TempoTransactionRequest::default();
422 assert!(request.valid_after.is_none());
423
424 request.set_valid_after(1234567800);
425 assert_eq!(request.valid_after, Some(1234567800));
426 }
427
428 #[test]
429 fn test_with_valid_before() {
430 let request = TempoTransactionRequest::default().with_valid_before(1234567890);
431 assert_eq!(request.valid_before, Some(1234567890));
432 }
433
434 #[test]
435 fn test_with_valid_after() {
436 let request = TempoTransactionRequest::default().with_valid_after(1234567800);
437 assert_eq!(request.valid_after, Some(1234567800));
438 }
439
440 #[test]
441 fn test_build_aa_with_validity_window() {
442 let request = TempoTransactionRequest::default()
443 .with_nonce_key(TEMPO_EXPIRING_NONCE_KEY)
444 .with_valid_before(1234567890)
445 .with_valid_after(1234567800);
446
447 let mut request = request;
449 request.inner.nonce = Some(0);
450 request.inner.gas = Some(21000);
451 request.inner.max_fee_per_gas = Some(1000000000);
452 request.inner.max_priority_fee_per_gas = Some(1000000);
453 request.inner.to = Some(address!("0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D").into());
454
455 let tx = request.build_aa().expect("should build transaction");
456 assert_eq!(tx.valid_before, Some(1234567890));
457 assert_eq!(tx.valid_after, Some(1234567800));
458 assert_eq!(tx.nonce_key, TEMPO_EXPIRING_NONCE_KEY);
459 assert_eq!(tx.nonce, 0);
460 }
461
462 #[test]
463 fn test_from_tempo_transaction_preserves_validity_window() {
464 let tx = TempoTransaction {
465 chain_id: 1,
466 nonce: 0,
467 fee_payer_signature: None,
468 valid_before: Some(1234567890),
469 valid_after: Some(1234567800),
470 gas_limit: 21000,
471 max_fee_per_gas: 1000000000,
472 max_priority_fee_per_gas: 1000000,
473 fee_token: None,
474 access_list: Default::default(),
475 calls: vec![Call {
476 to: address!("0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D").into(),
477 value: Default::default(),
478 input: Default::default(),
479 }],
480 tempo_authorization_list: vec![],
481 nonce_key: TEMPO_EXPIRING_NONCE_KEY,
482 key_authorization: None,
483 };
484
485 let request: TempoTransactionRequest = tx.into();
486 assert_eq!(request.valid_before, Some(1234567890));
487 assert_eq!(request.valid_after, Some(1234567800));
488 assert_eq!(request.nonce_key, Some(TEMPO_EXPIRING_NONCE_KEY));
489 }
490
491 #[test]
492 fn test_expiring_nonce_builder_chain() {
493 let request = TempoTransactionRequest::default()
494 .with_nonce_key(TEMPO_EXPIRING_NONCE_KEY)
495 .with_valid_before(1234567890)
496 .with_valid_after(1234567800)
497 .with_fee_token(address!("0x20c0000000000000000000000000000000000000"));
498
499 assert_eq!(request.nonce_key, Some(TEMPO_EXPIRING_NONCE_KEY));
500 assert_eq!(request.valid_before, Some(1234567890));
501 assert_eq!(request.valid_after, Some(1234567800));
502 assert_eq!(
503 request.fee_token,
504 Some(address!("0x20c0000000000000000000000000000000000000"))
505 );
506 }
507
508 #[test]
509 fn test_set_fee_payer_signature() {
510 use alloy_primitives::Signature;
511
512 let mut request = TempoTransactionRequest::default();
513 assert!(request.fee_payer_signature.is_none());
514
515 let sig = Signature::test_signature();
516 request.set_fee_payer_signature(sig);
517 assert!(request.fee_payer_signature.is_some());
518 }
519
520 #[test]
521 fn test_with_fee_payer_signature() {
522 use alloy_primitives::Signature;
523
524 let sig = Signature::test_signature();
525 let request = TempoTransactionRequest::default().with_fee_payer_signature(sig);
526 assert!(request.fee_payer_signature.is_some());
527 }
528
529 #[test]
530 fn test_build_aa_with_fee_payer_signature() {
531 use alloy_primitives::Signature;
532
533 let sig = Signature::test_signature();
534 let mut request = TempoTransactionRequest::default().with_fee_payer_signature(sig);
535
536 request.inner.nonce = Some(0);
537 request.inner.gas = Some(21000);
538 request.inner.max_fee_per_gas = Some(1000000000);
539 request.inner.max_priority_fee_per_gas = Some(1000000);
540 request.inner.to = Some(address!("0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D").into());
541
542 let tx = request.build_aa().expect("should build transaction");
543 assert_eq!(tx.fee_payer_signature, Some(sig));
544 }
545
546 #[test]
547 fn test_from_tempo_transaction_preserves_fee_payer_signature() {
548 use alloy_primitives::Signature;
549
550 let sig = Signature::test_signature();
551 let tx = TempoTransaction {
552 chain_id: 1,
553 nonce: 0,
554 fee_payer_signature: Some(sig),
555 valid_before: None,
556 valid_after: None,
557 gas_limit: 21000,
558 max_fee_per_gas: 1000000000,
559 max_priority_fee_per_gas: 1000000,
560 fee_token: None,
561 access_list: Default::default(),
562 calls: vec![Call {
563 to: address!("0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D").into(),
564 value: Default::default(),
565 input: Default::default(),
566 }],
567 tempo_authorization_list: vec![],
568 nonce_key: Default::default(),
569 key_authorization: None,
570 };
571
572 let request: TempoTransactionRequest = tx.into();
573 assert_eq!(request.fee_payer_signature, Some(sig));
574 }
575
576 #[test]
577 fn test_build_aa_preserves_key_authorization() {
578 use tempo_primitives::transaction::{
579 KeyAuthorization, PrimitiveSignature, SignedKeyAuthorization,
580 };
581
582 let key_auth = SignedKeyAuthorization {
583 authorization: KeyAuthorization {
584 chain_id: 4217,
585 key_type: SignatureType::Secp256k1,
586 key_id: address!("0x1111111111111111111111111111111111111111"),
587 expiry: None,
588 limits: None,
589 },
590 signature: PrimitiveSignature::default(),
591 };
592
593 let mut request = TempoTransactionRequest {
594 key_authorization: Some(key_auth.clone()),
595 ..Default::default()
596 };
597 request.inner.nonce = Some(0);
598 request.inner.gas = Some(21000);
599 request.inner.max_fee_per_gas = Some(1000000000);
600 request.inner.max_priority_fee_per_gas = Some(1000000);
601 request.inner.to = Some(address!("0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D").into());
602
603 let tx = request.build_aa().expect("should build transaction");
604 assert_eq!(
605 tx.key_authorization,
606 Some(key_auth),
607 "build_aa must preserve key_authorization from the request"
608 );
609 }
610
611 #[test]
612 fn test_aa_roundtrip_preserves_count() {
613 let base = TempoTransaction {
614 chain_id: 4217,
615 nonce: 1,
616 gas_limit: 100_000,
617 max_fee_per_gas: 1_000_000_000,
618 max_priority_fee_per_gas: 1_000_000,
619 calls: vec![],
620 ..Default::default()
621 };
622
623 let call = vec![Call {
625 to: address!("0x1111111111111111111111111111111111111111").into(),
626 value: U256::ZERO,
627 input: Bytes::from(vec![0xaa]),
628 }];
629 let mut original = base.clone();
630 original.calls = call.clone();
631
632 let roundtrip = TempoTransactionRequest::from(original)
633 .build_aa()
634 .expect("build_aa should succeed");
635 assert_eq!(
636 roundtrip.calls, call,
637 "single-call AA must not gain extra calls on round-trip"
638 );
639
640 let batch = vec![
642 Call {
643 to: address!("0x1111111111111111111111111111111111111111").into(),
644 value: U256::ZERO,
645 input: Bytes::from(vec![0xaa]),
646 },
647 Call {
648 to: address!("0x2222222222222222222222222222222222222222").into(),
649 value: U256::ZERO,
650 input: Bytes::from(vec![0xbb]),
651 },
652 ];
653 let mut original = base;
654 original.calls = batch.clone();
655
656 let roundtrip = TempoTransactionRequest::from(original)
657 .build_aa()
658 .expect("build_aa should succeed");
659 assert_eq!(
660 roundtrip.calls, batch,
661 "multi-call AA must not gain phantom calls on round-trip"
662 );
663 }
664
665 #[test]
666 #[cfg(feature = "tempo-compat")]
667 fn test_aa_roundtrip_via_tx_env() {
668 use reth_evm::EvmEnv;
669 use reth_rpc_convert::TryIntoTxEnv;
670
671 let calls = vec![
672 Call {
673 to: address!("0x1111111111111111111111111111111111111111").into(),
674 value: U256::ZERO,
675 input: Bytes::from(vec![0xaa]),
676 },
677 Call {
678 to: address!("0x2222222222222222222222222222222222222222").into(),
679 value: U256::ZERO,
680 input: Bytes::from(vec![0xbb]),
681 },
682 ];
683
684 let tx = TempoTransaction {
685 chain_id: 4217,
686 nonce: 1,
687 gas_limit: 100_000,
688 max_fee_per_gas: 1_000_000_000,
689 max_priority_fee_per_gas: 1_000_000,
690 calls: calls.clone(),
691 ..Default::default()
692 };
693
694 let req = TempoTransactionRequest::from(tx);
695
696 let evm_env = EvmEnv::default();
697 let tx_env = req.try_into_tx_env(&evm_env).expect("try_into_tx_env");
698 let aa_calls = tx_env.tempo_tx_env.expect("tempo_tx_env").aa_calls;
699
700 assert_eq!(
701 aa_calls, calls,
702 "roundtrip via try_into_tx_env must preserve exact call list"
703 );
704 }
705}