forge_script/
receipts.rs

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