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