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(_) => {
61 tokio::time::sleep(Duration::from_millis(500)).await;
63 Err(RetryError::Retry(PendingReceiptError { tx_hash: hash }.into()))
65 }
66 Err(_) => {
67 Ok(TxStatus::Dropped)
69 }
70 }
71 }
72 Err(e) => match provider.get_transaction_by_hash(hash).await {
73 Ok(_) => 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 Err(_) => Ok(TxStatus::Dropped),
82 },
83 }
84 })
85 .await;
86
87 (hash, result)
88}
89
90pub fn format_receipt<N: Network>(
92 chain: Chain,
93 receipt: &N::ReceiptResponse,
94 sequence: Option<&ScriptSequence<N>>,
95) -> String {
96 let gas_used = receipt.gas_used();
97 let gas_price = receipt.effective_gas_price();
98 let block_number = receipt.block_number().unwrap_or_default();
99 let success = receipt.status();
100
101 let (contract_name, function) = sequence
102 .and_then(|seq| {
103 seq.transactions
104 .iter()
105 .find(|tx| tx.hash == Some(receipt.transaction_hash()))
106 .map(|tx| (tx.contract_name.clone(), tx.function.clone()))
107 })
108 .unwrap_or((None, None));
109
110 if shell::is_json() {
111 let mut json = serde_json::json!({
112 "chain": chain,
113 "status": if success {
114 "success"
115 } else {
116 "failed"
117 },
118 "tx_hash": receipt.transaction_hash(),
119 "contract_address": receipt.contract_address().map(|addr| addr.to_string()),
120 "block_number": block_number,
121 "gas_used": gas_used,
122 "gas_price": gas_price,
123 });
124
125 if let Some(name) = &contract_name
126 && !name.is_empty()
127 {
128 json["contract_name"] = serde_json::Value::String(name.clone());
129 }
130 if let Some(func) = &function
131 && !func.is_empty()
132 {
133 json["function"] = serde_json::Value::String(func.clone());
134 }
135
136 let _ = sh_println!("{}", json);
137
138 String::new()
139 } else {
140 let contract_info = match &contract_name {
141 Some(name) if !name.is_empty() => format!("\nContract: {name}"),
142 _ => String::new(),
143 };
144
145 let function_info = match &function {
146 Some(func) if !func.is_empty() => format!("\nFunction: {func}"),
147 _ => String::new(),
148 };
149
150 format!(
151 "\n##### {chain}\n{status} Hash: {tx_hash:?}{contract_info}{function_info}{contract_address}\nBlock: {block_number}\n{gas}\n\n",
152 status = if success { "✅ [Success]" } else { "❌ [Failed]" },
153 tx_hash = receipt.transaction_hash(),
154 contract_address = if let Some(addr) = receipt.contract_address() {
155 format!("\nContract Address: {}", addr.to_checksum(None))
156 } else {
157 String::new()
158 },
159 gas = if gas_price == 0 {
160 format!("Gas Used: {gas_used}")
161 } else {
162 let paid = format_units((gas_used as u128).saturating_mul(gas_price), 18)
163 .unwrap_or_else(|_| "N/A".into());
164 let gas_price =
165 format_units(U256::from(gas_price), 9).unwrap_or_else(|_| "N/A".into());
166 let token_symbol = NamedChain::try_from(chain)
167 .unwrap_or_default()
168 .native_currency_symbol()
169 .unwrap_or("ETH");
170 format!(
171 "Paid: {} {} ({gas_used} gas * {} gwei)",
172 paid.trim_end_matches('0'),
173 token_symbol,
174 gas_price.trim_end_matches('0').trim_end_matches('.')
175 )
176 },
177 )
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use alloy_network::Ethereum;
185 use alloy_primitives::B256;
186 use alloy_rpc_types::TransactionReceipt;
187 use std::collections::VecDeque;
188
189 fn mock_receipt(tx_hash: B256, success: bool) -> TransactionReceipt {
190 serde_json::from_value(serde_json::json!({
191 "type": "0x02", "status": if success { "0x1" } else { "0x0" },
192 "cumulativeGasUsed": "0x5208", "logs": [], "transactionHash": tx_hash,
193 "logsBloom": format!("0x{}", "0".repeat(512)),
194 "transactionIndex": "0x0", "blockHash": B256::ZERO, "blockNumber": "0x3039",
195 "gasUsed": "0x5208", "effectiveGasPrice": "0x4a817c800",
196 "from": "0x0000000000000000000000000000000000000000",
197 "to": "0x0000000000000000000000000000000000000000", "contractAddress": null
198 }))
199 .unwrap()
200 }
201
202 fn mock_sequence(
203 tx_hash: B256,
204 contract: Option<&str>,
205 func: Option<&str>,
206 ) -> ScriptSequence<Ethereum> {
207 let tx = serde_json::from_value(serde_json::json!({
208 "hash": tx_hash, "transactionType": "CALL",
209 "contractName": contract, "contractAddress": null, "function": func,
210 "arguments": null, "additionalContracts": [], "isFixedGasLimit": false,
211 "transaction": {
212 "type": "0x02", "chainId": "0x1", "nonce": "0x0", "gas": "0x5208",
213 "maxFeePerGas": "0x4a817c800", "maxPriorityFeePerGas": "0x3b9aca00",
214 "to": "0x0000000000000000000000000000000000000000",
215 "value": "0x0", "input": "0x", "accessList": []
216 },
217 }))
218 .unwrap();
219 ScriptSequence { transactions: VecDeque::from([tx]), chain: 1, ..Default::default() }
220 }
221
222 #[test]
223 fn format_receipt_displays_contract_and_function() {
224 let hash = B256::repeat_byte(0x42);
225 let seq = mock_sequence(hash, Some("MyContract"), Some("init(address)"));
226 let out = format_receipt(Chain::mainnet(), &mock_receipt(hash, true), Some(&seq));
227
228 assert!(out.contains("Contract: MyContract"));
229 assert!(out.contains("Function: init(address)"));
230 assert!(out.contains("✅ [Success]"));
231 }
232
233 #[test]
234 fn format_receipt_without_sequence_omits_metadata() {
235 let hash = B256::repeat_byte(0x42);
236 let out = format_receipt::<Ethereum>(Chain::mainnet(), &mock_receipt(hash, true), None);
237
238 assert!(!out.contains("Contract:"));
239 assert!(!out.contains("Function:"));
240 }
241
242 #[test]
243 fn format_receipt_skips_empty_contract_name() {
244 let hash = B256::repeat_byte(0x42);
245 let seq = mock_sequence(hash, Some(""), Some("transfer(address)"));
246 let out = format_receipt(Chain::mainnet(), &mock_receipt(hash, true), Some(&seq));
247
248 assert!(!out.contains("Contract:"));
249 assert!(out.contains("Function: transfer(address)"));
250 }
251
252 #[test]
253 fn format_receipt_handles_missing_tx_in_sequence() {
254 let seq = mock_sequence(B256::repeat_byte(0x99), Some("Other"), Some("other()"));
255 let out = format_receipt(
256 Chain::mainnet(),
257 &mock_receipt(B256::repeat_byte(0x42), true),
258 Some(&seq),
259 );
260
261 assert!(!out.contains("Contract:"));
262 assert!(!out.contains("Function:"));
263 }
264
265 #[test]
266 fn format_receipt_shows_contract_on_failure() {
267 let hash = B256::repeat_byte(0x42);
268 let seq = mock_sequence(hash, Some("FailContract"), Some("fail()"));
269 let out = format_receipt(Chain::mainnet(), &mock_receipt(hash, false), Some(&seq));
270
271 assert!(out.contains("❌ [Failed]"));
272 assert!(out.contains("Contract: FailContract"));
273 }
274}