Skip to main content

r402_evm/chain/
provider.rs

1use std::num::NonZeroUsize;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicUsize, Ordering};
4
5use alloy_network::{Ethereum as AlloyEthereum, EthereumWallet, NetworkWallet, TransactionBuilder};
6use alloy_primitives::{Address, Bytes};
7use alloy_provider::fillers::{
8    BlobGasFiller, ChainIdFiller, FillProvider, GasFiller, JoinFill, NonceFiller, WalletFiller,
9};
10use alloy_provider::{
11    Identity, PendingTransactionError, Provider, ProviderBuilder, RootProvider, WalletProvider,
12};
13use alloy_rpc_client::RpcClient;
14use alloy_rpc_types_eth::{BlockId, TransactionReceipt, TransactionRequest};
15use alloy_transport::TransportError;
16use alloy_transport::layers::{FallbackLayer, ThrottleLayer};
17use alloy_transport_http::Http;
18use r402_core::chain::{ChainId, ChainProvider};
19use tower::ServiceBuilder;
20#[cfg(feature = "telemetry")]
21use tracing::Instrument;
22use url::Url;
23
24use crate::chain::nonce::PendingNonceManager;
25use crate::chain::types::Eip155ChainReference;
26
27/// Combined filler type for gas, blob gas, nonce, and chain ID.
28pub type InnerFiller = JoinFill<
29    GasFiller,
30    JoinFill<BlobGasFiller, JoinFill<NonceFiller<PendingNonceManager>, ChainIdFiller>>,
31>;
32
33/// The fully composed Ethereum provider type used in this project.
34///
35/// Combines multiple filler layers for gas, nonce, chain ID, blob gas, and wallet signing,
36/// and wraps a [`RootProvider`] for actual JSON-RPC communication.
37pub type InnerProvider = FillProvider<
38    JoinFill<JoinFill<Identity, InnerFiller>, WalletFiller<EthereumWallet>>,
39    RootProvider,
40>;
41
42/// Provider for interacting with EVM-compatible blockchains.
43///
44/// This provider handles:
45/// - Transaction signing with multiple signers (round-robin selection)
46/// - Nonce management with automatic reset on failures
47/// - Gas estimation and pricing (EIP-1559 and legacy)
48/// - Transaction receipt fetching with configurable timeouts
49///
50/// # Multiple Signers
51///
52/// The provider supports multiple signers for load distribution. When sending
53/// transactions, signers are selected in round-robin fashion to distribute
54/// the transaction load and avoid nonce conflicts.
55///
56/// # Nonce Management
57///
58/// Uses [`PendingNonceManager`] to track nonces locally and query pending
59/// transactions on initialization. If a transaction fails, the nonce is
60/// automatically reset to force a fresh query on the next transaction.
61#[derive(Debug)]
62pub struct Eip155ChainProvider {
63    chain: Eip155ChainReference,
64    eip1559: bool,
65    flashblocks: bool,
66    receipt_timeout_secs: u64,
67    inner: InnerProvider,
68    /// Available signer addresses for round-robin selection.
69    signer_addresses: Arc<[Address]>,
70    /// Current position in round-robin signer rotation.
71    signer_cursor: Arc<AtomicUsize>,
72    /// Nonce manager for resetting nonces on transaction failures.
73    nonce_manager: PendingNonceManager,
74}
75
76impl Eip155ChainProvider {
77    /// Creates a new EVM chain provider.
78    ///
79    /// # Parameters
80    ///
81    /// - `chain`: The numeric chain reference (e.g., 8453 for Base)
82    /// - `wallet`: A pre-built Ethereum wallet containing one or more signers
83    /// - `rpc_endpoints`: HTTP RPC endpoints as `(url, optional_rate_limit)` pairs
84    /// - `eip1559`: Whether the chain supports EIP-1559 gas pricing
85    /// - `flashblocks`: Whether the chain supports flashblocks
86    /// - `receipt_timeout_secs`: How long to wait for a transaction receipt
87    ///
88    /// # Errors
89    ///
90    /// Returns an error if the wallet has no signers or no HTTP RPC
91    /// endpoint remains after filtering.
92    pub fn new(
93        chain: Eip155ChainReference,
94        wallet: EthereumWallet,
95        rpc_endpoints: &[(Url, Option<u32>)],
96        eip1559: bool,
97        flashblocks: bool,
98        receipt_timeout_secs: u64,
99    ) -> Result<Self, Box<dyn std::error::Error>> {
100        let signer_addresses =
101            NetworkWallet::<AlloyEthereum>::signer_addresses(&wallet).collect::<Vec<_>>();
102        if signer_addresses.is_empty() {
103            return Err("at least one signer must be provided".into());
104        }
105        let signer_addresses: Arc<[Address]> = signer_addresses.into();
106        let signer_cursor = Arc::new(AtomicUsize::new(0));
107
108        let chain_id: ChainId = chain.into();
109        let client = Self::rpc_client(&chain_id, rpc_endpoints)?;
110
111        let nonce_manager = PendingNonceManager::default();
112        let filler = JoinFill::new(
113            GasFiller::default(),
114            JoinFill::new(
115                BlobGasFiller::default(),
116                JoinFill::new(
117                    NonceFiller::new(nonce_manager.clone()),
118                    ChainIdFiller::default(),
119                ),
120            ),
121        );
122        let inner: InnerProvider = ProviderBuilder::default()
123            .filler(filler)
124            .wallet(wallet)
125            .connect_client(client);
126
127        #[cfg(feature = "telemetry")]
128        tracing::info!(chain=%chain_id, signers=?signer_addresses, "Using EVM provider");
129
130        Ok(Self {
131            chain,
132            eip1559,
133            flashblocks,
134            receipt_timeout_secs,
135            inner,
136            signer_addresses,
137            signer_cursor,
138            nonce_manager,
139        })
140    }
141
142    /// Creates an RPC client from HTTP endpoint URLs with optional per-endpoint rate limits.
143    ///
144    /// Each entry in `endpoints` is a `(url, optional_rate_limit)` pair.
145    /// Non-HTTP(S) URLs are silently skipped.
146    ///
147    /// # Errors
148    ///
149    /// Returns an error if no HTTP(S) endpoint remains after filtering.
150    pub fn rpc_client(
151        chain_id: &ChainId,
152        endpoints: &[(Url, Option<u32>)],
153    ) -> Result<RpcClient, Box<dyn std::error::Error>> {
154        #[cfg(not(feature = "telemetry"))]
155        let _ = chain_id;
156        let transports = endpoints
157            .iter()
158            .filter_map(|(url, rate_limit)| {
159                let scheme = url.scheme();
160                let is_http = scheme == "http" || scheme == "https";
161                if !is_http {
162                    return None;
163                }
164                #[cfg(feature = "telemetry")]
165                tracing::info!(chain=%chain_id, rpc_url=%url, rate_limit=?rate_limit, "Using HTTP transport");
166                let limit = rate_limit.unwrap_or(u32::MAX);
167                let service = ServiceBuilder::new()
168                    .layer(ThrottleLayer::new(limit))
169                    .service(Http::new(url.clone()));
170                Some(service)
171            })
172            .collect::<Vec<_>>();
173        let count = NonZeroUsize::new(transports.len())
174            .ok_or("at least one HTTP RPC endpoint is required")?;
175        let fallback = ServiceBuilder::new()
176            .layer(FallbackLayer::default().with_active_transport_count(count))
177            .service(transports);
178        Ok(RpcClient::new(fallback, false))
179    }
180
181    /// Round-robin selection of next signer from wallet.
182    #[allow(
183        clippy::indexing_slicing,
184        reason = "bounds guaranteed by constructor requiring non-empty signers"
185    )]
186    fn next_signer_address(&self) -> Address {
187        debug_assert!(
188            !self.signer_addresses.is_empty(),
189            "signer_addresses must not be empty"
190        );
191        if self.signer_addresses.len() == 1 {
192            self.signer_addresses[0]
193        } else {
194            let next =
195                self.signer_cursor.fetch_add(1, Ordering::Relaxed) % self.signer_addresses.len();
196            self.signer_addresses[next]
197        }
198    }
199}
200
201/// Errors that can occur when sending a meta-transaction.
202#[derive(Debug, thiserror::Error)]
203pub enum MetaTransactionSendError {
204    /// RPC transport error.
205    #[error(transparent)]
206    Transport(#[from] TransportError),
207    /// Pending transaction error.
208    #[error(transparent)]
209    PendingTransaction(#[from] PendingTransactionError),
210    /// Custom error message.
211    #[error("{0}")]
212    Custom(String),
213}
214
215/// Meta-transaction parameters: target address, calldata, and required confirmations.
216#[derive(Debug, Clone)]
217pub struct MetaTransaction {
218    /// Target contract address.
219    pub to: Address,
220    /// Transaction calldata (encoded function call).
221    pub calldata: Bytes,
222    /// Number of block confirmations to wait for.
223    pub confirmations: u64,
224    /// Optional pinned signer address. When `None`, the provider picks a
225    /// signer using its standard rotation strategy (round-robin in
226    /// [`Eip155ChainProvider`]). When `Some`, the provider MUST submit the
227    /// transaction from that exact address (used by the upto scheme to
228    /// satisfy `msg.sender == witness.facilitator`).
229    pub from: Option<Address>,
230}
231
232impl MetaTransaction {
233    /// Builds a meta-transaction with no signer pinning.
234    #[must_use]
235    pub const fn new(to: Address, calldata: Bytes, confirmations: u64) -> Self {
236        Self {
237            to,
238            calldata,
239            confirmations,
240            from: None,
241        }
242    }
243
244    /// Sets the pinned signer address; consuming and returning `self`.
245    #[must_use]
246    pub const fn with_from(mut self, from: Address) -> Self {
247        self.from = Some(from);
248        self
249    }
250}
251
252impl ChainProvider for Eip155ChainProvider {
253    fn signer_addresses(&self) -> Vec<String> {
254        self.inner
255            .signer_addresses()
256            .map(|a| a.to_string())
257            .collect()
258    }
259
260    fn chain_id(&self) -> ChainId {
261        self.chain.into()
262    }
263}
264
265/// Trait for sending meta-transactions with custom target and calldata.
266pub trait Eip155MetaTransactionProvider {
267    /// Error type for operations.
268    type Error;
269    /// Underlying provider type.
270    type Inner: Provider;
271
272    /// Returns reference to underlying provider.
273    fn inner(&self) -> &Self::Inner;
274    /// Returns reference to chain descriptor.
275    fn chain(&self) -> &Eip155ChainReference;
276
277    /// Sends a meta-transaction to the network.
278    fn send_transaction(
279        &self,
280        tx: MetaTransaction,
281    ) -> impl Future<Output = Result<TransactionReceipt, Self::Error>> + Send;
282}
283
284impl<T: Eip155MetaTransactionProvider> Eip155MetaTransactionProvider for Arc<T> {
285    type Error = T::Error;
286    type Inner = T::Inner;
287
288    fn inner(&self) -> &Self::Inner {
289        (**self).inner()
290    }
291
292    fn chain(&self) -> &Eip155ChainReference {
293        (**self).chain()
294    }
295
296    fn send_transaction(
297        &self,
298        tx: MetaTransaction,
299    ) -> impl Future<Output = Result<TransactionReceipt, Self::Error>> + Send {
300        (**self).send_transaction(tx)
301    }
302}
303
304impl Eip155MetaTransactionProvider for Eip155ChainProvider {
305    type Error = MetaTransactionSendError;
306    type Inner = InnerProvider;
307
308    fn inner(&self) -> &Self::Inner {
309        &self.inner
310    }
311
312    fn chain(&self) -> &Eip155ChainReference {
313        &self.chain
314    }
315
316    /// Send a meta-transaction with provided `to`, `calldata`, and a signer.
317    ///
318    /// When [`MetaTransaction::from`] is set, the provider validates that
319    /// the address is in its wallet and uses it as the EOA submitter (the
320    /// upto scheme requires `msg.sender == witness.facilitator`). Otherwise
321    /// it falls back to round-robin selection across configured signers.
322    /// Gas pricing follows the network's EIP-1559 capability.
323    ///
324    /// If the transaction fails at any point (during submission or receipt fetching), the nonce
325    /// for the sending address is reset to force a fresh query on the next transaction. This
326    /// ensures correctness even when transactions partially succeed (e.g., submitted but receipt
327    /// fetch times out).
328    ///
329    /// # Gas Pricing Strategy
330    ///
331    /// - **EIP-1559 networks**: Uses automatic gas pricing via the provider's fillers.
332    /// - **Legacy networks**: Fetches the current gas price using `get_gas_price()` and sets it explicitly.
333    ///
334    /// # Timeout Configuration
335    ///
336    /// Receipt fetching is subject to a configurable timeout:
337    /// - Default: 30 seconds
338    /// - Override via `TX_RECEIPT_TIMEOUT_SECS` environment variable
339    /// - If the timeout expires, the nonce is reset and an error is returned
340    ///
341    /// # Parameters
342    ///
343    /// - `tx`: A [`MetaTransaction`] containing the target address and calldata.
344    ///
345    /// # Returns
346    ///
347    /// A [`TransactionReceipt`] once the transaction has been mined and confirmed.
348    ///
349    /// # Errors
350    ///
351    /// Returns `FacilitatorLocalError::ContractCall` if:
352    /// - Gas price fetching fails (on legacy networks)
353    /// - Transaction sending fails
354    /// - Receipt retrieval fails or times out
355    async fn send_transaction(
356        &self,
357        tx: MetaTransaction,
358    ) -> Result<TransactionReceipt, Self::Error> {
359        let from_address = match tx.from {
360            Some(pinned) => {
361                if !self.signer_addresses.contains(&pinned) {
362                    return Err(MetaTransactionSendError::Custom(format!(
363                        "requested signer {pinned} is not in the configured wallet"
364                    )));
365                }
366                pinned
367            }
368            None => self.next_signer_address(),
369        };
370        let mut txr = TransactionRequest::default()
371            .with_to(tx.to)
372            .with_from(from_address)
373            .with_input(tx.calldata);
374
375        if !self.eip1559 {
376            let provider = &self.inner;
377            let gas_fut = provider.get_gas_price();
378            #[cfg(feature = "telemetry")]
379            let gas: u128 = gas_fut
380                .instrument(tracing::info_span!("get_gas_price"))
381                .await?;
382            #[cfg(not(feature = "telemetry"))]
383            let gas: u128 = gas_fut.await?;
384            txr.set_gas_price(gas);
385        }
386
387        // Estimate gas if not provided
388        if txr.gas.is_none() {
389            let block_id = if self.flashblocks {
390                BlockId::latest()
391            } else {
392                BlockId::pending()
393            };
394            let gas_limit = self.inner.estimate_gas(txr.clone()).block(block_id).await?;
395            txr.set_gas_limit(gas_limit);
396        }
397
398        // Send transaction with error handling for nonce reset
399        let pending_tx = match self.inner.send_transaction(txr).await {
400            Ok(pending) => pending,
401            Err(e) => {
402                // Transaction submission failed - reset nonce to force requery
403                self.nonce_manager.reset_nonce(from_address).await;
404                return Err(MetaTransactionSendError::Transport(e));
405            }
406        };
407
408        // Get receipt with timeout and error handling for nonce reset
409        // Default timeout of 30 seconds is reasonable for most EVM chains
410        let timeout = std::time::Duration::from_secs(self.receipt_timeout_secs);
411
412        let watcher = pending_tx
413            .with_required_confirmations(tx.confirmations)
414            .with_timeout(Some(timeout));
415
416        match watcher.get_receipt().await {
417            Ok(receipt) => Ok(receipt),
418            Err(e) => {
419                // Receipt fetch failed (timeout or other error) - reset nonce to force requery
420                self.nonce_manager.reset_nonce(from_address).await;
421                Err(MetaTransactionSendError::PendingTransaction(e))
422            }
423        }
424    }
425}
426
427#[cfg(test)]
428#[allow(
429    clippy::expect_used,
430    clippy::unwrap_used,
431    reason = "test assertions on known-valid fixtures"
432)]
433mod tests {
434    use super::*;
435
436    fn chain_id() -> ChainId {
437        "eip155:8453".parse().expect("fixture chain id")
438    }
439
440    #[test]
441    fn rpc_client_rejects_empty_endpoints() {
442        let err = Eip155ChainProvider::rpc_client(&chain_id(), &[]);
443        assert!(
444            err.is_err(),
445            "empty endpoint list must return Err, not panic"
446        );
447    }
448
449    #[test]
450    fn rpc_client_rejects_non_http_endpoints() {
451        let ws = Url::parse("ws://127.0.0.1:8545").expect("fixture ws url");
452        let err = Eip155ChainProvider::rpc_client(&chain_id(), &[(ws, None)]);
453        assert!(
454            err.is_err(),
455            "non-HTTP endpoints must return Err after filtering"
456        );
457    }
458
459    #[test]
460    fn rpc_client_accepts_https_endpoint() {
461        let url = Url::parse("https://mainnet.base.org").expect("fixture rpc url");
462        Eip155ChainProvider::rpc_client(&chain_id(), &[(url, None)])
463            .expect("HTTPS endpoint must construct");
464    }
465}