xenith-read 0.1.0

Multi-chain parallel storage reads and divergence detection for xenith
Documentation
use alloy::{
    network::{Ethereum, Network},
    primitives::{Address, Bytes as AlBytes, U256},
    providers::{Provider, ProviderBuilder, ReqwestProvider},
    transports::http::reqwest,
};
use async_trait::async_trait;
use bytes::Bytes;
use xenith_core::{ChainId, XenithError};

use crate::provider::ChainProvider;

// The Ethereum network's concrete transaction request type without importing
// alloy-rpc-types-eth directly.
type EthTxRequest = <Ethereum as Network>::TransactionRequest;

/// [`ChainProvider`] backed by an alloy HTTP (reqwest) provider.
///
/// Wraps a [`ReqwestProvider`] so any Ethereum-compatible JSON-RPC node can be
/// plugged into [`crate::MultiChainReader`].
///
/// Construct via [`AlloyProvider::new`], passing the node's HTTP URL and the
/// [`ChainId`] this provider serves. The chain ID is used to identify the source
/// chain in transport errors.
pub struct AlloyProvider {
    inner: ReqwestProvider,
    chain_id: ChainId,
}

impl AlloyProvider {
    /// Create a new provider connected to the node at `rpc_url` serving `chain_id`.
    ///
    /// Returns an error if `rpc_url` is not a valid URL.
    pub fn new(rpc_url: &str, chain_id: ChainId) -> xenith_core::Result<Self> {
        let url = rpc_url
            .parse::<reqwest::Url>()
            .map_err(|e| XenithError::Transport {
                chain: chain_id,
                message: format!("invalid RPC URL: {e}"),
            })?;
        Ok(Self {
            inner: ProviderBuilder::new().on_http(url),
            chain_id,
        })
    }
}

#[async_trait]
impl ChainProvider for AlloyProvider {
    async fn read_storage(
        &self,
        address: [u8; 20],
        slot: [u8; 32],
    ) -> xenith_core::Result<[u8; 32]> {
        let addr = Address::from(address);
        let key = U256::from_be_bytes::<32>(slot);
        let result =
            self.inner
                .get_storage_at(addr, key)
                .await
                .map_err(|e| XenithError::Transport {
                    chain: self.chain_id,
                    message: e.to_string(),
                })?;
        Ok(result.to_be_bytes::<32>())
    }

    async fn call(&self, address: [u8; 20], calldata: Bytes) -> xenith_core::Result<Bytes> {
        // AlBytes::from converts bytes::Bytes → alloy::primitives::Bytes.
        // The .into() is inferred as Into<TransactionInput> from EthTxRequest::input's signature.
        let req = EthTxRequest::default()
            .to(Address::from(address))
            .input(AlBytes::from(calldata).into());
        let result = self
            .inner
            .call(&req)
            .await
            .map_err(|e| XenithError::Transport {
                chain: self.chain_id,
                message: e.to_string(),
            })?;
        // alloy::primitives::Bytes → bytes::Bytes via From impl
        Ok(result.into())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn alloy_provider_is_send_sync() {
        fn assert_send_sync<T: Send + Sync>() {}
        assert_send_sync::<AlloyProvider>();
    }

    #[test]
    fn new_rejects_invalid_url() {
        let err = AlloyProvider::new("not a url %%", ChainId(1)).unwrap_err();
        assert!(matches!(
            err,
            XenithError::Transport {
                chain: ChainId(1),
                ..
            }
        ));
    }
}