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#[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
21pub 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
34pub 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 match provider.get_transaction_by_hash(hash).await {
57 Ok(Some(_)) => {
58 tokio::time::sleep(Duration::from_millis(500)).await;
60 Err(RetryError::Retry(PendingReceiptError { tx_hash: hash }.into()))
62 }
63 Ok(None) => {
64 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
93pub(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
101pub 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(¬_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(¬_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 asserter.push_success(¬_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 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 const CHECK_TX_TIMEOUT: Duration = Duration::from_secs(15);
370
371 #[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 let unknown_hash = B256::repeat_byte(0xab);
383
384 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 #[tokio::test(flavor = "multi_thread")]
406 async fn check_tx_status_known_then_dropped_resolves_to_dropped() {
407 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 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 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 #[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 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}