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