Skip to main content

foundry_common/
transactions.rs

1//! Wrappers for transactions.
2
3use alloy_consensus::{Transaction, TxEnvelope, transaction::SignerRecoverable};
4use alloy_eips::eip7702::SignedAuthorization;
5use alloy_network::{AnyTransactionReceipt, Network, TransactionResponse};
6use alloy_primitives::{Address, Bytes, TxKind, U256};
7use alloy_provider::{
8    Provider,
9    network::{AnyNetwork, ReceiptResponse, TransactionBuilder},
10};
11use alloy_rpc_types::{BlockId, TransactionRequest};
12use alloy_serde::WithOtherFields;
13use eyre::Result;
14use foundry_common_fmt::{UIfmt, UIfmtReceiptExt, get_pretty_receipt_attr};
15use serde::{Deserialize, Serialize};
16
17/// Helper type to carry a transaction along with an optional revert reason
18#[derive(Clone, Debug, Serialize, Deserialize)]
19pub struct TransactionReceiptWithRevertReason<N: Network> {
20    /// The underlying transaction receipt
21    #[serde(flatten)]
22    pub receipt: N::ReceiptResponse,
23
24    /// The revert reason string if the transaction status is failed
25    #[serde(skip_serializing_if = "Option::is_none", rename = "revertReason")]
26    pub revert_reason: Option<String>,
27}
28
29impl<N: Network> TransactionReceiptWithRevertReason<N>
30where
31    N::TxEnvelope: Clone,
32    N::ReceiptResponse: UIfmtReceiptExt,
33{
34    /// Updates the revert reason field using `eth_call` and returns an Err variant if the revert
35    /// reason was not successfully updated
36    pub async fn update_revert_reason<P: Provider<N>>(&mut self, provider: &P) -> Result<()> {
37        self.revert_reason = self.fetch_revert_reason(provider).await?;
38        Ok(())
39    }
40
41    async fn fetch_revert_reason<P: Provider<N>>(&self, provider: &P) -> Result<Option<String>> {
42        // If the transaction succeeded, there is no revert reason to fetch
43        if self.receipt.status() {
44            return Ok(None);
45        }
46
47        let transaction = provider
48            .get_transaction_by_hash(self.receipt.transaction_hash())
49            .await
50            .map_err(|err| eyre::eyre!("unable to fetch transaction: {err}"))?
51            .ok_or_else(|| eyre::eyre!("transaction not found"))?;
52
53        if let Some(block_hash) = self.receipt.block_hash() {
54            let mut call_request: N::TransactionRequest = transaction.as_ref().clone().into();
55            call_request.set_from(transaction.from());
56            match provider.call(call_request).block(BlockId::Hash(block_hash.into())).await {
57                Err(e) => return Ok(extract_revert_reason(e.to_string())),
58                Ok(_) => eyre::bail!("no revert reason as transaction succeeded"),
59            }
60        }
61        eyre::bail!("unable to fetch block_hash")
62    }
63}
64
65impl From<AnyTransactionReceipt> for TransactionReceiptWithRevertReason<AnyNetwork> {
66    fn from(receipt: AnyTransactionReceipt) -> Self {
67        Self { receipt, revert_reason: None }
68    }
69}
70
71impl From<TransactionReceiptWithRevertReason<AnyNetwork>> for AnyTransactionReceipt {
72    fn from(receipt_with_reason: TransactionReceiptWithRevertReason<AnyNetwork>) -> Self {
73        receipt_with_reason.receipt
74    }
75}
76
77impl<N: Network> UIfmt for TransactionReceiptWithRevertReason<N>
78where
79    N::ReceiptResponse: UIfmt,
80{
81    fn pretty(&self) -> String {
82        if let Some(revert_reason) = &self.revert_reason {
83            format!(
84                "{}
85revertReason         {}",
86                self.receipt.pretty(),
87                revert_reason
88            )
89        } else {
90            self.receipt.pretty()
91        }
92    }
93}
94
95impl UIfmt for TransactionMaybeSigned {
96    fn pretty(&self) -> String {
97        match self {
98            Self::Signed { tx, .. } => tx.pretty(),
99            Self::Unsigned(tx) => format!(
100                "
101accessList           {}
102chainId              {}
103gasLimit             {}
104gasPrice             {}
105input                {}
106maxFeePerBlobGas     {}
107maxFeePerGas         {}
108maxPriorityFeePerGas {}
109nonce                {}
110to                   {}
111type                 {}
112value                {}",
113                tx.access_list
114                    .as_ref()
115                    .map(|a| a.iter().collect::<Vec<_>>())
116                    .unwrap_or_default()
117                    .pretty(),
118                tx.chain_id.pretty(),
119                tx.gas_limit().unwrap_or_default(),
120                tx.gas_price.pretty(),
121                tx.input.input.pretty(),
122                tx.max_fee_per_blob_gas.pretty(),
123                tx.max_fee_per_gas.pretty(),
124                tx.max_priority_fee_per_gas.pretty(),
125                tx.nonce.pretty(),
126                tx.to.as_ref().map(|a| a.to()).unwrap_or_default().pretty(),
127                tx.transaction_type.unwrap_or_default(),
128                tx.value.pretty(),
129            ),
130        }
131    }
132}
133
134fn extract_revert_reason<S: AsRef<str>>(error_string: S) -> Option<String> {
135    let message_substr = "execution reverted: ";
136    error_string
137        .as_ref()
138        .find(message_substr)
139        .map(|index| error_string.as_ref().split_at(index + message_substr.len()).1.to_string())
140}
141
142/// Returns the `UiFmt::pretty()` formatted attribute of the transaction receipt with revert reason
143pub fn get_pretty_receipt_w_reason_attr<N>(
144    receipt: &TransactionReceiptWithRevertReason<N>,
145    attr: &str,
146) -> Option<String>
147where
148    N: Network,
149    N::ReceiptResponse: UIfmtReceiptExt,
150{
151    // Handle revert reason first, then delegate to the receipt formatting function
152    if matches!(attr, "revertReason" | "revert_reason") {
153        return Some(receipt.revert_reason.pretty());
154    }
155    get_pretty_receipt_attr::<N>(&receipt.receipt, attr)
156}
157
158/// Used for broadcasting transactions
159/// A transaction can either be a [`TransactionRequest`] waiting to be signed
160/// or a [`TxEnvelope`], already signed
161#[derive(Clone, Debug, Serialize, Deserialize)]
162#[serde(untagged)]
163pub enum TransactionMaybeSigned {
164    Signed {
165        #[serde(flatten)]
166        tx: TxEnvelope,
167        from: Address,
168    },
169    Unsigned(WithOtherFields<TransactionRequest>),
170}
171
172impl TransactionMaybeSigned {
173    /// Creates a new (unsigned) transaction for broadcast
174    pub fn new(tx: WithOtherFields<TransactionRequest>) -> Self {
175        Self::Unsigned(tx)
176    }
177
178    /// Creates a new signed transaction for broadcast.
179    pub fn new_signed(
180        tx: TxEnvelope,
181    ) -> core::result::Result<Self, alloy_consensus::crypto::RecoveryError> {
182        let from = tx.recover_signer()?;
183        Ok(Self::Signed { tx, from })
184    }
185
186    pub fn is_unsigned(&self) -> bool {
187        matches!(self, Self::Unsigned(_))
188    }
189
190    pub fn as_unsigned_mut(&mut self) -> Option<&mut WithOtherFields<TransactionRequest>> {
191        match self {
192            Self::Unsigned(tx) => Some(tx),
193            _ => None,
194        }
195    }
196
197    pub fn from(&self) -> Option<Address> {
198        match self {
199            Self::Signed { from, .. } => Some(*from),
200            Self::Unsigned(tx) => tx.from,
201        }
202    }
203
204    pub fn input(&self) -> Option<&Bytes> {
205        match self {
206            Self::Signed { tx, .. } => Some(tx.input()),
207            Self::Unsigned(tx) => tx.input.input(),
208        }
209    }
210
211    pub fn to(&self) -> Option<TxKind> {
212        match self {
213            Self::Signed { tx, .. } => Some(tx.kind()),
214            Self::Unsigned(tx) => tx.to,
215        }
216    }
217
218    pub fn value(&self) -> Option<U256> {
219        match self {
220            Self::Signed { tx, .. } => Some(tx.value()),
221            Self::Unsigned(tx) => tx.value,
222        }
223    }
224
225    pub fn gas(&self) -> Option<u128> {
226        match self {
227            Self::Signed { tx, .. } => Some(tx.gas_limit() as u128),
228            Self::Unsigned(tx) => tx.gas_limit().map(|g| g as u128),
229        }
230    }
231
232    pub fn nonce(&self) -> Option<u64> {
233        match self {
234            Self::Signed { tx, .. } => Some(tx.nonce()),
235            Self::Unsigned(tx) => tx.nonce,
236        }
237    }
238
239    pub fn authorization_list(&self) -> Option<Vec<SignedAuthorization>> {
240        match self {
241            Self::Signed { tx, .. } => tx.authorization_list().map(|auths| auths.to_vec()),
242            Self::Unsigned(tx) => tx.authorization_list.as_deref().map(|auths| auths.to_vec()),
243        }
244        .filter(|auths| !auths.is_empty())
245    }
246}
247
248impl From<TransactionRequest> for TransactionMaybeSigned {
249    fn from(tx: TransactionRequest) -> Self {
250        Self::new(WithOtherFields::new(tx))
251    }
252}
253
254impl TryFrom<TxEnvelope> for TransactionMaybeSigned {
255    type Error = alloy_consensus::crypto::RecoveryError;
256
257    fn try_from(tx: TxEnvelope) -> core::result::Result<Self, Self::Error> {
258        Self::new_signed(tx)
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn test_extract_revert_reason() {
268        let error_string_1 = "server returned an error response: error code 3: execution reverted: Transaction too old";
269        let error_string_2 = "server returned an error response: error code 3: Invalid signature";
270
271        assert_eq!(extract_revert_reason(error_string_1), Some("Transaction too old".to_string()));
272        assert_eq!(extract_revert_reason(error_string_2), None);
273    }
274}