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