apex_sdk_substrate/
lib.rs

1//! Substrate blockchain adapter
2//!
3//! This module provides a comprehensive adapter for interacting with Substrate-based blockchains.
4//! It includes support for:
5//! - Connection management via WebSocket
6//! - Account and wallet management (SR25519, ED25519)
7//! - Transaction execution (extrinsics)
8//! - Storage queries
9//! - Connection pooling
10//! - Caching
11//! - Metrics collection
12
13use apex_sdk_types::{Address, TransactionStatus};
14use async_trait::async_trait;
15use subxt::{OnlineClient, PolkadotConfig};
16use thiserror::Error;
17use tracing::{debug, info};
18
19pub mod cache;
20pub mod contracts;
21pub mod metrics;
22pub mod pool;
23pub mod signer;
24pub mod storage;
25pub mod transaction;
26pub mod wallet;
27pub mod xcm;
28
29#[cfg(feature = "typed")]
30pub mod metadata;
31
32pub use cache::{Cache, CacheConfig};
33pub use contracts::{
34    parse_metadata, ContractCallBuilder, ContractClient, ContractMetadata, GasLimit,
35    StorageDepositLimit,
36};
37pub use metrics::{Metrics, MetricsSnapshot};
38pub use pool::{ConnectionPool, PoolConfig};
39pub use signer::{ApexSigner, Ed25519Signer, Sr25519Signer};
40pub use storage::{StorageClient, StorageQuery};
41pub use transaction::{
42    BatchCall, BatchMode, ExtrinsicBuilder, FeeConfig, RetryConfig, TransactionExecutor,
43};
44pub use wallet::{KeyPairType, Wallet, WalletManager};
45pub use xcm::{
46    AssetId, Fungibility, Junction, MultiLocation, NetworkId, WeightLimit, XcmAsset, XcmConfig,
47    XcmExecutor, XcmTransferType, XcmVersion,
48};
49
50/// Substrate adapter error
51#[derive(Error, Debug)]
52pub enum Error {
53    #[error("Connection error: {0}")]
54    Connection(String),
55
56    #[error("Transaction error: {0}")]
57    Transaction(String),
58
59    #[error("Metadata error: {0}")]
60    Metadata(String),
61
62    #[error("Storage error: {0}")]
63    Storage(String),
64
65    #[error("Wallet error: {0}")]
66    Wallet(String),
67
68    #[error("Signature error: {0}")]
69    Signature(String),
70
71    #[error("Encoding error: {0}")]
72    Encoding(String),
73
74    #[error("Subxt error: {0}")]
75    Subxt(#[from] subxt::Error),
76
77    #[error("Other error: {0}")]
78    Other(String),
79}
80
81/// Type alias for Result with our Error type
82pub type Result<T> = std::result::Result<T, Error>;
83
84/// Chain configuration for different Substrate chains
85#[derive(Debug, Clone)]
86pub struct ChainConfig {
87    /// Chain name
88    pub name: String,
89    /// WebSocket endpoint
90    pub endpoint: String,
91    /// SS58 address prefix
92    pub ss58_prefix: u16,
93    /// Token symbol
94    pub token_symbol: String,
95    /// Token decimals
96    pub token_decimals: u8,
97}
98
99impl ChainConfig {
100    /// Create configuration for Polkadot
101    pub fn polkadot() -> Self {
102        Self {
103            name: "Polkadot".to_string(),
104            endpoint: "wss://rpc.polkadot.io".to_string(),
105            ss58_prefix: 0,
106            token_symbol: "DOT".to_string(),
107            token_decimals: 10,
108        }
109    }
110
111    /// Create configuration for Kusama
112    pub fn kusama() -> Self {
113        Self {
114            name: "Kusama".to_string(),
115            endpoint: "wss://kusama-rpc.polkadot.io".to_string(),
116            ss58_prefix: 2,
117            token_symbol: "KSM".to_string(),
118            token_decimals: 12,
119        }
120    }
121
122    /// Create configuration for Westend (testnet)
123    pub fn westend() -> Self {
124        Self {
125            name: "Westend".to_string(),
126            endpoint: "wss://westend-rpc.polkadot.io".to_string(),
127            ss58_prefix: 42,
128            token_symbol: "WND".to_string(),
129            token_decimals: 12,
130        }
131    }
132
133    /// Create custom configuration
134    pub fn custom(name: impl Into<String>, endpoint: impl Into<String>, ss58_prefix: u16) -> Self {
135        Self {
136            name: name.into(),
137            endpoint: endpoint.into(),
138            ss58_prefix,
139            token_symbol: "UNIT".to_string(),
140            token_decimals: 12,
141        }
142    }
143}
144
145/// Substrate blockchain adapter
146pub struct SubstrateAdapter {
147    /// WebSocket endpoint
148    endpoint: String,
149    /// Subxt client
150    client: OnlineClient<PolkadotConfig>,
151    /// Chain configuration
152    config: ChainConfig,
153    /// Connection status
154    connected: bool,
155    /// Metrics collector
156    metrics: Metrics,
157}
158
159impl SubstrateAdapter {
160    /// Connect to a Substrate node using default Polkadot configuration
161    pub async fn connect(endpoint: &str) -> Result<Self> {
162        Self::connect_with_config(ChainConfig::custom("Substrate", endpoint, 42)).await
163    }
164
165    /// Connect to a Substrate node with specific chain configuration
166    pub async fn connect_with_config(config: ChainConfig) -> Result<Self> {
167        info!("Connecting to {} at {}", config.name, config.endpoint);
168
169        // Create subxt client
170        let client = OnlineClient::<PolkadotConfig>::from_url(&config.endpoint)
171            .await
172            .map_err(|e| Error::Connection(format!("Failed to connect: {}", e)))?;
173
174        // Verify connection by fetching metadata
175        let _metadata = client.metadata();
176        debug!("Connected to {}", config.name);
177
178        Ok(Self {
179            endpoint: config.endpoint.clone(),
180            client,
181            config,
182            connected: true,
183            metrics: Metrics::new(),
184        })
185    }
186
187    /// Get reference to the subxt client
188    pub fn client(&self) -> &OnlineClient<PolkadotConfig> {
189        &self.client
190    }
191
192    /// Get the endpoint URL
193    pub fn endpoint(&self) -> &str {
194        &self.endpoint
195    }
196
197    /// Get the chain configuration
198    pub fn config(&self) -> &ChainConfig {
199        &self.config
200    }
201
202    /// Check if connected
203    pub fn is_connected(&self) -> bool {
204        self.connected
205    }
206
207    /// Get metrics snapshot
208    pub fn metrics(&self) -> MetricsSnapshot {
209        self.metrics.snapshot()
210    }
211
212    /// Get transaction status by extrinsic hash
213    pub async fn get_transaction_status(&self, tx_hash: &str) -> Result<TransactionStatus> {
214        if !self.connected {
215            return Err(Error::Connection("Not connected".to_string()));
216        }
217
218        debug!("Getting transaction status for: {}", tx_hash);
219        self.metrics.record_rpc_call("get_transaction_status");
220
221        // Parse the transaction hash
222        let hash_bytes = hex::decode(tx_hash.trim_start_matches("0x"))
223            .map_err(|e| Error::Transaction(format!("Invalid transaction hash: {}", e)))?;
224
225        if hash_bytes.len() != 32 {
226            return Err(Error::Transaction(
227                "Transaction hash must be 32 bytes".to_string(),
228            ));
229        }
230
231        let mut hash_array = [0u8; 32];
232        hash_array.copy_from_slice(&hash_bytes);
233
234        // Try to subscribe to finalized blocks and check recent history
235        // Note: This is a simplified implementation that checks recent finalized blocks
236        // For production, consider maintaining a transaction pool and using event subscriptions
237
238        // Get the latest finalized block
239        let latest_block = self
240            .client
241            .blocks()
242            .at_latest()
243            .await
244            .map_err(|e| Error::Connection(format!("Failed to get latest block: {}", e)))?;
245
246        let latest_number = latest_block.number();
247
248        // Search backwards through recent blocks (up to 100 blocks)
249        let mut blocks_to_check = vec![];
250        let start_num = latest_number.saturating_sub(100);
251
252        // Subscribe to finalized blocks and iterate backwards
253        let mut current_block = latest_block;
254        for _ in 0..100 {
255            blocks_to_check.push((current_block.number(), current_block.hash()));
256
257            // Try to get parent block
258            match current_block.header().parent_hash {
259                parent_hash if current_block.number() > start_num => {
260                    match self.client.blocks().at(parent_hash).await {
261                        Ok(parent) => current_block = parent,
262                        Err(_) => break, // Can't go further back
263                    }
264                }
265                _ => break,
266            }
267        }
268
269        // Now check all collected blocks for the transaction
270        for (block_num, block_hash) in blocks_to_check {
271            let block = self
272                .client
273                .blocks()
274                .at(block_hash)
275                .await
276                .map_err(|e| Error::Connection(format!("Failed to get block: {}", e)))?;
277
278            // Get extrinsics from the block
279            let extrinsics = block
280                .extrinsics()
281                .await
282                .map_err(|e| Error::Transaction(format!("Failed to get extrinsics: {}", e)))?;
283
284            // Compute hash for each extrinsic and compare
285            for ext_details in extrinsics.iter() {
286                // ext_details is already an ExtrinsicDetails, no need for map_err
287                // Compute the hash from the extrinsic bytes
288                let ext_bytes = ext_details.bytes();
289                let computed_hash = sp_core::blake2_256(ext_bytes);
290
291                if computed_hash == hash_array {
292                    // Found the transaction! Get the extrinsic index
293                    let ext_index = ext_details.index();
294
295                    // Check events for this extrinsic
296                    let events = ext_details
297                        .events()
298                        .await
299                        .map_err(|e| Error::Transaction(format!("Failed to get events: {}", e)))?;
300
301                    let mut success = false;
302                    let mut error_msg = None;
303
304                    for event in events.iter() {
305                        let event = event.map_err(|e| {
306                            Error::Transaction(format!("Failed to decode event: {}", e))
307                        })?;
308
309                        // Check for ExtrinsicSuccess or ExtrinsicFailed
310                        if event.pallet_name() == "System" {
311                            if event.variant_name() == "ExtrinsicSuccess" {
312                                success = true;
313                            } else if event.variant_name() == "ExtrinsicFailed" {
314                                // Try to extract error details from event
315                                error_msg = Some(format!("Extrinsic {} failed", ext_index));
316                            }
317                        }
318                    }
319
320                    let confirmations = latest_number - block_num;
321
322                    return if success {
323                        // If confirmations >= 10, consider it finalized (Substrate-specific)
324                        if confirmations >= 10 {
325                            Ok(TransactionStatus::Finalized {
326                                block_hash: block_hash.to_string(),
327                                block_number: block_num as u64,
328                            })
329                        } else {
330                            Ok(TransactionStatus::Confirmed {
331                                block_hash: block_hash.to_string(),
332                                block_number: Some(block_num as u64),
333                            })
334                        }
335                    } else if let Some(error) = error_msg {
336                        Ok(TransactionStatus::Failed { error })
337                    } else {
338                        // Transaction found but status unclear
339                        Ok(TransactionStatus::Unknown)
340                    };
341                }
342            }
343        }
344
345        // Transaction not found in recent blocks
346        Ok(TransactionStatus::Unknown)
347    }
348
349    /// Validate a Substrate address (SS58 format)
350    pub fn validate_address(&self, address: &Address) -> bool {
351        match address {
352            Address::Substrate(addr) => {
353                // Use sp_core to validate SS58 address
354                use sp_core::crypto::Ss58Codec;
355                sp_core::sr25519::Public::from_ss58check(addr).is_ok()
356                    || sp_core::ed25519::Public::from_ss58check(addr).is_ok()
357            }
358            _ => false,
359        }
360    }
361
362    /// Get account balance using dynamic storage queries
363    pub async fn get_balance(&self, address: &str) -> Result<u128> {
364        if !self.connected {
365            return Err(Error::Connection("Not connected".to_string()));
366        }
367
368        debug!("Getting balance for address: {}", address);
369        self.metrics.record_rpc_call("get_balance");
370
371        // Parse SS58 address to get AccountId32
372        use sp_core::crypto::{AccountId32, Ss58Codec};
373        let account_id = AccountId32::from_ss58check(address)
374            .map_err(|e| Error::Storage(format!("Invalid SS58 address: {}", e)))?;
375
376        // Query account info from System pallet using dynamic API
377        let account_bytes: &[u8] = account_id.as_ref();
378        let storage_query = subxt::dynamic::storage(
379            "System",
380            "Account",
381            vec![subxt::dynamic::Value::from_bytes(account_bytes)],
382        );
383
384        let result = self
385            .client
386            .storage()
387            .at_latest()
388            .await
389            .map_err(|e| Error::Storage(format!("Failed to get latest block: {}", e)))?
390            .fetch(&storage_query)
391            .await
392            .map_err(|e| Error::Storage(format!("Failed to query storage: {}", e)))?;
393
394        if let Some(account_data) = result {
395            // Decode the storage value
396            let decoded = account_data
397                .to_value()
398                .map_err(|e| Error::Storage(format!("Failed to decode account data: {}", e)))?;
399
400            // Extract the free balance from the account data
401            // Account structure: { nonce, consumers, providers, sufficients, data: { free, reserved, ... } }
402            use subxt::dynamic::At as _;
403
404            let free_balance = decoded
405                .at("data")
406                .and_then(|data| data.at("free"))
407                .and_then(|free| free.as_u128())
408                .unwrap_or(0);
409
410            debug!("Balance for {}: {}", address, free_balance);
411            Ok(free_balance)
412        } else {
413            // Account doesn't exist, return 0
414            debug!("Account {} not found, returning 0 balance", address);
415            Ok(0)
416        }
417    }
418
419    /// Get formatted balance (with decimals)
420    pub async fn get_balance_formatted(&self, address: &str) -> Result<String> {
421        let balance = self.get_balance(address).await?;
422        let decimals = self.config.token_decimals as u32;
423        // Prevent overflow: 10u128.pow(decimals) will panic if decimals > 38
424        let divisor = if decimals <= 38 {
425            10u128.pow(decimals)
426        } else {
427            return Err(Error::Storage(format!(
428                "Token decimals too large: {}",
429                decimals
430            )));
431        };
432        let whole = balance / divisor;
433        let fraction = balance % divisor;
434
435        Ok(format!(
436            "{}.{:0width$} {}",
437            whole,
438            fraction,
439            self.config.token_symbol,
440            width = decimals as usize
441        ))
442    }
443
444    /// Create a storage client for querying chain storage
445    pub fn storage(&self) -> StorageClient {
446        StorageClient::new(self.client.clone(), self.metrics.clone())
447    }
448
449    /// Create a transaction executor
450    pub fn transaction_executor(&self) -> TransactionExecutor {
451        TransactionExecutor::new(self.client.clone(), self.metrics.clone())
452    }
453
454    /// Get runtime version
455    pub fn runtime_version(&self) -> u32 {
456        self.client.runtime_version().spec_version
457    }
458
459    /// Get chain name from metadata
460    pub fn chain_name(&self) -> &str {
461        &self.config.name
462    }
463}
464
465#[async_trait]
466impl apex_sdk_core::ChainAdapter for SubstrateAdapter {
467    async fn get_transaction_status(
468        &self,
469        tx_hash: &str,
470    ) -> std::result::Result<TransactionStatus, String> {
471        self.get_transaction_status(tx_hash)
472            .await
473            .map_err(|e| e.to_string())
474    }
475
476    fn validate_address(&self, address: &Address) -> bool {
477        self.validate_address(address)
478    }
479
480    fn chain_name(&self) -> &str {
481        self.chain_name()
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    #[test]
490    fn test_chain_config() {
491        let polkadot = ChainConfig::polkadot();
492        assert_eq!(polkadot.name, "Polkadot");
493        assert_eq!(polkadot.ss58_prefix, 0);
494        assert_eq!(polkadot.token_symbol, "DOT");
495
496        let kusama = ChainConfig::kusama();
497        assert_eq!(kusama.name, "Kusama");
498        assert_eq!(kusama.ss58_prefix, 2);
499        assert_eq!(kusama.token_symbol, "KSM");
500    }
501
502    #[tokio::test]
503    #[ignore] // Requires network connection
504    async fn test_substrate_adapter_connect() {
505        let adapter = SubstrateAdapter::connect("wss://westend-rpc.polkadot.io").await;
506        assert!(adapter.is_ok());
507
508        let adapter = adapter.unwrap();
509        assert!(adapter.is_connected());
510    }
511
512    #[tokio::test]
513    #[ignore] // Requires network connection
514    async fn test_polkadot_connection() {
515        let adapter = SubstrateAdapter::connect_with_config(ChainConfig::polkadot()).await;
516        assert!(adapter.is_ok());
517    }
518
519    #[test]
520    fn test_address_validation() {
521        // We'll need a connected adapter for proper validation
522        // For now, test the logic with mock data
523        let valid_polkadot_addr = "15oF4uVJwmo4TdGW7VfQxNLavjCXviqxT9S1MgbjMNHr6Sp5";
524        let valid_kusama_addr = "HNZata7iMYWmk5RvZRTiAsSDhV8366zq2YGb3tLH5Upf74F";
525
526        // These would validate with a real client
527        // Just ensure our structure is correct
528        assert!(!valid_polkadot_addr.is_empty());
529        assert!(!valid_kusama_addr.is_empty());
530    }
531}