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    confirmations: u64,
41) -> (TxHash, Result<TxStatus<N::ReceiptResponse>, eyre::Report>) {
42    let result = retry::Retry::new_no_delay(3)
43        .run_async_until_break(|| async {
44            match PendingTransactionBuilder::new(provider.clone(), hash)
45                .with_timeout(Some(Duration::from_secs(timeout)))
46                .with_required_confirmations(confirmations)
47                .get_receipt()
48                .await
49            {
50                Ok(receipt) => {
51                    if is_mined_receipt_for(&receipt, hash) {
52                        return Ok(receipt.into());
53                    }
54
55                    // Receipt is pending or foreign, try to sleep and retry a few times
56                    match provider.get_transaction_by_hash(hash).await {
57                        Ok(Some(_)) => {
58                            // Sleep for a short time to allow the transaction to be mined
59                            tokio::time::sleep(Duration::from_millis(500)).await;
60                            // Transaction is still known to the node, retry
61                            Err(RetryError::Retry(PendingReceiptError { tx_hash: hash }.into()))
62                        }
63                        Ok(None) => {
64                            // Transaction is not known to the node, mark it as dropped
65                            Ok(TxStatus::Dropped)
66                        }
67                        Err(err) => Err(RetryError::Retry(eyre!(
68                            "failed to check if transaction {hash} is still known to the node: {err}"
69                        ))),
70                    }
71                }
72                Err(e) => match provider.get_transaction_by_hash(hash).await {
73                    Ok(Some(_)) => 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                    Ok(None) => Ok(TxStatus::Dropped),
82                    Err(err) => Err(RetryError::Retry(eyre!(
83                        "failed to check if transaction {hash} is still known to the node after receipt error: {err}; receipt error: {e}"
84                    ))),
85                },
86            }
87        })
88        .await;
89
90    (hash, result)
91}
92
93/// Returns true if `receipt` is a mined receipt for `hash`.
94pub(crate) fn is_mined_receipt_for<R: ReceiptResponse>(receipt: &R, hash: TxHash) -> bool {
95    receipt.transaction_hash() == hash
96        && receipt.block_number().is_some()
97        && receipt.block_hash().is_some()
98        && receipt.transaction_index().is_some()
99}
100
101/// Prints parts of the receipt to stdout
102pub fn format_receipt<N: Network>(
103    chain: Chain,
104    receipt: &N::ReceiptResponse,
105    sequence: Option<&ScriptSequence<N>>,
106) -> String {
107    let gas_used = receipt.gas_used();
108    let gas_price = receipt.effective_gas_price();
109    let block_number = receipt.block_number().unwrap_or_default();
110    let success = receipt.status();
111
112    let (contract_name, function) = sequence
113        .and_then(|seq| {
114            seq.transactions
115                .iter()
116                .find(|tx| tx.hash == Some(receipt.transaction_hash()))
117                .map(|tx| (tx.contract_name.clone(), tx.function.clone()))
118        })
119        .unwrap_or((None, None));
120
121    if shell::is_json() {
122        let mut json = serde_json::json!({
123            "chain": chain,
124            "status": if success {
125                "success"
126            } else {
127                "failed"
128            },
129            "tx_hash": receipt.transaction_hash(),
130            "contract_address": receipt.contract_address().map(|addr| addr.to_string()),
131            "block_number": block_number,
132            "gas_used": gas_used,
133            "gas_price": gas_price,
134        });
135
136        if let Some(name) = &contract_name
137            && !name.is_empty()
138        {
139            json["contract_name"] = serde_json::Value::String(name.clone());
140        }
141        if let Some(func) = &function
142            && !func.is_empty()
143        {
144            json["function"] = serde_json::Value::String(func.clone());
145        }
146
147        let _ = sh_println!("{}", json);
148
149        String::new()
150    } else {
151        let contract_info = match &contract_name {
152            Some(name) if !name.is_empty() => format!("\nContract: {name}"),
153            _ => String::new(),
154        };
155
156        let function_info = match &function {
157            Some(func) if !func.is_empty() => format!("\nFunction: {func}"),
158            _ => String::new(),
159        };
160
161        format!(
162            "\n##### {chain}\n{status} Hash: {tx_hash:?}{contract_info}{function_info}{contract_address}\nBlock: {block_number}\n{gas}\n\n",
163            status = if success { "✅  [Success]" } else { "❌  [Failed]" },
164            tx_hash = receipt.transaction_hash(),
165            contract_address = if let Some(addr) = receipt.contract_address() {
166                format!("\nContract Address: {}", addr.to_checksum(None))
167            } else {
168                String::new()
169            },
170            gas = if gas_price == 0 {
171                format!("Gas Used: {gas_used}")
172            } else {
173                let paid = format_units((gas_used as u128).saturating_mul(gas_price), 18)
174                    .unwrap_or_else(|_| "N/A".into());
175                let gas_price =
176                    format_units(U256::from(gas_price), 9).unwrap_or_else(|_| "N/A".into());
177                let token_symbol = NamedChain::try_from(chain)
178                    .unwrap_or_default()
179                    .native_currency_symbol()
180                    .unwrap_or("ETH");
181                format!(
182                    "Paid: {} {} ({gas_used} gas * {} gwei)",
183                    paid.trim_end_matches('0'),
184                    token_symbol,
185                    gas_price.trim_end_matches('0').trim_end_matches('.')
186                )
187            },
188        )
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use alloy_network::{Ethereum, TransactionBuilder};
196    use alloy_primitives::{B256, Bloom};
197    use alloy_provider::{ProviderBuilder, mock::Asserter};
198    use alloy_rpc_types::{TransactionReceipt, TransactionRequest};
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!("{:#x}", Bloom::ZERO),
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
287    #[tokio::test]
288    async fn check_tx_status_marks_null_transaction_lookup_as_dropped() {
289        let hash = B256::repeat_byte(0x42);
290        let asserter = Asserter::new();
291        let provider: RootProvider<Ethereum> =
292            ProviderBuilder::default().connect_mocked_client(asserter.clone());
293        let not_found: Option<serde_json::Value> = None;
294
295        for _ in 0..50_000 {
296            asserter.push_success(&not_found);
297        }
298
299        let null_responder = tokio::spawn({
300            let asserter = asserter.clone();
301            async move {
302                let not_found: Option<serde_json::Value> = None;
303                loop {
304                    for _ in 0..1_000 {
305                        asserter.push_success(&not_found);
306                    }
307                    tokio::task::yield_now().await;
308                }
309            }
310        });
311
312        let result =
313            tokio::time::timeout(Duration::from_secs(2), check_tx_status(&provider, hash, 0, 1))
314                .await;
315        null_responder.abort();
316
317        let (returned_hash, status) = result.expect(
318            "check_tx_status should not keep waiting when eth_getTransactionByHash returns null",
319        );
320
321        assert_eq!(returned_hash, hash);
322        assert!(matches!(status.unwrap(), TxStatus::Dropped));
323    }
324
325    #[tokio::test]
326    async fn check_tx_status_does_not_mark_lookup_errors_as_dropped() {
327        let hash = B256::repeat_byte(0x42);
328        let asserter = Asserter::new();
329        let provider: RootProvider<Ethereum> =
330            ProviderBuilder::default().connect_mocked_client(asserter.clone());
331        let not_found: Option<serde_json::Value> = None;
332
333        // Initial receipt lookup while registering the pending transaction.
334        asserter.push_success(&not_found);
335        for _ in 0..50 {
336            asserter.push_failure_msg("lookup unavailable");
337        }
338
339        let (_, status) = check_tx_status(&provider, hash, 0, 1).await;
340        let err = match status {
341            Ok(_) => panic!("transaction lookup errors should not be marked as dropped"),
342            Err(err) => err.to_string(),
343        };
344
345        assert!(err.contains("failed to check if transaction"));
346        assert!(err.contains("lookup unavailable"));
347    }
348
349    #[tokio::test]
350    async fn check_tx_status_rejects_foreign_receipt() {
351        let hash = B256::repeat_byte(0x42);
352        let foreign = mock_receipt(B256::repeat_byte(0x99), true);
353        let asserter = Asserter::new();
354        let provider: RootProvider<Ethereum> =
355            ProviderBuilder::default().connect_mocked_client(asserter.clone());
356
357        // Receipt lookups while registering and polling, then the transaction lookup.
358        asserter.push_success(&foreign);
359        asserter.push_success(&foreign);
360        asserter.push_success(&None::<()>);
361
362        let (_, status) = check_tx_status(&provider, hash, 0, 1).await;
363
364        assert!(matches!(status.unwrap(), TxStatus::Dropped));
365    }
366
367    /// Upper bound for the anvil-based `check_tx_status` tests, so a hang fails fast
368    /// instead of stalling the suite.
369    const CHECK_TX_TIMEOUT: Duration = Duration::from_secs(15);
370
371    /// A tx hash the node doesn't know about (rejected at submission or dropped from
372    /// the mempool) must resolve to `TxStatus::Dropped` rather than polling forever.
373    #[tokio::test(flavor = "multi_thread")]
374    async fn check_tx_status_unknown_tx_is_dropped() {
375        let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await;
376        let provider = ProviderBuilder::new()
377            .connect_http(handle.http_endpoint().parse().unwrap())
378            .root()
379            .clone();
380
381        // Random hash the node has never seen.
382        let unknown_hash = B256::repeat_byte(0xab);
383
384        // Inner `timeout=1` bounds the pending-tx watcher; the outer `tokio::time::timeout`
385        // guarantees the test fails fast if the watcher ever hangs again.
386        let (returned_hash, status) = tokio::time::timeout(
387            CHECK_TX_TIMEOUT,
388            check_tx_status::<Ethereum>(&provider, unknown_hash, 1, 1),
389        )
390        .await
391        .expect("check_tx_status hung on an unknown tx hash");
392
393        assert_eq!(returned_hash, unknown_hash);
394        let status = status.expect("unknown tx should resolve to Ok(TxStatus::Dropped)");
395        assert!(
396            matches!(status, TxStatus::Dropped),
397            "expected TxStatus::Dropped for an unknown tx",
398        );
399    }
400
401    /// A tx that is initially in the mempool (so `eth_getTransactionByHash` returns
402    /// `Some`) and is later dropped from it must:
403    ///   1. keep retrying while the node still knows the tx (the `Ok(Some(_))` branch), and
404    ///   2. resolve to `TxStatus::Dropped` once the node forgets it (the `Ok(None)` branch).
405    #[tokio::test(flavor = "multi_thread")]
406    async fn check_tx_status_known_then_dropped_resolves_to_dropped() {
407        // `no_mining` keeps the tx pending so `get_receipt` always times out and the
408        // watcher exercises the `WatchTxError::Timeout` + `get_transaction_by_hash`
409        // branch on every retry.
410        let (api, handle) = anvil::spawn(anvil::NodeConfig::test().with_no_mining(true)).await;
411        let signer_provider =
412            ProviderBuilder::new().connect_http(handle.http_endpoint().parse().unwrap());
413
414        let mut wallets = handle.dev_wallets();
415        let from = wallets.next().unwrap().address();
416        let to = wallets.next().unwrap().address();
417        let tx = TransactionRequest::default().with_from(from).with_to(to).with_value(U256::ONE);
418
419        let pending = signer_provider.send_transaction(tx).await.unwrap();
420        let tx_hash = *pending.tx_hash();
421
422        // Run `check_tx_status` concurrently; while it loops on `Ok(Some(_))`, drop the
423        // tx from the mempool so the next iteration sees `Ok(None)` and exits.
424        let provider = signer_provider.root().clone();
425        let watcher = tokio::spawn(async move {
426            tokio::time::timeout(
427                CHECK_TX_TIMEOUT,
428                check_tx_status::<Ethereum>(&provider, tx_hash, 1, 1),
429            )
430            .await
431        });
432
433        // Give the watcher at least one Continue iteration before evicting the tx.
434        tokio::time::sleep(Duration::from_millis(1500)).await;
435        api.anvil_drop_transaction(tx_hash).await.unwrap();
436
437        let (returned_hash, status) = watcher
438            .await
439            .unwrap()
440            .expect("check_tx_status hung after the tx was dropped from the mempool");
441        assert_eq!(returned_hash, tx_hash);
442        let status = status.expect("dropped tx should resolve to Ok(TxStatus::Dropped)");
443        assert!(
444            matches!(status, TxStatus::Dropped),
445            "expected TxStatus::Dropped after the tx was evicted from the mempool",
446        );
447    }
448
449    /// A real, mined transaction must resolve to `TxStatus::Success` and not be
450    /// misclassified as dropped.
451    #[tokio::test(flavor = "multi_thread")]
452    async fn check_tx_status_mined_tx_is_success() {
453        let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await;
454        let signer_provider =
455            ProviderBuilder::new().connect_http(handle.http_endpoint().parse().unwrap());
456
457        let mut wallets = handle.dev_wallets();
458        let from = wallets.next().unwrap().address();
459        let to = wallets.next().unwrap().address();
460        let tx = TransactionRequest::default().with_from(from).with_to(to).with_value(U256::ONE);
461
462        // Send and mine the tx so a receipt is immediately available.
463        let pending = signer_provider.send_transaction(tx).await.unwrap();
464        let tx_hash = *pending.tx_hash();
465        let _ = pending.get_receipt().await.unwrap();
466
467        let provider = signer_provider.root().clone();
468        let (returned_hash, status) = tokio::time::timeout(
469            CHECK_TX_TIMEOUT,
470            check_tx_status::<Ethereum>(&provider, tx_hash, 5, 1),
471        )
472        .await
473        .expect("check_tx_status hung on a mined tx");
474
475        assert_eq!(returned_hash, tx_hash);
476        let status = status.expect("mined tx should resolve to Ok(TxStatus::Success)");
477        assert!(
478            matches!(status, TxStatus::Success(_)),
479            "expected TxStatus::Success for a mined ETH transfer",
480        );
481    }
482
483    #[tokio::test(flavor = "multi_thread")]
484    async fn check_tx_status_waits_for_confirmations() {
485        let (api, handle) = anvil::spawn(anvil::NodeConfig::test().with_no_mining(true)).await;
486        let signer_provider =
487            ProviderBuilder::new().connect_http(handle.http_endpoint().parse().unwrap());
488
489        let mut wallets = handle.dev_wallets();
490        let from = wallets.next().unwrap().address();
491        let to = wallets.next().unwrap().address();
492        let tx = TransactionRequest::default().with_from(from).with_to(to).with_value(U256::ONE);
493
494        let pending = signer_provider.send_transaction(tx).await.unwrap();
495        let tx_hash = *pending.tx_hash();
496        api.mine_one().await.unwrap();
497
498        let provider = signer_provider.root().clone();
499        let mut watcher =
500            tokio::spawn(
501                async move { check_tx_status::<Ethereum>(&provider, tx_hash, 5, 3).await },
502            );
503
504        assert!(tokio::time::timeout(Duration::from_millis(500), &mut watcher).await.is_err());
505
506        api.anvil_mine(Some(U256::from(2)), None).await.unwrap();
507        let (returned_hash, status) =
508            tokio::time::timeout(CHECK_TX_TIMEOUT, watcher).await.unwrap().unwrap();
509
510        assert_eq!(returned_hash, tx_hash);
511        assert!(matches!(status.unwrap(), TxStatus::Success(_)));
512    }
513}