Skip to main content

foundry_primitives/transaction/
request.rs

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/// Foundry transaction request builder.
30///
31/// This is a union of different transaction request types, instantiated from a
32/// [`WithOtherFields<TransactionRequest>`]. The specific variant is determined by the transaction
33/// type field and/or the presence of certain fields:
34/// - **Ethereum**: Default variant when no special fields are present
35/// - **Op**: When `sourceHash`, `mint`, and `isSystemTx` fields are present, or transaction type is
36///   `DEPOSIT_TX_TYPE_ID`
37/// - **Tempo**: When a Tempo-specific field is present, or transaction type is `TEMPO_TX_TYPE_ID`
38#[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    /// Returns `true` if this is an Ethereum transaction request.
63    pub const fn is_ethereum(&self) -> bool {
64        matches!(self, Self::Ethereum(_))
65    }
66
67    /// Returns `true` if this is an OP stack transaction request.
68    #[cfg(feature = "optimism")]
69    pub const fn is_op(&self) -> bool {
70        matches!(self, Self::Op(_))
71    }
72
73    /// Returns `true` if this is a Tempo transaction request.
74    pub const fn is_tempo(&self) -> bool {
75        matches!(self, Self::Tempo(_))
76    }
77
78    /// Create a new [`FoundryTransactionRequest`] from given
79    /// [`WithOtherFields<TransactionRequest>`].
80    #[inline]
81    pub fn new(inner: WithOtherFields<TransactionRequest>) -> serde_json::Result<Self> {
82        inner.try_into()
83    }
84
85    /// Consume the [`FoundryTransactionRequest`] and return the inner transaction request.
86    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    /// Get the deposit transaction parts from the request, calling [`get_deposit_tx_parts`] helper
96    /// with OtherFields.
97    ///
98    /// # Returns
99    /// - Ok(deposit_tx_parts) if all necessary keys are present to build a deposit transaction.
100    /// - Err(missing) if some keys are missing to build a deposit transaction.
101    #[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            // Not a deposit transaction request, so missing at least sourceHash, mint, and
106            // isSystemTx
107            _ => Err(vec!["sourceHash", "mint", "isSystemTx"]),
108        }
109    }
110
111    /// Returns the minimal transaction type this request can be converted into based on the fields
112    /// that are set. See [`TransactionRequest::preferred_type`].
113    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    /// Check if all necessary keys are present to build a 4844 transaction,
127    /// returning a list of keys that are missing.
128    ///
129    /// **NOTE:** Inner [`TransactionRequest::complete_4844`] method but "sidecar" key is filtered
130    /// from error.
131    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    /// Check if all necessary keys are present to build a Deposit transaction, returning a list of
143    /// keys that are missing.
144    #[cfg(feature = "optimism")]
145    pub fn complete_deposit(&self) -> Result<(), Vec<&'static str>> {
146        self.get_deposit_tx_parts().map(|_| ())
147    }
148
149    /// Check if all necessary keys are present to build a Tempo transaction, returning a list of
150    /// keys that are missing.
151    pub fn complete_tempo(&self) -> Result<(), Vec<&'static str>> {
152        match self {
153            Self::Tempo(tx) => tx.complete_type(TempoTxType::AA).map(|_| ()),
154            // Not a Tempo transaction request, so missing at least feeToken and nonceKey
155            _ => 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    /// Check if all necessary keys are present to build a transaction.
175    ///
176    /// # Returns
177    ///
178    /// - Ok(type) if all necessary keys are present to build the preferred type.
179    /// - Err((type, missing)) if some keys are missing to build the preferred type.
180    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    /// Build a typed transaction from this request.
191    ///
192    /// Converts the request into a `FoundryTypedTx`, handling all Ethereum and OP-stack transaction
193    /// types.
194    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            // Build deposit transaction
198            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            // Build Tempo transaction
213            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            // if request has eip4844 fields but no blob sidecar (neither eip4844 nor eip7594
218            // format), try to build to eip4844 without sidecar
219            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            // Use the inner transaction request to build EthereumTypedTransaction
225            let typed_tx = self.into_inner().build_typed_tx().map_err(Self::Ethereum)?;
226            // Convert EthereumTypedTransaction to FoundryTypedTx
227            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
418// TransactionBuilder trait implementation for FoundryNetwork
419impl 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    /// Prepares [`FoundryTransactionRequest`] by trimming conflicting fields, and filling with
565    /// default values the mandatory fields.
566    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        // Only provide 2 of 3 required Op fields
746        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}