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