Skip to main content

forge_script/
receipts.rs

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