Skip to main content

foundry_primitives/transaction/
request.rs

1use super::{FoundryTxEnvelope, FoundryTxType, FoundryTypedTx};
2use crate::FoundryNetwork;
3use alloy_consensus::{BlobTransactionSidecarVariant, EthereumTypedTransaction};
4use alloy_network::{
5    BuildResult, NetworkTransactionBuilder, NetworkWallet, TransactionBuilder,
6    TransactionBuilder4844, TransactionBuilderError,
7};
8use alloy_primitives::{Address, ChainId, TxKind, U256};
9use alloy_rpc_types::{AccessList, TransactionInputKind, TransactionRequest};
10use alloy_serde::WithOtherFields;
11use core::num::NonZeroU64;
12use serde::{Deserialize, Serialize};
13use tempo_primitives::{
14    SignatureType, TEMPO_TX_TYPE_ID, TempoTxType,
15    transaction::{Call, SignedKeyAuthorization, TempoSignedAuthorization},
16};
17
18#[cfg(all(feature = "base", not(feature = "optimism")))]
19use op_alloy_consensus::{DEPOSIT_TX_TYPE_ID, TxDeposit};
20
21#[cfg(any(feature = "base", feature = "optimism"))]
22use super::get_deposit_tx_parts;
23#[cfg(any(feature = "base", feature = "optimism"))]
24use op_revm::transaction::deposit::DepositTransactionParts;
25
26#[cfg(any(test, feature = "base", feature = "optimism"))]
27use alloy_serde::OtherFields;
28
29#[cfg(feature = "base")]
30use base_common_evm::EIP8130_TRANSACTION_TYPE;
31#[cfg(feature = "base")]
32use base_common_rpc_types::BaseTransactionRequest;
33
34#[cfg(feature = "optimism")]
35use op_alloy_consensus::{DEPOSIT_TX_TYPE_ID, POST_EXEC_TX_TYPE_ID, TxDeposit};
36
37pub use tempo_alloy::rpc::TempoTransactionRequest;
38
39/// Foundry transaction request builder.
40///
41/// This is a union of different transaction request types, instantiated from a
42/// [`WithOtherFields<TransactionRequest>`]. The specific variant is determined by the transaction
43/// type field and/or the presence of certain fields:
44/// - **Ethereum**: Default variant when no special fields are present
45/// - **Op**: When `sourceHash`, `mint`, and `isSystemTx` fields are present, or transaction type is
46///   `DEPOSIT_TX_TYPE_ID`
47/// - **Tempo**: When a Tempo-specific field is present, or transaction type is `TEMPO_TX_TYPE_ID`
48#[derive(Clone, Debug, PartialEq, Eq)]
49#[allow(clippy::large_enum_variant)]
50pub enum FoundryTransactionRequest {
51    Ethereum(TransactionRequest),
52    #[cfg(feature = "base")]
53    Base(BaseTransactionRequest),
54    #[cfg(any(feature = "base", feature = "optimism"))]
55    Op(WithOtherFields<TransactionRequest>),
56    Tempo(Box<TempoTransactionRequest>),
57}
58
59const TEMPO_REQUEST_FIELDS: &[&str] = &[
60    "feeToken",
61    "nonceKey",
62    "calls",
63    "keyType",
64    "keyData",
65    "keyId",
66    "aaAuthorizationList",
67    "keyAuthorization",
68    "validBefore",
69    "validAfter",
70    "feePayerSignature",
71];
72
73impl FoundryTransactionRequest {
74    /// Returns `true` if this is an Ethereum transaction request.
75    pub const fn is_ethereum(&self) -> bool {
76        matches!(self, Self::Ethereum(_))
77    }
78
79    /// Returns `true` if this is a Base EIP-8130 request.
80    #[cfg(feature = "base")]
81    pub const fn is_base(&self) -> bool {
82        matches!(self, Self::Base(_))
83    }
84
85    /// Returns the native Base request.
86    #[cfg(feature = "base")]
87    pub const fn as_base(&self) -> Option<&BaseTransactionRequest> {
88        match self {
89            Self::Base(request) => Some(request),
90            _ => None,
91        }
92    }
93
94    /// Returns `true` if this is an OP stack transaction request.
95    #[cfg(any(feature = "base", feature = "optimism"))]
96    pub const fn is_op(&self) -> bool {
97        matches!(self, Self::Op(_))
98    }
99
100    /// Returns `true` if this is a Tempo transaction request.
101    pub const fn is_tempo(&self) -> bool {
102        matches!(self, Self::Tempo(_))
103    }
104
105    /// Create a new [`FoundryTransactionRequest`] from given
106    /// [`WithOtherFields<TransactionRequest>`].
107    #[inline]
108    pub fn new(inner: WithOtherFields<TransactionRequest>) -> serde_json::Result<Self> {
109        inner.try_into()
110    }
111
112    /// Consume the [`FoundryTransactionRequest`] and return the inner transaction request.
113    pub fn into_inner(self) -> TransactionRequest {
114        match self {
115            Self::Ethereum(tx) => tx,
116            #[cfg(feature = "base")]
117            Self::Base(tx) => tx.into(),
118            #[cfg(any(feature = "base", feature = "optimism"))]
119            Self::Op(tx) => tx.inner,
120            Self::Tempo(tx) => tx.inner,
121        }
122    }
123
124    /// Get the deposit transaction parts from the request, calling [`get_deposit_tx_parts`] helper
125    /// with OtherFields.
126    ///
127    /// # Returns
128    /// - Ok(deposit_tx_parts) if all necessary keys are present to build a deposit transaction.
129    /// - Err(missing) if some keys are missing to build a deposit transaction.
130    #[cfg(any(feature = "base", feature = "optimism"))]
131    pub fn get_deposit_tx_parts(&self) -> Result<DepositTransactionParts, Vec<&'static str>> {
132        match self {
133            Self::Op(tx) => get_deposit_tx_parts(&tx.other),
134            // Not a deposit transaction request, so missing at least sourceHash, mint, and
135            // isSystemTx
136            _ => Err(vec!["sourceHash", "mint", "isSystemTx"]),
137        }
138    }
139
140    /// Returns the minimal transaction type this request can be converted into based on the fields
141    /// that are set. See [`TransactionRequest::preferred_type`].
142    pub fn preferred_type(&self) -> FoundryTxType {
143        match self {
144            Self::Ethereum(tx) => tx.preferred_type().into(),
145            #[cfg(feature = "base")]
146            Self::Base(_) => FoundryTxType::Eip8130,
147            #[cfg(feature = "optimism")]
148            Self::Op(tx) if tx.inner.transaction_type == Some(POST_EXEC_TX_TYPE_ID) => {
149                FoundryTxType::PostExec
150            }
151            #[cfg(any(feature = "base", feature = "optimism"))]
152            Self::Op(_) => FoundryTxType::Deposit,
153            Self::Tempo(_) => FoundryTxType::Tempo,
154        }
155    }
156
157    /// Check if all necessary keys are present to build a 4844 transaction,
158    /// returning a list of keys that are missing.
159    ///
160    /// **NOTE:** Inner [`TransactionRequest::complete_4844`] method but "sidecar" key is filtered
161    /// from error.
162    pub fn complete_4844(&self) -> Result<(), Vec<&'static str>> {
163        match self.as_ref().complete_4844() {
164            Ok(()) => Ok(()),
165            Err(missing) => {
166                let filtered: Vec<_> =
167                    missing.into_iter().filter(|&key| key != "sidecar").collect();
168                if filtered.is_empty() { Ok(()) } else { Err(filtered) }
169            }
170        }
171    }
172
173    /// Check if all necessary keys are present to build a Deposit transaction, returning a list of
174    /// keys that are missing.
175    #[cfg(any(feature = "base", feature = "optimism"))]
176    pub fn complete_deposit(&self) -> Result<(), Vec<&'static str>> {
177        self.get_deposit_tx_parts().map(|_| ())
178    }
179
180    /// Check if all necessary keys are present to build a Tempo transaction, returning a list of
181    /// keys that are missing.
182    pub fn complete_tempo(&self) -> Result<(), Vec<&'static str>> {
183        match self {
184            Self::Tempo(tx) => tx.complete_type(TempoTxType::AA).map(|_| ()),
185            // Not a Tempo transaction request, so missing at least feeToken and nonceKey
186            _ => Err(vec!["feeToken", "nonceKey"]),
187        }
188    }
189
190    fn check_type(&self, ty: FoundryTxType) -> Result<(), Vec<&'static str>> {
191        match ty {
192            FoundryTxType::Legacy => self.as_ref().complete_legacy(),
193            FoundryTxType::Eip2930 => self.as_ref().complete_2930(),
194            FoundryTxType::Eip1559 => self.as_ref().complete_1559(),
195            FoundryTxType::Eip4844 => self.as_ref().complete_4844(),
196            FoundryTxType::Eip7702 => self.as_ref().complete_7702(),
197            #[cfg(any(feature = "base", feature = "optimism"))]
198            FoundryTxType::Deposit => self.complete_deposit(),
199            #[cfg(feature = "optimism")]
200            FoundryTxType::PostExec => Err(vec!["not implemented for post-exec tx"]),
201            #[cfg(feature = "base")]
202            FoundryTxType::Eip8130 => {
203                Err(vec!["EIP-8130 requires a signed raw transaction envelope"])
204            }
205            FoundryTxType::Tempo => self.complete_tempo(),
206        }
207    }
208
209    /// Check if all necessary keys are present to build a transaction.
210    ///
211    /// # Returns
212    ///
213    /// - Ok(type) if all necessary keys are present to build the preferred type.
214    /// - Err((type, missing)) if some keys are missing to build the preferred type.
215    pub fn missing_keys(&self) -> Result<FoundryTxType, (FoundryTxType, Vec<&'static str>)> {
216        let pref = self.preferred_type();
217        let result = if pref.is_eip4844() { self.complete_4844() } else { self.check_type(pref) };
218        if let Err(missing) = result { Err((pref, missing)) } else { Ok(pref) }
219    }
220
221    /// Build a typed transaction from this request.
222    ///
223    /// Converts the request into a `FoundryTypedTx`, handling all Ethereum and OP-stack transaction
224    /// types.
225    pub fn build_typed_tx(self) -> Result<FoundryTypedTx, Self> {
226        #[cfg(feature = "base")]
227        if self.is_base() {
228            return Err(self);
229        }
230        #[cfg(any(feature = "base", feature = "optimism"))]
231        if let Ok(deposit_tx_parts) = self.get_deposit_tx_parts() {
232            // Build deposit transaction
233            return Ok(FoundryTypedTx::Deposit(TxDeposit {
234                from: self.from().unwrap_or_default(),
235                source_hash: deposit_tx_parts.source_hash,
236                to: self.kind().unwrap_or_default(),
237                mint: deposit_tx_parts.mint.unwrap_or_default(),
238                value: self.value().unwrap_or_default(),
239                gas_limit: self.gas_limit().unwrap_or_default(),
240                is_system_transaction: deposit_tx_parts.is_system_transaction,
241                input: self.input().cloned().unwrap_or_default(),
242            }));
243        }
244        if self.complete_tempo().is_ok()
245            && let Self::Tempo(tx_req) = self
246        {
247            // Build Tempo transaction
248            Ok(FoundryTypedTx::Tempo(
249                tx_req.build_aa().map_err(|e| Self::Tempo(Box::new(e.into_value())))?,
250            ))
251        } else if self.as_ref().has_eip4844_fields() && self.blob_sidecar().is_none() {
252            // if request has eip4844 fields but no blob sidecar (neither eip4844 nor eip7594
253            // format), try to build to eip4844 without sidecar
254            self.into_inner()
255                .build_4844_without_sidecar()
256                .map_err(|e| Self::Ethereum(e.into_value()))
257                .map(|tx| FoundryTypedTx::Eip4844(tx.into()))
258        } else {
259            // Use the inner transaction request to build EthereumTypedTransaction
260            let typed_tx = self.into_inner().build_typed_tx().map_err(Self::Ethereum)?;
261            // Convert EthereumTypedTransaction to FoundryTypedTx
262            Ok(match typed_tx {
263                EthereumTypedTransaction::Legacy(tx) => FoundryTypedTx::Legacy(tx),
264                EthereumTypedTransaction::Eip2930(tx) => FoundryTypedTx::Eip2930(tx),
265                EthereumTypedTransaction::Eip1559(tx) => FoundryTypedTx::Eip1559(tx),
266                EthereumTypedTransaction::Eip4844(tx) => FoundryTypedTx::Eip4844(tx),
267                EthereumTypedTransaction::Eip7702(tx) => FoundryTypedTx::Eip7702(tx),
268            })
269        }
270    }
271}
272
273impl Default for FoundryTransactionRequest {
274    fn default() -> Self {
275        Self::Ethereum(TransactionRequest::default())
276    }
277}
278
279impl Serialize for FoundryTransactionRequest {
280    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
281    where
282        S: serde::Serializer,
283    {
284        match self {
285            Self::Ethereum(tx) => tx.serialize(serializer),
286            #[cfg(feature = "base")]
287            Self::Base(tx) => tx.serialize(serializer),
288            #[cfg(any(feature = "base", feature = "optimism"))]
289            Self::Op(tx) => tx.serialize(serializer),
290            Self::Tempo(tx) => tx.serialize(serializer),
291        }
292    }
293}
294
295impl<'de> Deserialize<'de> for FoundryTransactionRequest {
296    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
297    where
298        D: serde::Deserializer<'de>,
299    {
300        WithOtherFields::<TransactionRequest>::deserialize(deserializer)?
301            .try_into()
302            .map_err(serde::de::Error::custom)
303    }
304}
305
306impl AsRef<TransactionRequest> for FoundryTransactionRequest {
307    fn as_ref(&self) -> &TransactionRequest {
308        match self {
309            Self::Ethereum(tx) => tx,
310            #[cfg(feature = "base")]
311            Self::Base(tx) => tx.as_ref(),
312            #[cfg(any(feature = "base", feature = "optimism"))]
313            Self::Op(tx) => tx,
314            Self::Tempo(tx) => tx.as_ref(),
315        }
316    }
317}
318
319impl AsMut<TransactionRequest> for FoundryTransactionRequest {
320    fn as_mut(&mut self) -> &mut TransactionRequest {
321        match self {
322            Self::Ethereum(tx) => tx,
323            #[cfg(feature = "base")]
324            Self::Base(tx) => tx.as_mut(),
325            #[cfg(any(feature = "base", feature = "optimism"))]
326            Self::Op(tx) => tx,
327            Self::Tempo(tx) => tx.as_mut(),
328        }
329    }
330}
331
332impl TryFrom<WithOtherFields<TransactionRequest>> for FoundryTransactionRequest {
333    type Error = serde_json::Error;
334
335    fn try_from(tx: WithOtherFields<TransactionRequest>) -> Result<Self, Self::Error> {
336        #[derive(Deserialize)]
337        struct NonZeroQuantity(
338            #[serde(
339                with = "tempo_primitives::transaction::key_authorization::serde_nonzero_quantity_opt"
340            )]
341            Option<NonZeroU64>,
342        );
343
344        #[cfg(feature = "base")]
345        {
346            let present = |field: &str| tx.other.get(field).is_some_and(|value| !value.is_null());
347            let base_fields =
348                ["accountChanges", "metadata", "sender", "senderAuth", "payer", "payerAuth"]
349                    .iter()
350                    .any(|field| present(field));
351            let phased_calls = tx
352                .other
353                .get("calls")
354                .and_then(serde_json::Value::as_array)
355                .is_some_and(|calls| calls.first().is_some_and(serde_json::Value::is_array));
356            let explicit_base = tx.transaction_type == Some(EIP8130_TRANSACTION_TYPE);
357            if base_fields || phased_calls || explicit_base {
358                if tx.transaction_type.is_some() && !explicit_base {
359                    return Err(serde::de::Error::custom(
360                        "Base fields conflict with transaction type",
361                    ));
362                }
363                if TEMPO_REQUEST_FIELDS.iter().any(|field| {
364                    !matches!(*field, "nonceKey" | "calls" | "validBefore" | "validAfter")
365                        && present(field)
366                }) {
367                    return Err(serde::de::Error::custom(
368                        "conflicting Base and Tempo request fields",
369                    ));
370                }
371                let mut base =
372                    serde_json::from_value::<BaseTransactionRequest>(serde_json::to_value(&tx)?)?;
373                base.as_mut().transaction_type = Some(EIP8130_TRANSACTION_TYPE);
374                return Ok(Self::Base(base));
375            }
376        }
377
378        if tx.transaction_type == Some(TEMPO_TX_TYPE_ID)
379            || TEMPO_REQUEST_FIELDS.iter().any(|field| {
380                tx.other.get(*field).is_some_and(|value| {
381                    !value.is_null()
382                        && !matches!(
383                            (*field, value),
384                            (
385                                "calls" | "aaAuthorizationList",
386                                serde_json::Value::Array(values)
387                            ) if values.is_empty()
388                        )
389                })
390            })
391        {
392            let mut tempo_tx_req: TempoTransactionRequest = tx.inner.into();
393            tempo_tx_req.fee_token =
394                tx.other.get_deserialized::<Option<Address>>("feeToken").transpose()?.flatten();
395            tempo_tx_req.nonce_key =
396                tx.other.get_deserialized::<Option<U256>>("nonceKey").transpose()?.flatten();
397            tempo_tx_req.calls =
398                tx.other.get_deserialized::<Vec<Call>>("calls").transpose()?.unwrap_or_default();
399            tempo_tx_req.key_type = tx
400                .other
401                .get_deserialized::<Option<SignatureType>>("keyType")
402                .transpose()?
403                .flatten();
404            tempo_tx_req.key_data =
405                tx.other.get_deserialized::<Option<_>>("keyData").transpose()?.flatten();
406            tempo_tx_req.key_id =
407                tx.other.get_deserialized::<Option<_>>("keyId").transpose()?.flatten();
408            tempo_tx_req.tempo_authorization_list = tx
409                .other
410                .get_deserialized::<Vec<TempoSignedAuthorization>>("aaAuthorizationList")
411                .transpose()?
412                .unwrap_or_default();
413            tempo_tx_req.key_authorization = tx
414                .other
415                .get_deserialized::<Option<SignedKeyAuthorization>>("keyAuthorization")
416                .transpose()?
417                .flatten();
418            tempo_tx_req.valid_before = tx
419                .other
420                .get_deserialized::<NonZeroQuantity>("validBefore")
421                .transpose()?
422                .and_then(|value| value.0);
423            tempo_tx_req.valid_after = tx
424                .other
425                .get_deserialized::<NonZeroQuantity>("validAfter")
426                .transpose()?
427                .and_then(|value| value.0);
428            tempo_tx_req.fee_payer_signature =
429                tx.other.get_deserialized::<Option<_>>("feePayerSignature").transpose()?.flatten();
430            return Ok(Self::Tempo(Box::new(tempo_tx_req)));
431        }
432        #[cfg(all(feature = "base", not(feature = "optimism")))]
433        if tx.transaction_type == Some(DEPOSIT_TX_TYPE_ID)
434            || get_deposit_tx_parts(&tx.other).is_ok()
435        {
436            return Ok(Self::Op(tx));
437        }
438        #[cfg(feature = "optimism")]
439        if tx.transaction_type == Some(DEPOSIT_TX_TYPE_ID)
440            || tx.transaction_type == Some(POST_EXEC_TX_TYPE_ID)
441            || get_deposit_tx_parts(&tx.other).is_ok()
442        {
443            return Ok(Self::Op(tx));
444        }
445        Ok(Self::Ethereum(tx.into_inner()))
446    }
447}
448
449impl From<TransactionRequest> for FoundryTransactionRequest {
450    fn from(tx: TransactionRequest) -> Self {
451        Self::Ethereum(tx)
452    }
453}
454
455impl From<FoundryTypedTx> for FoundryTransactionRequest {
456    fn from(tx: FoundryTypedTx) -> Self {
457        match tx {
458            FoundryTypedTx::Legacy(tx) => Self::Ethereum(Into::<TransactionRequest>::into(tx)),
459            FoundryTypedTx::Eip2930(tx) => Self::Ethereum(Into::<TransactionRequest>::into(tx)),
460            FoundryTypedTx::Eip1559(tx) => Self::Ethereum(Into::<TransactionRequest>::into(tx)),
461            FoundryTypedTx::Eip4844(tx) => Self::Ethereum(Into::<TransactionRequest>::into(tx)),
462            FoundryTypedTx::Eip7702(tx) => Self::Ethereum(Into::<TransactionRequest>::into(tx)),
463            #[cfg(any(feature = "base", feature = "optimism"))]
464            FoundryTypedTx::Deposit(tx) => {
465                let other = OtherFields::from_iter([
466                    ("sourceHash", serde_json::to_value(tx.source_hash).unwrap()),
467                    ("mint", serde_json::to_value(U256::from(tx.mint)).unwrap()),
468                    ("isSystemTx", serde_json::to_value(tx.is_system_transaction).unwrap()),
469                ]);
470                WithOtherFields { inner: Into::<TransactionRequest>::into(tx), other }
471                    .try_into()
472                    .expect("valid deposit transaction request")
473            }
474            #[cfg(feature = "optimism")]
475            FoundryTypedTx::PostExec(tx) => WithOtherFields {
476                inner: Into::<TransactionRequest>::into(tx),
477                other: OtherFields::default(),
478            }
479            .try_into()
480            .expect("valid OP post-exec transaction request"),
481            #[cfg(feature = "base")]
482            FoundryTypedTx::Eip8130(tx) => {
483                Self::Base(super::base::simulation_request(tx, None, None, None))
484            }
485            FoundryTypedTx::Tempo(tx) => Self::Tempo(Box::new(tx.into())),
486        }
487    }
488}
489
490impl From<FoundryTxEnvelope> for FoundryTransactionRequest {
491    fn from(tx: FoundryTxEnvelope) -> Self {
492        #[cfg(feature = "base")]
493        if let FoundryTxEnvelope::Eip8130(tx) = tx {
494            let from = tx.recover_sender().ok();
495            let sender_auth = Some(tx.sender_auth().clone());
496            let payer_auth = Some(tx.payer_auth().clone());
497            return Self::Base(super::base::simulation_request(
498                tx.into_tx(),
499                from,
500                sender_auth,
501                payer_auth,
502            ));
503        }
504        FoundryTypedTx::from(tx).into()
505    }
506}
507
508#[cfg(not(feature = "optimism"))]
509impl From<alloy_rpc_types_eth::Transaction<FoundryTxEnvelope>> for FoundryTransactionRequest {
510    fn from(tx: alloy_rpc_types_eth::Transaction<FoundryTxEnvelope>) -> Self {
511        tx.inner.into_inner().into()
512    }
513}
514
515// TransactionBuilder trait implementation for FoundryNetwork
516impl TransactionBuilder for FoundryTransactionRequest {
517    fn chain_id(&self) -> Option<ChainId> {
518        self.as_ref().chain_id
519    }
520
521    fn set_chain_id(&mut self, chain_id: ChainId) {
522        self.as_mut().chain_id = Some(chain_id);
523    }
524
525    fn nonce(&self) -> Option<u64> {
526        self.as_ref().nonce
527    }
528
529    fn set_nonce(&mut self, nonce: u64) {
530        self.as_mut().nonce = Some(nonce);
531    }
532
533    fn take_nonce(&mut self) -> Option<u64> {
534        self.as_mut().nonce.take()
535    }
536
537    fn input(&self) -> Option<&alloy_primitives::Bytes> {
538        self.as_ref().input.input()
539    }
540
541    fn set_input<T: Into<alloy_primitives::Bytes>>(&mut self, input: T) {
542        self.as_mut().input.input = Some(input.into());
543    }
544
545    fn set_input_kind<T: Into<alloy_primitives::Bytes>>(
546        &mut self,
547        input: T,
548        kind: TransactionInputKind,
549    ) {
550        let inner = self.as_mut();
551        match kind {
552            TransactionInputKind::Input => inner.input.input = Some(input.into()),
553            TransactionInputKind::Data => inner.input.data = Some(input.into()),
554            TransactionInputKind::Both => {
555                let bytes = input.into();
556                inner.input.input = Some(bytes.clone());
557                inner.input.data = Some(bytes);
558            }
559        }
560    }
561
562    fn from(&self) -> Option<Address> {
563        self.as_ref().from
564    }
565
566    fn set_from(&mut self, from: Address) {
567        self.as_mut().from = Some(from);
568    }
569
570    fn kind(&self) -> Option<TxKind> {
571        self.as_ref().to
572    }
573
574    fn clear_kind(&mut self) {
575        self.as_mut().to = None;
576    }
577
578    fn set_kind(&mut self, kind: TxKind) {
579        self.as_mut().to = Some(kind);
580    }
581
582    fn value(&self) -> Option<U256> {
583        self.as_ref().value
584    }
585
586    fn set_value(&mut self, value: U256) {
587        self.as_mut().value = Some(value);
588    }
589
590    fn gas_price(&self) -> Option<u128> {
591        self.as_ref().gas_price
592    }
593
594    fn set_gas_price(&mut self, gas_price: u128) {
595        self.as_mut().gas_price = Some(gas_price);
596    }
597
598    fn max_fee_per_gas(&self) -> Option<u128> {
599        self.as_ref().max_fee_per_gas
600    }
601
602    fn set_max_fee_per_gas(&mut self, max_fee_per_gas: u128) {
603        self.as_mut().max_fee_per_gas = Some(max_fee_per_gas);
604    }
605
606    fn max_priority_fee_per_gas(&self) -> Option<u128> {
607        self.as_ref().max_priority_fee_per_gas
608    }
609
610    fn set_max_priority_fee_per_gas(&mut self, max_priority_fee_per_gas: u128) {
611        self.as_mut().max_priority_fee_per_gas = Some(max_priority_fee_per_gas);
612    }
613
614    fn gas_limit(&self) -> Option<u64> {
615        self.as_ref().gas
616    }
617
618    fn set_gas_limit(&mut self, gas_limit: u64) {
619        self.as_mut().gas = Some(gas_limit);
620    }
621
622    fn access_list(&self) -> Option<&AccessList> {
623        self.as_ref().access_list.as_ref()
624    }
625
626    fn set_access_list(&mut self, access_list: AccessList) {
627        self.as_mut().access_list = Some(access_list);
628    }
629}
630
631impl NetworkTransactionBuilder<FoundryNetwork> for FoundryTransactionRequest {
632    fn complete_type(&self, ty: FoundryTxType) -> Result<(), Vec<&'static str>> {
633        self.check_type(ty)
634    }
635
636    fn can_submit(&self) -> bool {
637        self.from().is_some()
638    }
639
640    fn can_build(&self) -> bool {
641        #[cfg(feature = "base")]
642        if self.is_base() {
643            return false;
644        }
645        if self.as_ref().can_build() || self.complete_tempo().is_ok() {
646            return true;
647        }
648        #[cfg(any(feature = "base", feature = "optimism"))]
649        if self.complete_deposit().is_ok() {
650            return true;
651        }
652        false
653    }
654
655    fn output_tx_type(&self) -> FoundryTxType {
656        self.preferred_type()
657    }
658
659    fn output_tx_type_checked(&self) -> Option<FoundryTxType> {
660        let pref = self.preferred_type();
661        self.check_type(pref).ok()?;
662        Some(pref)
663    }
664
665    /// Prepares [`FoundryTransactionRequest`] by trimming conflicting fields, and filling with
666    /// default values the mandatory fields.
667    fn prep_for_submission(&mut self) {
668        #[cfg(feature = "base")]
669        if self.is_base() {
670            return;
671        }
672        let preferred_type = self.preferred_type();
673        let inner = self.as_mut();
674        inner.transaction_type = Some(preferred_type as u8);
675        inner.gas.is_none().then(|| inner.set_gas_limit(Default::default()));
676        let is_deposit = {
677            #[cfg(any(feature = "base", feature = "optimism"))]
678            {
679                preferred_type.is_deposit()
680            }
681            #[cfg(not(any(feature = "base", feature = "optimism")))]
682            {
683                false
684            }
685        };
686        if !is_deposit && !preferred_type.is_tempo() {
687            inner.trim_conflicting_keys();
688            inner.populate_blob_hashes();
689        }
690        if !is_deposit {
691            inner.nonce.is_none().then(|| inner.set_nonce(Default::default()));
692        }
693        if preferred_type.is_legacy() || preferred_type.is_eip2930() {
694            inner.gas_price.is_none().then(|| inner.set_gas_price(Default::default()));
695        }
696        if preferred_type.is_eip2930() {
697            inner.access_list.is_none().then(|| inner.set_access_list(Default::default()));
698        }
699        if preferred_type.is_eip1559()
700            || preferred_type.is_eip4844()
701            || preferred_type.is_eip7702()
702            || preferred_type.is_tempo()
703        {
704            inner
705                .max_priority_fee_per_gas
706                .is_none()
707                .then(|| inner.set_max_priority_fee_per_gas(Default::default()));
708            inner.max_fee_per_gas.is_none().then(|| inner.set_max_fee_per_gas(Default::default()));
709        }
710        if preferred_type.is_eip4844() {
711            inner
712                .as_ref()
713                .max_fee_per_blob_gas()
714                .is_none()
715                .then(|| inner.as_mut().set_max_fee_per_blob_gas(Default::default()));
716        }
717    }
718
719    fn build_unsigned(self) -> BuildResult<FoundryTypedTx, FoundryNetwork> {
720        if let Err((tx_type, missing)) = self.missing_keys() {
721            return Err(TransactionBuilderError::InvalidTransactionRequest(tx_type, missing)
722                .into_unbuilt(self));
723        }
724        Ok(self.build_typed_tx().expect("checked by missing_keys"))
725    }
726
727    async fn build<W: NetworkWallet<FoundryNetwork>>(
728        self,
729        wallet: &W,
730    ) -> Result<FoundryTxEnvelope, TransactionBuilderError<FoundryNetwork>> {
731        Ok(wallet.sign_request(self).await?)
732    }
733}
734
735impl TransactionBuilder4844 for FoundryTransactionRequest {
736    fn max_fee_per_blob_gas(&self) -> Option<u128> {
737        self.as_ref().max_fee_per_blob_gas()
738    }
739
740    fn set_max_fee_per_blob_gas(&mut self, max_fee_per_blob_gas: u128) {
741        self.as_mut().set_max_fee_per_blob_gas(max_fee_per_blob_gas);
742    }
743
744    fn blob_sidecar(&self) -> Option<&BlobTransactionSidecarVariant> {
745        self.as_ref().blob_sidecar()
746    }
747
748    fn set_blob_sidecar(&mut self, sidecar: BlobTransactionSidecarVariant) {
749        self.as_mut().set_blob_sidecar(sidecar);
750    }
751}
752
753#[cfg(test)]
754mod tests {
755    use super::*;
756    use alloy_primitives::{B256, Bytes, Signature};
757    use tempo_primitives::{
758        TempoSignature, TempoTransaction,
759        transaction::{Authorization, KeyAuthorization, PrimitiveSignature},
760    };
761
762    fn default_tx_req() -> TransactionRequest {
763        TransactionRequest::default()
764            .with_to(Address::random())
765            .with_nonce(1)
766            .with_value(U256::from(1000000))
767            .with_gas_limit(1000000)
768            .with_max_fee_per_gas(1000000)
769            .with_max_priority_fee_per_gas(1000000)
770    }
771
772    #[test]
773    fn request_predicates() {
774        let ethereum = FoundryTransactionRequest::default();
775        assert!(ethereum.is_ethereum());
776        assert!(!ethereum.is_tempo());
777
778        let tempo = FoundryTransactionRequest::Tempo(Box::default());
779        assert!(tempo.is_tempo());
780        assert!(!tempo.is_ethereum());
781
782        #[cfg(any(feature = "base", feature = "optimism"))]
783        {
784            let op = FoundryTransactionRequest::Op(WithOtherFields::default());
785            assert!(op.is_op());
786            assert!(!op.is_ethereum());
787            assert!(!op.is_tempo());
788        }
789    }
790
791    #[test]
792    fn test_routing_ethereum_default() {
793        let tx = default_tx_req();
794        let req: FoundryTransactionRequest = WithOtherFields::new(tx).try_into().unwrap();
795
796        assert!(req.is_ethereum());
797        assert!(matches!(req.build_unsigned(), Ok(FoundryTypedTx::Eip1559(_))));
798    }
799
800    #[test]
801    fn test_routing_tempo_by_fee_token() {
802        let tx = default_tx_req();
803        let mut other = OtherFields::default();
804        other.insert("feeToken".to_string(), serde_json::to_value(Address::random()).unwrap());
805
806        let req: FoundryTransactionRequest =
807            WithOtherFields { inner: tx, other }.try_into().unwrap();
808
809        assert!(req.is_tempo());
810        assert!(matches!(req.build_unsigned(), Ok(FoundryTypedTx::Tempo(_))));
811    }
812
813    #[cfg(feature = "base")]
814    #[test]
815    fn base_and_tempo_request_routing() {
816        let call = serde_json::json!({ "to": Address::ZERO, "data": "0x", "value": "0x0" });
817        for (value, expected_type) in [
818            (serde_json::json!({"calls": [call]}), FoundryTxType::Tempo),
819            (serde_json::json!({"nonceKey": "0x1"}), FoundryTxType::Tempo),
820            (serde_json::json!({"validBefore": "0x123"}), FoundryTxType::Tempo),
821            (serde_json::json!({"calls": []}), FoundryTxType::Eip1559),
822            (serde_json::json!({"calls": [[call]], "validBefore": 123}), FoundryTxType::Eip8130),
823            (serde_json::json!({"type": "0x79"}), FoundryTxType::Eip8130),
824            (serde_json::json!({"type": "0x79", "nonceKey": "0x1"}), FoundryTxType::Eip8130),
825            (serde_json::json!({"type": "0x76", "calls": [call]}), FoundryTxType::Tempo),
826            (
827                serde_json::json!({"sender": Address::ZERO, "feeToken": null}),
828                FoundryTxType::Eip8130,
829            ),
830        ] {
831            let request: FoundryTransactionRequest = serde_json::from_value(value.clone())
832                .unwrap_or_else(|err| panic!("{value}: {err}"));
833            assert_eq!(request.preferred_type(), expected_type, "{value}");
834            if expected_type == FoundryTxType::Eip8130 {
835                assert!(request.is_base(), "{value}");
836                assert!(!request.can_build(), "{value}");
837            }
838            let roundtrip: FoundryTransactionRequest =
839                serde_json::from_value(serde_json::to_value(&request).unwrap()).unwrap();
840            assert_eq!(request, roundtrip);
841        }
842        for value in [
843            serde_json::json!({"type": "0x76", "calls": [[call]]}),
844            serde_json::json!({"type": "0x79", "feeToken": Address::ZERO}),
845            serde_json::json!({"type": "0x2", "sender": Address::ZERO}),
846            serde_json::json!({"type": "0x79", "calls": [call]}),
847        ] {
848            assert!(
849                serde_json::from_value::<FoundryTransactionRequest>(value.clone()).is_err(),
850                "{value}"
851            );
852        }
853    }
854
855    #[test]
856    fn test_routing_serialized_non_aa_tempo_request_to_ethereum() {
857        let request = TempoTransactionRequest { inner: default_tx_req(), ..Default::default() };
858        let request = serde_json::from_value::<WithOtherFields<TransactionRequest>>(
859            serde_json::to_value(request).unwrap(),
860        )
861        .unwrap();
862
863        let request = FoundryTransactionRequest::try_from(request).unwrap();
864
865        assert!(matches!(request, FoundryTransactionRequest::Ethereum(_)));
866        assert!(matches!(request.build_unsigned(), Ok(FoundryTypedTx::Eip1559(_))));
867    }
868
869    #[test]
870    #[cfg(any(feature = "base", feature = "optimism"))]
871    fn test_routing_op_by_deposit_fields() {
872        let tx = default_tx_req();
873        let mut other = OtherFields::default();
874        other.insert("sourceHash".to_string(), serde_json::to_value(B256::ZERO).unwrap());
875        other.insert("mint".to_string(), serde_json::to_value(U256::from(1000)).unwrap());
876        other.insert("isSystemTx".to_string(), serde_json::to_value(false).unwrap());
877
878        let req: FoundryTransactionRequest =
879            WithOtherFields { inner: tx, other }.try_into().unwrap();
880
881        assert!(req.is_op());
882        assert!(matches!(req.build_unsigned(), Ok(FoundryTypedTx::Deposit(_))));
883    }
884
885    #[test]
886    fn test_op_incomplete_routes_to_ethereum() {
887        let tx = default_tx_req();
888        let mut other = OtherFields::default();
889        // Only provide 2 of 3 required Op fields
890        other.insert("sourceHash".to_string(), serde_json::to_value(B256::ZERO).unwrap());
891        other.insert("mint".to_string(), serde_json::to_value(U256::from(1000)).unwrap());
892
893        let req: FoundryTransactionRequest =
894            WithOtherFields { inner: tx, other }.try_into().unwrap();
895
896        assert!(req.is_ethereum());
897        assert!(matches!(req.build_unsigned(), Ok(FoundryTypedTx::Eip1559(_))));
898    }
899
900    #[test]
901    fn test_ethereum_with_unrelated_other_fields() {
902        let tx = default_tx_req();
903        let mut other = OtherFields::default();
904        other.insert("anotherField".to_string(), serde_json::to_value(123).unwrap());
905
906        let req: FoundryTransactionRequest =
907            WithOtherFields { inner: tx, other }.try_into().unwrap();
908
909        assert!(req.is_ethereum());
910        assert!(matches!(req.build_unsigned(), Ok(FoundryTypedTx::Eip1559(_))));
911    }
912
913    #[test]
914    fn test_serialization_ethereum() {
915        let tx = default_tx_req();
916        let original: FoundryTransactionRequest = WithOtherFields::new(tx).try_into().unwrap();
917
918        let serialized = serde_json::to_string(&original).unwrap();
919        let deserialized: FoundryTransactionRequest = serde_json::from_str(&serialized).unwrap();
920
921        assert!(deserialized.is_ethereum());
922    }
923
924    #[test]
925    #[cfg(any(feature = "base", feature = "optimism"))]
926    fn test_serialization_op() {
927        let tx = default_tx_req();
928        let mut other = OtherFields::default();
929        other.insert("sourceHash".to_string(), serde_json::to_value(B256::ZERO).unwrap());
930        other.insert("mint".to_string(), serde_json::to_value(U256::from(1000)).unwrap());
931        other.insert("isSystemTx".to_string(), serde_json::to_value(false).unwrap());
932
933        let original: FoundryTransactionRequest =
934            WithOtherFields { inner: tx, other }.try_into().unwrap();
935
936        let serialized = serde_json::to_string(&original).unwrap();
937        let deserialized: FoundryTransactionRequest = serde_json::from_str(&serialized).unwrap();
938
939        assert!(deserialized.is_op());
940    }
941
942    #[test]
943    fn test_serialization_tempo() {
944        let tx = default_tx_req();
945        let mut other = OtherFields::default();
946        other.insert("feeToken".to_string(), serde_json::to_value(Address::ZERO).unwrap());
947        other.insert("nonceKey".to_string(), serde_json::to_value(U256::from(42)).unwrap());
948
949        let original: FoundryTransactionRequest =
950            WithOtherFields { inner: tx, other }.try_into().unwrap();
951
952        let serialized = serde_json::to_string(&original).unwrap();
953        let deserialized: FoundryTransactionRequest = serde_json::from_str(&serialized).unwrap();
954
955        assert!(deserialized.is_tempo());
956    }
957
958    #[test]
959    fn test_tempo_request_decodes_all_extension_fields() {
960        let signature = Signature::test_signature();
961        let expected = TempoTransactionRequest {
962            inner: TransactionRequest {
963                transaction_type: Some(TEMPO_TX_TYPE_ID),
964                ..default_tx_req()
965            },
966            fee_token: Some(Address::repeat_byte(0x11)),
967            nonce_key: Some(U256::from(42)),
968            calls: vec![Call {
969                to: TxKind::Call(Address::repeat_byte(0x22)),
970                value: U256::from(7),
971                input: Bytes::from_static(&[0xde, 0xad]),
972            }],
973            key_type: Some(SignatureType::WebAuthn),
974            key_data: Some(Bytes::from_static(&[0xbe, 0xef])),
975            key_id: Some(Address::repeat_byte(0x33)),
976            tempo_authorization_list: vec![TempoSignedAuthorization::new_unchecked(
977                Authorization {
978                    chain_id: U256::from(4217),
979                    address: Address::repeat_byte(0x44),
980                    nonce: 3,
981                },
982                TempoSignature::default(),
983            )],
984            key_authorization: Some(
985                KeyAuthorization::unrestricted(
986                    4217,
987                    SignatureType::Secp256k1,
988                    Address::repeat_byte(0x55),
989                )
990                .into_signed(PrimitiveSignature::Secp256k1(signature)),
991            ),
992            valid_before: NonZeroU64::new(100),
993            valid_after: NonZeroU64::new(10),
994            fee_payer_signature: Some(signature),
995        };
996        let request = serde_json::from_value::<WithOtherFields<TransactionRequest>>(
997            serde_json::to_value(&expected).unwrap(),
998        )
999        .unwrap();
1000
1001        let decoded = FoundryTransactionRequest::try_from(request).unwrap();
1002
1003        let FoundryTransactionRequest::Tempo(decoded) = decoded else { panic!() };
1004        assert_eq!(*decoded, expected);
1005    }
1006
1007    #[test]
1008    fn test_malformed_tempo_field_is_rejected() {
1009        let mut other = OtherFields::default();
1010        other.insert("nonceKey".to_string(), serde_json::json!("not a quantity"));
1011
1012        let result =
1013            FoundryTransactionRequest::new(WithOtherFields { inner: default_tx_req(), other });
1014
1015        assert!(result.is_err());
1016    }
1017
1018    #[test]
1019    fn test_tempo_validity_uses_rpc_quantities() {
1020        let request = serde_json::from_value::<FoundryTransactionRequest>(serde_json::json!({
1021            "type": "0x76",
1022            "validBefore": "0x64",
1023            "validAfter": "0xa",
1024        }))
1025        .unwrap();
1026
1027        let FoundryTransactionRequest::Tempo(request) = request else { panic!() };
1028        assert_eq!(request.valid_before, NonZeroU64::new(100));
1029        assert_eq!(request.valid_after, NonZeroU64::new(10));
1030    }
1031
1032    #[test]
1033    fn test_tempo_typed_request_roundtrip_preserves_fields() {
1034        let tx = TempoTransaction {
1035            chain_id: 4217,
1036            nonce: 7,
1037            fee_payer_signature: None,
1038            valid_before: NonZeroU64::new(100),
1039            valid_after: NonZeroU64::new(10),
1040            gas_limit: 100_000,
1041            max_fee_per_gas: 20,
1042            max_priority_fee_per_gas: 2,
1043            fee_token: Some(Address::random()),
1044            access_list: Default::default(),
1045            calls: vec![Call {
1046                to: TxKind::Call(Address::random()),
1047                value: U256::from(42),
1048                input: vec![1, 2, 3].into(),
1049            }],
1050            tempo_authorization_list: Vec::new(),
1051            nonce_key: U256::from(9),
1052            key_authorization: None,
1053        };
1054
1055        let request: FoundryTransactionRequest = FoundryTypedTx::Tempo(tx.clone()).into();
1056        let rebuilt = request.build_unsigned().unwrap();
1057
1058        assert_eq!(rebuilt, FoundryTypedTx::Tempo(tx));
1059    }
1060
1061    #[test]
1062    #[cfg(any(feature = "base", feature = "optimism"))]
1063    fn test_deposit_typed_tx_roundtrip() {
1064        let deposit_tx = TxDeposit {
1065            from: Address::random(),
1066            source_hash: B256::random(),
1067            to: TxKind::Call(Address::random()),
1068            mint: 1000u128,
1069            value: U256::from(500),
1070            gas_limit: 21000,
1071            is_system_transaction: true,
1072            input: Default::default(),
1073        };
1074
1075        let req: FoundryTransactionRequest = FoundryTypedTx::Deposit(deposit_tx.clone()).into();
1076
1077        assert!(req.is_op());
1078
1079        let parts = req.get_deposit_tx_parts().expect("should parse deposit parts");
1080        assert_eq!(parts.source_hash, deposit_tx.source_hash);
1081        assert_eq!(parts.mint, Some(deposit_tx.mint));
1082        assert_eq!(parts.is_system_transaction, deposit_tx.is_system_transaction);
1083    }
1084}