apex_sdk_evm/
lib.rs

1//! # Apex SDK EVM Adapter
2//!
3//! EVM blockchain adapter for the Apex SDK, providing unified access to Ethereum
4//! and EVM-compatible chains.
5//!
6//! ## Supported Networks
7//!
8//! - Ethereum Mainnet
9//! - Binance Smart Chain (BSC)
10//! - Polygon (Matic)
11//! - Avalanche C-Chain
12//! - And other EVM-compatible chains
13//!
14//! ## Features
15//!
16//! - **HTTP and WebSocket Support**: Flexible connection types
17//! - **Transaction Management**: Send, track, and query transactions
18//! - **Smart Contract Interaction**: Call and deploy contracts
19//! - **Wallet Integration**: Built-in wallet and signing support
20//! - **Connection Pooling**: Efficient resource management
21//! - **Metrics Collection**: Performance monitoring
22//!
23//! ## Quick Start
24//!
25//! ```rust,no_run
26//! use apex_sdk_evm::EvmAdapter;
27//!
28//! #[tokio::main]
29//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
30//!     // Connect to Ethereum mainnet
31//!     let adapter = EvmAdapter::connect("https://eth.llamarpc.com").await?;
32//!
33//!     // Get balance
34//!     let balance = adapter.get_balance("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7").await?;
35//!     println!("Balance: {} wei", balance);
36//!
37//!     Ok(())
38//! }
39//! ```
40
41pub mod cache;
42pub mod metrics;
43pub mod pool;
44pub mod transaction;
45pub mod wallet;
46
47use apex_sdk_types::{Address, TransactionStatus};
48use async_trait::async_trait;
49use thiserror::Error;
50
51// Alloy imports
52use alloy::primitives::{Address as EthAddress, B256, U256};
53use alloy::providers::{Provider, ProviderBuilder};
54use alloy::rpc::types::TransactionReceipt;
55
56/// EVM adapter error
57#[derive(Error, Debug)]
58pub enum Error {
59    #[error("Connection error: {0}")]
60    Connection(String),
61
62    #[error("Transaction error: {0}")]
63    Transaction(String),
64
65    #[error("Contract error: {0}")]
66    Contract(String),
67
68    #[error("Invalid address: {0}")]
69    InvalidAddress(String),
70
71    #[error("Other error: {0}")]
72    Other(String),
73}
74
75/// Type alias for the complex Alloy provider type with all fillers
76type AlloyHttpProvider = alloy::providers::fillers::FillProvider<
77    alloy::providers::fillers::JoinFill<
78        alloy::providers::Identity,
79        alloy::providers::fillers::JoinFill<
80            alloy::providers::fillers::GasFiller,
81            alloy::providers::fillers::JoinFill<
82                alloy::providers::fillers::BlobGasFiller,
83                alloy::providers::fillers::JoinFill<
84                    alloy::providers::fillers::NonceFiller,
85                    alloy::providers::fillers::ChainIdFiller,
86                >,
87            >,
88        >,
89    >,
90    alloy::providers::RootProvider<alloy::network::Ethereum>,
91    alloy::network::Ethereum,
92>;
93
94/// Provider type that supports HTTP connections
95/// Uses dynamic dispatch to support multiple transport types
96#[derive(Clone)]
97pub struct ProviderType {
98    inner: AlloyHttpProvider,
99}
100
101impl ProviderType {
102    /// Get the current block number
103    async fn get_block_number(&self) -> Result<u64, Error> {
104        self.inner
105            .get_block_number()
106            .await
107            .map_err(|e| Error::Connection(format!("Failed to get block number: {}", e)))
108    }
109
110    async fn get_transaction_receipt(
111        &self,
112        hash: B256,
113    ) -> Result<Option<TransactionReceipt>, Error> {
114        self.inner
115            .get_transaction_receipt(hash)
116            .await
117            .map_err(|e| Error::Transaction(format!("Failed to get receipt: {}", e)))
118    }
119
120    async fn get_transaction(
121        &self,
122        hash: B256,
123    ) -> Result<Option<alloy::rpc::types::Transaction>, Error> {
124        self.inner
125            .get_transaction_by_hash(hash)
126            .await
127            .map_err(|e| Error::Transaction(format!("Failed to get transaction: {}", e)))
128    }
129
130    async fn get_balance(&self, address: EthAddress) -> Result<U256, Error> {
131        self.inner
132            .get_balance(address)
133            .await
134            .map_err(|e| Error::Connection(format!("Failed to get balance: {}", e)))
135    }
136
137    pub async fn get_chain_id(&self) -> Result<u64, Error> {
138        self.inner
139            .get_chain_id()
140            .await
141            .map_err(|e| Error::Connection(format!("Failed to get chain ID: {}", e)))
142    }
143}
144
145/// EVM blockchain adapter
146pub struct EvmAdapter {
147    endpoint: String,
148    provider: ProviderType,
149    connected: bool,
150}
151
152impl EvmAdapter {
153    /// Get the endpoint URL this adapter is connected to
154    pub fn endpoint(&self) -> &str {
155        &self.endpoint
156    }
157}
158
159impl EvmAdapter {
160    /// Get a reference to the Alloy provider
161    pub fn provider(&self) -> &ProviderType {
162        &self.provider
163    }
164
165    /// Create a transaction executor for this adapter
166    pub fn transaction_executor(&self) -> transaction::TransactionExecutor {
167        transaction::TransactionExecutor::new(self.provider.clone())
168    }
169}
170
171impl EvmAdapter {
172    /// Connect to an EVM node via HTTP
173    pub async fn connect(endpoint: &str) -> Result<Self, Error> {
174        tracing::info!("Connecting to EVM endpoint: {}", endpoint);
175
176        // HTTP connection
177        tracing::debug!("Using HTTP connection");
178        let parsed_url = endpoint
179            .parse()
180            .map_err(|e| Error::Connection(format!("Invalid URL: {}", e)))?;
181        let inner = ProviderBuilder::new().connect_http(parsed_url);
182        let provider = ProviderType { inner };
183
184        // Verify connection by getting chain ID
185        let chain_id = provider.get_chain_id().await?;
186        tracing::info!("Connected to chain ID: {}", chain_id);
187
188        Ok(Self {
189            endpoint: endpoint.to_string(),
190            provider,
191            connected: true,
192        })
193    }
194
195    /// Get transaction status
196    pub async fn get_transaction_status(&self, tx_hash: &str) -> Result<TransactionStatus, Error> {
197        if !self.connected {
198            return Err(Error::Connection("Not connected".to_string()));
199        }
200
201        tracing::debug!("Getting transaction status for: {}", tx_hash);
202
203        // Validate tx hash format (0x + 64 hex chars)
204        if !tx_hash.starts_with("0x") || tx_hash.len() != 66 {
205            return Err(Error::Transaction("Invalid transaction hash".to_string()));
206        }
207
208        // Parse transaction hash
209        let hash: B256 = tx_hash
210            .parse()
211            .map_err(|e| Error::Transaction(format!("Invalid hash format: {}", e)))?;
212
213        // Query transaction receipt
214        match self.provider.get_transaction_receipt(hash).await? {
215            Some(receipt) => {
216                // Get current block number for confirmations
217                let current_block = self.provider.get_block_number().await?;
218
219                let _confirmations = if let Some(block_number) = receipt.block_number {
220                    current_block.saturating_sub(block_number) as u32
221                } else {
222                    0
223                };
224
225                // Check if transaction succeeded (status is bool in Alloy)
226                if receipt.status() {
227                    Ok(TransactionStatus::Confirmed {
228                        block_hash: receipt
229                            .block_hash
230                            .map(|h| format!("{:?}", h))
231                            .unwrap_or_default(),
232                        block_number: receipt.block_number,
233                    })
234                } else {
235                    Ok(TransactionStatus::Failed {
236                        error: "Transaction reverted".to_string(),
237                    })
238                }
239            }
240            None => {
241                // Transaction not found in a block - check if it's in mempool
242                match self.provider.get_transaction(hash).await? {
243                    Some(_) => Ok(TransactionStatus::Pending),
244                    None => Ok(TransactionStatus::Unknown),
245                }
246            }
247        }
248    }
249
250    /// Get balance of an address in wei
251    pub async fn get_balance(&self, address: &str) -> Result<U256, Error> {
252        if !self.connected {
253            return Err(Error::Connection("Not connected".to_string()));
254        }
255
256        tracing::debug!("Getting balance for address: {}", address);
257
258        // Parse address
259        let addr: EthAddress = address
260            .parse()
261            .map_err(|e| Error::InvalidAddress(format!("Invalid address format: {}", e)))?;
262
263        // Query balance at latest block
264        self.provider.get_balance(addr).await
265    }
266
267    /// Get balance of an address in a human-readable format (ETH)
268    pub async fn get_balance_eth(&self, address: &str) -> Result<String, Error> {
269        let balance_wei = self.get_balance(address).await?;
270
271        // Convert wei to ETH (1 ETH = 10^18 wei)
272        let eth_divisor = U256::from(10_u64.pow(18));
273        let eth_value = balance_wei / eth_divisor;
274        let remainder = balance_wei % eth_divisor;
275
276        // Format with up to 18 decimal places
277        Ok(format!("{}.{:018}", eth_value, remainder))
278    }
279
280    /// Validate an EVM address (0x + 40 hex chars)
281    pub fn validate_address(&self, address: &Address) -> bool {
282        match address {
283            Address::Evm(addr) => {
284                addr.starts_with("0x")
285                    && addr.len() == 42
286                    && addr[2..].chars().all(|c| c.is_ascii_hexdigit())
287            }
288            _ => false,
289        }
290    }
291
292    /// Get contract instance
293    pub fn contract(&self, address: &str) -> Result<ContractInfo<'_>, Error> {
294        if !self.connected {
295            return Err(Error::Connection("Not connected".to_string()));
296        }
297
298        if !self.validate_address(&Address::evm(address)) {
299            return Err(Error::InvalidAddress(address.to_string()));
300        }
301
302        Ok(ContractInfo {
303            address: address.to_string(),
304            adapter: self,
305        })
306    }
307}
308
309/// Contract information and interaction
310pub struct ContractInfo<'a> {
311    address: String,
312    #[allow(dead_code)]
313    adapter: &'a EvmAdapter,
314}
315
316impl ContractInfo<'_> {
317    /// Get the contract address
318    pub fn address(&self) -> &str {
319        &self.address
320    }
321}
322
323#[async_trait]
324impl apex_sdk_core::ChainAdapter for EvmAdapter {
325    async fn get_transaction_status(&self, tx_hash: &str) -> Result<TransactionStatus, String> {
326        self.get_transaction_status(tx_hash)
327            .await
328            .map_err(|e| e.to_string())
329    }
330
331    fn validate_address(&self, address: &Address) -> bool {
332        self.validate_address(address)
333    }
334
335    fn chain_name(&self) -> &str {
336        "EVM"
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[tokio::test]
345    #[ignore] // Requires network connection
346    async fn test_evm_adapter_connect() {
347        let adapter = EvmAdapter::connect("https://eth.llamarpc.com").await;
348        assert!(adapter.is_ok());
349    }
350
351    #[tokio::test]
352    #[ignore] // Requires network connection
353    async fn test_address_validation() {
354        let adapter = EvmAdapter::connect("https://eth.llamarpc.com")
355            .await
356            .unwrap();
357
358        let valid_addr = Address::evm("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7");
359        assert!(adapter.validate_address(&valid_addr));
360
361        let invalid_addr = Address::evm("invalid");
362        assert!(!adapter.validate_address(&invalid_addr));
363
364        let invalid_addr2 = Address::evm("0x123");
365        assert!(!adapter.validate_address(&invalid_addr2));
366    }
367
368    #[tokio::test]
369    #[ignore] // Requires network connection
370    async fn test_transaction_status() {
371        let adapter = EvmAdapter::connect("https://eth.llamarpc.com")
372            .await
373            .unwrap();
374
375        // Test with a known transaction hash (first ETH transaction ever)
376        let result = adapter
377            .get_transaction_status(
378                "0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060",
379            )
380            .await;
381        assert!(result.is_ok());
382
383        let invalid_result = adapter.get_transaction_status("invalid").await;
384        assert!(invalid_result.is_err());
385    }
386
387    #[test]
388    fn test_invalid_url_format() {
389        // Test that invalid URLs are rejected during parsing
390        // This doesn't require async or network
391        let url = url::Url::parse("not-a-valid-url");
392        assert!(url.is_err(), "Expected invalid URL to fail parsing");
393    }
394}