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) -> (TxHash, Result<TxStatus<N::ReceiptResponse>, eyre::Report>) {
41 let result = retry::Retry::new_no_delay(3)
42 .run_async_until_break(|| async {
43 match PendingTransactionBuilder::new(provider.clone(), hash)
44 .with_timeout(Some(Duration::from_secs(timeout)))
45 .get_receipt()
46 .await
47 {
48 Ok(receipt) => {
49 let is_pending = receipt.block_number().is_none()
51 || receipt.block_hash().is_none()
52 || receipt.transaction_index().is_none();
53
54 if !is_pending {
55 return Ok(receipt.into());
56 }
57
58 match provider.get_transaction_by_hash(hash).await {
60 Ok(Some(_)) => {
61 tokio::time::sleep(Duration::from_millis(500)).await;
63 Err(RetryError::Retry(PendingReceiptError { tx_hash: hash }.into()))
65 }
66 Ok(None) => {
67 Ok(TxStatus::Dropped)
69 }
70 Err(err) => Err(RetryError::Retry(eyre!(
71 "failed to check if transaction {hash} is still known to the node: {err}"
72 ))),
73 }
74 }
75 Err(e) => match provider.get_transaction_by_hash(hash).await {
76 Ok(Some(_)) => match e {
77 PendingTransactionError::TxWatcher(WatchTxError::Timeout) => {
78 Err(RetryError::Continue(eyre!(
79 "tx is still known to the node, waiting for receipt"
80 )))
81 }
82 _ => Err(RetryError::Retry(e.into())),
83 },
84 Ok(None) => Ok(TxStatus::Dropped),
85 Err(err) => Err(RetryError::Retry(eyre!(
86 "failed to check if transaction {hash} is still known to the node after receipt error: {err}; receipt error: {e}"
87 ))),
88 },
89 }
90 })
91 .await;
92
93 (hash, result)
94}
95
96pub fn format_receipt<N: Network>(
98 chain: Chain,
99 receipt: &N::ReceiptResponse,
100 sequence: Option<&ScriptSequence<N>>,
101) -> String {
102 let gas_used = receipt.gas_used();
103 let gas_price = receipt.effective_gas_price();
104 let block_number = receipt.block_number().unwrap_or_default();
105 let success = receipt.status();
106
107 let (contract_name, function) = sequence
108 .and_then(|seq| {
109 seq.transactions
110 .iter()
111 .find(|tx| tx.hash == Some(receipt.transaction_hash()))
112 .map(|tx| (tx.contract_name.clone(), tx.function.clone()))
113 })
114 .unwrap_or((None, None));
115
116 if shell::is_json() {
117 let mut json = serde_json::json!({
118 "chain": chain,
119 "status": if success {
120 "success"
121 } else {
122 "failed"
123 },
124 "tx_hash": receipt.transaction_hash(),
125 "contract_address": receipt.contract_address().map(|addr| addr.to_string()),
126 "block_number": block_number,
127 "gas_used": gas_used,
128 "gas_price": gas_price,
129 });
130
131 if let Some(name) = &contract_name
132 && !name.is_empty()
133 {
134 json["contract_name"] = serde_json::Value::String(name.clone());
135 }
136 if let Some(func) = &function
137 && !func.is_empty()
138 {
139 json["function"] = serde_json::Value::String(func.clone());
140 }
141
142 let _ = sh_println!("{}", json);
143
144 String::new()
145 } else {
146 let contract_info = match &contract_name {
147 Some(name) if !name.is_empty() => format!("\nContract: {name}"),
148 _ => String::new(),
149 };
150
151 let function_info = match &function {
152 Some(func) if !func.is_empty() => format!("\nFunction: {func}"),
153 _ => String::new(),
154 };
155
156 format!(
157 "\n##### {chain}\n{status} Hash: {tx_hash:?}{contract_info}{function_info}{contract_address}\nBlock: {block_number}\n{gas}\n\n",
158 status = if success { "✅ [Success]" } else { "❌ [Failed]" },
159 tx_hash = receipt.transaction_hash(),
160 contract_address = if let Some(addr) = receipt.contract_address() {
161 format!("\nContract Address: {}", addr.to_checksum(None))
162 } else {
163 String::new()
164 },
165 gas = if gas_price == 0 {
166 format!("Gas Used: {gas_used}")
167 } else {
168 let paid = format_units((gas_used as u128).saturating_mul(gas_price), 18)
169 .unwrap_or_else(|_| "N/A".into());
170 let gas_price =
171 format_units(U256::from(gas_price), 9).unwrap_or_else(|_| "N/A".into());
172 let token_symbol = NamedChain::try_from(chain)
173 .unwrap_or_default()
174 .native_currency_symbol()
175 .unwrap_or("ETH");
176 format!(
177 "Paid: {} {} ({gas_used} gas * {} gwei)",
178 paid.trim_end_matches('0'),
179 token_symbol,
180 gas_price.trim_end_matches('0').trim_end_matches('.')
181 )
182 },
183 )
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use alloy_network::{Ethereum, TransactionBuilder};
191 use alloy_primitives::B256;
192 use alloy_provider::{ProviderBuilder, mock::Asserter};
193 use alloy_rpc_types::{TransactionReceipt, TransactionRequest};
194 use std::collections::VecDeque;
195
196 fn mock_receipt(tx_hash: B256, success: bool) -> TransactionReceipt {
197 serde_json::from_value(serde_json::json!({
198 "type": "0x02", "status": if success { "0x1" } else { "0x0" },
199 "cumulativeGasUsed": "0x5208", "logs": [], "transactionHash": tx_hash,
200 "logsBloom": format!("0x{}", "0".repeat(512)),
201 "transactionIndex": "0x0", "blockHash": B256::ZERO, "blockNumber": "0x3039",
202 "gasUsed": "0x5208", "effectiveGasPrice": "0x4a817c800",
203 "from": "0x0000000000000000000000000000000000000000",
204 "to": "0x0000000000000000000000000000000000000000", "contractAddress": null
205 }))
206 .unwrap()
207 }
208
209 fn mock_sequence(
210 tx_hash: B256,
211 contract: Option<&str>,
212 func: Option<&str>,
213 ) -> ScriptSequence<Ethereum> {
214 let tx = serde_json::from_value(serde_json::json!({
215 "hash": tx_hash, "transactionType": "CALL",
216 "contractName": contract, "contractAddress": null, "function": func,
217 "arguments": null, "additionalContracts": [], "isFixedGasLimit": false,
218 "transaction": {
219 "type": "0x02", "chainId": "0x1", "nonce": "0x0", "gas": "0x5208",
220 "maxFeePerGas": "0x4a817c800", "maxPriorityFeePerGas": "0x3b9aca00",
221 "to": "0x0000000000000000000000000000000000000000",
222 "value": "0x0", "input": "0x", "accessList": []
223 },
224 }))
225 .unwrap();
226 ScriptSequence { transactions: VecDeque::from([tx]), chain: 1, ..Default::default() }
227 }
228
229 #[test]
230 fn format_receipt_displays_contract_and_function() {
231 let hash = B256::repeat_byte(0x42);
232 let seq = mock_sequence(hash, Some("MyContract"), Some("init(address)"));
233 let out = format_receipt(Chain::mainnet(), &mock_receipt(hash, true), Some(&seq));
234
235 assert!(out.contains("Contract: MyContract"));
236 assert!(out.contains("Function: init(address)"));
237 assert!(out.contains("✅ [Success]"));
238 }
239
240 #[test]
241 fn format_receipt_without_sequence_omits_metadata() {
242 let hash = B256::repeat_byte(0x42);
243 let out = format_receipt::<Ethereum>(Chain::mainnet(), &mock_receipt(hash, true), None);
244
245 assert!(!out.contains("Contract:"));
246 assert!(!out.contains("Function:"));
247 }
248
249 #[test]
250 fn format_receipt_skips_empty_contract_name() {
251 let hash = B256::repeat_byte(0x42);
252 let seq = mock_sequence(hash, Some(""), Some("transfer(address)"));
253 let out = format_receipt(Chain::mainnet(), &mock_receipt(hash, true), Some(&seq));
254
255 assert!(!out.contains("Contract:"));
256 assert!(out.contains("Function: transfer(address)"));
257 }
258
259 #[test]
260 fn format_receipt_handles_missing_tx_in_sequence() {
261 let seq = mock_sequence(B256::repeat_byte(0x99), Some("Other"), Some("other()"));
262 let out = format_receipt(
263 Chain::mainnet(),
264 &mock_receipt(B256::repeat_byte(0x42), true),
265 Some(&seq),
266 );
267
268 assert!(!out.contains("Contract:"));
269 assert!(!out.contains("Function:"));
270 }
271
272 #[test]
273 fn format_receipt_shows_contract_on_failure() {
274 let hash = B256::repeat_byte(0x42);
275 let seq = mock_sequence(hash, Some("FailContract"), Some("fail()"));
276 let out = format_receipt(Chain::mainnet(), &mock_receipt(hash, false), Some(&seq));
277
278 assert!(out.contains("❌ [Failed]"));
279 assert!(out.contains("Contract: FailContract"));
280 }
281
282 #[tokio::test]
283 async fn check_tx_status_marks_null_transaction_lookup_as_dropped() {
284 let hash = B256::repeat_byte(0x42);
285 let asserter = Asserter::new();
286 let provider: RootProvider<Ethereum> =
287 ProviderBuilder::default().connect_mocked_client(asserter.clone());
288 let not_found: Option<serde_json::Value> = None;
289
290 for _ in 0..50_000 {
291 asserter.push_success(¬_found);
292 }
293
294 let null_responder = tokio::spawn({
295 let asserter = asserter.clone();
296 async move {
297 let not_found: Option<serde_json::Value> = None;
298 loop {
299 for _ in 0..1_000 {
300 asserter.push_success(¬_found);
301 }
302 tokio::task::yield_now().await;
303 }
304 }
305 });
306
307 let result =
308 tokio::time::timeout(Duration::from_secs(2), check_tx_status(&provider, hash, 0)).await;
309 null_responder.abort();
310
311 let (returned_hash, status) = result.expect(
312 "check_tx_status should not keep waiting when eth_getTransactionByHash returns null",
313 );
314
315 assert_eq!(returned_hash, hash);
316 assert!(matches!(status.unwrap(), TxStatus::Dropped));
317 }
318
319 #[tokio::test]
320 async fn check_tx_status_does_not_mark_lookup_errors_as_dropped() {
321 let hash = B256::repeat_byte(0x42);
322 let asserter = Asserter::new();
323 let provider: RootProvider<Ethereum> =
324 ProviderBuilder::default().connect_mocked_client(asserter.clone());
325 let not_found: Option<serde_json::Value> = None;
326
327 asserter.push_success(¬_found);
329 for _ in 0..50 {
330 asserter.push_failure_msg("lookup unavailable");
331 }
332
333 let (_, status) = check_tx_status(&provider, hash, 0).await;
334 let err = match status {
335 Ok(_) => panic!("transaction lookup errors should not be marked as dropped"),
336 Err(err) => err.to_string(),
337 };
338
339 assert!(err.contains("failed to check if transaction"));
340 assert!(err.contains("lookup unavailable"));
341 }
342
343 const CHECK_TX_TIMEOUT: Duration = Duration::from_secs(15);
346
347 #[tokio::test(flavor = "multi_thread")]
350 async fn check_tx_status_unknown_tx_is_dropped() {
351 let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await;
352 let provider = ProviderBuilder::new()
353 .connect_http(handle.http_endpoint().parse().unwrap())
354 .root()
355 .clone();
356
357 let unknown_hash = B256::repeat_byte(0xab);
359
360 let (returned_hash, status) = tokio::time::timeout(
363 CHECK_TX_TIMEOUT,
364 check_tx_status::<Ethereum>(&provider, unknown_hash, 1),
365 )
366 .await
367 .expect("check_tx_status hung on an unknown tx hash");
368
369 assert_eq!(returned_hash, unknown_hash);
370 let status = status.expect("unknown tx should resolve to Ok(TxStatus::Dropped)");
371 assert!(
372 matches!(status, TxStatus::Dropped),
373 "expected TxStatus::Dropped for an unknown tx",
374 );
375 }
376
377 #[tokio::test(flavor = "multi_thread")]
382 async fn check_tx_status_known_then_dropped_resolves_to_dropped() {
383 let (api, handle) = anvil::spawn(anvil::NodeConfig::test().with_no_mining(true)).await;
387 let signer_provider =
388 ProviderBuilder::new().connect_http(handle.http_endpoint().parse().unwrap());
389
390 let mut wallets = handle.dev_wallets();
391 let from = wallets.next().unwrap().address();
392 let to = wallets.next().unwrap().address();
393 let tx =
394 TransactionRequest::default().with_from(from).with_to(to).with_value(U256::from(1));
395
396 let pending = signer_provider.send_transaction(tx).await.unwrap();
397 let tx_hash = *pending.tx_hash();
398
399 let provider = signer_provider.root().clone();
402 let watcher = tokio::spawn(async move {
403 tokio::time::timeout(
404 CHECK_TX_TIMEOUT,
405 check_tx_status::<Ethereum>(&provider, tx_hash, 1),
406 )
407 .await
408 });
409
410 tokio::time::sleep(Duration::from_millis(1500)).await;
412 api.anvil_drop_transaction(tx_hash).await.unwrap();
413
414 let (returned_hash, status) = watcher
415 .await
416 .unwrap()
417 .expect("check_tx_status hung after the tx was dropped from the mempool");
418 assert_eq!(returned_hash, tx_hash);
419 let status = status.expect("dropped tx should resolve to Ok(TxStatus::Dropped)");
420 assert!(
421 matches!(status, TxStatus::Dropped),
422 "expected TxStatus::Dropped after the tx was evicted from the mempool",
423 );
424 }
425
426 #[tokio::test(flavor = "multi_thread")]
429 async fn check_tx_status_mined_tx_is_success() {
430 let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await;
431 let signer_provider =
432 ProviderBuilder::new().connect_http(handle.http_endpoint().parse().unwrap());
433
434 let mut wallets = handle.dev_wallets();
435 let from = wallets.next().unwrap().address();
436 let to = wallets.next().unwrap().address();
437 let tx =
438 TransactionRequest::default().with_from(from).with_to(to).with_value(U256::from(1));
439
440 let pending = signer_provider.send_transaction(tx).await.unwrap();
442 let tx_hash = *pending.tx_hash();
443 let _ = pending.get_receipt().await.unwrap();
444
445 let provider = signer_provider.root().clone();
446 let (returned_hash, status) = tokio::time::timeout(
447 CHECK_TX_TIMEOUT,
448 check_tx_status::<Ethereum>(&provider, tx_hash, 5),
449 )
450 .await
451 .expect("check_tx_status hung on a mined tx");
452
453 assert_eq!(returned_hash, tx_hash);
454 let status = status.expect("mined tx should resolve to Ok(TxStatus::Success)");
455 assert!(
456 matches!(status, TxStatus::Success(_)),
457 "expected TxStatus::Success for a mined ETH transfer",
458 );
459 }
460}