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