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) as u32;
321
322                    return if success {
323                        Ok(TransactionStatus::Confirmed {
324                            block_number: block_num as u64,
325                            confirmations,
326                        })
327                    } else if let Some(error) = error_msg {
328                        Ok(TransactionStatus::Failed { error })
329                    } else {
330                        // Transaction found but status unclear
331                        Ok(TransactionStatus::Unknown)
332                    };
333                }
334            }
335        }
336
337        // Transaction not found in recent blocks
338        Ok(TransactionStatus::Unknown)
339    }
340
341    /// Validate a Substrate address (SS58 format)
342    pub fn validate_address(&self, address: &Address) -> bool {
343        match address {
344            Address::Substrate(addr) => {
345                // Use sp_core to validate SS58 address
346                use sp_core::crypto::Ss58Codec;
347                sp_core::sr25519::Public::from_ss58check(addr).is_ok()
348                    || sp_core::ed25519::Public::from_ss58check(addr).is_ok()
349            }
350            _ => false,
351        }
352    }
353
354    /// Get account balance using dynamic storage queries
355    pub async fn get_balance(&self, address: &str) -> Result<u128> {
356        if !self.connected {
357            return Err(Error::Connection("Not connected".to_string()));
358        }
359
360        debug!("Getting balance for address: {}", address);
361        self.metrics.record_rpc_call("get_balance");
362
363        // Parse SS58 address to get AccountId32
364        use sp_core::crypto::{AccountId32, Ss58Codec};
365        let account_id = AccountId32::from_ss58check(address)
366            .map_err(|e| Error::Storage(format!("Invalid SS58 address: {}", e)))?;
367
368        // Query account info from System pallet using dynamic API
369        let account_bytes: &[u8] = account_id.as_ref();
370        let storage_query = subxt::dynamic::storage(
371            "System",
372            "Account",
373            vec![subxt::dynamic::Value::from_bytes(account_bytes)],
374        );
375
376        let result = self
377            .client
378            .storage()
379            .at_latest()
380            .await
381            .map_err(|e| Error::Storage(format!("Failed to get latest block: {}", e)))?
382            .fetch(&storage_query)
383            .await
384            .map_err(|e| Error::Storage(format!("Failed to query storage: {}", e)))?;
385
386        if let Some(account_data) = result {
387            // Decode the storage value
388            let decoded = account_data
389                .to_value()
390                .map_err(|e| Error::Storage(format!("Failed to decode account data: {}", e)))?;
391
392            // Extract the free balance from the account data
393            // Account structure: { nonce, consumers, providers, sufficients, data: { free, reserved, ... } }
394            use subxt::dynamic::At as _;
395
396            let free_balance = decoded
397                .at("data")
398                .and_then(|data| data.at("free"))
399                .and_then(|free| free.as_u128())
400                .unwrap_or(0);
401
402            debug!("Balance for {}: {}", address, free_balance);
403            Ok(free_balance)
404        } else {
405            // Account doesn't exist, return 0
406            debug!("Account {} not found, returning 0 balance", address);
407            Ok(0)
408        }
409    }
410
411    /// Get formatted balance (with decimals)
412    pub async fn get_balance_formatted(&self, address: &str) -> Result<String> {
413        let balance = self.get_balance(address).await?;
414        let decimals = self.config.token_decimals as u32;
415        // Prevent overflow: 10u128.pow(decimals) will panic if decimals > 38
416        let divisor = if decimals <= 38 {
417            10u128.pow(decimals)
418        } else {
419            return Err(Error::Storage(format!(
420                "Token decimals too large: {}",
421                decimals
422            )));
423        };
424        let whole = balance / divisor;
425        let fraction = balance % divisor;
426
427        Ok(format!(
428            "{}.{:0width$} {}",
429            whole,
430            fraction,
431            self.config.token_symbol,
432            width = decimals as usize
433        ))
434    }
435
436    /// Create a storage client for querying chain storage
437    pub fn storage(&self) -> StorageClient {
438        StorageClient::new(self.client.clone(), self.metrics.clone())
439    }
440
441    /// Create a transaction executor
442    pub fn transaction_executor(&self) -> TransactionExecutor {
443        TransactionExecutor::new(self.client.clone(), self.metrics.clone())
444    }
445
446    /// Get runtime version
447    pub fn runtime_version(&self) -> u32 {
448        self.client.runtime_version().spec_version
449    }
450
451    /// Get chain name from metadata
452    pub fn chain_name(&self) -> &str {
453        &self.config.name
454    }
455}
456
457#[async_trait]
458impl apex_sdk_core::ChainAdapter for SubstrateAdapter {
459    async fn get_transaction_status(
460        &self,
461        tx_hash: &str,
462    ) -> std::result::Result<TransactionStatus, String> {
463        self.get_transaction_status(tx_hash)
464            .await
465            .map_err(|e| e.to_string())
466    }
467
468    fn validate_address(&self, address: &Address) -> bool {
469        self.validate_address(address)
470    }
471
472    fn chain_name(&self) -> &str {
473        self.chain_name()
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    #[test]
482    fn test_chain_config() {
483        let polkadot = ChainConfig::polkadot();
484        assert_eq!(polkadot.name, "Polkadot");
485        assert_eq!(polkadot.ss58_prefix, 0);
486        assert_eq!(polkadot.token_symbol, "DOT");
487
488        let kusama = ChainConfig::kusama();
489        assert_eq!(kusama.name, "Kusama");
490        assert_eq!(kusama.ss58_prefix, 2);
491        assert_eq!(kusama.token_symbol, "KSM");
492    }
493
494    #[tokio::test]
495    #[ignore] // Requires network connection
496    async fn test_substrate_adapter_connect() {
497        let adapter = SubstrateAdapter::connect("wss://westend-rpc.polkadot.io").await;
498        assert!(adapter.is_ok());
499
500        let adapter = adapter.unwrap();
501        assert!(adapter.is_connected());
502    }
503
504    #[tokio::test]
505    #[ignore] // Requires network connection
506    async fn test_polkadot_connection() {
507        let adapter = SubstrateAdapter::connect_with_config(ChainConfig::polkadot()).await;
508        assert!(adapter.is_ok());
509    }
510
511    #[test]
512    fn test_address_validation() {
513        // We'll need a connected adapter for proper validation
514        // For now, test the logic with mock data
515        let valid_polkadot_addr = "15oF4uVJwmo4TdGW7VfQxNLavjCXviqxT9S1MgbjMNHr6Sp5";
516        let valid_kusama_addr = "HNZata7iMYWmk5RvZRTiAsSDhV8366zq2YGb3tLH5Upf74F";
517
518        // These would validate with a real client
519        // Just ensure our structure is correct
520        assert!(!valid_polkadot_addr.is_empty());
521        assert!(!valid_kusama_addr.is_empty());
522    }
523}