anvil/eth/otterscan/
api.rs

1use crate::eth::{
2    error::{BlockchainError, Result},
3    macros::node_info,
4    EthApi,
5};
6use alloy_consensus::Transaction as TransactionTrait;
7use alloy_network::{
8    AnyHeader, AnyRpcBlock, AnyRpcHeader, AnyRpcTransaction, AnyTxEnvelope, BlockResponse,
9    TransactionResponse,
10};
11use alloy_primitives::{Address, Bytes, B256, U256};
12use alloy_rpc_types::{
13    trace::{
14        otterscan::{
15            BlockDetails, ContractCreator, InternalOperation, OtsBlock, OtsBlockTransactions,
16            OtsReceipt, OtsSlimBlock, OtsTransactionReceipt, TraceEntry, TransactionsWithReceipts,
17        },
18        parity::{
19            Action, CallAction, CallType, CreateAction, CreateOutput, LocalizedTransactionTrace,
20            RewardAction, TraceOutput,
21        },
22    },
23    Block, BlockId, BlockNumberOrTag as BlockNumber, BlockTransactions,
24};
25use itertools::Itertools;
26
27use futures::future::join_all;
28
29pub fn mentions_address(trace: LocalizedTransactionTrace, address: Address) -> Option<B256> {
30    match (trace.trace.action, trace.trace.result) {
31        (Action::Call(CallAction { from, to, .. }), _) if from == address || to == address => {
32            trace.transaction_hash
33        }
34        (_, Some(TraceOutput::Create(CreateOutput { address: created_address, .. })))
35            if created_address == address =>
36        {
37            trace.transaction_hash
38        }
39        (Action::Create(CreateAction { from, .. }), _) if from == address => trace.transaction_hash,
40        (Action::Reward(RewardAction { author, .. }), _) if author == address => {
41            trace.transaction_hash
42        }
43        _ => None,
44    }
45}
46
47/// Converts the list of traces for a transaction into the expected Otterscan format.
48///
49/// Follows format specified in the [`ots_traceTransaction`](https://docs.otterscan.io/api-docs/ots-api#ots_tracetransaction) spec.
50pub fn batch_build_ots_traces(traces: Vec<LocalizedTransactionTrace>) -> Vec<TraceEntry> {
51    traces
52        .into_iter()
53        .filter_map(|trace| {
54            let output = trace
55                .trace
56                .result
57                .map(|r| match r {
58                    TraceOutput::Call(output) => output.output,
59                    TraceOutput::Create(output) => output.code,
60                })
61                .unwrap_or_default();
62            match trace.trace.action {
63                Action::Call(call) => Some(TraceEntry {
64                    r#type: match call.call_type {
65                        CallType::Call => "CALL",
66                        CallType::CallCode => "CALLCODE",
67                        CallType::DelegateCall => "DELEGATECALL",
68                        CallType::StaticCall => "STATICCALL",
69                        CallType::AuthCall => "AUTHCALL",
70                        CallType::None => "NONE",
71                    }
72                    .to_string(),
73                    depth: trace.trace.trace_address.len() as u32,
74                    from: call.from,
75                    to: call.to,
76                    value: call.value,
77                    input: call.input,
78                    output,
79                }),
80                Action::Create(_) | Action::Selfdestruct(_) | Action::Reward(_) => None,
81            }
82        })
83        .collect()
84}
85
86impl EthApi {
87    /// Otterscan currently requires this endpoint, even though it's not part of the `ots_*`.
88    /// Ref: <https://github.com/otterscan/otterscan/blob/071d8c55202badf01804f6f8d53ef9311d4a9e47/src/useProvider.ts#L71>
89    ///
90    /// As a faster alternative to `eth_getBlockByNumber` (by excluding uncle block
91    /// information), which is not relevant in the context of an anvil node
92    pub async fn erigon_get_header_by_number(
93        &self,
94        number: BlockNumber,
95    ) -> Result<Option<AnyRpcBlock>> {
96        node_info!("ots_getApiLevel");
97
98        self.backend.block_by_number(number).await
99    }
100
101    /// As per the latest Otterscan source code, at least version 8 is needed.
102    /// Ref: <https://github.com/otterscan/otterscan/blob/071d8c55202badf01804f6f8d53ef9311d4a9e47/src/params.ts#L1C2-L1C2>
103    pub async fn ots_get_api_level(&self) -> Result<u64> {
104        node_info!("ots_getApiLevel");
105
106        // as required by current otterscan's source code
107        Ok(8)
108    }
109
110    /// Trace internal ETH transfers, contracts creation (CREATE/CREATE2) and self-destructs for a
111    /// certain transaction.
112    pub async fn ots_get_internal_operations(&self, hash: B256) -> Result<Vec<InternalOperation>> {
113        node_info!("ots_getInternalOperations");
114
115        self.backend
116            .mined_transaction(hash)
117            .map(|tx| tx.ots_internal_operations())
118            .ok_or_else(|| BlockchainError::DataUnavailable)
119    }
120
121    /// Check if an ETH address contains code at a certain block number.
122    pub async fn ots_has_code(&self, address: Address, block_number: BlockNumber) -> Result<bool> {
123        node_info!("ots_hasCode");
124        let block_id = Some(BlockId::Number(block_number));
125        Ok(!self.get_code(address, block_id).await?.is_empty())
126    }
127
128    /// Trace a transaction and generate a trace call tree.
129    pub async fn ots_trace_transaction(&self, hash: B256) -> Result<Vec<TraceEntry>> {
130        node_info!("ots_traceTransaction");
131
132        Ok(batch_build_ots_traces(self.backend.trace_transaction(hash).await?))
133    }
134
135    /// Given a transaction hash, returns its raw revert reason.
136    pub async fn ots_get_transaction_error(&self, hash: B256) -> Result<Bytes> {
137        node_info!("ots_getTransactionError");
138
139        if let Some(receipt) = self.backend.mined_transaction_receipt(hash) {
140            if !receipt.inner.inner.as_receipt_with_bloom().receipt.status.coerce_status() {
141                return Ok(receipt.out.map(|b| b.0.into()).unwrap_or(Bytes::default()));
142            }
143        }
144
145        Ok(Bytes::default())
146    }
147
148    /// For simplicity purposes, we return the entire block instead of emptying the values that
149    /// Otterscan doesn't want. This is the original purpose of the endpoint (to save bandwidth),
150    /// but it doesn't seem necessary in the context of an anvil node
151    pub async fn ots_get_block_details(
152        &self,
153        number: BlockNumber,
154    ) -> Result<BlockDetails<AnyRpcHeader>> {
155        node_info!("ots_getBlockDetails");
156
157        if let Some(block) = self.backend.block_by_number(number).await? {
158            let ots_block = self.build_ots_block_details(block).await?;
159            Ok(ots_block)
160        } else {
161            Err(BlockchainError::BlockNotFound)
162        }
163    }
164
165    /// For simplicity purposes, we return the entire block instead of emptying the values that
166    /// Otterscan doesn't want. This is the original purpose of the endpoint (to save bandwidth),
167    /// but it doesn't seem necessary in the context of an anvil node
168    pub async fn ots_get_block_details_by_hash(
169        &self,
170        hash: B256,
171    ) -> Result<BlockDetails<AnyRpcHeader>> {
172        node_info!("ots_getBlockDetailsByHash");
173
174        if let Some(block) = self.backend.block_by_hash(hash).await? {
175            let ots_block = self.build_ots_block_details(block).await?;
176            Ok(ots_block)
177        } else {
178            Err(BlockchainError::BlockNotFound)
179        }
180    }
181
182    /// Gets paginated transaction data for a certain block. Return data is similar to
183    /// eth_getBlockBy* + eth_getTransactionReceipt.
184    pub async fn ots_get_block_transactions(
185        &self,
186        number: u64,
187        page: usize,
188        page_size: usize,
189    ) -> Result<OtsBlockTransactions<AnyRpcTransaction, AnyRpcHeader>> {
190        node_info!("ots_getBlockTransactions");
191
192        match self.backend.block_by_number_full(number.into()).await? {
193            Some(block) => self.build_ots_block_tx(block, page, page_size).await,
194            None => Err(BlockchainError::BlockNotFound),
195        }
196    }
197
198    /// Address history navigation. searches backwards from certain point in time.
199    pub async fn ots_search_transactions_before(
200        &self,
201        address: Address,
202        block_number: u64,
203        page_size: usize,
204    ) -> Result<TransactionsWithReceipts<alloy_rpc_types::Transaction<AnyTxEnvelope>>> {
205        node_info!("ots_searchTransactionsBefore");
206
207        let best = self.backend.best_number();
208        // we go from given block (defaulting to best) down to first block
209        // considering only post-fork
210        let from = if block_number == 0 { best } else { block_number - 1 };
211        let to = self.get_fork().map(|f| f.block_number() + 1).unwrap_or(1);
212
213        let first_page = from >= best;
214        let mut last_page = false;
215
216        let mut res: Vec<_> = vec![];
217
218        for n in (to..=from).rev() {
219            if let Some(traces) = self.backend.mined_parity_trace_block(n) {
220                let hashes = traces
221                    .into_iter()
222                    .rev()
223                    .filter_map(|trace| mentions_address(trace, address))
224                    .unique();
225
226                if res.len() >= page_size {
227                    break;
228                }
229
230                res.extend(hashes);
231            }
232
233            if n == to {
234                last_page = true;
235            }
236        }
237
238        self.build_ots_search_transactions(res, first_page, last_page).await
239    }
240
241    /// Address history navigation. searches forward from certain point in time.
242    pub async fn ots_search_transactions_after(
243        &self,
244        address: Address,
245        block_number: u64,
246        page_size: usize,
247    ) -> Result<TransactionsWithReceipts<alloy_rpc_types::Transaction<AnyTxEnvelope>>> {
248        node_info!("ots_searchTransactionsAfter");
249
250        let best = self.backend.best_number();
251        // we go from the first post-fork block, up to the tip
252        let first_block = self.get_fork().map(|f| f.block_number() + 1).unwrap_or(1);
253        let from = if block_number == 0 { first_block } else { block_number + 1 };
254        let to = best;
255
256        let mut first_page = from >= best;
257        let mut last_page = false;
258
259        let mut res: Vec<_> = vec![];
260
261        for n in from..=to {
262            if n == first_block {
263                last_page = true;
264            }
265
266            if let Some(traces) = self.backend.mined_parity_trace_block(n) {
267                let hashes = traces
268                    .into_iter()
269                    .rev()
270                    .filter_map(|trace| mentions_address(trace, address))
271                    .unique();
272
273                if res.len() >= page_size {
274                    break;
275                }
276
277                res.extend(hashes);
278            }
279
280            if n == to {
281                first_page = true;
282            }
283        }
284
285        // Results are always sent in reverse chronological order, according to the Otterscan spec
286        res.reverse();
287        self.build_ots_search_transactions(res, first_page, last_page).await
288    }
289
290    /// Given a sender address and a nonce, returns the tx hash or null if not found. It returns
291    /// only the tx hash on success, you can use the standard eth_getTransactionByHash after that to
292    /// get the full transaction data.
293    pub async fn ots_get_transaction_by_sender_and_nonce(
294        &self,
295        address: Address,
296        nonce: U256,
297    ) -> Result<Option<B256>> {
298        node_info!("ots_getTransactionBySenderAndNonce");
299
300        let from = self.get_fork().map(|f| f.block_number() + 1).unwrap_or_default();
301        let to = self.backend.best_number();
302
303        for n in (from..=to).rev() {
304            if let Some(txs) = self.backend.mined_transactions_by_block_number(n.into()).await {
305                for tx in txs {
306                    if U256::from(tx.nonce()) == nonce && tx.from() == address {
307                        return Ok(Some(tx.tx_hash()));
308                    }
309                }
310            }
311        }
312
313        Ok(None)
314    }
315
316    /// Given an ETH contract address, returns the tx hash and the direct address who created the
317    /// contract.
318    pub async fn ots_get_contract_creator(&self, addr: Address) -> Result<Option<ContractCreator>> {
319        node_info!("ots_getContractCreator");
320
321        let from = self.get_fork().map(|f| f.block_number()).unwrap_or_default();
322        let to = self.backend.best_number();
323
324        // loop in reverse, since we want the latest deploy to the address
325        for n in (from..=to).rev() {
326            if let Some(traces) = self.backend.mined_parity_trace_block(n) {
327                for trace in traces.into_iter().rev() {
328                    match (trace.trace.action, trace.trace.result) {
329                        (
330                            Action::Create(CreateAction { from, .. }),
331                            Some(TraceOutput::Create(CreateOutput { address, .. })),
332                        ) if address == addr => {
333                            return Ok(Some(ContractCreator {
334                                hash: trace.transaction_hash.unwrap(),
335                                creator: from,
336                            }));
337                        }
338                        _ => {}
339                    }
340                }
341            }
342        }
343
344        Ok(None)
345    }
346    /// The response for ots_getBlockDetails includes an `issuance` object that requires computing
347    /// the total gas spent in a given block.
348    ///
349    /// The only way to do this with the existing API is to explicitly fetch all receipts, to get
350    /// their `gas_used`. This would be extremely inefficient in a real blockchain RPC, but we can
351    /// get away with that in this context.
352    ///
353    /// The [original spec](https://docs.otterscan.io/api-docs/ots-api#ots_getblockdetails)
354    /// also mentions we can hardcode `transactions` and `logsBloom` to an empty array to save
355    /// bandwidth, because fields weren't intended to be used in the Otterscan UI at this point.
356    ///
357    /// This has two problems though:
358    ///   - It makes the endpoint too specific to Otterscan's implementation
359    ///   - It breaks the abstraction built in `OtsBlock<TX>` which computes `transaction_count`
360    ///     based on the existing list.
361    ///
362    /// Therefore we keep it simple by keeping the data in the response
363    pub async fn build_ots_block_details(
364        &self,
365        block: AnyRpcBlock,
366    ) -> Result<BlockDetails<alloy_rpc_types::Header<AnyHeader>>> {
367        if block.transactions.is_uncle() {
368            return Err(BlockchainError::DataUnavailable);
369        }
370        let receipts_futs = block
371            .transactions
372            .hashes()
373            .map(|hash| async move { self.transaction_receipt(hash).await });
374
375        // fetch all receipts
376        let receipts = join_all(receipts_futs)
377            .await
378            .into_iter()
379            .map(|r| match r {
380                Ok(Some(r)) => Ok(r),
381                _ => Err(BlockchainError::DataUnavailable),
382            })
383            .collect::<Result<Vec<_>>>()?;
384
385        let total_fees = receipts
386            .iter()
387            .fold(0, |acc, receipt| acc + (receipt.gas_used as u128) * receipt.effective_gas_price);
388
389        let Block { header, uncles, transactions, withdrawals } = block.into_inner();
390
391        let block =
392            OtsSlimBlock { header, uncles, transaction_count: transactions.len(), withdrawals };
393
394        Ok(BlockDetails {
395            block,
396            total_fees: U256::from(total_fees),
397            // issuance has no meaningful value in anvil's backend. just default to 0
398            issuance: Default::default(),
399        })
400    }
401
402    /// Fetches all receipts for the blocks's transactions, as required by the
403    /// [`ots_getBlockTransactions`] endpoint spec, and returns the final response object.
404    ///
405    /// [`ots_getBlockTransactions`]: https://docs.otterscan.io/api-docs/ots-api#ots_getblocktransactions
406    pub async fn build_ots_block_tx(
407        &self,
408        mut block: AnyRpcBlock,
409        page: usize,
410        page_size: usize,
411    ) -> Result<OtsBlockTransactions<AnyRpcTransaction, AnyRpcHeader>> {
412        if block.transactions.is_uncle() {
413            return Err(BlockchainError::DataUnavailable);
414        }
415
416        block.transactions = match block.transactions() {
417            BlockTransactions::Full(txs) => BlockTransactions::Full(
418                txs.iter().skip(page * page_size).take(page_size).cloned().collect(),
419            ),
420            BlockTransactions::Hashes(txs) => BlockTransactions::Hashes(
421                txs.iter().skip(page * page_size).take(page_size).cloned().collect(),
422            ),
423            BlockTransactions::Uncle => unreachable!(),
424        };
425
426        let receipt_futs = block.transactions.hashes().map(|hash| self.transaction_receipt(hash));
427
428        let receipts = join_all(receipt_futs.map(|r| async {
429            if let Ok(Some(r)) = r.await {
430                let block = self.block_by_number(r.block_number.unwrap().into()).await?;
431                let timestamp = block.ok_or(BlockchainError::BlockNotFound)?.header.timestamp;
432                let receipt = r.map_inner(OtsReceipt::from);
433                Ok(OtsTransactionReceipt { receipt, timestamp: Some(timestamp) })
434            } else {
435                Err(BlockchainError::BlockNotFound)
436            }
437        }))
438        .await
439        .into_iter()
440        .collect::<Result<Vec<_>>>()?;
441
442        let transaction_count = block.transactions().len();
443        let fullblock = OtsBlock { block: block.inner.clone(), transaction_count };
444
445        let ots_block_txs = OtsBlockTransactions { fullblock, receipts };
446
447        Ok(ots_block_txs)
448    }
449
450    pub async fn build_ots_search_transactions(
451        &self,
452        hashes: Vec<B256>,
453        first_page: bool,
454        last_page: bool,
455    ) -> Result<TransactionsWithReceipts<alloy_rpc_types::Transaction<AnyTxEnvelope>>> {
456        let txs_futs = hashes.iter().map(|hash| async { self.transaction_by_hash(*hash).await });
457
458        let txs = join_all(txs_futs)
459            .await
460            .into_iter()
461            .map(|t| match t {
462                Ok(Some(t)) => Ok(t.into_inner()),
463                _ => Err(BlockchainError::DataUnavailable),
464            })
465            .collect::<Result<Vec<_>>>()?;
466
467        let receipt_futs = hashes.iter().map(|hash| self.transaction_receipt(*hash));
468
469        let receipts = join_all(receipt_futs.map(|r| async {
470            if let Ok(Some(r)) = r.await {
471                let block = self.block_by_number(r.block_number.unwrap().into()).await?;
472                let timestamp = block.ok_or(BlockchainError::BlockNotFound)?.header.timestamp;
473                let receipt = r.map_inner(OtsReceipt::from);
474                Ok(OtsTransactionReceipt { receipt, timestamp: Some(timestamp) })
475            } else {
476                Err(BlockchainError::BlockNotFound)
477            }
478        }))
479        .await
480        .into_iter()
481        .collect::<Result<Vec<_>>>()?;
482
483        Ok(TransactionsWithReceipts { txs, receipts, first_page, last_page })
484    }
485}