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