1use alloy_chains::{Chain, NamedChain};
2use alloy_network::AnyTransactionReceipt;
3use alloy_primitives::{TxHash, U256, utils::format_units};
4use alloy_provider::{PendingTransactionBuilder, PendingTransactionError, Provider, WatchTxError};
5use eyre::{Result, eyre};
6use forge_script_sequence::ScriptSequence;
7use foundry_common::{provider::RetryProvider, retry, retry::RetryError, shell};
8use std::time::Duration;
9
10#[derive(Debug, thiserror::Error)]
12#[error(
13 "Received a pending receipt for {tx_hash}, but transaction is still known to the node, retrying"
14)]
15pub struct PendingReceiptError {
16 pub tx_hash: TxHash,
17}
18
19pub enum TxStatus {
21 Dropped,
22 Success(AnyTransactionReceipt),
23 Revert(AnyTransactionReceipt),
24}
25
26impl From<AnyTransactionReceipt> for TxStatus {
27 fn from(receipt: AnyTransactionReceipt) -> Self {
28 if !receipt.inner.inner.inner.receipt.status.coerce_status() {
29 Self::Revert(receipt)
30 } else {
31 Self::Success(receipt)
32 }
33 }
34}
35
36pub async fn check_tx_status(
39 provider: &RetryProvider,
40 hash: TxHash,
41 timeout: u64,
42) -> (TxHash, Result<TxStatus, eyre::Report>) {
43 let result = retry::Retry::new_no_delay(3)
44 .run_async_until_break(|| async {
45 match PendingTransactionBuilder::new(provider.clone(), hash)
46 .with_timeout(Some(Duration::from_secs(timeout)))
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(_) => {
63 tokio::time::sleep(Duration::from_millis(500)).await;
65 Err(RetryError::Retry(PendingReceiptError { tx_hash: hash }.into()))
67 }
68 Err(_) => {
69 Ok(TxStatus::Dropped)
71 }
72 }
73 }
74 Err(e) => match provider.get_transaction_by_hash(hash).await {
75 Ok(_) => match e {
76 PendingTransactionError::TxWatcher(WatchTxError::Timeout) => {
77 Err(RetryError::Continue(eyre!(
78 "tx is still known to the node, waiting for receipt"
79 )))
80 }
81 _ => Err(RetryError::Retry(e.into())),
82 },
83 Err(_) => Ok(TxStatus::Dropped),
84 },
85 }
86 })
87 .await;
88
89 (hash, result)
90}
91
92pub fn format_receipt(
94 chain: Chain,
95 receipt: &AnyTransactionReceipt,
96 sequence: Option<&ScriptSequence>,
97) -> String {
98 let gas_used = receipt.gas_used;
99 let gas_price = receipt.effective_gas_price;
100 let block_number = receipt.block_number.unwrap_or_default();
101 let success = receipt.inner.inner.inner.receipt.status.coerce_status();
102
103 let (contract_name, function) = sequence
104 .and_then(|seq| {
105 seq.transactions
106 .iter()
107 .find(|tx| tx.hash == Some(receipt.transaction_hash))
108 .map(|tx| (tx.contract_name.clone(), tx.function.clone()))
109 })
110 .unwrap_or((None, None));
111
112 if shell::is_json() {
113 let mut json = serde_json::json!({
114 "chain": chain,
115 "status": if success {
116 "success"
117 } else {
118 "failed"
119 },
120 "tx_hash": receipt.transaction_hash,
121 "contract_address": receipt.contract_address.map(|addr| addr.to_string()),
122 "block_number": block_number,
123 "gas_used": gas_used,
124 "gas_price": gas_price,
125 });
126
127 if let Some(name) = &contract_name
128 && !name.is_empty()
129 {
130 json["contract_name"] = serde_json::Value::String(name.clone());
131 }
132 if let Some(func) = &function
133 && !func.is_empty()
134 {
135 json["function"] = serde_json::Value::String(func.clone());
136 }
137
138 let _ = sh_println!("{}", json);
139
140 String::new()
141 } else {
142 let contract_info = match &contract_name {
143 Some(name) if !name.is_empty() => format!("\nContract: {name}"),
144 _ => String::new(),
145 };
146
147 let function_info = match &function {
148 Some(func) if !func.is_empty() => format!("\nFunction: {func}"),
149 _ => String::new(),
150 };
151
152 format!(
153 "\n##### {chain}\n{status} Hash: {tx_hash:?}{contract_info}{function_info}{contract_address}\nBlock: {block_number}\n{gas}\n\n",
154 status = if success { "✅ [Success]" } else { "❌ [Failed]" },
155 tx_hash = receipt.transaction_hash,
156 contract_address = if let Some(addr) = &receipt.contract_address {
157 format!("\nContract Address: {}", addr.to_checksum(None))
158 } else {
159 String::new()
160 },
161 gas = if gas_price == 0 {
162 format!("Gas Used: {gas_used}")
163 } else {
164 let paid = format_units((gas_used as u128).saturating_mul(gas_price), 18)
165 .unwrap_or_else(|_| "N/A".into());
166 let gas_price =
167 format_units(U256::from(gas_price), 9).unwrap_or_else(|_| "N/A".into());
168 let token_symbol = NamedChain::try_from(chain)
169 .unwrap_or_default()
170 .native_currency_symbol()
171 .unwrap_or("ETH");
172 format!(
173 "Paid: {} {} ({gas_used} gas * {} gwei)",
174 paid.trim_end_matches('0'),
175 token_symbol,
176 gas_price.trim_end_matches('0').trim_end_matches('.')
177 )
178 },
179 )
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use alloy_primitives::B256;
187 use std::collections::VecDeque;
188
189 fn mock_receipt(tx_hash: B256, success: bool) -> AnyTransactionReceipt {
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(tx_hash: B256, contract: Option<&str>, func: Option<&str>) -> ScriptSequence {
203 let tx = serde_json::from_value(serde_json::json!({
204 "hash": tx_hash, "transactionType": "CALL",
205 "contractName": contract, "contractAddress": null, "function": func,
206 "arguments": null, "additionalContracts": [], "isFixedGasLimit": false,
207 "transaction": {
208 "type": "0x02", "chainId": "0x1", "nonce": "0x0", "gas": "0x5208",
209 "maxFeePerGas": "0x4a817c800", "maxPriorityFeePerGas": "0x3b9aca00",
210 "to": "0x0000000000000000000000000000000000000000",
211 "value": "0x0", "input": "0x", "accessList": []
212 },
213 }))
214 .unwrap();
215 ScriptSequence { transactions: VecDeque::from([tx]), chain: 1, ..Default::default() }
216 }
217
218 #[test]
219 fn format_receipt_displays_contract_and_function() {
220 let hash = B256::repeat_byte(0x42);
221 let seq = mock_sequence(hash, Some("MyContract"), Some("init(address)"));
222 let out = format_receipt(Chain::mainnet(), &mock_receipt(hash, true), Some(&seq));
223
224 assert!(out.contains("Contract: MyContract"));
225 assert!(out.contains("Function: init(address)"));
226 assert!(out.contains("✅ [Success]"));
227 }
228
229 #[test]
230 fn format_receipt_without_sequence_omits_metadata() {
231 let hash = B256::repeat_byte(0x42);
232 let out = format_receipt(Chain::mainnet(), &mock_receipt(hash, true), None);
233
234 assert!(!out.contains("Contract:"));
235 assert!(!out.contains("Function:"));
236 }
237
238 #[test]
239 fn format_receipt_skips_empty_contract_name() {
240 let hash = B256::repeat_byte(0x42);
241 let seq = mock_sequence(hash, Some(""), Some("transfer(address)"));
242 let out = format_receipt(Chain::mainnet(), &mock_receipt(hash, true), Some(&seq));
243
244 assert!(!out.contains("Contract:"));
245 assert!(out.contains("Function: transfer(address)"));
246 }
247
248 #[test]
249 fn format_receipt_handles_missing_tx_in_sequence() {
250 let seq = mock_sequence(B256::repeat_byte(0x99), Some("Other"), Some("other()"));
251 let out = format_receipt(
252 Chain::mainnet(),
253 &mock_receipt(B256::repeat_byte(0x42), true),
254 Some(&seq),
255 );
256
257 assert!(!out.contains("Contract:"));
258 assert!(!out.contains("Function:"));
259 }
260
261 #[test]
262 fn format_receipt_shows_contract_on_failure() {
263 let hash = B256::repeat_byte(0x42);
264 let seq = mock_sequence(hash, Some("FailContract"), Some("fail()"));
265 let out = format_receipt(Chain::mainnet(), &mock_receipt(hash, false), Some(&seq));
266
267 assert!(out.contains("❌ [Failed]"));
268 assert!(out.contains("Contract: FailContract"));
269 }
270}