Skip to main content

reth_consensus_debug_client/providers/
etherscan.rs

1use crate::PayloadProvider;
2use alloy_eips::BlockNumberOrTag;
3use alloy_json_rpc::{Response, ResponsePayload};
4use futures::future::BoxFuture;
5use reqwest::Client;
6use reth_node_api::ExecutionPayload;
7use serde::{de::DeserializeOwned, Serialize};
8use std::{sync::Arc, time::Duration};
9use tokio::{sync::mpsc, time::interval};
10use tracing::{debug, warn};
11
12/// Block provider that fetches new blocks from Etherscan API.
13#[derive(derive_more::Debug)]
14pub struct EtherscanBlockProvider<RpcBlock, ExecutionData> {
15    http_client: Client,
16    base_url: String,
17    api_key: String,
18    chain_id: u64,
19    interval: Duration,
20    #[debug(skip)]
21    convert: Arc<dyn Fn(RpcBlock) -> ExecutionData + Send + Sync>,
22}
23
24impl<RpcBlock, ExecutionData> EtherscanBlockProvider<RpcBlock, ExecutionData>
25where
26    RpcBlock: Serialize + DeserializeOwned,
27{
28    /// Create a new Etherscan block provider with the given base URL and API key.
29    pub fn new(
30        base_url: String,
31        api_key: String,
32        chain_id: u64,
33        convert: impl Fn(RpcBlock) -> ExecutionData + Send + Sync + 'static,
34    ) -> Self {
35        Self {
36            http_client: Client::new(),
37            base_url,
38            api_key,
39            chain_id,
40            interval: Duration::from_secs(3),
41            convert: Arc::new(convert),
42        }
43    }
44
45    /// Sets the interval at which the provider fetches new blocks.
46    pub const fn with_interval(mut self, interval: Duration) -> Self {
47        self.interval = interval;
48        self
49    }
50
51    /// Load block using Etherscan API. Note: only `BlockNumberOrTag::Latest`,
52    /// `BlockNumberOrTag::Earliest`, `BlockNumberOrTag::Pending`, `BlockNumberOrTag::Number(u64)`
53    /// are supported.
54    pub async fn load_payload(
55        &self,
56        block_number_or_tag: BlockNumberOrTag,
57    ) -> eyre::Result<ExecutionData> {
58        let resp = fetch_block(
59            &self.http_client,
60            &self.base_url,
61            &self.api_key,
62            self.chain_id,
63            block_number_or_tag,
64        )
65        .await?;
66
67        let resp: Response<RpcBlock> = serde_json::from_str(&resp).inspect_err(|err| {
68            warn!(target: "etherscan", "Failed to parse block response from etherscan: {}", err);
69        })?;
70
71        let payload = resp.payload;
72        match payload {
73            ResponsePayload::Success(block) => Ok((self.convert)(block)),
74            ResponsePayload::Failure(err) => Err(eyre::eyre!("Failed to get block: {err}")),
75        }
76    }
77}
78
79// Implemented manually so cloning doesn't require `RpcBlock: Clone` and `ExecutionData: Clone`,
80// which are only used by the shared conversion function.
81impl<RpcBlock, ExecutionData> Clone for EtherscanBlockProvider<RpcBlock, ExecutionData> {
82    fn clone(&self) -> Self {
83        Self {
84            http_client: self.http_client.clone(),
85            base_url: self.base_url.clone(),
86            api_key: self.api_key.clone(),
87            chain_id: self.chain_id,
88            interval: self.interval,
89            convert: Arc::clone(&self.convert),
90        }
91    }
92}
93
94impl<RpcBlock, ExecutionData> PayloadProvider for EtherscanBlockProvider<RpcBlock, ExecutionData>
95where
96    RpcBlock: Serialize + DeserializeOwned + 'static,
97    ExecutionData: ExecutionPayload,
98{
99    type ExecutionData = ExecutionData;
100
101    async fn subscribe_payloads(&self, tx: mpsc::Sender<Self::ExecutionData>) {
102        let mut last_block_number: Option<u64> = None;
103        let mut interval = interval(self.interval);
104        loop {
105            interval.tick().await;
106            let payload = match self.load_payload(BlockNumberOrTag::Latest).await {
107                Ok(payload) => payload,
108                Err(err) => {
109                    warn!(
110                        target: "consensus::debug-client",
111                        %err,
112                        "Failed to fetch a block from Etherscan",
113                    );
114                    continue
115                }
116            };
117            let block_number = payload.block_number();
118            if Some(block_number) == last_block_number {
119                continue;
120            }
121
122            if tx.send(payload).await.is_err() {
123                // Channel closed.
124                break;
125            }
126
127            last_block_number = Some(block_number);
128        }
129    }
130
131    async fn get_payload(&self, block_number: u64) -> eyre::Result<Self::ExecutionData> {
132        self.load_payload(BlockNumberOrTag::Number(block_number)).await
133    }
134}
135
136/// Requests a block from Etherscan and returns the raw response body.
137///
138/// Boxed so the `reqwest` future is compiled here, not in each crate that polls it.
139fn fetch_block<'a>(
140    http_client: &'a Client,
141    base_url: &'a str,
142    api_key: &'a str,
143    chain_id: u64,
144    block_number_or_tag: BlockNumberOrTag,
145) -> BoxFuture<'a, eyre::Result<String>> {
146    Box::pin(async move {
147        let tag = match block_number_or_tag {
148            BlockNumberOrTag::Number(num) => format!("{num:#x}"),
149            tag => tag.to_string(),
150        };
151
152        let mut req = http_client.get(base_url).query(&[
153            ("module", "proxy"),
154            ("action", "eth_getBlockByNumber"),
155            ("tag", &tag),
156            ("boolean", "true"),
157            ("apikey", api_key),
158        ]);
159
160        if !base_url.contains("chainid=") {
161            // only append chainid if not part of the base url already
162            req = req.query(&[("chainid", &chain_id.to_string())]);
163        }
164
165        let resp = req.send().await?.text().await?;
166
167        debug!(target: "etherscan", %tag, response_bytes = resp.len(), "fetched block from etherscan");
168
169        Ok(resp)
170    })
171}