Skip to main content

forge_script/
receipts.rs

1use alloy_chains::{Chain, NamedChain};
2use alloy_network::{Network, ReceiptResponse};
3use alloy_primitives::{Address, TxHash, U256, utils::format_units};
4use alloy_provider::{
5    PendingTransactionBuilder, PendingTransactionError, Provider, RootProvider, WatchTxError,
6};
7use alloy_rpc_types::TransactionReceipt;
8use eyre::{Result, eyre};
9use forge_script_sequence::ScriptSequence;
10use foundry_common::{retry, retry::RetryError, shell};
11use std::time::Duration;
12
13/// Helper trait providing `contract_address` setter for generic `ReceiptResponse`
14pub trait FoundryReceiptResponse {
15    /// Sets address of the created contract, or `None` if the transaction was not a deployment.
16    fn set_contract_address(&mut self, contract_address: Address);
17}
18
19impl FoundryReceiptResponse for TransactionReceipt {
20    fn set_contract_address(&mut self, contract_address: Address) {
21        self.contract_address = Some(contract_address);
22    }
23}
24
25/// Marker error type for pending receipts
26#[derive(Debug, thiserror::Error)]
27#[error(
28    "Received a pending receipt for {tx_hash}, but transaction is still known to the node, retrying"
29)]
30pub struct PendingReceiptError {
31    pub tx_hash: TxHash,
32}
33
34/// Convenience enum for internal signalling of transaction status
35pub enum TxStatus<R: ReceiptResponse> {
36    Dropped,
37    Success(R),
38    Revert(R),
39}
40
41impl<R: ReceiptResponse> From<R> for TxStatus<R> {
42    fn from(receipt: R) -> Self {
43        if !receipt.status() { Self::Revert(receipt) } else { Self::Success(receipt) }
44    }
45}
46
47/// Checks the status of a txhash by first polling for a receipt, then for
48/// mempool inclusion. Returns the tx hash, and a status
49pub async fn check_tx_status<N: Network>(
50    provider: &RootProvider<N>,
51    hash: TxHash,
52    timeout: u64,
53) -> (TxHash, Result<TxStatus<N::ReceiptResponse>, eyre::Report>) {
54    let result = retry::Retry::new_no_delay(3)
55        .run_async_until_break(|| async {
56            match PendingTransactionBuilder::new(provider.clone(), hash)
57                .with_timeout(Some(Duration::from_secs(timeout)))
58                .get_receipt()
59                .await
60            {
61                Ok(receipt) => {
62                    // Check if the receipt is pending (missing block information)
63                    let is_pending = receipt.block_number().is_none()
64                        || receipt.block_hash().is_none()
65                        || receipt.transaction_index().is_none();
66
67                    if !is_pending {
68                        return Ok(receipt.into());
69                    }
70
71                    // Receipt is pending, try to sleep and retry a few times
72                    match provider.get_transaction_by_hash(hash).await {
73                        Ok(_) => {
74                            // Sleep for a short time to allow the transaction to be mined
75                            tokio::time::sleep(Duration::from_millis(500)).await;
76                            // Transaction is still known to the node, retry
77                            Err(RetryError::Retry(PendingReceiptError { tx_hash: hash }.into()))
78                        }
79                        Err(_) => {
80                            // Transaction is not known to the node, mark it as dropped
81                            Ok(TxStatus::Dropped)
82                        }
83                    }
84                }
85                Err(e) => match provider.get_transaction_by_hash(hash).await {
86                    Ok(_) => match e {
87                        PendingTransactionError::TxWatcher(WatchTxError::Timeout) => {
88                            Err(RetryError::Continue(eyre!(
89                                "tx is still known to the node, waiting for receipt"
90                            )))
91                        }
92                        _ => Err(RetryError::Retry(e.into())),
93                    },
94                    Err(_) => Ok(TxStatus::Dropped),
95                },
96            }
97        })
98        .await;
99
100    (hash, result)
101}
102
103/// Prints parts of the receipt to stdout
104pub fn format_receipt<N: Network>(
105    chain: Chain,
106    receipt: &N::ReceiptResponse,
107    sequence: Option<&ScriptSequence<N>>,
108) -> String {
109    let gas_used = receipt.gas_used();
110    let gas_price = receipt.effective_gas_price();
111    let block_number = receipt.block_number().unwrap_or_default();
112    let success = receipt.status();
113
114    let (contract_name, function) = sequence
115        .and_then(|seq| {
116            seq.transactions
117                .iter()
118                .find(|tx| tx.hash == Some(receipt.transaction_hash()))
119                .map(|tx| (tx.contract_name.clone(), tx.function.clone()))
120        })
121        .unwrap_or((None, None));
122
123    if shell::is_json() {
124        let mut json = serde_json::json!({
125            "chain": chain,
126            "status": if success {
127                "success"
128            } else {
129                "failed"
130            },
131            "tx_hash": receipt.transaction_hash(),
132            "contract_address": receipt.contract_address().map(|addr| addr.to_string()),
133            "block_number": block_number,
134            "gas_used": gas_used,
135            "gas_price": gas_price,
136        });
137
138        if let Some(name) = &contract_name
139            && !name.is_empty()
140        {
141            json["contract_name"] = serde_json::Value::String(name.clone());
142        }
143        if let Some(func) = &function
144            && !func.is_empty()
145        {
146            json["function"] = serde_json::Value::String(func.clone());
147        }
148
149        let _ = sh_println!("{}", json);
150
151        String::new()
152    } else {
153        let contract_info = match &contract_name {
154            Some(name) if !name.is_empty() => format!("\nContract: {name}"),
155            _ => String::new(),
156        };
157
158        let function_info = match &function {
159            Some(func) if !func.is_empty() => format!("\nFunction: {func}"),
160            _ => String::new(),
161        };
162
163        format!(
164            "\n##### {chain}\n{status} Hash: {tx_hash:?}{contract_info}{function_info}{contract_address}\nBlock: {block_number}\n{gas}\n\n",
165            status = if success { "✅  [Success]" } else { "❌  [Failed]" },
166            tx_hash = receipt.transaction_hash(),
167            contract_address = if let Some(addr) = receipt.contract_address() {
168                format!("\nContract Address: {}", addr.to_checksum(None))
169            } else {
170                String::new()
171            },
172            gas = if gas_price == 0 {
173                format!("Gas Used: {gas_used}")
174            } else {
175                let paid = format_units((gas_used as u128).saturating_mul(gas_price), 18)
176                    .unwrap_or_else(|_| "N/A".into());
177                let gas_price =
178                    format_units(U256::from(gas_price), 9).unwrap_or_else(|_| "N/A".into());
179                let token_symbol = NamedChain::try_from(chain)
180                    .unwrap_or_default()
181                    .native_currency_symbol()
182                    .unwrap_or("ETH");
183                format!(
184                    "Paid: {} {} ({gas_used} gas * {} gwei)",
185                    paid.trim_end_matches('0'),
186                    token_symbol,
187                    gas_price.trim_end_matches('0').trim_end_matches('.')
188                )
189            },
190        )
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use alloy_network::Ethereum;
198    use alloy_primitives::B256;
199    use std::collections::VecDeque;
200
201    fn mock_receipt(tx_hash: B256, success: bool) -> TransactionReceipt {
202        serde_json::from_value(serde_json::json!({
203            "type": "0x02", "status": if success { "0x1" } else { "0x0" },
204            "cumulativeGasUsed": "0x5208", "logs": [], "transactionHash": tx_hash,
205            "logsBloom": format!("0x{}", "0".repeat(512)),
206            "transactionIndex": "0x0", "blockHash": B256::ZERO, "blockNumber": "0x3039",
207            "gasUsed": "0x5208", "effectiveGasPrice": "0x4a817c800",
208            "from": "0x0000000000000000000000000000000000000000",
209            "to": "0x0000000000000000000000000000000000000000", "contractAddress": null
210        }))
211        .unwrap()
212    }
213
214    fn mock_sequence(
215        tx_hash: B256,
216        contract: Option<&str>,
217        func: Option<&str>,
218    ) -> ScriptSequence<Ethereum> {
219        let tx = serde_json::from_value(serde_json::json!({
220            "hash": tx_hash, "transactionType": "CALL",
221            "contractName": contract, "contractAddress": null, "function": func,
222            "arguments": null, "additionalContracts": [], "isFixedGasLimit": false,
223            "transaction": {
224                "type": "0x02", "chainId": "0x1", "nonce": "0x0", "gas": "0x5208",
225                "maxFeePerGas": "0x4a817c800", "maxPriorityFeePerGas": "0x3b9aca00",
226                "to": "0x0000000000000000000000000000000000000000",
227                "value": "0x0", "input": "0x", "accessList": []
228            },
229        }))
230        .unwrap();
231        ScriptSequence { transactions: VecDeque::from([tx]), chain: 1, ..Default::default() }
232    }
233
234    #[test]
235    fn format_receipt_displays_contract_and_function() {
236        let hash = B256::repeat_byte(0x42);
237        let seq = mock_sequence(hash, Some("MyContract"), Some("init(address)"));
238        let out = format_receipt(Chain::mainnet(), &mock_receipt(hash, true), Some(&seq));
239
240        assert!(out.contains("Contract: MyContract"));
241        assert!(out.contains("Function: init(address)"));
242        assert!(out.contains("✅  [Success]"));
243    }
244
245    #[test]
246    fn format_receipt_without_sequence_omits_metadata() {
247        let hash = B256::repeat_byte(0x42);
248        let out = format_receipt::<Ethereum>(Chain::mainnet(), &mock_receipt(hash, true), None);
249
250        assert!(!out.contains("Contract:"));
251        assert!(!out.contains("Function:"));
252    }
253
254    #[test]
255    fn format_receipt_skips_empty_contract_name() {
256        let hash = B256::repeat_byte(0x42);
257        let seq = mock_sequence(hash, Some(""), Some("transfer(address)"));
258        let out = format_receipt(Chain::mainnet(), &mock_receipt(hash, true), Some(&seq));
259
260        assert!(!out.contains("Contract:"));
261        assert!(out.contains("Function: transfer(address)"));
262    }
263
264    #[test]
265    fn format_receipt_handles_missing_tx_in_sequence() {
266        let seq = mock_sequence(B256::repeat_byte(0x99), Some("Other"), Some("other()"));
267        let out = format_receipt(
268            Chain::mainnet(),
269            &mock_receipt(B256::repeat_byte(0x42), true),
270            Some(&seq),
271        );
272
273        assert!(!out.contains("Contract:"));
274        assert!(!out.contains("Function:"));
275    }
276
277    #[test]
278    fn format_receipt_shows_contract_on_failure() {
279        let hash = B256::repeat_byte(0x42);
280        let seq = mock_sequence(hash, Some("FailContract"), Some("fail()"));
281        let out = format_receipt(Chain::mainnet(), &mock_receipt(hash, false), Some(&seq));
282
283        assert!(out.contains("❌  [Failed]"));
284        assert!(out.contains("Contract: FailContract"));
285    }
286}