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 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
118pub(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
126pub 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(¬_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(¬_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 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 asserter.push_success(¬_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 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 const CHECK_TX_TIMEOUT: Duration = Duration::from_secs(15);
416
417 #[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 let unknown_hash = B256::repeat_byte(0xab);
429
430 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 #[tokio::test(flavor = "multi_thread")]
452 async fn check_tx_status_known_then_dropped_resolves_to_dropped() {
453 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 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 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 #[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 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}