apex_sdk_substrate/
xcm.rs

1//! XCM (Cross-Consensus Messaging) support for cross-chain transfers
2//!
3//! This module provides functionality for sending XCM messages across parachains
4//! and relay chains in the Polkadot ecosystem.
5//!
6//! ## Features
7//!
8//! - Reserve transfers (transfer assets via reserve chain)
9//! - Teleport transfers (burn and mint across chains)
10//! - Multi-location address handling
11//! - XCM v3/v4 support
12//! - Parachain-to-parachain transfers
13//! - Parachain-to-relay transfers
14//!
15//! ## Example
16//!
17//! ```rust,ignore
18//! use apex_sdk_substrate::xcm::{XcmExecutor, XcmTransferType, MultiLocation};
19//!
20//! let executor = XcmExecutor::new(client);
21//!
22//! // Transfer from parachain to relay chain
23//! let tx_hash = executor
24//!     .transfer(
25//!         beneficiary,
26//!         amount,
27//!         XcmTransferType::ReserveTransfer,
28//!         MultiLocation::parent(),
29//!     )
30//!     .await?;
31//! ```
32
33use crate::{Error, Result, Sr25519Signer, Wallet};
34use subxt::{OnlineClient, PolkadotConfig};
35use tracing::{debug, info};
36
37/// XCM version to use for message construction
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum XcmVersion {
40    /// XCM version 2
41    V2,
42    /// XCM version 3 (recommended)
43    V3,
44    /// XCM version 4 (latest)
45    V4,
46}
47
48impl Default for XcmVersion {
49    fn default() -> Self {
50        Self::V3
51    }
52}
53
54/// Type of XCM transfer to perform
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum XcmTransferType {
57    /// Reserve transfer - assets are held in reserve on the origin chain
58    /// Suitable for transfers to parachains that trust the origin
59    ReserveTransfer,
60
61    /// Teleport - assets are burned on origin and minted on destination
62    /// Requires mutual trust between chains
63    Teleport,
64
65    /// Limited reserve transfer - like reserve transfer but with weight limits
66    LimitedReserveTransfer,
67
68    /// Limited teleport - like teleport but with weight limits
69    LimitedTeleport,
70}
71
72/// Multi-location representation for XCM addressing
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct MultiLocation {
75    /// Number of parent levels to traverse
76    pub parents: u8,
77    /// Interior junctions (e.g., parachain ID, account ID)
78    pub interior: Vec<Junction>,
79}
80
81impl MultiLocation {
82    /// Create a MultiLocation pointing to the parent (relay chain)
83    pub fn parent() -> Self {
84        Self {
85            parents: 1,
86            interior: vec![],
87        }
88    }
89
90    /// Create a MultiLocation for a specific parachain
91    pub fn parachain(para_id: u32) -> Self {
92        Self {
93            parents: 1,
94            interior: vec![Junction::Parachain(para_id)],
95        }
96    }
97
98    /// Create a MultiLocation for an account on the current chain
99    pub fn account(account_id: [u8; 32]) -> Self {
100        Self {
101            parents: 0,
102            interior: vec![Junction::AccountId32 {
103                network: None,
104                id: account_id,
105            }],
106        }
107    }
108
109    /// Create a MultiLocation for an account on a specific parachain
110    pub fn parachain_account(para_id: u32, account_id: [u8; 32]) -> Self {
111        Self {
112            parents: 1,
113            interior: vec![
114                Junction::Parachain(para_id),
115                Junction::AccountId32 {
116                    network: None,
117                    id: account_id,
118                },
119            ],
120        }
121    }
122
123    /// Create a MultiLocation from raw parts
124    pub fn new(parents: u8, interior: Vec<Junction>) -> Self {
125        Self { parents, interior }
126    }
127
128    /// Check if this location points to the parent chain
129    pub fn is_parent(&self) -> bool {
130        self.parents == 1 && self.interior.is_empty()
131    }
132
133    /// Check if this location points to a parachain
134    pub fn is_parachain(&self) -> bool {
135        matches!(self.interior.first(), Some(Junction::Parachain(_)))
136    }
137
138    /// Get the parachain ID if this location points to a parachain
139    pub fn parachain_id(&self) -> Option<u32> {
140        self.interior.first().and_then(|j| {
141            if let Junction::Parachain(id) = j {
142                Some(*id)
143            } else {
144                None
145            }
146        })
147    }
148}
149
150/// Interior junction types for multi-location
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum Junction {
153    /// Parachain junction with parachain ID
154    Parachain(u32),
155
156    /// AccountId32 junction
157    AccountId32 {
158        network: Option<NetworkId>,
159        id: [u8; 32],
160    },
161
162    /// AccountId20 junction (for EVM accounts)
163    AccountId20 {
164        network: Option<NetworkId>,
165        key: [u8; 20],
166    },
167
168    /// General index junction
169    GeneralIndex(u128),
170
171    /// General key junction
172    GeneralKey { data: Vec<u8> },
173
174    /// Pallet instance junction
175    PalletInstance(u8),
176}
177
178/// Network identifier for cross-consensus messaging
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum NetworkId {
181    /// Polkadot relay chain
182    Polkadot,
183    /// Kusama relay chain
184    Kusama,
185    /// Westend test network
186    Westend,
187    /// Rococo test network
188    Rococo,
189    /// Generic network by ID
190    ByGenesis([u8; 32]),
191}
192
193/// XCM asset representation
194#[derive(Debug, Clone)]
195pub struct XcmAsset {
196    /// Asset identifier (multi-location)
197    pub id: AssetId,
198    /// Fungibility (amount or instance)
199    pub fun: Fungibility,
200}
201
202/// Asset identifier for XCM
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub enum AssetId {
205    /// Concrete asset identified by multi-location
206    Concrete(MultiLocation),
207    /// Abstract asset identified by bytes
208    Abstract(Vec<u8>),
209}
210
211/// Fungibility of an asset
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub enum Fungibility {
214    /// Fungible asset with amount
215    Fungible(u128),
216    /// Non-fungible asset with instance ID
217    NonFungible(u128),
218}
219
220impl XcmAsset {
221    /// Create a fungible asset (e.g., tokens)
222    pub fn fungible(id: AssetId, amount: u128) -> Self {
223        Self {
224            id,
225            fun: Fungibility::Fungible(amount),
226        }
227    }
228
229    /// Create a non-fungible asset (e.g., NFT)
230    pub fn non_fungible(id: AssetId, instance: u128) -> Self {
231        Self {
232            id,
233            fun: Fungibility::NonFungible(instance),
234        }
235    }
236
237    /// Create a native token asset for the current chain
238    pub fn native(amount: u128) -> Self {
239        Self::fungible(AssetId::Concrete(MultiLocation::new(0, vec![])), amount)
240    }
241}
242
243/// Weight limit for XCM execution
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245pub enum WeightLimit {
246    /// Unlimited weight (dangerous, use with caution)
247    Unlimited,
248    /// Limited to specific weight units
249    Limited(u64),
250}
251
252impl Default for WeightLimit {
253    fn default() -> Self {
254        // Default to a conservative weight limit (5 billion units)
255        Self::Limited(5_000_000_000)
256    }
257}
258
259/// Configuration for XCM transfers
260#[derive(Debug, Clone, Default)]
261pub struct XcmConfig {
262    /// XCM version to use
263    #[allow(clippy::derivable_impls)]
264    pub version: XcmVersion,
265    /// Weight limit for execution
266    pub weight_limit: WeightLimit,
267    /// Fee asset to use (defaults to native token)
268    pub fee_asset: Option<XcmAsset>,
269}
270
271/// XCM executor for sending cross-chain messages
272pub struct XcmExecutor {
273    client: OnlineClient<PolkadotConfig>,
274    config: XcmConfig,
275}
276
277impl XcmExecutor {
278    /// Create a new XCM executor
279    pub fn new(client: OnlineClient<PolkadotConfig>) -> Self {
280        Self {
281            client,
282            config: XcmConfig::default(),
283        }
284    }
285
286    /// Create a new XCM executor with custom configuration
287    pub fn with_config(client: OnlineClient<PolkadotConfig>, config: XcmConfig) -> Self {
288        Self { client, config }
289    }
290
291    /// Set the XCM version
292    pub fn with_version(mut self, version: XcmVersion) -> Self {
293        self.config.version = version;
294        self
295    }
296
297    /// Set the weight limit
298    pub fn with_weight_limit(mut self, limit: WeightLimit) -> Self {
299        self.config.weight_limit = limit;
300        self
301    }
302
303    /// Execute a reserve transfer to another chain
304    ///
305    /// # Arguments
306    ///
307    /// * `wallet` - Wallet to sign the transaction
308    /// * `dest` - Destination multi-location (parachain or relay chain)
309    /// * `beneficiary` - Beneficiary account on the destination chain
310    /// * `assets` - Assets to transfer
311    ///
312    /// # Returns
313    ///
314    /// Transaction hash of the XCM transfer extrinsic
315    pub async fn reserve_transfer(
316        &self,
317        wallet: &Wallet,
318        dest: MultiLocation,
319        beneficiary: [u8; 32],
320        assets: Vec<XcmAsset>,
321    ) -> Result<String> {
322        info!("Executing reserve transfer to {:?} for beneficiary", dest);
323
324        // Build the reserve transfer call using dynamic API
325        let dest_value = self.encode_multilocation(&dest)?;
326        let beneficiary_value = self.encode_multilocation(&MultiLocation::account(beneficiary))?;
327        let assets_value = self.encode_assets(&assets)?;
328        let fee_index = 0u32; // Use first asset for fees
329
330        let call = subxt::dynamic::tx(
331            "XcmPallet",
332            "limited_reserve_transfer_assets",
333            vec![
334                dest_value,
335                beneficiary_value,
336                assets_value,
337                subxt::dynamic::Value::u128(fee_index as u128),
338                self.encode_weight_limit()?,
339            ],
340        );
341
342        self.submit_xcm_call(&call, wallet).await
343    }
344
345    /// Execute a teleport transfer to another chain
346    ///
347    /// # Arguments
348    ///
349    /// * `wallet` - Wallet to sign the transaction
350    /// * `dest` - Destination multi-location
351    /// * `beneficiary` - Beneficiary account on the destination chain
352    /// * `assets` - Assets to transfer
353    ///
354    /// # Returns
355    ///
356    /// Transaction hash of the XCM transfer extrinsic
357    pub async fn teleport(
358        &self,
359        wallet: &Wallet,
360        dest: MultiLocation,
361        beneficiary: [u8; 32],
362        assets: Vec<XcmAsset>,
363    ) -> Result<String> {
364        info!("Executing teleport to {:?} for beneficiary", dest);
365
366        let dest_value = self.encode_multilocation(&dest)?;
367        let beneficiary_value = self.encode_multilocation(&MultiLocation::account(beneficiary))?;
368        let assets_value = self.encode_assets(&assets)?;
369        let fee_index = 0u32;
370
371        let call = subxt::dynamic::tx(
372            "XcmPallet",
373            "limited_teleport_assets",
374            vec![
375                dest_value,
376                beneficiary_value,
377                assets_value,
378                subxt::dynamic::Value::u128(fee_index as u128),
379                self.encode_weight_limit()?,
380            ],
381        );
382
383        self.submit_xcm_call(&call, wallet).await
384    }
385
386    /// Transfer to relay chain (convenience method)
387    ///
388    /// Automatically uses reserve transfer to parent chain
389    pub async fn transfer_to_relay(
390        &self,
391        wallet: &Wallet,
392        beneficiary: [u8; 32],
393        amount: u128,
394    ) -> Result<String> {
395        debug!("Transferring {} to relay chain", amount);
396
397        self.reserve_transfer(
398            wallet,
399            MultiLocation::parent(),
400            beneficiary,
401            vec![XcmAsset::native(amount)],
402        )
403        .await
404    }
405
406    /// Transfer to another parachain
407    ///
408    /// Automatically uses reserve transfer via relay chain
409    pub async fn transfer_to_parachain(
410        &self,
411        wallet: &Wallet,
412        para_id: u32,
413        beneficiary: [u8; 32],
414        amount: u128,
415    ) -> Result<String> {
416        debug!("Transferring {} to parachain {}", amount, para_id);
417
418        self.reserve_transfer(
419            wallet,
420            MultiLocation::parachain(para_id),
421            beneficiary,
422            vec![XcmAsset::native(amount)],
423        )
424        .await
425    }
426
427    // Helper methods for encoding XCM types
428
429    #[allow(clippy::result_large_err)]
430    fn encode_multilocation(&self, location: &MultiLocation) -> Result<subxt::dynamic::Value> {
431        // Encode MultiLocation as composite value
432        // Structure: { parents: u8, interior: Junctions }
433
434        let interior = self.encode_junctions(&location.interior)?;
435
436        Ok(subxt::dynamic::Value::named_composite([
437            (
438                "parents",
439                subxt::dynamic::Value::u128(location.parents as u128),
440            ),
441            ("interior", interior),
442        ]))
443    }
444
445    #[allow(clippy::result_large_err)]
446    fn encode_junctions(&self, junctions: &[Junction]) -> Result<subxt::dynamic::Value> {
447        if junctions.is_empty() {
448            // X0 (Here) variant
449            return Ok(subxt::dynamic::Value::unnamed_variant("Here", vec![]));
450        }
451
452        // Encode junctions as nested X1, X2, etc.
453        let encoded_junctions: Vec<subxt::dynamic::Value> = junctions
454            .iter()
455            .map(|j| self.encode_junction(j))
456            .collect::<Result<Vec<_>>>()?;
457
458        // Use appropriate variant based on number of junctions
459        let variant_name = match junctions.len() {
460            1 => "X1",
461            2 => "X2",
462            3 => "X3",
463            4 => "X4",
464            5 => "X5",
465            6 => "X6",
466            7 => "X7",
467            8 => "X8",
468            _ => return Err(Error::Transaction("Too many junctions (max 8)".to_string())),
469        };
470
471        Ok(subxt::dynamic::Value::unnamed_variant(
472            variant_name,
473            encoded_junctions,
474        ))
475    }
476
477    #[allow(clippy::result_large_err)]
478    fn encode_junction(&self, junction: &Junction) -> Result<subxt::dynamic::Value> {
479        match junction {
480            Junction::Parachain(id) => Ok(subxt::dynamic::Value::unnamed_variant(
481                "Parachain",
482                vec![subxt::dynamic::Value::u128(*id as u128)],
483            )),
484            Junction::AccountId32 { network, id } => {
485                let network_value = if let Some(_net) = network {
486                    // Encode network if present
487                    subxt::dynamic::Value::unnamed_variant("Some", vec![])
488                } else {
489                    subxt::dynamic::Value::unnamed_variant("None", vec![])
490                };
491
492                Ok(subxt::dynamic::Value::unnamed_variant(
493                    "AccountId32",
494                    vec![network_value, subxt::dynamic::Value::from_bytes(id)],
495                ))
496            }
497            Junction::AccountId20 { network, key } => {
498                let network_value = if let Some(_net) = network {
499                    subxt::dynamic::Value::unnamed_variant("Some", vec![])
500                } else {
501                    subxt::dynamic::Value::unnamed_variant("None", vec![])
502                };
503
504                Ok(subxt::dynamic::Value::unnamed_variant(
505                    "AccountId20",
506                    vec![network_value, subxt::dynamic::Value::from_bytes(key)],
507                ))
508            }
509            Junction::GeneralIndex(index) => Ok(subxt::dynamic::Value::unnamed_variant(
510                "GeneralIndex",
511                vec![subxt::dynamic::Value::u128(*index)],
512            )),
513            Junction::GeneralKey { data } => Ok(subxt::dynamic::Value::unnamed_variant(
514                "GeneralKey",
515                vec![subxt::dynamic::Value::from_bytes(data)],
516            )),
517            Junction::PalletInstance(instance) => Ok(subxt::dynamic::Value::unnamed_variant(
518                "PalletInstance",
519                vec![subxt::dynamic::Value::u128(*instance as u128)],
520            )),
521        }
522    }
523
524    #[allow(clippy::result_large_err)]
525    fn encode_assets(&self, assets: &[XcmAsset]) -> Result<subxt::dynamic::Value> {
526        let encoded_assets: Vec<subxt::dynamic::Value> = assets
527            .iter()
528            .map(|asset| {
529                let id_value = match &asset.id {
530                    AssetId::Concrete(location) => {
531                        let loc = self.encode_multilocation(location)?;
532                        subxt::dynamic::Value::unnamed_variant("Concrete", vec![loc])
533                    }
534                    AssetId::Abstract(data) => subxt::dynamic::Value::unnamed_variant(
535                        "Abstract",
536                        vec![subxt::dynamic::Value::from_bytes(data)],
537                    ),
538                };
539
540                let fun_value = match asset.fun {
541                    Fungibility::Fungible(amount) => subxt::dynamic::Value::unnamed_variant(
542                        "Fungible",
543                        vec![subxt::dynamic::Value::u128(amount)],
544                    ),
545                    Fungibility::NonFungible(instance) => subxt::dynamic::Value::unnamed_variant(
546                        "NonFungible",
547                        vec![subxt::dynamic::Value::u128(instance)],
548                    ),
549                };
550
551                Ok(subxt::dynamic::Value::named_composite([
552                    ("id", id_value),
553                    ("fun", fun_value),
554                ]))
555            })
556            .collect::<Result<Vec<_>>>()?;
557
558        // Wrap in VersionedAssets::V3
559        Ok(subxt::dynamic::Value::unnamed_variant(
560            "V3",
561            vec![subxt::dynamic::Value::unnamed_composite(encoded_assets)],
562        ))
563    }
564
565    #[allow(clippy::result_large_err)]
566    fn encode_weight_limit(&self) -> Result<subxt::dynamic::Value> {
567        match self.config.weight_limit {
568            WeightLimit::Unlimited => {
569                Ok(subxt::dynamic::Value::unnamed_variant("Unlimited", vec![]))
570            }
571            WeightLimit::Limited(weight) => Ok(subxt::dynamic::Value::unnamed_variant(
572                "Limited",
573                vec![subxt::dynamic::Value::u128(weight as u128)],
574            )),
575        }
576    }
577
578    async fn submit_xcm_call<Call>(&self, call: &Call, wallet: &Wallet) -> Result<String>
579    where
580        Call: subxt::tx::Payload,
581    {
582        debug!("Submitting XCM extrinsic");
583
584        let pair = wallet
585            .sr25519_pair()
586            .ok_or_else(|| Error::Transaction("Wallet does not have SR25519 key".to_string()))?;
587
588        let signer = Sr25519Signer::new(pair.clone());
589
590        let mut progress = self
591            .client
592            .tx()
593            .sign_and_submit_then_watch_default(call, &signer)
594            .await
595            .map_err(|e| Error::Transaction(format!("Failed to submit XCM transaction: {}", e)))?;
596
597        // Wait for finalization
598        while let Some(event) = progress.next().await {
599            let event =
600                event.map_err(|e| Error::Transaction(format!("XCM transaction error: {}", e)))?;
601
602            if event.as_in_block().is_some() {
603                info!("XCM transaction included in block");
604            }
605
606            if let Some(finalized) = event.as_finalized() {
607                let tx_hash = format!("0x{}", hex::encode(finalized.extrinsic_hash()));
608                info!("XCM transaction finalized: {}", tx_hash);
609
610                finalized
611                    .wait_for_success()
612                    .await
613                    .map_err(|e| Error::Transaction(format!("XCM transaction failed: {}", e)))?;
614
615                return Ok(tx_hash);
616            }
617        }
618
619        Err(Error::Transaction(
620            "XCM transaction stream ended without finalization".to_string(),
621        ))
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628
629    #[test]
630    fn test_multilocation_parent() {
631        let location = MultiLocation::parent();
632        assert_eq!(location.parents, 1);
633        assert!(location.interior.is_empty());
634        assert!(location.is_parent());
635    }
636
637    #[test]
638    fn test_multilocation_parachain() {
639        let location = MultiLocation::parachain(2000);
640        assert_eq!(location.parents, 1);
641        assert_eq!(location.parachain_id(), Some(2000));
642        assert!(location.is_parachain());
643    }
644
645    #[test]
646    fn test_multilocation_account() {
647        let account = [1u8; 32];
648        let location = MultiLocation::account(account);
649        assert_eq!(location.parents, 0);
650        assert_eq!(location.interior.len(), 1);
651    }
652
653    #[test]
654    fn test_xcm_asset_native() {
655        let asset = XcmAsset::native(1000);
656        assert!(matches!(asset.fun, Fungibility::Fungible(1000)));
657    }
658
659    #[test]
660    fn test_weight_limit_default() {
661        let limit = WeightLimit::default();
662        assert!(matches!(limit, WeightLimit::Limited(5_000_000_000)));
663    }
664
665    #[test]
666    fn test_xcm_config_default() {
667        let config = XcmConfig::default();
668        assert_eq!(config.version, XcmVersion::V3);
669        assert!(matches!(config.weight_limit, WeightLimit::Limited(_)));
670    }
671}