1use alloy_consensus::{BlobTransactionSidecarVariant, EthereumTypedTransaction};
2use alloy_network::{
3 BuildResult, NetworkTransactionBuilder, NetworkWallet, TransactionBuilder,
4 TransactionBuilder4844, TransactionBuilderError,
5};
6use alloy_primitives::{Address, ChainId, TxKind, U256};
7use alloy_rpc_types::{AccessList, TransactionInputKind, TransactionRequest};
8#[cfg(any(test, feature = "optimism"))]
9use alloy_serde::OtherFields;
10use alloy_serde::WithOtherFields;
11use core::num::NonZeroU64;
12#[cfg(feature = "optimism")]
13use op_alloy_consensus::{DEPOSIT_TX_TYPE_ID, POST_EXEC_TX_TYPE_ID, TxDeposit};
14#[cfg(feature = "optimism")]
15use op_revm::transaction::deposit::DepositTransactionParts;
16use serde::{Deserialize, Serialize};
17use tempo_primitives::{
18 SignatureType, TEMPO_TX_TYPE_ID, TempoTxType,
19 transaction::{Call, SignedKeyAuthorization, TempoSignedAuthorization},
20};
21
22pub use tempo_alloy::rpc::TempoTransactionRequest;
23
24#[cfg(feature = "optimism")]
25use super::optimism::get_deposit_tx_parts;
26use super::{FoundryTxEnvelope, FoundryTxType, FoundryTypedTx};
27use crate::FoundryNetwork;
28
29#[derive(Clone, Debug, PartialEq, Eq)]
39#[allow(clippy::large_enum_variant)]
40pub enum FoundryTransactionRequest {
41 Ethereum(TransactionRequest),
42 #[cfg(feature = "optimism")]
43 Op(WithOtherFields<TransactionRequest>),
44 Tempo(Box<TempoTransactionRequest>),
45}
46
47const TEMPO_REQUEST_FIELDS: &[&str] = &[
48 "feeToken",
49 "nonceKey",
50 "calls",
51 "keyType",
52 "keyData",
53 "keyId",
54 "aaAuthorizationList",
55 "keyAuthorization",
56 "validBefore",
57 "validAfter",
58 "feePayerSignature",
59];
60
61impl FoundryTransactionRequest {
62 pub const fn is_ethereum(&self) -> bool {
64 matches!(self, Self::Ethereum(_))
65 }
66
67 #[cfg(feature = "optimism")]
69 pub const fn is_op(&self) -> bool {
70 matches!(self, Self::Op(_))
71 }
72
73 pub const fn is_tempo(&self) -> bool {
75 matches!(self, Self::Tempo(_))
76 }
77
78 #[inline]
81 pub fn new(inner: WithOtherFields<TransactionRequest>) -> serde_json::Result<Self> {
82 inner.try_into()
83 }
84
85 pub fn into_inner(self) -> TransactionRequest {
87 match self {
88 Self::Ethereum(tx) => tx,
89 #[cfg(feature = "optimism")]
90 Self::Op(tx) => tx.inner,
91 Self::Tempo(tx) => tx.inner,
92 }
93 }
94
95 #[cfg(feature = "optimism")]
102 pub fn get_deposit_tx_parts(&self) -> Result<DepositTransactionParts, Vec<&'static str>> {
103 match self {
104 Self::Op(tx) => get_deposit_tx_parts(&tx.other),
105 _ => Err(vec!["sourceHash", "mint", "isSystemTx"]),
108 }
109 }
110
111 pub fn preferred_type(&self) -> FoundryTxType {
114 match self {
115 Self::Ethereum(tx) => tx.preferred_type().into(),
116 #[cfg(feature = "optimism")]
117 Self::Op(tx) if tx.inner.transaction_type == Some(POST_EXEC_TX_TYPE_ID) => {
118 FoundryTxType::PostExec
119 }
120 #[cfg(feature = "optimism")]
121 Self::Op(_) => FoundryTxType::Deposit,
122 Self::Tempo(_) => FoundryTxType::Tempo,
123 }
124 }
125
126 pub fn complete_4844(&self) -> Result<(), Vec<&'static str>> {
132 match self.as_ref().complete_4844() {
133 Ok(()) => Ok(()),
134 Err(missing) => {
135 let filtered: Vec<_> =
136 missing.into_iter().filter(|&key| key != "sidecar").collect();
137 if filtered.is_empty() { Ok(()) } else { Err(filtered) }
138 }
139 }
140 }
141
142 #[cfg(feature = "optimism")]
145 pub fn complete_deposit(&self) -> Result<(), Vec<&'static str>> {
146 self.get_deposit_tx_parts().map(|_| ())
147 }
148
149 pub fn complete_tempo(&self) -> Result<(), Vec<&'static str>> {
152 match self {
153 Self::Tempo(tx) => tx.complete_type(TempoTxType::AA).map(|_| ()),
154 _ => Err(vec!["feeToken", "nonceKey"]),
156 }
157 }
158
159 fn check_type(&self, ty: FoundryTxType) -> Result<(), Vec<&'static str>> {
160 match ty {
161 FoundryTxType::Legacy => self.as_ref().complete_legacy(),
162 FoundryTxType::Eip2930 => self.as_ref().complete_2930(),
163 FoundryTxType::Eip1559 => self.as_ref().complete_1559(),
164 FoundryTxType::Eip4844 => self.as_ref().complete_4844(),
165 FoundryTxType::Eip7702 => self.as_ref().complete_7702(),
166 #[cfg(feature = "optimism")]
167 FoundryTxType::Deposit => self.complete_deposit(),
168 #[cfg(feature = "optimism")]
169 FoundryTxType::PostExec => Err(vec!["not implemented for post-exec tx"]),
170 FoundryTxType::Tempo => self.complete_tempo(),
171 }
172 }
173
174 pub fn missing_keys(&self) -> Result<FoundryTxType, (FoundryTxType, Vec<&'static str>)> {
181 let pref = self.preferred_type();
182 let result = if pref == FoundryTxType::Eip4844 {
183 self.complete_4844()
184 } else {
185 self.check_type(pref)
186 };
187 if let Err(missing) = result { Err((pref, missing)) } else { Ok(pref) }
188 }
189
190 pub fn build_typed_tx(self) -> Result<FoundryTypedTx, Self> {
195 #[cfg(feature = "optimism")]
196 if let Ok(deposit_tx_parts) = self.get_deposit_tx_parts() {
197 return Ok(FoundryTypedTx::Deposit(TxDeposit {
199 from: self.from().unwrap_or_default(),
200 source_hash: deposit_tx_parts.source_hash,
201 to: self.kind().unwrap_or_default(),
202 mint: deposit_tx_parts.mint.unwrap_or_default(),
203 value: self.value().unwrap_or_default(),
204 gas_limit: self.gas_limit().unwrap_or_default(),
205 is_system_transaction: deposit_tx_parts.is_system_transaction,
206 input: self.input().cloned().unwrap_or_default(),
207 }));
208 }
209 if self.complete_tempo().is_ok()
210 && let Self::Tempo(tx_req) = self
211 {
212 Ok(FoundryTypedTx::Tempo(
214 tx_req.build_aa().map_err(|e| Self::Tempo(Box::new(e.into_value())))?,
215 ))
216 } else if self.as_ref().has_eip4844_fields() && self.blob_sidecar().is_none() {
217 self.into_inner()
220 .build_4844_without_sidecar()
221 .map_err(|e| Self::Ethereum(e.into_value()))
222 .map(|tx| FoundryTypedTx::Eip4844(tx.into()))
223 } else {
224 let typed_tx = self.into_inner().build_typed_tx().map_err(Self::Ethereum)?;
226 Ok(match typed_tx {
228 EthereumTypedTransaction::Legacy(tx) => FoundryTypedTx::Legacy(tx),
229 EthereumTypedTransaction::Eip2930(tx) => FoundryTypedTx::Eip2930(tx),
230 EthereumTypedTransaction::Eip1559(tx) => FoundryTypedTx::Eip1559(tx),
231 EthereumTypedTransaction::Eip4844(tx) => FoundryTypedTx::Eip4844(tx),
232 EthereumTypedTransaction::Eip7702(tx) => FoundryTypedTx::Eip7702(tx),
233 })
234 }
235 }
236}
237
238impl Default for FoundryTransactionRequest {
239 fn default() -> Self {
240 Self::Ethereum(TransactionRequest::default())
241 }
242}
243
244impl Serialize for FoundryTransactionRequest {
245 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
246 where
247 S: serde::Serializer,
248 {
249 match self {
250 Self::Ethereum(tx) => tx.serialize(serializer),
251 #[cfg(feature = "optimism")]
252 Self::Op(tx) => tx.serialize(serializer),
253 Self::Tempo(tx) => tx.serialize(serializer),
254 }
255 }
256}
257
258impl<'de> Deserialize<'de> for FoundryTransactionRequest {
259 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
260 where
261 D: serde::Deserializer<'de>,
262 {
263 WithOtherFields::<TransactionRequest>::deserialize(deserializer)?
264 .try_into()
265 .map_err(serde::de::Error::custom)
266 }
267}
268
269impl AsRef<TransactionRequest> for FoundryTransactionRequest {
270 fn as_ref(&self) -> &TransactionRequest {
271 match self {
272 Self::Ethereum(tx) => tx,
273 #[cfg(feature = "optimism")]
274 Self::Op(tx) => tx,
275 Self::Tempo(tx) => tx.as_ref(),
276 }
277 }
278}
279
280impl AsMut<TransactionRequest> for FoundryTransactionRequest {
281 fn as_mut(&mut self) -> &mut TransactionRequest {
282 match self {
283 Self::Ethereum(tx) => tx,
284 #[cfg(feature = "optimism")]
285 Self::Op(tx) => tx,
286 Self::Tempo(tx) => tx.as_mut(),
287 }
288 }
289}
290
291impl TryFrom<WithOtherFields<TransactionRequest>> for FoundryTransactionRequest {
292 type Error = serde_json::Error;
293
294 fn try_from(tx: WithOtherFields<TransactionRequest>) -> Result<Self, Self::Error> {
295 #[derive(Deserialize)]
296 struct NonZeroQuantity(
297 #[serde(
298 with = "tempo_primitives::transaction::key_authorization::serde_nonzero_quantity_opt"
299 )]
300 Option<NonZeroU64>,
301 );
302
303 if tx.transaction_type == Some(TEMPO_TX_TYPE_ID)
304 || TEMPO_REQUEST_FIELDS.iter().any(|field| {
305 tx.other.get(*field).is_some_and(|value| {
306 !value.is_null()
307 && !matches!(
308 (*field, value),
309 (
310 "calls" | "aaAuthorizationList",
311 serde_json::Value::Array(values)
312 ) if values.is_empty()
313 )
314 })
315 })
316 {
317 let mut tempo_tx_req: TempoTransactionRequest = tx.inner.into();
318 tempo_tx_req.fee_token =
319 tx.other.get_deserialized::<Option<Address>>("feeToken").transpose()?.flatten();
320 tempo_tx_req.nonce_key =
321 tx.other.get_deserialized::<Option<U256>>("nonceKey").transpose()?.flatten();
322 tempo_tx_req.calls =
323 tx.other.get_deserialized::<Vec<Call>>("calls").transpose()?.unwrap_or_default();
324 tempo_tx_req.key_type = tx
325 .other
326 .get_deserialized::<Option<SignatureType>>("keyType")
327 .transpose()?
328 .flatten();
329 tempo_tx_req.key_data =
330 tx.other.get_deserialized::<Option<_>>("keyData").transpose()?.flatten();
331 tempo_tx_req.key_id =
332 tx.other.get_deserialized::<Option<_>>("keyId").transpose()?.flatten();
333 tempo_tx_req.tempo_authorization_list = tx
334 .other
335 .get_deserialized::<Vec<TempoSignedAuthorization>>("aaAuthorizationList")
336 .transpose()?
337 .unwrap_or_default();
338 tempo_tx_req.key_authorization = tx
339 .other
340 .get_deserialized::<Option<SignedKeyAuthorization>>("keyAuthorization")
341 .transpose()?
342 .flatten();
343 tempo_tx_req.valid_before = tx
344 .other
345 .get_deserialized::<NonZeroQuantity>("validBefore")
346 .transpose()?
347 .and_then(|value| value.0);
348 tempo_tx_req.valid_after = tx
349 .other
350 .get_deserialized::<NonZeroQuantity>("validAfter")
351 .transpose()?
352 .and_then(|value| value.0);
353 tempo_tx_req.fee_payer_signature =
354 tx.other.get_deserialized::<Option<_>>("feePayerSignature").transpose()?.flatten();
355 return Ok(Self::Tempo(Box::new(tempo_tx_req)));
356 }
357 #[cfg(feature = "optimism")]
358 if tx.transaction_type == Some(DEPOSIT_TX_TYPE_ID)
359 || tx.transaction_type == Some(POST_EXEC_TX_TYPE_ID)
360 || get_deposit_tx_parts(&tx.other).is_ok()
361 {
362 return Ok(Self::Op(tx));
363 }
364 Ok(Self::Ethereum(tx.into_inner()))
365 }
366}
367
368impl From<TransactionRequest> for FoundryTransactionRequest {
369 fn from(tx: TransactionRequest) -> Self {
370 Self::Ethereum(tx)
371 }
372}
373
374impl From<FoundryTypedTx> for FoundryTransactionRequest {
375 fn from(tx: FoundryTypedTx) -> Self {
376 match tx {
377 FoundryTypedTx::Legacy(tx) => Self::Ethereum(Into::<TransactionRequest>::into(tx)),
378 FoundryTypedTx::Eip2930(tx) => Self::Ethereum(Into::<TransactionRequest>::into(tx)),
379 FoundryTypedTx::Eip1559(tx) => Self::Ethereum(Into::<TransactionRequest>::into(tx)),
380 FoundryTypedTx::Eip4844(tx) => Self::Ethereum(Into::<TransactionRequest>::into(tx)),
381 FoundryTypedTx::Eip7702(tx) => Self::Ethereum(Into::<TransactionRequest>::into(tx)),
382 #[cfg(feature = "optimism")]
383 FoundryTypedTx::Deposit(tx) => {
384 let other = OtherFields::from_iter([
385 ("sourceHash", serde_json::to_value(tx.source_hash).unwrap()),
386 ("mint", serde_json::to_value(U256::from(tx.mint)).unwrap()),
387 ("isSystemTx", serde_json::to_value(tx.is_system_transaction).unwrap()),
388 ]);
389 WithOtherFields { inner: Into::<TransactionRequest>::into(tx), other }
390 .try_into()
391 .expect("valid OP transaction request")
392 }
393 #[cfg(feature = "optimism")]
394 FoundryTypedTx::PostExec(tx) => WithOtherFields {
395 inner: Into::<TransactionRequest>::into(tx),
396 other: OtherFields::default(),
397 }
398 .try_into()
399 .expect("valid OP post-exec transaction request"),
400 FoundryTypedTx::Tempo(tx) => Self::Tempo(Box::new(tx.into())),
401 }
402 }
403}
404
405impl From<FoundryTxEnvelope> for FoundryTransactionRequest {
406 fn from(tx: FoundryTxEnvelope) -> Self {
407 FoundryTypedTx::from(tx).into()
408 }
409}
410
411#[cfg(not(feature = "optimism"))]
412impl From<alloy_rpc_types_eth::Transaction<FoundryTxEnvelope>> for FoundryTransactionRequest {
413 fn from(tx: alloy_rpc_types_eth::Transaction<FoundryTxEnvelope>) -> Self {
414 tx.inner.into_inner().into()
415 }
416}
417
418impl TransactionBuilder for FoundryTransactionRequest {
420 fn chain_id(&self) -> Option<ChainId> {
421 self.as_ref().chain_id
422 }
423
424 fn set_chain_id(&mut self, chain_id: ChainId) {
425 self.as_mut().chain_id = Some(chain_id);
426 }
427
428 fn nonce(&self) -> Option<u64> {
429 self.as_ref().nonce
430 }
431
432 fn set_nonce(&mut self, nonce: u64) {
433 self.as_mut().nonce = Some(nonce);
434 }
435
436 fn take_nonce(&mut self) -> Option<u64> {
437 self.as_mut().nonce.take()
438 }
439
440 fn input(&self) -> Option<&alloy_primitives::Bytes> {
441 self.as_ref().input.input()
442 }
443
444 fn set_input<T: Into<alloy_primitives::Bytes>>(&mut self, input: T) {
445 self.as_mut().input.input = Some(input.into());
446 }
447
448 fn set_input_kind<T: Into<alloy_primitives::Bytes>>(
449 &mut self,
450 input: T,
451 kind: TransactionInputKind,
452 ) {
453 let inner = self.as_mut();
454 match kind {
455 TransactionInputKind::Input => inner.input.input = Some(input.into()),
456 TransactionInputKind::Data => inner.input.data = Some(input.into()),
457 TransactionInputKind::Both => {
458 let bytes = input.into();
459 inner.input.input = Some(bytes.clone());
460 inner.input.data = Some(bytes);
461 }
462 }
463 }
464
465 fn from(&self) -> Option<Address> {
466 self.as_ref().from
467 }
468
469 fn set_from(&mut self, from: Address) {
470 self.as_mut().from = Some(from);
471 }
472
473 fn kind(&self) -> Option<TxKind> {
474 self.as_ref().to
475 }
476
477 fn clear_kind(&mut self) {
478 self.as_mut().to = None;
479 }
480
481 fn set_kind(&mut self, kind: TxKind) {
482 self.as_mut().to = Some(kind);
483 }
484
485 fn value(&self) -> Option<U256> {
486 self.as_ref().value
487 }
488
489 fn set_value(&mut self, value: U256) {
490 self.as_mut().value = Some(value);
491 }
492
493 fn gas_price(&self) -> Option<u128> {
494 self.as_ref().gas_price
495 }
496
497 fn set_gas_price(&mut self, gas_price: u128) {
498 self.as_mut().gas_price = Some(gas_price);
499 }
500
501 fn max_fee_per_gas(&self) -> Option<u128> {
502 self.as_ref().max_fee_per_gas
503 }
504
505 fn set_max_fee_per_gas(&mut self, max_fee_per_gas: u128) {
506 self.as_mut().max_fee_per_gas = Some(max_fee_per_gas);
507 }
508
509 fn max_priority_fee_per_gas(&self) -> Option<u128> {
510 self.as_ref().max_priority_fee_per_gas
511 }
512
513 fn set_max_priority_fee_per_gas(&mut self, max_priority_fee_per_gas: u128) {
514 self.as_mut().max_priority_fee_per_gas = Some(max_priority_fee_per_gas);
515 }
516
517 fn gas_limit(&self) -> Option<u64> {
518 self.as_ref().gas
519 }
520
521 fn set_gas_limit(&mut self, gas_limit: u64) {
522 self.as_mut().gas = Some(gas_limit);
523 }
524
525 fn access_list(&self) -> Option<&AccessList> {
526 self.as_ref().access_list.as_ref()
527 }
528
529 fn set_access_list(&mut self, access_list: AccessList) {
530 self.as_mut().access_list = Some(access_list);
531 }
532}
533
534impl NetworkTransactionBuilder<FoundryNetwork> for FoundryTransactionRequest {
535 fn complete_type(&self, ty: FoundryTxType) -> Result<(), Vec<&'static str>> {
536 self.check_type(ty)
537 }
538
539 fn can_submit(&self) -> bool {
540 self.from().is_some()
541 }
542
543 fn can_build(&self) -> bool {
544 if self.as_ref().can_build() || self.complete_tempo().is_ok() {
545 return true;
546 }
547 #[cfg(feature = "optimism")]
548 if self.complete_deposit().is_ok() {
549 return true;
550 }
551 false
552 }
553
554 fn output_tx_type(&self) -> FoundryTxType {
555 self.preferred_type()
556 }
557
558 fn output_tx_type_checked(&self) -> Option<FoundryTxType> {
559 let pref = self.preferred_type();
560 self.check_type(pref).ok()?;
561 Some(pref)
562 }
563
564 fn prep_for_submission(&mut self) {
567 let preferred_type = self.preferred_type();
568 let inner = self.as_mut();
569 inner.transaction_type = Some(preferred_type as u8);
570 inner.gas.is_none().then(|| inner.set_gas_limit(Default::default()));
571 let is_deposit = {
572 #[cfg(feature = "optimism")]
573 {
574 preferred_type == FoundryTxType::Deposit
575 }
576 #[cfg(not(feature = "optimism"))]
577 {
578 false
579 }
580 };
581 if !is_deposit && preferred_type != FoundryTxType::Tempo {
582 inner.trim_conflicting_keys();
583 inner.populate_blob_hashes();
584 }
585 if !is_deposit {
586 inner.nonce.is_none().then(|| inner.set_nonce(Default::default()));
587 }
588 if matches!(preferred_type, FoundryTxType::Legacy | FoundryTxType::Eip2930) {
589 inner.gas_price.is_none().then(|| inner.set_gas_price(Default::default()));
590 }
591 if preferred_type == FoundryTxType::Eip2930 {
592 inner.access_list.is_none().then(|| inner.set_access_list(Default::default()));
593 }
594 if matches!(
595 preferred_type,
596 FoundryTxType::Eip1559
597 | FoundryTxType::Eip4844
598 | FoundryTxType::Eip7702
599 | FoundryTxType::Tempo
600 ) {
601 inner
602 .max_priority_fee_per_gas
603 .is_none()
604 .then(|| inner.set_max_priority_fee_per_gas(Default::default()));
605 inner.max_fee_per_gas.is_none().then(|| inner.set_max_fee_per_gas(Default::default()));
606 }
607 if preferred_type == FoundryTxType::Eip4844 {
608 inner
609 .as_ref()
610 .max_fee_per_blob_gas()
611 .is_none()
612 .then(|| inner.as_mut().set_max_fee_per_blob_gas(Default::default()));
613 }
614 }
615
616 fn build_unsigned(self) -> BuildResult<FoundryTypedTx, FoundryNetwork> {
617 if let Err((tx_type, missing)) = self.missing_keys() {
618 return Err(TransactionBuilderError::InvalidTransactionRequest(tx_type, missing)
619 .into_unbuilt(self));
620 }
621 Ok(self.build_typed_tx().expect("checked by missing_keys"))
622 }
623
624 async fn build<W: NetworkWallet<FoundryNetwork>>(
625 self,
626 wallet: &W,
627 ) -> Result<FoundryTxEnvelope, TransactionBuilderError<FoundryNetwork>> {
628 Ok(wallet.sign_request(self).await?)
629 }
630}
631
632impl TransactionBuilder4844 for FoundryTransactionRequest {
633 fn max_fee_per_blob_gas(&self) -> Option<u128> {
634 self.as_ref().max_fee_per_blob_gas()
635 }
636
637 fn set_max_fee_per_blob_gas(&mut self, max_fee_per_blob_gas: u128) {
638 self.as_mut().set_max_fee_per_blob_gas(max_fee_per_blob_gas);
639 }
640
641 fn blob_sidecar(&self) -> Option<&BlobTransactionSidecarVariant> {
642 self.as_ref().blob_sidecar()
643 }
644
645 fn set_blob_sidecar(&mut self, sidecar: BlobTransactionSidecarVariant) {
646 self.as_mut().set_blob_sidecar(sidecar);
647 }
648}
649
650#[cfg(test)]
651mod tests {
652 use alloy_primitives::{B256, Bytes, Signature};
653 use tempo_primitives::{
654 TempoSignature, TempoTransaction,
655 transaction::{Authorization, KeyAuthorization, PrimitiveSignature},
656 };
657
658 use super::*;
659
660 fn default_tx_req() -> TransactionRequest {
661 TransactionRequest::default()
662 .with_to(Address::random())
663 .with_nonce(1)
664 .with_value(U256::from(1000000))
665 .with_gas_limit(1000000)
666 .with_max_fee_per_gas(1000000)
667 .with_max_priority_fee_per_gas(1000000)
668 }
669
670 #[test]
671 fn request_predicates() {
672 let ethereum = FoundryTransactionRequest::default();
673 assert!(ethereum.is_ethereum());
674 assert!(!ethereum.is_tempo());
675
676 let tempo = FoundryTransactionRequest::Tempo(Box::default());
677 assert!(tempo.is_tempo());
678 assert!(!tempo.is_ethereum());
679
680 #[cfg(feature = "optimism")]
681 {
682 let op = FoundryTransactionRequest::Op(WithOtherFields::default());
683 assert!(op.is_op());
684 assert!(!op.is_ethereum());
685 assert!(!op.is_tempo());
686 }
687 }
688
689 #[test]
690 fn test_routing_ethereum_default() {
691 let tx = default_tx_req();
692 let req: FoundryTransactionRequest = WithOtherFields::new(tx).try_into().unwrap();
693
694 assert!(req.is_ethereum());
695 assert!(matches!(req.build_unsigned(), Ok(FoundryTypedTx::Eip1559(_))));
696 }
697
698 #[test]
699 fn test_routing_tempo_by_fee_token() {
700 let tx = default_tx_req();
701 let mut other = OtherFields::default();
702 other.insert("feeToken".to_string(), serde_json::to_value(Address::random()).unwrap());
703
704 let req: FoundryTransactionRequest =
705 WithOtherFields { inner: tx, other }.try_into().unwrap();
706
707 assert!(req.is_tempo());
708 assert!(matches!(req.build_unsigned(), Ok(FoundryTypedTx::Tempo(_))));
709 }
710
711 #[test]
712 fn test_routing_serialized_non_aa_tempo_request_to_ethereum() {
713 let request = TempoTransactionRequest { inner: default_tx_req(), ..Default::default() };
714 let request = serde_json::from_value::<WithOtherFields<TransactionRequest>>(
715 serde_json::to_value(request).unwrap(),
716 )
717 .unwrap();
718
719 let request = FoundryTransactionRequest::try_from(request).unwrap();
720
721 assert!(matches!(request, FoundryTransactionRequest::Ethereum(_)));
722 assert!(matches!(request.build_unsigned(), Ok(FoundryTypedTx::Eip1559(_))));
723 }
724
725 #[test]
726 #[cfg(feature = "optimism")]
727 fn test_routing_op_by_deposit_fields() {
728 let tx = default_tx_req();
729 let mut other = OtherFields::default();
730 other.insert("sourceHash".to_string(), serde_json::to_value(B256::ZERO).unwrap());
731 other.insert("mint".to_string(), serde_json::to_value(U256::from(1000)).unwrap());
732 other.insert("isSystemTx".to_string(), serde_json::to_value(false).unwrap());
733
734 let req: FoundryTransactionRequest =
735 WithOtherFields { inner: tx, other }.try_into().unwrap();
736
737 assert!(req.is_op());
738 assert!(matches!(req.build_unsigned(), Ok(FoundryTypedTx::Deposit(_))));
739 }
740
741 #[test]
742 fn test_op_incomplete_routes_to_ethereum() {
743 let tx = default_tx_req();
744 let mut other = OtherFields::default();
745 other.insert("sourceHash".to_string(), serde_json::to_value(B256::ZERO).unwrap());
747 other.insert("mint".to_string(), serde_json::to_value(U256::from(1000)).unwrap());
748
749 let req: FoundryTransactionRequest =
750 WithOtherFields { inner: tx, other }.try_into().unwrap();
751
752 assert!(req.is_ethereum());
753 assert!(matches!(req.build_unsigned(), Ok(FoundryTypedTx::Eip1559(_))));
754 }
755
756 #[test]
757 fn test_ethereum_with_unrelated_other_fields() {
758 let tx = default_tx_req();
759 let mut other = OtherFields::default();
760 other.insert("anotherField".to_string(), serde_json::to_value(123).unwrap());
761
762 let req: FoundryTransactionRequest =
763 WithOtherFields { inner: tx, other }.try_into().unwrap();
764
765 assert!(req.is_ethereum());
766 assert!(matches!(req.build_unsigned(), Ok(FoundryTypedTx::Eip1559(_))));
767 }
768
769 #[test]
770 fn test_serialization_ethereum() {
771 let tx = default_tx_req();
772 let original: FoundryTransactionRequest = WithOtherFields::new(tx).try_into().unwrap();
773
774 let serialized = serde_json::to_string(&original).unwrap();
775 let deserialized: FoundryTransactionRequest = serde_json::from_str(&serialized).unwrap();
776
777 assert!(deserialized.is_ethereum());
778 }
779
780 #[test]
781 #[cfg(feature = "optimism")]
782 fn test_serialization_op() {
783 let tx = default_tx_req();
784 let mut other = OtherFields::default();
785 other.insert("sourceHash".to_string(), serde_json::to_value(B256::ZERO).unwrap());
786 other.insert("mint".to_string(), serde_json::to_value(U256::from(1000)).unwrap());
787 other.insert("isSystemTx".to_string(), serde_json::to_value(false).unwrap());
788
789 let original: FoundryTransactionRequest =
790 WithOtherFields { inner: tx, other }.try_into().unwrap();
791
792 let serialized = serde_json::to_string(&original).unwrap();
793 let deserialized: FoundryTransactionRequest = serde_json::from_str(&serialized).unwrap();
794
795 assert!(deserialized.is_op());
796 }
797
798 #[test]
799 fn test_serialization_tempo() {
800 let tx = default_tx_req();
801 let mut other = OtherFields::default();
802 other.insert("feeToken".to_string(), serde_json::to_value(Address::ZERO).unwrap());
803 other.insert("nonceKey".to_string(), serde_json::to_value(U256::from(42)).unwrap());
804
805 let original: FoundryTransactionRequest =
806 WithOtherFields { inner: tx, other }.try_into().unwrap();
807
808 let serialized = serde_json::to_string(&original).unwrap();
809 let deserialized: FoundryTransactionRequest = serde_json::from_str(&serialized).unwrap();
810
811 assert!(deserialized.is_tempo());
812 }
813
814 #[test]
815 fn test_tempo_request_decodes_all_extension_fields() {
816 let signature = Signature::test_signature();
817 let expected = TempoTransactionRequest {
818 inner: TransactionRequest {
819 transaction_type: Some(TEMPO_TX_TYPE_ID),
820 ..default_tx_req()
821 },
822 fee_token: Some(Address::repeat_byte(0x11)),
823 nonce_key: Some(U256::from(42)),
824 calls: vec![Call {
825 to: TxKind::Call(Address::repeat_byte(0x22)),
826 value: U256::from(7),
827 input: Bytes::from_static(&[0xde, 0xad]),
828 }],
829 key_type: Some(SignatureType::WebAuthn),
830 key_data: Some(Bytes::from_static(&[0xbe, 0xef])),
831 key_id: Some(Address::repeat_byte(0x33)),
832 tempo_authorization_list: vec![TempoSignedAuthorization::new_unchecked(
833 Authorization {
834 chain_id: U256::from(4217),
835 address: Address::repeat_byte(0x44),
836 nonce: 3,
837 },
838 TempoSignature::default(),
839 )],
840 key_authorization: Some(
841 KeyAuthorization::unrestricted(
842 4217,
843 SignatureType::Secp256k1,
844 Address::repeat_byte(0x55),
845 )
846 .into_signed(PrimitiveSignature::Secp256k1(signature)),
847 ),
848 valid_before: NonZeroU64::new(100),
849 valid_after: NonZeroU64::new(10),
850 fee_payer_signature: Some(signature),
851 };
852 let request = serde_json::from_value::<WithOtherFields<TransactionRequest>>(
853 serde_json::to_value(&expected).unwrap(),
854 )
855 .unwrap();
856
857 let decoded = FoundryTransactionRequest::try_from(request).unwrap();
858
859 let FoundryTransactionRequest::Tempo(decoded) = decoded else { panic!() };
860 assert_eq!(*decoded, expected);
861 }
862
863 #[test]
864 fn test_malformed_tempo_field_is_rejected() {
865 let mut other = OtherFields::default();
866 other.insert("nonceKey".to_string(), serde_json::json!("not a quantity"));
867
868 let result =
869 FoundryTransactionRequest::new(WithOtherFields { inner: default_tx_req(), other });
870
871 assert!(result.is_err());
872 }
873
874 #[test]
875 fn test_tempo_validity_uses_rpc_quantities() {
876 let request = serde_json::from_value::<FoundryTransactionRequest>(serde_json::json!({
877 "type": "0x76",
878 "validBefore": "0x64",
879 "validAfter": "0xa",
880 }))
881 .unwrap();
882
883 let FoundryTransactionRequest::Tempo(request) = request else { panic!() };
884 assert_eq!(request.valid_before, NonZeroU64::new(100));
885 assert_eq!(request.valid_after, NonZeroU64::new(10));
886 }
887
888 #[test]
889 fn test_tempo_typed_request_roundtrip_preserves_fields() {
890 let tx = TempoTransaction {
891 chain_id: 4217,
892 nonce: 7,
893 fee_payer_signature: None,
894 valid_before: NonZeroU64::new(100),
895 valid_after: NonZeroU64::new(10),
896 gas_limit: 100_000,
897 max_fee_per_gas: 20,
898 max_priority_fee_per_gas: 2,
899 fee_token: Some(Address::random()),
900 access_list: Default::default(),
901 calls: vec![Call {
902 to: TxKind::Call(Address::random()),
903 value: U256::from(42),
904 input: vec![1, 2, 3].into(),
905 }],
906 tempo_authorization_list: Vec::new(),
907 nonce_key: U256::from(9),
908 key_authorization: None,
909 };
910
911 let request: FoundryTransactionRequest = FoundryTypedTx::Tempo(tx.clone()).into();
912 let rebuilt = request.build_unsigned().unwrap();
913
914 assert_eq!(rebuilt, FoundryTypedTx::Tempo(tx));
915 }
916
917 #[test]
918 #[cfg(feature = "optimism")]
919 fn test_deposit_typed_tx_roundtrip() {
920 let deposit_tx = TxDeposit {
921 from: Address::random(),
922 source_hash: B256::random(),
923 to: TxKind::Call(Address::random()),
924 mint: 1000u128,
925 value: U256::from(500),
926 gas_limit: 21000,
927 is_system_transaction: true,
928 input: Default::default(),
929 };
930
931 let req: FoundryTransactionRequest = FoundryTypedTx::Deposit(deposit_tx.clone()).into();
932
933 assert!(req.is_op());
934
935 let parts = req.get_deposit_tx_parts().expect("should parse deposit parts");
936 assert_eq!(parts.source_hash, deposit_tx.source_hash);
937 assert_eq!(parts.mint, Some(deposit_tx.mint));
938 assert_eq!(parts.is_system_transaction, deposit_tx.is_system_transaction);
939 }
940}