apex_sdk_evm/
transaction.rs

1//! Transaction execution for EVM chains
2//!
3//! This module provides comprehensive transaction execution including:
4//! - Gas estimation (EIP-1559 and legacy)
5//! - Transaction signing
6//! - Transaction submission with retry logic
7//! - Transaction monitoring
8
9use crate::{wallet::Wallet, Error, ProviderType};
10use alloy::network::TransactionBuilder;
11use alloy::primitives::{Address as EthAddress, Bytes, B256, U256};
12use alloy::providers::Provider;
13use alloy::rpc::types::{Block, BlockNumberOrTag, TransactionReceipt, TransactionRequest};
14use std::time::Duration;
15
16/// Configuration for gas estimation and pricing
17#[derive(Debug, Clone)]
18pub struct GasConfig {
19    /// Gas limit multiplier for safety margin (default: 1.2 = 20% buffer)
20    pub gas_limit_multiplier: f64,
21    /// Max priority fee per gas (EIP-1559) in gwei
22    pub max_priority_fee_per_gas: Option<U256>,
23    /// Max fee per gas (EIP-1559) in gwei
24    pub max_fee_per_gas: Option<U256>,
25    /// Gas price for legacy transactions in gwei
26    pub gas_price: Option<U256>,
27}
28
29impl Default for GasConfig {
30    fn default() -> Self {
31        Self {
32            gas_limit_multiplier: 1.2,
33            max_priority_fee_per_gas: None,
34            max_fee_per_gas: None,
35            gas_price: None,
36        }
37    }
38}
39
40/// Configuration for transaction retry logic
41#[derive(Debug, Clone)]
42pub struct RetryConfig {
43    /// Maximum number of retries
44    pub max_retries: u32,
45    /// Initial backoff duration in milliseconds
46    pub initial_backoff_ms: u64,
47    /// Maximum backoff duration in milliseconds
48    pub max_backoff_ms: u64,
49    /// Backoff multiplier for exponential backoff
50    pub backoff_multiplier: f64,
51    /// Whether to add jitter to backoff to avoid thundering herd
52    pub use_jitter: bool,
53}
54
55impl Default for RetryConfig {
56    fn default() -> Self {
57        Self {
58            max_retries: 3,
59            initial_backoff_ms: 1000,
60            max_backoff_ms: 30000,
61            backoff_multiplier: 2.0,
62            use_jitter: true,
63        }
64    }
65}
66
67/// Gas estimation result
68#[derive(Debug, Clone)]
69pub struct GasEstimate {
70    /// Estimated gas limit
71    pub gas_limit: U256,
72    /// Estimated gas price or max fee per gas (EIP-1559)
73    pub gas_price: U256,
74    /// Base fee per gas (EIP-1559 only)
75    pub base_fee_per_gas: Option<U256>,
76    /// Max priority fee per gas (EIP-1559 only)
77    pub max_priority_fee_per_gas: Option<U256>,
78    /// Whether this is an EIP-1559 transaction
79    pub is_eip1559: bool,
80    /// Estimated total cost in wei
81    pub total_cost: U256,
82}
83
84impl GasEstimate {
85    /// Format gas price in Gwei
86    pub fn gas_price_gwei(&self) -> String {
87        format_gwei(self.gas_price)
88    }
89
90    /// Format base fee in Gwei (EIP-1559)
91    pub fn base_fee_gwei(&self) -> Option<String> {
92        self.base_fee_per_gas.map(format_gwei)
93    }
94
95    /// Format priority fee in Gwei (EIP-1559)
96    pub fn priority_fee_gwei(&self) -> Option<String> {
97        self.max_priority_fee_per_gas.map(format_gwei)
98    }
99
100    /// Format total cost in ETH
101    pub fn total_cost_eth(&self) -> String {
102        format_eth(self.total_cost)
103    }
104}
105
106/// Transaction executor with gas estimation and retry logic
107pub struct TransactionExecutor {
108    provider: ProviderType,
109    gas_config: GasConfig,
110    retry_config: RetryConfig,
111}
112
113impl TransactionExecutor {
114    /// Create a new transaction executor
115    pub fn new(provider: ProviderType) -> Self {
116        Self {
117            provider,
118            gas_config: GasConfig::default(),
119            retry_config: RetryConfig::default(),
120        }
121    }
122
123    /// Set gas configuration
124    pub fn with_gas_config(mut self, config: GasConfig) -> Self {
125        self.gas_config = config;
126        self
127    }
128
129    /// Set retry configuration
130    pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
131        self.retry_config = config;
132        self
133    }
134
135    /// Estimate gas for a transaction
136    ///
137    /// This handles both EIP-1559 (London fork) and legacy transactions
138    pub async fn estimate_gas(
139        &self,
140        from: EthAddress,
141        to: Option<EthAddress>,
142        value: Option<U256>,
143        data: Option<Vec<u8>>,
144    ) -> Result<GasEstimate, Error> {
145        tracing::debug!("Estimating gas for transaction");
146
147        // Build transaction request
148        let mut tx = TransactionRequest::default()
149            .from(from)
150            .value(value.unwrap_or(U256::ZERO));
151
152        if let Some(to_addr) = to {
153            tx = tx.to(to_addr);
154        }
155
156        if let Some(tx_data) = data {
157            tx = tx.input(tx_data.into());
158        }
159
160        // Estimate gas limit
161        let estimated_gas = self.estimate_gas_limit(&tx).await?;
162
163        // Apply safety multiplier
164        let gas_limit = U256::from(
165            (estimated_gas.to::<u128>() as f64 * self.gas_config.gas_limit_multiplier) as u128,
166        );
167
168        tracing::debug!(
169            "Estimated gas limit: {} (with {}% buffer)",
170            gas_limit,
171            (self.gas_config.gas_limit_multiplier - 1.0) * 100.0
172        );
173
174        // Get gas pricing
175        let (gas_price, base_fee, priority_fee, is_eip1559) = self.estimate_gas_price().await?;
176
177        // Calculate total cost
178        let total_cost = gas_limit * gas_price;
179
180        Ok(GasEstimate {
181            gas_limit,
182            gas_price,
183            base_fee_per_gas: base_fee,
184            max_priority_fee_per_gas: priority_fee,
185            is_eip1559,
186            total_cost,
187        })
188    }
189
190    /// Estimate gas limit for a transaction
191    async fn estimate_gas_limit(&self, tx: &TransactionRequest) -> Result<U256, Error> {
192        let gas = self
193            .provider
194            .inner
195            .estimate_gas(tx.clone())
196            .await
197            .map_err(|e| Error::Transaction(format!("Gas estimation failed: {}", e)))?;
198
199        Ok(U256::from(gas))
200    }
201
202    /// Estimate gas price (handles both EIP-1559 and legacy)
203    async fn estimate_gas_price(&self) -> Result<(U256, Option<U256>, Option<U256>, bool), Error> {
204        // Try EIP-1559 first
205        match self.get_eip1559_fees().await {
206            Ok((base_fee, priority_fee)) => {
207                let max_fee = base_fee * U256::from(2) + priority_fee;
208                tracing::debug!(
209                    "Using EIP-1559: base={} gwei, priority={} gwei, max={} gwei",
210                    format_gwei(base_fee),
211                    format_gwei(priority_fee),
212                    format_gwei(max_fee)
213                );
214                Ok((max_fee, Some(base_fee), Some(priority_fee), true))
215            }
216            Err(_) => {
217                // Fallback to legacy gas price
218                let gas_price = self.get_legacy_gas_price().await?;
219                tracing::debug!("Using legacy gas price: {} gwei", format_gwei(gas_price));
220                Ok((gas_price, None, None, false))
221            }
222        }
223    }
224
225    /// Get EIP-1559 fee estimates
226    async fn get_eip1559_fees(&self) -> Result<(U256, U256), Error> {
227        // Get base fee from latest block
228        let block: Block = self
229            .provider
230            .inner
231            .get_block_by_number(BlockNumberOrTag::Latest)
232            .await
233            .map_err(|e| Error::Connection(format!("Failed to get block: {}", e)))?
234            .ok_or_else(|| Error::Connection("No latest block".to_string()))?;
235
236        let base_fee = block
237            .header
238            .base_fee_per_gas
239            .map(U256::from)
240            .ok_or_else(|| Error::Other("EIP-1559 not supported".to_string()))?;
241
242        // Use configured priority fee or default to 2 gwei
243        let priority_fee = self
244            .gas_config
245            .max_priority_fee_per_gas
246            .unwrap_or_else(|| U256::from(2_000_000_000u64)); // 2 gwei
247
248        Ok((base_fee, priority_fee))
249    }
250
251    /// Get legacy gas price
252    async fn get_legacy_gas_price(&self) -> Result<U256, Error> {
253        if let Some(price) = self.gas_config.gas_price {
254            return Ok(price);
255        }
256
257        let gas_price = self
258            .provider
259            .inner
260            .get_gas_price()
261            .await
262            .map_err(|e| Error::Connection(format!("Failed to get gas price: {}", e)))?;
263
264        Ok(U256::from(gas_price))
265    }
266
267    /// Build a transaction
268    pub async fn build_transaction(
269        &self,
270        wallet: &Wallet,
271        to: EthAddress,
272        value: U256,
273        data: Option<Vec<u8>>,
274        gas_estimate: Option<GasEstimate>,
275    ) -> Result<TransactionRequest, Error> {
276        let from = wallet.eth_address();
277
278        // Get gas estimate if not provided
279        let gas_est = if let Some(est) = gas_estimate {
280            est
281        } else {
282            self.estimate_gas(from, Some(to), Some(value), data.clone())
283                .await?
284        };
285
286        // Get nonce
287        let nonce = self.get_transaction_count(from).await?;
288
289        // Build transaction request
290        let mut tx = TransactionRequest::default()
291            .with_from(from)
292            .with_to(to)
293            .with_value(value)
294            .with_gas_limit(gas_est.gas_limit.to::<u64>())
295            .with_nonce(nonce.to::<u64>());
296
297        // Set gas parameters based on EIP-1559 support
298        if gas_est.is_eip1559 {
299            if let Some(base_fee) = gas_est.base_fee_per_gas {
300                let max_fee = base_fee * U256::from(2)
301                    + gas_est
302                        .max_priority_fee_per_gas
303                        .unwrap_or_else(|| U256::from(2_000_000_000u64));
304                tx = tx.with_max_fee_per_gas(max_fee.to::<u128>());
305            }
306
307            if let Some(priority_fee) = gas_est.max_priority_fee_per_gas {
308                tx = tx.with_max_priority_fee_per_gas(priority_fee.to::<u128>());
309            }
310        } else {
311            tx = tx.with_gas_price(gas_est.gas_price.to::<u128>());
312        }
313
314        // Set data if provided
315        if let Some(tx_data) = data {
316            tx = tx.with_input(Bytes::from(tx_data));
317        }
318
319        // Set chain ID if available
320        if let Some(chain_id) = wallet.chain_id() {
321            tx = tx.with_chain_id(chain_id);
322        }
323
324        Ok(tx)
325    }
326
327    /// Get transaction count (nonce) for an address
328    async fn get_transaction_count(&self, address: EthAddress) -> Result<U256, Error> {
329        let nonce = self
330            .provider
331            .inner
332            .get_transaction_count(address)
333            .await
334            .map_err(|e| Error::Connection(format!("Failed to get nonce: {}", e)))?;
335
336        Ok(U256::from(nonce))
337    }
338
339    /// Send a signed transaction with retry logic
340    pub async fn send_transaction(
341        &self,
342        wallet: &Wallet,
343        to: EthAddress,
344        value: U256,
345        data: Option<Vec<u8>>,
346    ) -> Result<B256, Error> {
347        let tx = self
348            .build_transaction(wallet, to, value, data, None)
349            .await?;
350
351        self.send_raw_transaction(wallet, tx).await
352    }
353
354    /// Send a pre-built transaction with retry logic
355    pub async fn send_raw_transaction(
356        &self,
357        wallet: &Wallet,
358        tx: TransactionRequest,
359    ) -> Result<B256, Error> {
360        let mut attempts = 0;
361        let mut backoff = Duration::from_millis(self.retry_config.initial_backoff_ms);
362
363        loop {
364            match self.try_send_transaction(wallet, &tx).await {
365                Ok(tx_hash) => {
366                    tracing::info!("Transaction sent successfully: {:?}", tx_hash);
367                    return Ok(tx_hash);
368                }
369                Err(e) if attempts < self.retry_config.max_retries => {
370                    attempts += 1;
371                    tracing::warn!(
372                        "Transaction failed (attempt {}/{}): {}",
373                        attempts,
374                        self.retry_config.max_retries,
375                        e
376                    );
377
378                    // Add jitter if configured
379                    let delay = if self.retry_config.use_jitter {
380                        let jitter =
381                            (rand::random::<f64>() * 0.3 + 0.85) * backoff.as_millis() as f64;
382                        Duration::from_millis(jitter as u64)
383                    } else {
384                        backoff
385                    };
386
387                    tokio::time::sleep(delay).await;
388
389                    // Exponential backoff
390                    backoff = Duration::from_millis(std::cmp::min(
391                        (backoff.as_millis() as f64 * self.retry_config.backoff_multiplier) as u64,
392                        self.retry_config.max_backoff_ms,
393                    ));
394                }
395                Err(e) => {
396                    tracing::error!("Transaction failed after {} attempts: {}", attempts, e);
397                    return Err(e);
398                }
399            }
400        }
401    }
402
403    /// Try to send a transaction (single attempt)
404    async fn try_send_transaction(
405        &self,
406        _wallet: &Wallet,
407        tx: &TransactionRequest,
408    ) -> Result<B256, Error> {
409        // For now, we'll use the provider's built-in signing via fillers
410        // The provider should have wallet/signer configured if needed
411        // Send the transaction request directly
412        let pending_tx = self
413            .provider
414            .inner
415            .send_transaction(tx.clone())
416            .await
417            .map_err(|e| Error::Transaction(format!("Failed to send transaction: {}", e)))?;
418
419        // Get the transaction hash
420        let tx_hash = *pending_tx.tx_hash();
421
422        Ok(tx_hash)
423    }
424
425    /// Wait for transaction confirmation
426    pub async fn wait_for_confirmation(
427        &self,
428        tx_hash: B256,
429        _confirmations: usize,
430    ) -> Result<Option<TransactionReceipt>, Error> {
431        tracing::info!("Waiting for transaction confirmation: {:?}", tx_hash);
432
433        let receipt = self
434            .provider
435            .inner
436            .get_transaction_receipt(tx_hash)
437            .await
438            .map_err(|e| Error::Transaction(format!("Failed to get receipt: {}", e)))?;
439
440        if let Some(ref r) = receipt {
441            tracing::info!(
442                "Transaction confirmed in block {}: status={}",
443                r.block_number.unwrap_or_default(),
444                r.status()
445            );
446        }
447
448        Ok(receipt)
449    }
450}
451
452/// Helper function to format wei to gwei
453fn format_gwei(wei: U256) -> String {
454    let gwei_divisor = U256::from(1_000_000_000u64);
455    let gwei_whole = wei / gwei_divisor;
456    let remainder = wei % gwei_divisor;
457
458    // Convert to string and trim trailing zeros for readability
459    let formatted = format!("{}.{:09}", gwei_whole, remainder);
460    formatted
461        .trim_end_matches('0')
462        .trim_end_matches('.')
463        .to_string()
464}
465
466/// Helper function to format wei to ETH
467fn format_eth(wei: U256) -> String {
468    let eth_divisor = U256::from(10_u64.pow(18));
469    let eth_whole = wei / eth_divisor;
470    let remainder = wei % eth_divisor;
471
472    // Convert to string and trim trailing zeros for readability
473    let formatted = format!("{}.{:018}", eth_whole, remainder);
474    formatted
475        .trim_end_matches('0')
476        .trim_end_matches('.')
477        .to_string()
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    #[test]
485    fn test_gas_config_default() {
486        let config = GasConfig::default();
487        assert_eq!(config.gas_limit_multiplier, 1.2);
488    }
489
490    #[test]
491    fn test_retry_config_default() {
492        let config = RetryConfig::default();
493        assert_eq!(config.max_retries, 3);
494        assert_eq!(config.initial_backoff_ms, 1000);
495        assert!(config.use_jitter);
496    }
497
498    #[test]
499    fn test_format_gwei() {
500        let wei = U256::from(1_000_000_000u64);
501        assert_eq!(format_gwei(wei), "1");
502
503        let wei = U256::from(2_500_000_000u64);
504        assert_eq!(format_gwei(wei), "2.5");
505
506        let wei = U256::from(2_540_000_000u64);
507        assert_eq!(format_gwei(wei), "2.54");
508    }
509
510    #[test]
511    fn test_format_eth() {
512        let wei = U256::from(10_u64.pow(18));
513        assert_eq!(format_eth(wei), "1");
514
515        let wei = U256::from(5 * 10_u64.pow(17));
516        assert_eq!(format_eth(wei), "0.5");
517
518        let wei = U256::from(123 * 10_u64.pow(16));
519        assert_eq!(format_eth(wei), "1.23");
520    }
521}