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 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 match provider.get_transaction_by_hash(hash).await {
62 Ok(Some(_)) => {
63 tokio::time::sleep(Duration::from_millis(500)).await;
65 Err(RetryError::Retry(PendingReceiptError { tx_hash: hash }.into()))
67 }
68 Ok(None) => {
69 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
98pub 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(¬_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(¬_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 asserter.push_success(¬_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 const CHECK_TX_TIMEOUT: Duration = Duration::from_secs(15);
349
350 #[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 let unknown_hash = B256::repeat_byte(0xab);
362
363 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 #[tokio::test(flavor = "multi_thread")]
385 async fn check_tx_status_known_then_dropped_resolves_to_dropped() {
386 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 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 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 #[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 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}