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(Some(_)) => {
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                        Ok(None) => {
67                            // Transaction is not known to the node, mark it as dropped
68                            Ok(TxStatus::Dropped)
69                        }
70                        Err(err) => Err(RetryError::Retry(eyre!(
71                            "failed to check if transaction {hash} is still known to the node: {err}"
72                        ))),
73                    }
74                }
75                Err(e) => match provider.get_transaction_by_hash(hash).await {
76                    Ok(Some(_)) => match e {
77                        PendingTransactionError::TxWatcher(WatchTxError::Timeout) => {
78                            Err(RetryError::Continue(eyre!(
79                                "tx is still known to the node, waiting for receipt"
80                            )))
81                        }
82                        _ => Err(RetryError::Retry(e.into())),
83                    },
84                    Ok(None) => Ok(TxStatus::Dropped),
85                    Err(err) => Err(RetryError::Retry(eyre!(
86                        "failed to check if transaction {hash} is still known to the node after receipt error: {err}; receipt error: {e}"
87                    ))),
88                },
89            }
90        })
91        .await;
92
93    (hash, result)
94}
95
96/// Prints parts of the receipt to stdout
97pub fn format_receipt<N: Network>(
98    chain: Chain,
99    receipt: &N::ReceiptResponse,
100    sequence: Option<&ScriptSequence<N>>,
101) -> String {
102    let gas_used = receipt.gas_used();
103    let gas_price = receipt.effective_gas_price();
104    let block_number = receipt.block_number().unwrap_or_default();
105    let success = receipt.status();
106
107    let (contract_name, function) = sequence
108        .and_then(|seq| {
109            seq.transactions
110                .iter()
111                .find(|tx| tx.hash == Some(receipt.transaction_hash()))
112                .map(|tx| (tx.contract_name.clone(), tx.function.clone()))
113        })
114        .unwrap_or((None, None));
115
116    if shell::is_json() {
117        let mut json = serde_json::json!({
118            "chain": chain,
119            "status": if success {
120                "success"
121            } else {
122                "failed"
123            },
124            "tx_hash": receipt.transaction_hash(),
125            "contract_address": receipt.contract_address().map(|addr| addr.to_string()),
126            "block_number": block_number,
127            "gas_used": gas_used,
128            "gas_price": gas_price,
129        });
130
131        if let Some(name) = &contract_name
132            && !name.is_empty()
133        {
134            json["contract_name"] = serde_json::Value::String(name.clone());
135        }
136        if let Some(func) = &function
137            && !func.is_empty()
138        {
139            json["function"] = serde_json::Value::String(func.clone());
140        }
141
142        let _ = sh_println!("{}", json);
143
144        String::new()
145    } else {
146        let contract_info = match &contract_name {
147            Some(name) if !name.is_empty() => format!("\nContract: {name}"),
148            _ => String::new(),
149        };
150
151        let function_info = match &function {
152            Some(func) if !func.is_empty() => format!("\nFunction: {func}"),
153            _ => String::new(),
154        };
155
156        format!(
157            "\n##### {chain}\n{status} Hash: {tx_hash:?}{contract_info}{function_info}{contract_address}\nBlock: {block_number}\n{gas}\n\n",
158            status = if success { "✅  [Success]" } else { "❌  [Failed]" },
159            tx_hash = receipt.transaction_hash(),
160            contract_address = if let Some(addr) = receipt.contract_address() {
161                format!("\nContract Address: {}", addr.to_checksum(None))
162            } else {
163                String::new()
164            },
165            gas = if gas_price == 0 {
166                format!("Gas Used: {gas_used}")
167            } else {
168                let paid = format_units((gas_used as u128).saturating_mul(gas_price), 18)
169                    .unwrap_or_else(|_| "N/A".into());
170                let gas_price =
171                    format_units(U256::from(gas_price), 9).unwrap_or_else(|_| "N/A".into());
172                let token_symbol = NamedChain::try_from(chain)
173                    .unwrap_or_default()
174                    .native_currency_symbol()
175                    .unwrap_or("ETH");
176                format!(
177                    "Paid: {} {} ({gas_used} gas * {} gwei)",
178                    paid.trim_end_matches('0'),
179                    token_symbol,
180                    gas_price.trim_end_matches('0').trim_end_matches('.')
181                )
182            },
183        )
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use alloy_network::{Ethereum, TransactionBuilder};
191    use alloy_primitives::B256;
192    use alloy_provider::{ProviderBuilder, mock::Asserter};
193    use alloy_rpc_types::{TransactionReceipt, TransactionRequest};
194    use std::collections::VecDeque;
195
196    fn mock_receipt(tx_hash: B256, success: bool) -> TransactionReceipt {
197        serde_json::from_value(serde_json::json!({
198            "type": "0x02", "status": if success { "0x1" } else { "0x0" },
199            "cumulativeGasUsed": "0x5208", "logs": [], "transactionHash": tx_hash,
200            "logsBloom": format!("0x{}", "0".repeat(512)),
201            "transactionIndex": "0x0", "blockHash": B256::ZERO, "blockNumber": "0x3039",
202            "gasUsed": "0x5208", "effectiveGasPrice": "0x4a817c800",
203            "from": "0x0000000000000000000000000000000000000000",
204            "to": "0x0000000000000000000000000000000000000000", "contractAddress": null
205        }))
206        .unwrap()
207    }
208
209    fn mock_sequence(
210        tx_hash: B256,
211        contract: Option<&str>,
212        func: Option<&str>,
213    ) -> ScriptSequence<Ethereum> {
214        let tx = serde_json::from_value(serde_json::json!({
215            "hash": tx_hash, "transactionType": "CALL",
216            "contractName": contract, "contractAddress": null, "function": func,
217            "arguments": null, "additionalContracts": [], "isFixedGasLimit": false,
218            "transaction": {
219                "type": "0x02", "chainId": "0x1", "nonce": "0x0", "gas": "0x5208",
220                "maxFeePerGas": "0x4a817c800", "maxPriorityFeePerGas": "0x3b9aca00",
221                "to": "0x0000000000000000000000000000000000000000",
222                "value": "0x0", "input": "0x", "accessList": []
223            },
224        }))
225        .unwrap();
226        ScriptSequence { transactions: VecDeque::from([tx]), chain: 1, ..Default::default() }
227    }
228
229    #[test]
230    fn format_receipt_displays_contract_and_function() {
231        let hash = B256::repeat_byte(0x42);
232        let seq = mock_sequence(hash, Some("MyContract"), Some("init(address)"));
233        let out = format_receipt(Chain::mainnet(), &mock_receipt(hash, true), Some(&seq));
234
235        assert!(out.contains("Contract: MyContract"));
236        assert!(out.contains("Function: init(address)"));
237        assert!(out.contains("✅  [Success]"));
238    }
239
240    #[test]
241    fn format_receipt_without_sequence_omits_metadata() {
242        let hash = B256::repeat_byte(0x42);
243        let out = format_receipt::<Ethereum>(Chain::mainnet(), &mock_receipt(hash, true), None);
244
245        assert!(!out.contains("Contract:"));
246        assert!(!out.contains("Function:"));
247    }
248
249    #[test]
250    fn format_receipt_skips_empty_contract_name() {
251        let hash = B256::repeat_byte(0x42);
252        let seq = mock_sequence(hash, Some(""), Some("transfer(address)"));
253        let out = format_receipt(Chain::mainnet(), &mock_receipt(hash, true), Some(&seq));
254
255        assert!(!out.contains("Contract:"));
256        assert!(out.contains("Function: transfer(address)"));
257    }
258
259    #[test]
260    fn format_receipt_handles_missing_tx_in_sequence() {
261        let seq = mock_sequence(B256::repeat_byte(0x99), Some("Other"), Some("other()"));
262        let out = format_receipt(
263            Chain::mainnet(),
264            &mock_receipt(B256::repeat_byte(0x42), true),
265            Some(&seq),
266        );
267
268        assert!(!out.contains("Contract:"));
269        assert!(!out.contains("Function:"));
270    }
271
272    #[test]
273    fn format_receipt_shows_contract_on_failure() {
274        let hash = B256::repeat_byte(0x42);
275        let seq = mock_sequence(hash, Some("FailContract"), Some("fail()"));
276        let out = format_receipt(Chain::mainnet(), &mock_receipt(hash, false), Some(&seq));
277
278        assert!(out.contains("❌  [Failed]"));
279        assert!(out.contains("Contract: FailContract"));
280    }
281
282    #[tokio::test]
283    async fn check_tx_status_marks_null_transaction_lookup_as_dropped() {
284        let hash = B256::repeat_byte(0x42);
285        let asserter = Asserter::new();
286        let provider: RootProvider<Ethereum> =
287            ProviderBuilder::default().connect_mocked_client(asserter.clone());
288        let not_found: Option<serde_json::Value> = None;
289
290        for _ in 0..50_000 {
291            asserter.push_success(&not_found);
292        }
293
294        let null_responder = tokio::spawn({
295            let asserter = asserter.clone();
296            async move {
297                let not_found: Option<serde_json::Value> = None;
298                loop {
299                    for _ in 0..1_000 {
300                        asserter.push_success(&not_found);
301                    }
302                    tokio::task::yield_now().await;
303                }
304            }
305        });
306
307        let result =
308            tokio::time::timeout(Duration::from_secs(2), check_tx_status(&provider, hash, 0)).await;
309        null_responder.abort();
310
311        let (returned_hash, status) = result.expect(
312            "check_tx_status should not keep waiting when eth_getTransactionByHash returns null",
313        );
314
315        assert_eq!(returned_hash, hash);
316        assert!(matches!(status.unwrap(), TxStatus::Dropped));
317    }
318
319    #[tokio::test]
320    async fn check_tx_status_does_not_mark_lookup_errors_as_dropped() {
321        let hash = B256::repeat_byte(0x42);
322        let asserter = Asserter::new();
323        let provider: RootProvider<Ethereum> =
324            ProviderBuilder::default().connect_mocked_client(asserter.clone());
325        let not_found: Option<serde_json::Value> = None;
326
327        // Initial receipt lookup while registering the pending transaction.
328        asserter.push_success(&not_found);
329        for _ in 0..50 {
330            asserter.push_failure_msg("lookup unavailable");
331        }
332
333        let (_, status) = check_tx_status(&provider, hash, 0).await;
334        let err = match status {
335            Ok(_) => panic!("transaction lookup errors should not be marked as dropped"),
336            Err(err) => err.to_string(),
337        };
338
339        assert!(err.contains("failed to check if transaction"));
340        assert!(err.contains("lookup unavailable"));
341    }
342
343    /// Upper bound for the anvil-based `check_tx_status` tests, so a hang fails fast
344    /// instead of stalling the suite.
345    const CHECK_TX_TIMEOUT: Duration = Duration::from_secs(15);
346
347    /// A tx hash the node doesn't know about (rejected at submission or dropped from
348    /// the mempool) must resolve to `TxStatus::Dropped` rather than polling forever.
349    #[tokio::test(flavor = "multi_thread")]
350    async fn check_tx_status_unknown_tx_is_dropped() {
351        let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await;
352        let provider = ProviderBuilder::new()
353            .connect_http(handle.http_endpoint().parse().unwrap())
354            .root()
355            .clone();
356
357        // Random hash the node has never seen.
358        let unknown_hash = B256::repeat_byte(0xab);
359
360        // Inner `timeout=1` bounds the pending-tx watcher; the outer `tokio::time::timeout`
361        // guarantees the test fails fast if the watcher ever hangs again.
362        let (returned_hash, status) = tokio::time::timeout(
363            CHECK_TX_TIMEOUT,
364            check_tx_status::<Ethereum>(&provider, unknown_hash, 1),
365        )
366        .await
367        .expect("check_tx_status hung on an unknown tx hash");
368
369        assert_eq!(returned_hash, unknown_hash);
370        let status = status.expect("unknown tx should resolve to Ok(TxStatus::Dropped)");
371        assert!(
372            matches!(status, TxStatus::Dropped),
373            "expected TxStatus::Dropped for an unknown tx",
374        );
375    }
376
377    /// A tx that is initially in the mempool (so `eth_getTransactionByHash` returns
378    /// `Some`) and is later dropped from it must:
379    ///   1. keep retrying while the node still knows the tx (the `Ok(Some(_))` branch), and
380    ///   2. resolve to `TxStatus::Dropped` once the node forgets it (the `Ok(None)` branch).
381    #[tokio::test(flavor = "multi_thread")]
382    async fn check_tx_status_known_then_dropped_resolves_to_dropped() {
383        // `no_mining` keeps the tx pending so `get_receipt` always times out and the
384        // watcher exercises the `WatchTxError::Timeout` + `get_transaction_by_hash`
385        // branch on every retry.
386        let (api, handle) = anvil::spawn(anvil::NodeConfig::test().with_no_mining(true)).await;
387        let signer_provider =
388            ProviderBuilder::new().connect_http(handle.http_endpoint().parse().unwrap());
389
390        let mut wallets = handle.dev_wallets();
391        let from = wallets.next().unwrap().address();
392        let to = wallets.next().unwrap().address();
393        let tx =
394            TransactionRequest::default().with_from(from).with_to(to).with_value(U256::from(1));
395
396        let pending = signer_provider.send_transaction(tx).await.unwrap();
397        let tx_hash = *pending.tx_hash();
398
399        // Run `check_tx_status` concurrently; while it loops on `Ok(Some(_))`, drop the
400        // tx from the mempool so the next iteration sees `Ok(None)` and exits.
401        let provider = signer_provider.root().clone();
402        let watcher = tokio::spawn(async move {
403            tokio::time::timeout(
404                CHECK_TX_TIMEOUT,
405                check_tx_status::<Ethereum>(&provider, tx_hash, 1),
406            )
407            .await
408        });
409
410        // Give the watcher at least one Continue iteration before evicting the tx.
411        tokio::time::sleep(Duration::from_millis(1500)).await;
412        api.anvil_drop_transaction(tx_hash).await.unwrap();
413
414        let (returned_hash, status) = watcher
415            .await
416            .unwrap()
417            .expect("check_tx_status hung after the tx was dropped from the mempool");
418        assert_eq!(returned_hash, tx_hash);
419        let status = status.expect("dropped tx should resolve to Ok(TxStatus::Dropped)");
420        assert!(
421            matches!(status, TxStatus::Dropped),
422            "expected TxStatus::Dropped after the tx was evicted from the mempool",
423        );
424    }
425
426    /// A real, mined transaction must resolve to `TxStatus::Success` and not be
427    /// misclassified as dropped.
428    #[tokio::test(flavor = "multi_thread")]
429    async fn check_tx_status_mined_tx_is_success() {
430        let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await;
431        let signer_provider =
432            ProviderBuilder::new().connect_http(handle.http_endpoint().parse().unwrap());
433
434        let mut wallets = handle.dev_wallets();
435        let from = wallets.next().unwrap().address();
436        let to = wallets.next().unwrap().address();
437        let tx =
438            TransactionRequest::default().with_from(from).with_to(to).with_value(U256::from(1));
439
440        // Send and mine the tx so a receipt is immediately available.
441        let pending = signer_provider.send_transaction(tx).await.unwrap();
442        let tx_hash = *pending.tx_hash();
443        let _ = pending.get_receipt().await.unwrap();
444
445        let provider = signer_provider.root().clone();
446        let (returned_hash, status) = tokio::time::timeout(
447            CHECK_TX_TIMEOUT,
448            check_tx_status::<Ethereum>(&provider, tx_hash, 5),
449        )
450        .await
451        .expect("check_tx_status hung on a mined tx");
452
453        assert_eq!(returned_hash, tx_hash);
454        let status = status.expect("mined tx should resolve to Ok(TxStatus::Success)");
455        assert!(
456            matches!(status, TxStatus::Success(_)),
457            "expected TxStatus::Success for a mined ETH transfer",
458        );
459    }
460}