Skip to main content

taceo_nodes_common/
web3.rs

1//! HTTP RPC provider utilities for interacting with Ethereum nodes.
2//!
3//! This module provides configurable HTTP RPC providers built on top of
4//! [`alloy`] transports. It supports:
5//!
6//! - HTTP RPC with automatic retry and exponential backoff
7//! - Multiple HTTP endpoints with automatic failover
8//! - Optional wallet integration for transaction signing
9//!
10//! Use [`HttpRpcProviderBuilder`] to build an HTTP RPC provider.
11//! HTTP transports are wrapped with retry and fallback layers to improve
12//! reliability when interacting with RPC endpoints.
13//!
14//! Use [`GetReceiptExt::get_receipt_with_retry`] to reliably wait for a
15//! transaction receipt, working around cases where a load-balanced RPC
16//! endpoint reports a transaction as confirmed but still returns no receipt
17//! for it.
18use core::fmt;
19use std::{
20    future::Future,
21    num::NonZeroUsize,
22    ops::Deref,
23    task::{Context, Poll},
24    time::Duration,
25};
26
27#[cfg(feature = "web3-asserter")]
28use alloy::providers::mock::Asserter;
29use alloy::{
30    network::{Ethereum, EthereumWallet},
31    primitives::ChainId,
32    providers::{
33        DynProvider, PendingTransactionBuilder, PendingTransactionError, Provider, ProviderBuilder,
34        fillers::{BlobGasFiller, ChainIdFiller, NonceManager, SimpleNonceManager},
35    },
36    rpc::{
37        client::RpcClient,
38        json_rpc::{RequestPacket, ResponsePacket},
39        types::TransactionReceipt,
40    },
41    transports::{
42        RpcError, Transport, TransportError, TransportErrorKind, TransportFut,
43        http::{
44            Http,
45            reqwest::{self, IntoUrl, Url},
46        },
47        layers::{FallbackLayer, OrRetryPolicyFn, RateLimitRetryPolicy, RetryPolicy},
48    },
49};
50use backon::{BackoffBuilder, ExponentialBuilder, Retryable as _};
51use serde::Deserialize;
52use tower::{Layer, Service};
53
54use crate::Environment;
55
56pub use backon;
57
58pub mod erc165;
59pub mod event_stream;
60pub mod signers;
61
62/// A dedicated HTTP RPC provider.
63///
64/// This provider should be used for regular RPC calls, transaction
65/// submission, and helpers such as ERC-165 queries.
66#[derive(Clone)]
67pub struct HttpRpcProvider(DynProvider);
68
69/// Helper struct to redact the URLs when debug printing the config.
70///
71/// We don't use secret-string, because we want URL validation during config deserialization to fail early.
72#[derive(Clone, Deserialize)]
73#[serde(transparent)]
74pub struct UrlRedacted(Url);
75
76impl fmt::Debug for UrlRedacted {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        f.write_str("[REDACTED]")
79    }
80}
81
82/// Configuration for building an [`HttpRpcProvider`].
83///
84/// Multiple HTTP endpoints can be provided to enable automatic failover.
85/// Retry behavior can be tuned via [`RetryPolicyConfig`].
86#[derive(Debug, Clone, Deserialize)]
87#[non_exhaustive]
88pub struct HttpRpcProviderConfig {
89    /// List of HTTP RPC endpoints used for requests.
90    ///
91    /// Uses alloy's [`FallbackService`](https://docs.rs/alloy/latest/alloy/providers/transport/layers/struct.FallbackLayer.html) and configures each endpoint as one potential transport.
92    pub http_urls: Vec<UrlRedacted>,
93    /// Optional chain ID used by the provider.
94    ///
95    /// If provided, the [`ChainIdFiller`] will automatically populate
96    /// transactions with this value.
97    #[serde(default)]
98    pub chain_id: Option<ChainId>,
99    /// The poll interval for the confirmation heartbeat for alloy.
100    ///
101    /// Uses alloy's default setting if omitted. For `dev` environment 250ms
102    /// and for all other environments 7s.
103    #[serde(default)]
104    #[serde(with = "humantime_serde")]
105    pub confirmations_poll_interval: Option<Duration>,
106    /// Retry configuration applied to RPC requests.
107    #[serde(default)]
108    pub retry_policy_config: RetryPolicyConfig,
109}
110
111/// Configuration for RPC retry behavior.
112///
113/// Requests that fail with retryable errors will be retried using
114/// exponential backoff.
115#[derive(Debug, Clone, Deserialize)]
116#[non_exhaustive]
117pub struct RetryPolicyConfig {
118    /// Minimum delay between retries.
119    ///
120    /// Defaults to **1 second**.
121    #[serde(default = "RetryPolicyConfig::default_min_delay")]
122    #[serde(with = "humantime_serde")]
123    pub min_delay: Duration,
124
125    /// Maximum delay between retries.
126    ///
127    /// Defaults to **8 seconds**.
128    #[serde(default = "RetryPolicyConfig::default_max_delay")]
129    #[serde(with = "humantime_serde")]
130    pub max_delay: Duration,
131
132    /// Maximum number of retry attempts.
133    ///
134    /// Defaults to **5 retries**.
135    #[serde(default = "RetryPolicyConfig::default_max_times")]
136    pub max_times: usize,
137}
138
139impl HttpRpcProviderConfig {
140    /// Creates a new configuration using default retry settings.
141    ///
142    /// # Errors
143    ///
144    /// Returns an error if any of the provided URLs cannot be parsed.
145    pub fn with_default_values<I, U>(http_urls: I) -> reqwest::Result<Self>
146    where
147        I: IntoIterator<Item = U>,
148        U: IntoUrl,
149    {
150        let http_urls = http_urls
151            .into_iter()
152            .map(|x| x.into_url().map(UrlRedacted))
153            .collect::<reqwest::Result<Vec<_>>>()?;
154        Ok(Self {
155            http_urls,
156            confirmations_poll_interval: None,
157            chain_id: None,
158            retry_policy_config: RetryPolicyConfig::default(),
159        })
160    }
161}
162
163impl RetryPolicyConfig {
164    /// Default minimum delay between retries: 1 second
165    fn default_min_delay() -> Duration {
166        Duration::from_secs(1)
167    }
168
169    /// Default maximum delay between retries: 8 seconds
170    fn default_max_delay() -> Duration {
171        Duration::from_secs(8)
172    }
173
174    /// Default maximum number of retry attempts: 5
175    fn default_max_times() -> usize {
176        5
177    }
178
179    /// Initialize a `RetryPolicyConfig` with default values
180    fn with_default_values() -> Self {
181        Self {
182            min_delay: Self::default_min_delay(),
183            max_delay: Self::default_max_delay(),
184            max_times: Self::default_max_times(),
185        }
186    }
187}
188
189impl Default for RetryPolicyConfig {
190    fn default() -> Self {
191        Self::with_default_values()
192    }
193}
194
195fn build_transport_stack<S>(
196    transports: Vec<S>,
197    retry_policy_config: &RetryPolicyConfig,
198) -> impl Transport + Clone
199where
200    S: Service<RequestPacket, Response = ResponsePacket, Error = TransportError>
201        + Clone
202        + Send
203        + Sync
204        + 'static,
205    S::Future: Send,
206{
207    let retry_layer = RetryLayer::new(http_retry_policy(), retry_policy_config);
208    let retrying_transports = transports
209        .into_iter()
210        .map(|transport| retry_layer.layer(transport))
211        .collect::<Vec<_>>();
212    let transport_count =
213        NonZeroUsize::new(retrying_transports.len()).expect("transport stack must not be empty");
214
215    // Retry each transport before fallback so JSON-RPC error responses cannot
216    // win the fallback race against a slower healthy endpoint.
217    FallbackLayer::default()
218        .with_active_transport_count(transport_count)
219        .layer(retrying_transports)
220}
221
222fn http_retry_policy() -> OrRetryPolicyFn {
223    // Configure retry policy.
224    //
225    // The RateLimitRetryPolicy already handles 503 Service Unavailable and other common RPC errors.
226    // We additionally check for other common transient errors:
227    //   - 403 Forbidden
228    //   - 408 Request Timeout
229    //   - 502 Bad Gateway
230    //   - 504 Gateway Timeout
231    RateLimitRetryPolicy::default().or(|error: &TransportError| match error {
232        RpcError::Transport(TransportErrorKind::HttpError(e)) => {
233            matches!(e.status, 403 | 408 | 502 | 504)
234        }
235        RpcError::Transport(kind) => kind
236            .as_custom()
237            .and_then(|error| error.downcast_ref::<reqwest::Error>())
238            .is_some_and(reqwest::Error::is_timeout),
239        _ => false,
240    })
241}
242
243/// Builder for constructing an [`HttpRpcProvider`].
244///
245/// The builder configures retry behavior, fallback transports, optional
246/// wallet integration, and provider fillers before creating the provider.
247pub struct HttpRpcProviderBuilder {
248    http_urls: Vec<UrlRedacted>,
249    retry_policy_config: RetryPolicyConfig,
250    chain_id: Option<ChainId>,
251    confirmations_poll_interval: Option<Duration>,
252    is_local: bool,
253    wallet: Option<EthereumWallet>,
254    reqwest_client: Option<reqwest::Client>,
255}
256
257impl From<HttpRpcProviderConfig> for HttpRpcProviderBuilder {
258    fn from(value: HttpRpcProviderConfig) -> Self {
259        Self::from(&value)
260    }
261}
262
263impl From<&HttpRpcProviderConfig> for HttpRpcProviderBuilder {
264    fn from(value: &HttpRpcProviderConfig) -> Self {
265        Self::with_config(value)
266    }
267}
268
269impl HttpRpcProviderBuilder {
270    /// Creates a new builder from the given configuration.
271    ///
272    /// # Panics
273    ///
274    /// Panics if `config.http_urls` is empty. At least one HTTP endpoint
275    /// must be provided so that a transport stack can be constructed.
276    #[must_use]
277    pub fn with_config(config: &HttpRpcProviderConfig) -> Self {
278        assert!(!config.http_urls.is_empty(), "http URLs must not be empty");
279        Self {
280            http_urls: config.http_urls.clone(),
281            retry_policy_config: config.retry_policy_config.clone(),
282            chain_id: config.chain_id,
283            is_local: false,
284            wallet: None,
285            confirmations_poll_interval: config.confirmations_poll_interval,
286            reqwest_client: None,
287        }
288    }
289
290    /// Creates a new builder using default retry settings.
291    ///
292    /// # Errors
293    ///
294    /// Returns an error if any of the provided URLs cannot be parsed.
295    ///
296    /// # Example
297    ///
298    /// ```
299    /// use taceo_nodes_common::web3::HttpRpcProviderBuilder;
300    ///
301    /// let builder = HttpRpcProviderBuilder::with_default_values(["http://127.0.0.1:8545"])?;
302    /// # Ok::<(), Box<dyn std::error::Error>>(())
303    /// ```
304    pub fn with_default_values<I, U>(http_urls: I) -> reqwest::Result<Self>
305    where
306        I: IntoIterator<Item = U>,
307        U: IntoUrl,
308    {
309        Ok(Self::with_config(
310            &HttpRpcProviderConfig::with_default_values(http_urls)?,
311        ))
312    }
313
314    /// Explicitly sets the underling `reqwest::Client` for alloy.
315    ///
316    /// If not set, the builder will build a client with default configuration.
317    #[must_use]
318    pub fn reqwest_client(mut self, reqwest_client: reqwest::Client) -> Self {
319        self.reqwest_client = Some(reqwest_client);
320        self
321    }
322
323    /// Configures the environment used by the provider.
324    #[must_use]
325    pub fn environment(mut self, environment: Environment) -> Self {
326        self.is_local = environment.is_dev();
327        self
328    }
329
330    /// Sets the poll interval in which alloy fetches blocks for transaction confirmations.
331    #[must_use]
332    pub fn confirmations_poll_interval(mut self, confirmations_poll_interval: Duration) -> Self {
333        self.confirmations_poll_interval = Some(confirmations_poll_interval);
334        self
335    }
336
337    /// Sets the chain ID used by the provider.
338    #[must_use]
339    pub fn chain_id(mut self, chain_id: ChainId) -> Self {
340        self.chain_id = Some(chain_id);
341        self
342    }
343
344    /// Configures the retry behavior for HTTP RPC requests.
345    #[must_use]
346    pub fn retry_policy(mut self, retry_policy_config: RetryPolicyConfig) -> Self {
347        self.retry_policy_config = retry_policy_config;
348        self
349    }
350
351    /// Adds a wallet used for signing transactions.
352    #[must_use]
353    pub fn wallet(mut self, wallet: EthereumWallet) -> Self {
354        self.wallet = Some(wallet);
355        self
356    }
357
358    /// Builds the [`HttpRpcProvider`].
359    ///
360    /// Uses [`SimpleNonceManager::default()`] for nonce management. Use
361    /// [`Self::build_with_nonce_manager`] to provide a custom nonce manager.
362    ///
363    /// # Errors
364    ///
365    /// Returns a [`TransportError`] if the HTTP transport stack cannot be
366    /// initialized, including failures to create the underlying reqwest client.
367    pub fn build(self) -> Result<HttpRpcProvider, TransportError> {
368        self.build_with_nonce_manager(SimpleNonceManager::default())
369    }
370
371    /// Builds the [`HttpRpcProvider`] using the provided nonce manager.
372    ///
373    /// This allows callers to customize how transaction nonces are tracked
374    /// while keeping the rest of the builder configuration unchanged.
375    ///
376    /// # Errors
377    ///
378    /// Returns a [`TransportError`] if the HTTP transport stack cannot be
379    /// initialized, including failures to create the underlying reqwest client.
380    pub fn build_with_nonce_manager<N: NonceManager + 'static>(
381        self,
382        nonce_manager: N,
383    ) -> Result<HttpRpcProvider, TransportError> {
384        let HttpRpcProviderBuilder {
385            http_urls,
386            retry_policy_config,
387            chain_id,
388            is_local,
389            wallet,
390            confirmations_poll_interval,
391            reqwest_client,
392        } = self;
393
394        let reqwest = if let Some(reqwest_client) = reqwest_client {
395            reqwest_client
396        } else {
397            reqwest::ClientBuilder::new()
398                .build()
399                .map_err(TransportErrorKind::custom)?
400        };
401
402        let transports = http_urls
403            .into_iter()
404            .map(|url| Http::with_client(reqwest.clone(), url.0))
405            .collect::<Vec<_>>();
406        let transport = build_transport_stack(transports, &retry_policy_config);
407
408        let client = RpcClient::builder().transport(transport, is_local);
409        let client = if let Some(confirmations_poll_interval) = confirmations_poll_interval {
410            client.with_poll_interval(confirmations_poll_interval)
411        } else {
412            client
413        };
414
415        let http_provider_builder = ProviderBuilder::new()
416            .filler(ChainIdFiller::new(chain_id))
417            .filler(BlobGasFiller::default())
418            .with_nonce_management(nonce_manager)
419            .with_gas_estimation();
420
421        let provider = if let Some(wallet) = wallet {
422            http_provider_builder
423                .wallet(wallet)
424                .connect_client(client)
425                .erased()
426        } else {
427            http_provider_builder.connect_client(client).erased()
428        };
429
430        Ok(HttpRpcProvider(provider))
431    }
432}
433
434impl HttpRpcProvider {
435    /// Returns the HTTP RPC provider.
436    #[must_use]
437    #[inline]
438    pub fn inner(&self) -> DynProvider {
439        self.0.clone()
440    }
441
442    /// Creates a provider backed by the given mocked [`Asserter`].
443    ///
444    /// This is intended for tests and uses Alloy's mocked provider
445    /// infrastructure. See the Alloy mocking provider example for details:
446    /// <https://alloy.rs/examples/providers/mocking/>.
447    ///
448    /// This method does not build a retry-layer. It must only be used for tests.
449    #[cfg(feature = "web3-asserter")]
450    #[must_use]
451    pub fn with_mock_asserter(asserter: Asserter) -> Self {
452        Self(
453            ProviderBuilder::new()
454                .connect_mocked_client(asserter)
455                .erased(),
456        )
457    }
458}
459
460#[cfg(feature = "web3-asserter")]
461impl From<Asserter> for HttpRpcProvider {
462    fn from(value: Asserter) -> Self {
463        Self::with_mock_asserter(value)
464    }
465}
466
467impl AsRef<DynProvider> for HttpRpcProvider {
468    fn as_ref(&self) -> &DynProvider {
469        self
470    }
471}
472
473impl Deref for HttpRpcProvider {
474    type Target = DynProvider;
475
476    fn deref(&self) -> &Self::Target {
477        &self.0
478    }
479}
480
481#[derive(Debug, Clone)]
482struct RetryLayer {
483    policy: OrRetryPolicyFn,
484    backoff: ExponentialBuilder,
485}
486
487impl RetryLayer {
488    /// Creates a new retry layer using the provided retry policy and configuration.
489    ///
490    /// The retry behavior is implemented using exponential backoff with jitter.
491    ///
492    /// The following parameters are taken from [`RetryPolicyConfig`]:
493    ///
494    /// - minimum retry delay
495    /// - maximum retry delay
496    /// - maximum number of retry attempts
497    pub fn new(policy: OrRetryPolicyFn, config: &RetryPolicyConfig) -> Self {
498        let backoff = ExponentialBuilder::default()
499            .with_min_delay(config.min_delay)
500            .with_max_delay(config.max_delay)
501            .with_max_times(config.max_times)
502            .with_jitter();
503        Self { policy, backoff }
504    }
505}
506
507impl<S> Layer<S> for RetryLayer {
508    type Service = RetryService<S>;
509
510    fn layer(&self, inner: S) -> Self::Service {
511        RetryService {
512            inner,
513            policy: self.policy.clone(),
514            backoff: self.backoff,
515        }
516    }
517}
518
519/// Tower service that wraps each request in a retry loop with exponential backoff.
520#[derive(Debug, Clone)]
521struct RetryService<S> {
522    inner: S,
523    policy: OrRetryPolicyFn,
524    backoff: ExponentialBuilder,
525}
526
527impl<S> Service<RequestPacket> for RetryService<S>
528where
529    S: Service<RequestPacket, Response = ResponsePacket, Error = TransportError>
530        + Clone
531        + Send
532        + Sync
533        + 'static,
534    S::Future: Send,
535{
536    type Response = ResponsePacket;
537    type Error = TransportError;
538    type Future = TransportFut<'static>;
539
540    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
541        self.inner.poll_ready(cx)
542    }
543
544    fn call(&mut self, request: RequestPacket) -> Self::Future {
545        let service = self.clone();
546        let backoff = self.backoff;
547        let policy = self.policy.clone();
548
549        Box::pin(async move {
550            (|| service.clone().call_and_parse_error(request.clone()))
551                .retry(backoff)
552                .sleep(tokio::time::sleep)
553                .when(|e| policy.should_retry(e))
554                .notify(|err, duration| {
555                    tracing::warn!(
556                        ?err,
557                        "Retrying RPC request after: {duration:?}. Reason: {err}"
558                    );
559                })
560                // Adjust the backoff duration based on the policy and the current hint:
561                // - If `dur` is `None`, we stop retrying (max attempts reached).
562                // - If `dur` is `Some(d)` and the policy provides a backoff hint, use the policy hint.
563                // - If `dur` is `Some(d)` and the policy hint is `None`, use the original `d`.
564                .adjust(|e, dur| dur.and_then(|d| policy.backoff_hint(e).or(Some(d))))
565                .await
566        })
567    }
568}
569
570impl<S> RetryService<S>
571where
572    S: Service<RequestPacket, Response = ResponsePacket, Error = TransportError>
573        + Clone
574        + Send
575        + Sync
576        + 'static,
577    S::Future: Send,
578{
579    async fn call_and_parse_error(
580        mut self,
581        request: RequestPacket,
582    ) -> Result<ResponsePacket, RpcError<TransportErrorKind>> {
583        let resp = self.inner.call(request).await?;
584        if let Some(e) = resp.as_error() {
585            Err(TransportError::ErrorResp(e.to_owned()))
586        } else {
587            Ok(resp)
588        }
589    }
590}
591
592/// Extension trait adding [`get_receipt_with_retry`](GetReceiptExt::get_receipt_with_retry)
593/// to [`PendingTransactionBuilder`].
594pub trait GetReceiptExt: Sized {
595    /// Waits for the receipt of an already-broadcast transaction, re-polling
596    /// by transaction hash if the RPC does not serve the receipt right away.
597    ///
598    /// This works around a gap in `alloy`'s own
599    /// [`PendingTransactionBuilder::get_receipt`]: it retries while a
600    /// transaction is unconfirmed, but once its confirmation watcher sees the
601    /// transaction confirmed and the immediately-following
602    /// `eth_getTransactionReceipt` call returns `null` anyway, it gives up
603    /// with no further retry. This is common behind a load-balanced RPC
604    /// endpoint where the backend serving that second call hasn't caught up
605    /// yet -- the transaction is already on chain, so treating a missing
606    /// receipt as a failed submission is wrong.
607    ///
608    /// Tries [`PendingTransactionBuilder::get_receipt`] first, which already
609    /// waits for the transaction to confirm. If that fails, falls back to
610    /// retrying [`PendingTransactionBuilder::get_receipt`] again on a fresh
611    /// builder for the same transaction hash and required confirmations,
612    /// according to the given `builder` (e.g.
613    /// [`ConstantBuilder`](backon::ConstantBuilder) or [`ExponentialBuilder`])
614    /// until it stops yielding backoff durations.
615    ///
616    /// # Errors
617    ///
618    /// Returns the last [`PendingTransactionError`] seen once retries are
619    /// exhausted.
620    fn get_receipt_with_retry(
621        self,
622        builder: impl BackoffBuilder,
623    ) -> impl Future<Output = Result<TransactionReceipt, PendingTransactionError>> + Send;
624
625    /// Waits for the receipt of an already-broadcast transaction, re-polling
626    /// by transaction hash if the RPC does not serve the receipt right away.
627    ///
628    /// This works around a gap in `alloy`'s own
629    /// [`PendingTransactionBuilder::get_receipt`]: it retries while a
630    /// transaction is unconfirmed, but once its confirmation watcher sees the
631    /// transaction confirmed and the immediately-following
632    /// `eth_getTransactionReceipt` call returns `null` anyway, it gives up
633    /// with no further retry. This is common behind a load-balanced RPC
634    /// endpoint where the backend serving that second call hasn't caught up
635    /// yet -- the transaction is already on chain, so treating a missing
636    /// receipt as a failed submission is wrong.
637    ///
638    /// Tries [`PendingTransactionBuilder::get_receipt`] first, which already
639    /// waits for the transaction to confirm. If that fails, falls back to
640    /// retrying [`PendingTransactionBuilder::get_receipt`] again on a fresh
641    /// builder for the same transaction hash and required confirmations,
642    /// 10 times with a 2-second delay between attempts.
643    ///
644    /// # Errors
645    ///
646    /// Returns the last [`PendingTransactionError`] seen once retries are
647    /// exhausted.
648    fn get_receipt_with_default_retry(
649        self,
650    ) -> impl Future<Output = Result<TransactionReceipt, PendingTransactionError>> + Send {
651        self.get_receipt_with_retry(
652            backon::ConstantBuilder::new()
653                .with_delay(Duration::from_secs(2))
654                .with_max_times(10),
655        )
656    }
657}
658
659impl GetReceiptExt for PendingTransactionBuilder<Ethereum> {
660    async fn get_receipt_with_retry(
661        self,
662        builder: impl BackoffBuilder,
663    ) -> Result<TransactionReceipt, PendingTransactionError> {
664        let tx_hash = *self.tx_hash();
665        let provider = self.provider().clone();
666        let config = self.inner().clone();
667
668        match self.get_receipt().await {
669            Ok(receipt) => return Ok(receipt),
670            Err(err) => {
671                tracing::warn!("no receipt for transaction {tx_hash} yet ({err}), re-polling");
672            }
673        }
674
675        let poll = || async {
676            let pending = PendingTransactionBuilder::from_config(provider.clone(), config.clone());
677            pending.get_receipt().await
678        };
679
680        poll.retry(builder)
681            .sleep(tokio::time::sleep)
682            .notify(|err, dur| {
683                tracing::warn!(
684                    "failed to fetch receipt for transaction {tx_hash} ({err}), retrying in {dur:?}"
685                );
686            })
687            .await
688    }
689}
690
691#[cfg(test)]
692pub(crate) mod tests;