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