1
// Copyright (C) Moondance Labs Ltd.
2
// This file is part of Tanssi.
3

            
4
// Tanssi is free software: you can redistribute it and/or modify
5
// it under the terms of the GNU General Public License as published by
6
// the Free Software Foundation, either version 3 of the License, or
7
// (at your option) any later version.
8

            
9
// Tanssi is distributed in the hope that it will be useful,
10
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
// GNU General Public License for more details.
13

            
14
// You should have received a copy of the GNU General Public License
15
// along with Tanssi.  If not, see <http://www.gnu.org/licenses/>
16

            
17
//! # LayerZero Router Pallet
18
//!
19
//! Routes LayerZero messages between container chains and external chains (Ethereum, etc.)
20
//! via the relay chain, using Snowbridge as the underlying bridge infrastructure.
21
//!
22
//! ## Overview
23
//!
24
//! This pallet acts as a router on the relay chain, enabling container chains (parachains)
25
//! to send and receive LayerZero messages to/from external chains. It integrates with
26
//! Snowbridge to communicate with Ethereum, which serves as a hub for LayerZero messaging.
27
//!
28
//! ## Message Flows
29
//!
30
//! ### Inbound: External Chain → Ethereum → Relay (Router) → Container Chain
31
//!
32
//! 1. External chain sends LayerZero message to Ethereum
33
//! 2. Snowbridge relays the message to the relay chain
34
//! 3. `LayerZeroInboundMessageProcessorV2` calls `handle_inbound_message`
35
//! 4. Router validates sender is whitelisted for the destination chain
36
//! 5. Router sends XCM `Transact` to the destination container chain
37
//! 6. Container chain's configured pallet receives the message
38
//!
39
//! ### Outbound: Container Chain → Relay (Router) → Ethereum → External Chain
40
//!
41
//! 1. Container chain calls `send_message_to_ethereum` via XCM
42
//! 2. Router validates origin, minimum reward, and transfers fee from sovereign account
43
//! 3. Router creates LayerZero message
44
//! 4. Router ABI-encodes the message and creates `CallContract` command
45
//! 5. Router sends to Snowbridge V2 outbound queue targeting LayerZero hub on Ethereum
46
//! 6. Snowbridge relays to Ethereum, which forwards via LayerZero to the destination
47
//!
48
//! ## Configuration
49
//!
50
//! ### For Container Chains (Inbound)
51
//!
52
//! Call `update_routing_config` via XCM to configure:
53
//! - **Whitelisted senders**: `(LayerZeroEndpoint, LayerZeroAddress)` tuples allowed to send
54
//! - **Notification destination**: `(pallet_index, call_index)` to receive messages
55
//!
56
//! Example: A container chain can whitelist an Ethereum contract at LayerZero endpoint 30101
57
//! and specify that messages should be delivered to pallet 79, call 0.
58
//!
59
//! ### For Relay Chain (Outbound)
60
//!
61
//! The relay chain runtime must configure:
62
//! - `LayerZeroHubAddress`: Address of the LayerZero hub contract on Ethereum
63
//! - `MinOutboundReward`: Minimum fee for sending messages
64
//! - `FeesAccount`: Account where routing fees are deposited
65
//!
66
//! ## Security
67
//!
68
//! - Container chains control their own routing configuration (via XCM)
69
//! - Whitelist enforcement prevents unauthorized message delivery
70
//! - Sovereign account funds are used for outbound fees (not user accounts)
71
//! - Minimum reward requirement prevents spam/DoS
72
//!
73
//! ## See Also
74
//!
75
//! - `pallet-lz-receiver-example`: Reference implementation for receiving messages
76
//! - `tp-bridge::layerzero_message`: Message type definitions and encoding
77
//! - Snowbridge pallets: Underlying bridge infrastructure
78

            
79
#![cfg_attr(not(feature = "std"), no_std)]
80

            
81
mod types;
82

            
83
#[cfg(test)]
84
mod mock;
85

            
86
#[cfg(test)]
87
mod tests;
88

            
89
extern crate alloc;
90

            
91
use crate::types::ChainId;
92
use alloy_core::sol_types::SolValue;
93
pub use pallet::*;
94
use xcm::latest::Location;
95
use xcm::prelude::{Parachain, Unlimited};
96
use {
97
    alloc::vec::Vec,
98
    frame_support::{
99
        pallet_prelude::BoundedVec,
100
        traits::{
101
            fungible::{Inspect, Mutate},
102
            tokens::Preservation,
103
        },
104
    },
105
    frame_system::unique,
106
    parity_scale_codec::Encode,
107
    snowbridge_outbound_queue_primitives::v2::{
108
        Command as SnowbridgeCommandV2, Message as SnowbridgeMessageV2,
109
        SendMessage as SendMessageV2,
110
    },
111
    snowbridge_outbound_queue_primitives::SendError,
112
    sp_core::{H160, H256},
113
    sp_runtime::traits::Get,
114
    tp_bridge::{
115
        layerzero_message::{
116
            InboundMessage, LayerZeroAddress, LayerZeroEndpoint, LayerZeroOutboundPayload,
117
            OutboundMessage, OutboundSolMessage,
118
        },
119
        ConvertLocation, TicketInfo,
120
    },
121
    xcm::prelude::InteriorLocation,
122
    xcm_executor::traits::ConvertLocation as XcmConvertLocation,
123
};
124

            
125
/// Extract the container chain (parachain) ID from an XCM location.
126
///
127
/// Validates that the location represents a direct parachain (same consensus, no hops)
128
/// and extracts its para ID.
129
///
130
/// ## Expected Format:
131
/// - Parents: 0 (same consensus)
132
/// - Interior: Single `Parachain(id)` junction
133
///
134
/// ## Parameters:
135
/// - `location`: The XCM location to extract from
136
///
137
/// ## Returns:
138
/// - `Some(ChainId)`: The parachain ID if the location is valid
139
/// - `None`: If the location doesn't represent a container chain
140
20
fn extract_container_chain_id(location: &Location) -> Option<ChainId> {
141
20
    match location.unpack() {
142
20
        (0, [Parachain(id)]) => Some(*id),
143
        _ => None,
144
    }
145
20
}
146

            
147
#[frame_support::pallet]
148
pub mod pallet {
149
    use crate::types::{ChainId, RoutingConfig};
150
    use alloc::vec;
151
    use snowbridge_inbound_queue_primitives::v2::MessageProcessorError;
152
    use xcm::latest::{send_xcm, Location, Reanchorable, Xcm};
153
    use xcm::prelude::{OriginKind, Transact, UnpaidExecution};
154
    use {
155
        super::*,
156
        frame_support::{pallet_prelude::*, traits::EnsureOrigin},
157
        frame_system::pallet_prelude::*,
158
    };
159

            
160
    /// Configure the pallet by specifying the parameters and types on which it depends.
161
    #[pallet::config]
162
    pub trait Config: frame_system::Config + pallet_xcm::Config {
163
        #[pallet::constant]
164
        type MaxWhitelistedSenders: Get<u32> + Clone;
165
        /// Origin locations allowed to update routing configurations
166
        type ContainerChainOrigin: EnsureOrigin<
167
            <Self as frame_system::Config>::RuntimeOrigin,
168
            Success = Location,
169
        >;
170

            
171
        /// Validate and send a message to Ethereum V2.
172
        type OutboundQueueV2: SendMessageV2<Ticket: TicketInfo>;
173

            
174
        /// The address of the LayerZero hub contract on Ethereum.
175
        #[pallet::constant]
176
        type LayerZeroHubAddress: Get<H160>;
177

            
178
        /// Minimum reward for outbound messages.
179
        #[pallet::constant]
180
        type MinOutboundReward: Get<u128>;
181

            
182
        /// Converts Location to H256 for message origin.
183
        type LocationHashOf: ConvertLocation<H256>;
184

            
185
        /// The bridge's configured Ethereum location.
186
        type EthereumLocation: Get<Location>;
187

            
188
        /// This chain's Universal Location.
189
        type UniversalLocation: Get<InteriorLocation>;
190

            
191
        /// Currency for handling fee transfers.
192
        type Currency: Inspect<Self::AccountId, Balance = u128> + Mutate<Self::AccountId>;
193

            
194
        /// Account where fees are deposited.
195
        type FeesAccount: Get<Self::AccountId>;
196

            
197
        /// Converts a Location to an AccountId (for sovereign accounts).
198
        type LocationToAccountId: XcmConvertLocation<Self::AccountId>;
199
    }
200

            
201
    #[pallet::pallet]
202
    pub struct Pallet<T>(_);
203

            
204
    /// Routing configuration per container chain
205
    #[pallet::storage]
206
    pub type RoutingConfigs<T: Config> =
207
        StorageMap<_, Twox64Concat, ChainId, RoutingConfig<T>, OptionQuery>;
208

            
209
    #[pallet::error]
210
    pub enum Error<T> {
211
        /// The provided origin location is not a container chain
212
        LocationIsNotAContainerChain,
213
        /// No routing configuration found for the destination chain
214
        NoRoutingConfig,
215
        /// The sender (address+endpoint) is not whitelisted to forward messages to the destination chain
216
        NotWhitelistedSender,
217
        /// Setting the same configuration that already exists
218
        SameConfigAlreadyExists,
219
        /// The outbound message is invalid prior to send.
220
        InvalidMessage(SendError),
221
        /// The outbound message could not be sent.
222
        MessageNotSent(SendError),
223
        /// Too many commands in the message.
224
        TooManyCommands,
225
        /// The reward provided is below the minimum required.
226
        MinRewardNotAchieved,
227
        /// Failed to convert location to origin hash.
228
        LocationToOriginConversionFailed,
229
        /// Failed to reanchor location.
230
        LocationReanchorFailed,
231
        /// Failed to convert location to account ID.
232
        LocationToAccountConversionFailed,
233
    }
234

            
235
    #[pallet::event]
236
    #[pallet::generate_deposit(pub(crate) fn deposit_event)]
237
    pub enum Event<T: Config> {
238
        /// Routing configuration updated for a container chain
239
        RoutingConfigUpdated {
240
            chain_id: ChainId,
241
            new_config: RoutingConfig<T>,
242
            old_config: Option<RoutingConfig<T>>,
243
        },
244
        /// Inbound message routed to a container chain
245
        InboundMessageRouted {
246
            chain_id: ChainId,
247
            message: InboundMessage,
248
        },
249
        /// Outbound message sent to Ethereum/LayerZero
250
        OutboundMessageSent {
251
            message_id: H256,
252
            message: OutboundMessage,
253
            reward: u128,
254
            gas: u64,
255
        },
256
    }
257

            
258
    #[pallet::call]
259
    impl<T: Config> Pallet<T> {
260
        /// Update routing configuration for a container chain.
261
        ///
262
        /// Configures how the container chain receives inbound LayerZero messages.
263
        /// Must be called via XCM from the container chain itself.
264
        ///
265
        /// The configuration specifies:
266
        /// - **Whitelisted Senders**: `(LayerZeroEndpoint, LayerZeroAddress)` tuples allowed to send
267
        /// - **Notification Destination**: `(pallet_index, call_index)` to handle incoming messages
268
        ///
269
        /// Emits `RoutingConfigUpdated` event.
270
        #[pallet::call_index(0)]
271
        #[pallet::weight(Weight::from_parts(10_000, 0) + T::DbWeight::get().writes(1))]
272
        pub fn update_routing_config(
273
            origin: OriginFor<T>,
274
            new_config: RoutingConfig<T>,
275
9
        ) -> DispatchResult {
276
9
            let origin_location = T::ContainerChainOrigin::ensure_origin(origin)?;
277
9
            let chain_id = extract_container_chain_id(&origin_location)
278
9
                .ok_or(Error::<T>::LocationIsNotAContainerChain)?;
279

            
280
9
            let old_config = RoutingConfigs::<T>::get(chain_id);
281
9
            ensure!(
282
9
                old_config != Some(new_config.clone()),
283
1
                Error::<T>::SameConfigAlreadyExists
284
            );
285

            
286
8
            RoutingConfigs::<T>::insert(chain_id, new_config.clone());
287

            
288
8
            Self::deposit_event(Event::RoutingConfigUpdated {
289
8
                chain_id,
290
8
                new_config,
291
8
                old_config,
292
8
            });
293

            
294
8
            Ok(())
295
        }
296

            
297
        /// Send an outbound message to Ethereum/LayerZero.
298
        ///
299
        /// Called via XCM from a container chain to send a LayerZero message to an external chain.
300
        /// The message is ABI-encoded and sent as a `CallContract` to the LayerZero hub on Ethereum.
301
        ///
302
        /// The reward is transferred from the container chain's sovereign account to the fees account.
303
        ///
304
        /// Emits `OutboundMessageSent` event.
305
        #[pallet::call_index(1)]
306
        #[pallet::weight(Weight::from_parts(10_000, 0) + T::DbWeight::get().writes(1))]
307
        pub fn send_message_to_ethereum(
308
            origin: OriginFor<T>,
309
            lz_destination_address: LayerZeroAddress,
310
            lz_destination_endpoint: LayerZeroEndpoint,
311
            payload: LayerZeroOutboundPayload,
312
            reward: u128,
313
            gas: u64,
314
12
        ) -> DispatchResult {
315
12
            let origin_location = T::ContainerChainOrigin::ensure_origin(origin)?;
316
11
            let source_chain = extract_container_chain_id(&origin_location)
317
11
                .ok_or(Error::<T>::LocationIsNotAContainerChain)?;
318

            
319
            // Check for minimum reward
320
11
            ensure!(
321
11
                reward >= T::MinOutboundReward::get(),
322
1
                Error::<T>::MinRewardNotAchieved
323
            );
324

            
325
            // Get the sovereign account of the container chain
326
10
            let sovereign_account = T::LocationToAccountId::convert_location(&origin_location)
327
10
                .ok_or(Error::<T>::LocationToAccountConversionFailed)?;
328

            
329
            // Transfer fee from container chain's sovereign account to fees account
330
10
            <T as Config>::Currency::transfer(
331
10
                &sovereign_account,
332
10
                &T::FeesAccount::get(),
333
10
                reward,
334
10
                Preservation::Preserve,
335
1
            )?;
336

            
337
            // Build the outbound message
338
9
            let message = OutboundMessage {
339
9
                source_chain,
340
9
                lz_destination_address: lz_destination_address.clone(),
341
9
                lz_destination_endpoint,
342
9
                payload,
343
9
            };
344

            
345
            // Convert to ABI-encodable message
346
9
            let sol_message: OutboundSolMessage = message.clone().into();
347
            // TODO: encode the function selector also
348
            // | 4 bytes  |   N × 32 bytes |
349
            // | selector | ABI-encoded arguments |
350
9
            let calldata = sol_message.abi_encode();
351

            
352
            // Create CallContract command targeting the LayerZero hub on Ethereum
353
9
            let command = SnowbridgeCommandV2::CallContract {
354
9
                target: T::LayerZeroHubAddress::get(),
355
9
                calldata,
356
9
                gas,
357
9
                value: 0, // No ETH value sent with the call
358
9
            };
359

            
360
            // Convert location to message origin (reanchored relative to Ethereum)
361
9
            let origin = Self::location_to_message_origin(origin_location)?;
362
9
            let id = unique((origin, &command)).into();
363

            
364
9
            let commands: Vec<SnowbridgeCommandV2> = vec![command];
365

            
366
9
            let snowbridge_message = SnowbridgeMessageV2 {
367
9
                id,
368
9
                commands: BoundedVec::try_from(commands)
369
9
                    .map_err(|_| Error::<T>::TooManyCommands)?,
370
9
                fee: reward,
371
9
                origin,
372
            };
373

            
374
            // Validate and deliver the message
375
9
            let ticket = T::OutboundQueueV2::validate(&snowbridge_message)
376
9
                .map_err(|err| Error::<T>::InvalidMessage(err))?;
377
9
            let message_id = ticket.message_id();
378

            
379
9
            T::OutboundQueueV2::deliver(ticket).map_err(|err| Error::<T>::MessageNotSent(err))?;
380

            
381
9
            Self::deposit_event(Event::OutboundMessageSent {
382
9
                message_id,
383
9
                message,
384
9
                reward,
385
9
                gas,
386
9
            });
387

            
388
9
            Ok(())
389
        }
390
    }
391

            
392
    impl<T: Config> Pallet<T> {
393
        /// Handle an inbound LayerZero message by forwarding it to the destination container chain.
394
        ///
395
        /// Called by `LayerZeroMessageProcessor` when messages arrive from Ethereum.
396
        /// Validates the sender is whitelisted and sends an XCM `Transact` to the configured
397
        /// destination pallet on the container chain.
398
        ///
399
        /// Returns `Ok(())` if routed successfully, `Err(MessageProcessorError)` otherwise.
400
6
        pub fn handle_inbound_message(
401
6
            message: InboundMessage,
402
6
        ) -> Result<(), MessageProcessorError> {
403
6
            let dest_chain_id: ChainId = message.destination_chain;
404

            
405
6
            let config = RoutingConfigs::<T>::get(dest_chain_id).ok_or(
406
6
                MessageProcessorError::ProcessMessage(Error::<T>::NoRoutingConfig.into()),
407
1
            )?;
408

            
409
5
            let sender = (
410
5
                message.lz_source_endpoint,
411
5
                message.lz_source_address.clone(),
412
5
            );
413
5
            if !config.whitelisted_senders.contains(&sender) {
414
2
                return Err(MessageProcessorError::ProcessMessage(
415
2
                    Error::<T>::NotWhitelistedSender.into(),
416
2
                ));
417
3
            }
418

            
419
3
            let container_chain_location = Location::new(0, [Parachain(dest_chain_id)]);
420

            
421
            // Craft a Transact XCM to send the message to the destination chain
422
3
            let pallet_index = config.notification_destination.0;
423
3
            let call_index = config.notification_destination.1;
424

            
425
3
            let remote_xcm = Xcm::<()>(vec![
426
3
                UnpaidExecution {
427
3
                    weight_limit: Unlimited,
428
3
                    check_origin: None,
429
3
                },
430
3
                Transact {
431
3
                    origin_kind: OriginKind::Xcm,
432
3
                    fallback_max_weight: None,
433
3
                    call: (pallet_index, call_index, message.encode()).encode().into(),
434
3
                },
435
3
            ]);
436

            
437
3
            send_xcm::<<T as pallet_xcm::Config>::XcmRouter>(
438
3
                container_chain_location.clone(),
439
3
                remote_xcm.clone(),
440
            )
441
3
            .map_err(MessageProcessorError::SendMessage)?;
442

            
443
3
            Self::deposit_event(Event::InboundMessageRouted {
444
3
                chain_id: dest_chain_id,
445
3
                message,
446
3
            });
447

            
448
3
            Ok(())
449
6
        }
450

            
451
        /// Convert a location to a message origin hash by reanchoring relative to Ethereum.
452
        ///
453
        /// This is used to create a consistent origin identifier that Ethereum can understand.
454
        /// The process involves:
455
        /// 1. Reanchoring the location from the relay chain's perspective to Ethereum's perspective
456
        /// 2. Hashing the reanchored location to produce a unique H256 identifier
457
        ///
458
        /// ## Parameters:
459
        /// - `location`: The XCM location to convert (typically a container chain location)
460
        ///
461
        /// ## Returns:
462
        /// - `Ok(H256)`: The unique origin hash for the location
463
        /// - `Err(Error<T>)`: Conversion failed
464
        ///
465
        /// ## Errors:
466
        /// - `LocationReanchorFailed`: The location couldn't be reanchored to Ethereum's context
467
        /// - `LocationToOriginConversionFailed`: The location hash conversion failed
468
9
        pub fn location_to_message_origin(location: Location) -> Result<H256, Error<T>> {
469
9
            let reanchored_location = Self::reanchor(location)?;
470
9
            T::LocationHashOf::convert_location(&reanchored_location)
471
9
                .ok_or(Error::<T>::LocationToOriginConversionFailed)
472
9
        }
473

            
474
        /// Reanchor a location from the relay chain's perspective to Ethereum's perspective.
475
        ///
476
        /// XCM locations are relative, so the same location appears differently depending on
477
        /// the observer. This function converts a location from "how the relay chain sees it"
478
        /// to "how Ethereum sees it" by using the universal location system.
479
        ///
480
        /// ## Example:
481
        /// A container chain at `Parachain(2000)` on the relay chain would be reanchored to
482
        /// include the full path from Ethereum's perspective (e.g., including the relay chain
483
        /// identifier).
484
        ///
485
        /// ## Parameters:
486
        /// - `location`: The location to reanchor
487
        ///
488
        /// ## Returns:
489
        /// - `Ok(Location)`: The reanchored location from Ethereum's perspective
490
        /// - `Err(Error<T>)`: Reanchoring failed
491
        ///
492
        /// ## Errors:
493
        /// - `LocationReanchorFailed`: The reanchoring operation failed (e.g., incompatible locations)
494
9
        pub fn reanchor(location: Location) -> Result<Location, Error<T>> {
495
9
            location
496
9
                .reanchored(
497
9
                    &T::EthereumLocation::get(),
498
9
                    &<T as Config>::UniversalLocation::get(),
499
                )
500
9
                .map_err(|_| Error::<T>::LocationReanchorFailed)
501
9
        }
502
    }
503
}