mpc_wallet_core/chain/
mod.rs

1//! # Chain Adapters
2//!
3//! This module provides chain-agnostic interfaces for interacting with different blockchains.
4//! Each chain has its own adapter that implements the common `ChainAdapter` trait.
5//!
6//! ## Supported Chains
7//!
8//! - **EVM** - Ethereum and EVM-compatible chains (with EIP-1559 support)
9//! - **Solana** - Solana with priority fee estimation and versioned transactions
10//!
11//! ## Example
12//!
13//! ```rust,ignore
14//! use mpc_wallet_core::chain::{ChainAdapter, EvmAdapter, ChainConfig};
15//!
16//! // Create an EVM adapter for Ethereum mainnet
17//! let adapter = EvmAdapter::new(ChainConfig::ethereum_mainnet());
18//!
19//! // Get balance
20//! let balance = adapter.get_balance("0x...").await?;
21//!
22//! // Build and sign a transaction
23//! let tx = adapter.build_transaction(tx_params).await?;
24//! ```
25
26#[cfg(feature = "evm")]
27pub mod evm;
28
29#[cfg(feature = "solana")]
30pub mod solana;
31
32use crate::{Error, Result, Signature};
33use async_trait::async_trait;
34use serde::{Deserialize, Serialize};
35use std::fmt;
36
37#[cfg(feature = "evm")]
38pub use evm::{EvmAdapter, EvmConfig};
39
40#[cfg(feature = "aa")]
41pub use evm::aa::{SmartAccountConfig, SmartAccountModule, UserOperation};
42
43#[cfg(feature = "solana")]
44pub use solana::{SolanaAdapter, SolanaConfig};
45
46// ============================================================================
47// Core Types
48// ============================================================================
49
50/// Blockchain identifier
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
52pub struct ChainId(pub u64);
53
54impl ChainId {
55    // EVM chains
56    pub const ETHEREUM_MAINNET: ChainId = ChainId(1);
57    pub const ETHEREUM_SEPOLIA: ChainId = ChainId(11155111);
58    pub const ARBITRUM_ONE: ChainId = ChainId(42161);
59    pub const OPTIMISM: ChainId = ChainId(10);
60    pub const BASE: ChainId = ChainId(8453);
61    pub const POLYGON: ChainId = ChainId(137);
62    pub const BSC: ChainId = ChainId(56);
63    pub const AVALANCHE: ChainId = ChainId(43114);
64
65    // Solana (uses genesis hash as ID, but we use a conventional value)
66    pub const SOLANA_MAINNET: ChainId = ChainId(101);
67    pub const SOLANA_DEVNET: ChainId = ChainId(102);
68    pub const SOLANA_TESTNET: ChainId = ChainId(103);
69
70    /// Get the name for this chain
71    pub fn name(&self) -> &'static str {
72        match self.0 {
73            1 => "Ethereum Mainnet",
74            11155111 => "Ethereum Sepolia",
75            42161 => "Arbitrum One",
76            10 => "Optimism",
77            8453 => "Base",
78            137 => "Polygon",
79            56 => "BNB Smart Chain",
80            43114 => "Avalanche C-Chain",
81            101 => "Solana Mainnet",
82            102 => "Solana Devnet",
83            103 => "Solana Testnet",
84            _ => "Unknown Chain",
85        }
86    }
87
88    /// Check if this is a Solana chain
89    pub fn is_solana(&self) -> bool {
90        matches!(self.0, 101 | 102 | 103)
91    }
92
93    /// Check if this is an EVM chain
94    pub fn is_evm(&self) -> bool {
95        !self.is_solana()
96    }
97}
98
99impl fmt::Display for ChainId {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        write!(f, "{} ({})", self.name(), self.0)
102    }
103}
104
105impl From<u64> for ChainId {
106    fn from(id: u64) -> Self {
107        ChainId(id)
108    }
109}
110
111/// Balance representation for any chain
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct Balance {
114    /// Raw balance value (smallest unit: wei for ETH, lamports for SOL)
115    pub raw: String,
116    /// Human-readable balance with decimals
117    pub formatted: String,
118    /// Symbol of the token/native currency
119    pub symbol: String,
120    /// Number of decimals
121    pub decimals: u8,
122}
123
124impl Balance {
125    /// Create a new balance
126    pub fn new(raw: impl Into<String>, decimals: u8, symbol: impl Into<String>) -> Self {
127        let raw_str = raw.into();
128        let symbol_str = symbol.into();
129        let formatted = Self::format_balance(&raw_str, decimals);
130
131        Self {
132            raw: raw_str,
133            formatted,
134            symbol: symbol_str,
135            decimals,
136        }
137    }
138
139    /// Format a raw balance with decimals
140    fn format_balance(raw: &str, decimals: u8) -> String {
141        let raw_value: u128 = raw.parse().unwrap_or(0);
142        if raw_value == 0 {
143            return "0".to_string();
144        }
145
146        let divisor = 10u128.pow(decimals as u32);
147        let whole = raw_value / divisor;
148        let fraction = raw_value % divisor;
149
150        if fraction == 0 {
151            whole.to_string()
152        } else {
153            let fraction_str = format!("{:0>width$}", fraction, width = decimals as usize);
154            let trimmed = fraction_str.trim_end_matches('0');
155            format!("{}.{}", whole, trimmed)
156        }
157    }
158
159    /// Check if balance is zero
160    pub fn is_zero(&self) -> bool {
161        self.raw == "0" || self.raw.is_empty()
162    }
163
164    /// Parse raw value as u128
165    pub fn raw_value(&self) -> u128 {
166        self.raw.parse().unwrap_or(0)
167    }
168}
169
170/// Parameters for building a transaction
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct TxParams {
173    /// Sender address
174    pub from: String,
175    /// Recipient address
176    pub to: String,
177    /// Amount to send (in human-readable format)
178    pub value: String,
179    /// Contract call data (optional)
180    #[serde(default)]
181    pub data: Option<Vec<u8>>,
182    /// Gas limit (EVM) / Compute units (Solana)
183    #[serde(default)]
184    pub gas_limit: Option<u64>,
185    /// Nonce override
186    #[serde(default)]
187    pub nonce: Option<u64>,
188    /// Priority (for gas price estimation)
189    #[serde(default)]
190    pub priority: TxPriority,
191}
192
193impl TxParams {
194    /// Create new transaction parameters
195    pub fn new(from: impl Into<String>, to: impl Into<String>, value: impl Into<String>) -> Self {
196        Self {
197            from: from.into(),
198            to: to.into(),
199            value: value.into(),
200            data: None,
201            gas_limit: None,
202            nonce: None,
203            priority: TxPriority::Medium,
204        }
205    }
206
207    /// Add contract call data
208    pub fn with_data(mut self, data: Vec<u8>) -> Self {
209        self.data = Some(data);
210        self
211    }
212
213    /// Set gas limit
214    pub fn with_gas_limit(mut self, limit: u64) -> Self {
215        self.gas_limit = Some(limit);
216        self
217    }
218
219    /// Set nonce
220    pub fn with_nonce(mut self, nonce: u64) -> Self {
221        self.nonce = Some(nonce);
222        self
223    }
224
225    /// Set priority
226    pub fn with_priority(mut self, priority: TxPriority) -> Self {
227        self.priority = priority;
228        self
229    }
230}
231
232/// Transaction priority for gas estimation
233#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "lowercase")]
235pub enum TxPriority {
236    /// Low priority - slower but cheaper
237    Low,
238    /// Medium priority - balanced
239    #[default]
240    Medium,
241    /// High priority - faster but more expensive
242    High,
243    /// Urgent priority - fastest confirmation
244    Urgent,
245}
246
247/// Unsigned transaction ready for signing
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct UnsignedTx {
250    /// Chain identifier
251    pub chain_id: ChainId,
252    /// Message to be signed (transaction hash or serialized tx)
253    pub signing_payload: Vec<u8>,
254    /// Serialized transaction (chain-specific format)
255    pub raw_tx: Vec<u8>,
256    /// Human-readable transaction summary
257    pub summary: TxSummary,
258}
259
260/// Human-readable transaction summary
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct TxSummary {
263    /// Transaction type description
264    pub tx_type: String,
265    /// From address
266    pub from: String,
267    /// To address
268    pub to: String,
269    /// Value being transferred
270    pub value: String,
271    /// Estimated fee
272    pub estimated_fee: String,
273    /// Additional details
274    #[serde(default)]
275    pub details: Option<String>,
276}
277
278/// Signed transaction ready for broadcast
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct SignedTx {
281    /// Chain identifier
282    pub chain_id: ChainId,
283    /// Serialized signed transaction
284    pub raw_tx: Vec<u8>,
285    /// Transaction hash (pre-computed)
286    pub tx_hash: String,
287}
288
289/// Transaction hash returned after broadcast
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct TxHash {
292    /// The transaction hash/signature
293    pub hash: String,
294    /// Explorer URL (if available)
295    pub explorer_url: Option<String>,
296}
297
298impl TxHash {
299    /// Create a new transaction hash
300    pub fn new(hash: impl Into<String>) -> Self {
301        Self {
302            hash: hash.into(),
303            explorer_url: None,
304        }
305    }
306
307    /// Add explorer URL
308    pub fn with_explorer_url(mut self, url: impl Into<String>) -> Self {
309        self.explorer_url = Some(url.into());
310        self
311    }
312}
313
314/// Gas price information
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct GasPrices {
317    /// Low priority gas price
318    pub low: GasPrice,
319    /// Medium priority gas price
320    pub medium: GasPrice,
321    /// High priority gas price
322    pub high: GasPrice,
323    /// Current base fee (EIP-1559)
324    pub base_fee: Option<u128>,
325}
326
327/// Individual gas price entry
328#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct GasPrice {
330    /// Max fee per gas (gwei for EVM)
331    pub max_fee: u128,
332    /// Max priority fee (tip)
333    pub max_priority_fee: u128,
334    /// Estimated wait time in seconds
335    pub estimated_wait_secs: Option<u64>,
336}
337
338// ============================================================================
339// Chain Adapter Trait
340// ============================================================================
341
342/// Trait for chain-specific operations
343///
344/// This trait abstracts blockchain interactions, allowing the wallet to work
345/// with multiple chains through a unified interface.
346#[async_trait]
347pub trait ChainAdapter: Send + Sync {
348    /// Get the chain identifier
349    fn chain_id(&self) -> ChainId;
350
351    /// Get the native currency symbol
352    fn native_symbol(&self) -> &str;
353
354    /// Get the native currency decimals
355    fn native_decimals(&self) -> u8;
356
357    /// Get the native balance for an address
358    async fn get_balance(&self, address: &str) -> Result<Balance>;
359
360    /// Get the current nonce/sequence for an address
361    async fn get_nonce(&self, address: &str) -> Result<u64>;
362
363    /// Build an unsigned transaction
364    async fn build_transaction(&self, params: TxParams) -> Result<UnsignedTx>;
365
366    /// Broadcast a signed transaction
367    async fn broadcast(&self, signed_tx: &SignedTx) -> Result<TxHash>;
368
369    /// Derive address from public key bytes
370    fn derive_address(&self, public_key: &[u8]) -> Result<String>;
371
372    /// Get current gas prices (for EVM) or priority fees (for Solana)
373    async fn get_gas_prices(&self) -> Result<GasPrices>;
374
375    /// Estimate gas for a transaction
376    async fn estimate_gas(&self, params: &TxParams) -> Result<u64>;
377
378    /// Wait for transaction confirmation
379    async fn wait_for_confirmation(&self, tx_hash: &str, timeout_secs: u64) -> Result<TxReceipt>;
380
381    /// Check if an address is valid for this chain
382    fn is_valid_address(&self, address: &str) -> bool;
383
384    /// Get the explorer URL for a transaction
385    fn explorer_tx_url(&self, tx_hash: &str) -> Option<String>;
386
387    /// Get the explorer URL for an address
388    fn explorer_address_url(&self, address: &str) -> Option<String>;
389
390    /// Finalize a transaction with signature (chain-specific encoding)
391    fn finalize_transaction(
392        &self,
393        unsigned_tx: &UnsignedTx,
394        signature: &Signature,
395    ) -> Result<SignedTx>;
396}
397
398/// Transaction receipt after confirmation
399#[derive(Debug, Clone, Serialize, Deserialize)]
400pub struct TxReceipt {
401    /// Transaction hash
402    pub tx_hash: String,
403    /// Block number/slot
404    pub block_number: u64,
405    /// Transaction status
406    pub status: TxStatus,
407    /// Gas used
408    pub gas_used: Option<u64>,
409    /// Effective gas price
410    pub effective_gas_price: Option<u128>,
411}
412
413/// Transaction status
414#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
415pub enum TxStatus {
416    /// Transaction succeeded
417    Success,
418    /// Transaction failed
419    Failed,
420    /// Transaction is pending
421    Pending,
422}
423
424// ============================================================================
425// RPC Client (requires runtime feature)
426// ============================================================================
427
428/// HTTP RPC client with failover support
429#[cfg(feature = "runtime")]
430#[derive(Clone)]
431pub struct RpcClient {
432    urls: Vec<String>,
433    client: reqwest::Client,
434    current_index: std::sync::Arc<std::sync::atomic::AtomicUsize>,
435}
436
437#[cfg(feature = "runtime")]
438impl RpcClient {
439    /// Create a new RPC client with failover URLs
440    pub fn new(urls: Vec<String>) -> Result<Self> {
441        if urls.is_empty() {
442            return Err(Error::InvalidConfig("At least one RPC URL required".into()));
443        }
444
445        let client = reqwest::Client::builder()
446            .timeout(std::time::Duration::from_secs(30))
447            .build()
448            .map_err(|e| Error::ChainError(format!("Failed to create HTTP client: {}", e)))?;
449
450        Ok(Self {
451            urls,
452            client,
453            current_index: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
454        })
455    }
456
457    /// Get the current RPC URL
458    fn current_url(&self) -> &str {
459        let idx = self
460            .current_index
461            .load(std::sync::atomic::Ordering::Relaxed);
462        &self.urls[idx % self.urls.len()]
463    }
464
465    /// Rotate to the next RPC URL
466    fn rotate_url(&self) {
467        self.current_index
468            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
469    }
470
471    /// Make a JSON-RPC request with automatic failover
472    pub async fn request<T: serde::de::DeserializeOwned>(
473        &self,
474        method: &str,
475        params: serde_json::Value,
476    ) -> Result<T> {
477        let mut last_error = None;
478
479        for _ in 0..self.urls.len() {
480            let url = self.current_url();
481
482            match self.make_request(url, method, params.clone()).await {
483                Ok(result) => return Ok(result),
484                Err(e) => {
485                    tracing::warn!("RPC request failed on {}: {}", url, e);
486                    last_error = Some(e);
487                    self.rotate_url();
488                }
489            }
490        }
491
492        Err(last_error.unwrap_or_else(|| Error::ChainError("All RPC endpoints failed".into())))
493    }
494
495    async fn make_request<T: serde::de::DeserializeOwned>(
496        &self,
497        url: &str,
498        method: &str,
499        params: serde_json::Value,
500    ) -> Result<T> {
501        let request_body = serde_json::json!({
502            "jsonrpc": "2.0",
503            "method": method,
504            "params": params,
505            "id": 1
506        });
507
508        let response = self
509            .client
510            .post(url)
511            .json(&request_body)
512            .send()
513            .await
514            .map_err(|e| Error::ChainError(format!("RPC request failed: {}", e)))?;
515
516        let response_body: serde_json::Value = response
517            .json()
518            .await
519            .map_err(|e| Error::ChainError(format!("Failed to parse RPC response: {}", e)))?;
520
521        if let Some(error) = response_body.get("error") {
522            return Err(Error::ChainError(format!("RPC error: {}", error)));
523        }
524
525        let result = response_body
526            .get("result")
527            .ok_or_else(|| Error::ChainError("Missing result in RPC response".into()))?;
528
529        serde_json::from_value(result.clone())
530            .map_err(|e| Error::ChainError(format!("Failed to deserialize result: {}", e)))
531    }
532}
533
534#[cfg(feature = "runtime")]
535impl std::fmt::Debug for RpcClient {
536    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
537        f.debug_struct("RpcClient")
538            .field("urls", &self.urls)
539            .field(
540                "current_index",
541                &self
542                    .current_index
543                    .load(std::sync::atomic::Ordering::Relaxed),
544            )
545            .finish()
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    #[test]
554    fn test_chain_id_names() {
555        assert_eq!(ChainId::ETHEREUM_MAINNET.name(), "Ethereum Mainnet");
556        assert_eq!(ChainId::SOLANA_MAINNET.name(), "Solana Mainnet");
557        assert!(ChainId::ETHEREUM_MAINNET.is_evm());
558        assert!(ChainId::SOLANA_MAINNET.is_solana());
559    }
560
561    #[test]
562    fn test_balance_formatting() {
563        // 1 ETH = 10^18 wei
564        let balance = Balance::new("1000000000000000000", 18, "ETH");
565        assert_eq!(balance.formatted, "1");
566
567        // 1.5 ETH
568        let balance = Balance::new("1500000000000000000", 18, "ETH");
569        assert_eq!(balance.formatted, "1.5");
570
571        // 0.001 ETH
572        let balance = Balance::new("1000000000000000", 18, "ETH");
573        assert_eq!(balance.formatted, "0.001");
574
575        // 0 ETH
576        let balance = Balance::new("0", 18, "ETH");
577        assert_eq!(balance.formatted, "0");
578    }
579
580    #[test]
581    fn test_tx_params_builder() {
582        let params = TxParams::new("0xfrom", "0xto", "1.0")
583            .with_gas_limit(21000)
584            .with_priority(TxPriority::High);
585
586        assert_eq!(params.gas_limit, Some(21000));
587        assert_eq!(params.priority, TxPriority::High);
588    }
589
590    #[test]
591    fn test_tx_priority_default() {
592        let priority = TxPriority::default();
593        assert_eq!(priority, TxPriority::Medium);
594    }
595}