From 6ad4e07ed8d67dc7659a3d51232698c98ba40baf Mon Sep 17 00:00:00 2001 From: Ahmad Kaouk <56095276+ahmadkaouk@users.noreply.github.com> Date: Thu, 3 Jul 2025 16:41:51 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=20=E2=9C=A8=20Add=20native=20token=20t?= =?UTF-8?q?ransfer=20support=20from=20Ethereum=20to=20DataHaven=20(#97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Overview This PR implements the **return path** for DataHaven's native token bridge, enabling tokens that were previously transferred from DataHaven to Ethereum to flow back to DataHaven. **Context**: DataHaven native tokens can be transferred to Ethereum where they exist as wrapped ERC20 tokens. This PR completes the bidirectional bridge by implementing the mechanism to transfer these tokens back from Ethereum to DataHaven, effectively "unwrapping" them and restoring them to their native form. ## Key Changes - **Added `NativeTokenTransferMessageProcessor`** , a new message processor specifically designed to handle native token return transfers from Ethereum, and extended `InboundQueueV2` processor tuple to include native token transfer handling alongside existing EigenLayer message processing ## How It Works ### Detailed Flow: Ethereum → DataHaven #### 1. **Ethereum Initiation** - **User Transaction**: User calls `v2_sendMessage` on Snowbridge Gateway contract: ```solidity v2_sendMessage( assets: [{ kind: ForeignToken, assetId: , // Must match pre-registered token ID amount: }], claimer: , // 20-byte recipient address (SCALE-encoded) message: [], // Empty for native transfers // ... standard Snowbridge fees ) ``` - **Token Burning**: Gateway burns the wrapped ERC20 tokens on Ethereum (removing them from circulation) - **Message Encoding**: Gateway encodes transfer details into standardized Snowbridge message format - **Event Emission**: `MessageAccepted` event logged on Ethereum for relayers to pick up #### 2. **Cross-Chain Delivery Infrastructure** - **Relayer Network**: Snowbridge relayers monitor Ethereum events and submit messages to DataHaven - **Consensus Verification**: DataHaven's `EthereumBeaconClient` pallet verifies Ethereum beacon chain consensus - **Cryptographic Proofs**: Messages include Merkle proofs and execution receipts for tamper-proof verification - **Direct Processing**: Relayers submit messages via `InboundQueueV2::submit()` which processes them immediately #### 3. **DataHaven Message Processing** **Immediate Processing Flow:** When a relayer calls `InboundQueueV2::submit()`, the message is processed immediately using a tuple-based processor system: ```rust type MessageProcessor = ( EigenLayerMessageProcessor, // Handles validator operations NativeTokenTransferMessageProcessor, // Handles native token returns ); ``` **Processing Steps:** 1. **Message Verification**: Cryptographic verification and nonce checking (prevents replay attacks) 2. **Processor Selection**: Evaluation of processors (First processor returning `true` handles the message): - `EigenLayerMessageProcessor::can_process_message()` - returns `false` for token transfers - `NativeTokenTransferMessageProcessor::can_process_message()` - validates: - Native token is registered via `SnowbridgeSystemV2::register_token()` (`T::NativeTokenId::get().is_some()`) - All assets are `ForeignTokenERC20` with matching native token ID - Assets array is not empty **Token Unlocking Process:** 1. **Recipient Extraction**: Decodes H160 address from `claimer` field → converts to DataHaven AccountId (must contain valid SCALE-encoded H160 address) 2. **Sovereign Account Transfer**: Moves tokens from Ethereum sovereign account to recipient (requires sufficient balance from previous outbound transfers) 3. **Event Emission**: `TokensUnlocked` event confirms successful transfer completion --------- Co-authored-by: Tobi Demeco <50408393+TDemeco@users.noreply.github.com> Co-authored-by: Claude --- operator/Cargo.lock | 2 + operator/primitives/bridge/Cargo.toml | 4 + operator/primitives/bridge/src/lib.rs | 107 ++- operator/runtime/mainnet/src/configs/mod.rs | 7 +- operator/runtime/mainnet/tests/common.rs | 6 +- .../mainnet/tests/native_token_transfer.rs | 677 +++++++++--------- operator/runtime/stagenet/src/configs/mod.rs | 7 +- .../stagenet/src/configs/runtime_params.rs | 2 +- operator/runtime/stagenet/tests/common.rs | 6 +- .../stagenet/tests/native_token_transfer.rs | 665 ++++++++--------- operator/runtime/testnet/src/configs/mod.rs | 7 +- operator/runtime/testnet/tests/common.rs | 6 +- .../testnet/tests/native_token_transfer.rs | 665 ++++++++--------- 13 files changed, 1148 insertions(+), 1013 deletions(-) diff --git a/operator/Cargo.lock b/operator/Cargo.lock index 01b00e21..b90817ab 100644 --- a/operator/Cargo.lock +++ b/operator/Cargo.lock @@ -2881,10 +2881,12 @@ dependencies = [ "frame-support", "frame-system", "hex", + "pallet-datahaven-native-transfer", "pallet-external-validators", "parity-scale-codec", "snowbridge-core 0.2.0", "snowbridge-inbound-queue-primitives", + "sp-core", "sp-std", ] diff --git a/operator/primitives/bridge/Cargo.toml b/operator/primitives/bridge/Cargo.toml index b87aad6e..cf413036 100644 --- a/operator/primitives/bridge/Cargo.toml +++ b/operator/primitives/bridge/Cargo.toml @@ -11,9 +11,11 @@ version = "0.1.0" frame-support = { workspace = true } frame-system = { workspace = true } pallet-external-validators = { workspace = true } +pallet-datahaven-native-transfer = { workspace = true } parity-scale-codec = { workspace = true } snowbridge-core = { workspace = true } snowbridge-inbound-queue-primitives = { workspace = true } +sp-core = { workspace = true } sp-std = { workspace = true } hex = { workspace = true } @@ -25,6 +27,8 @@ std = [ "snowbridge-core/std", "parity-scale-codec/std", "pallet-external-validators/std", + "pallet-datahaven-native-transfer/std", + "sp-core/std", "sp-std/std", "snowbridge-inbound-queue-primitives/std", ] diff --git a/operator/primitives/bridge/src/lib.rs b/operator/primitives/bridge/src/lib.rs index e10cd01c..55c45140 100644 --- a/operator/primitives/bridge/src/lib.rs +++ b/operator/primitives/bridge/src/lib.rs @@ -2,13 +2,19 @@ use frame_support::pallet_prelude::*; use parity_scale_codec::DecodeAll; -use snowbridge_inbound_queue_primitives::v2::{Message as SnowbridgeMessage, MessageProcessor}; +use snowbridge_inbound_queue_primitives::v2::{ + EthereumAsset, Message as SnowbridgeMessage, MessageProcessor, +}; +use sp_core::H160; use sp_std::vec::Vec; // Message ID. This is not expected to change and its arbitrary bytes defined here. // It should match the EL_MESSAGE_ID in DataHavenSnowbridgeMessages.sol pub const EL_MESSAGE_ID: [u8; 4] = [112, 21, 0, 56]; // 0x70150038 +// Message ID for native token transfers +pub const NATIVE_TRANSFER_MESSAGE_ID: [u8; 4] = [112, 21, 0, 57]; // 0x70150039 + #[derive(Encode, Decode)] pub struct Payload where @@ -76,9 +82,10 @@ where fn process_message( _who: AccountId, - message: SnowbridgeMessage, + snow_msg: SnowbridgeMessage, ) -> Result<[u8; 32], DispatchError> { - let payload = match &message.xcm { + // Extract and decode the raw payload that came from Ethereum + let payload = match &snow_msg.xcm { snowbridge_inbound_queue_primitives::v2::Payload::Raw(payload) => payload, snowbridge_inbound_queue_primitives::v2::Payload::CreateAsset { token: _, @@ -86,13 +93,13 @@ where } => return Err(DispatchError::Other("Invalid Message")), }; let decode_result = Self::decode_message(payload.as_slice()); - let message = if let Ok(payload) = decode_result { + let inner_message = if let Ok(payload) = decode_result { payload.message } else { return Err(DispatchError::Other("unable to parse the message payload")); }; - match message { + match inner_message { Message::V1(InboundCommand::ReceiveValidators { validators, external_index, @@ -101,6 +108,7 @@ where validators, external_index, )?; + // Return a 32-byte identifier using the message type ID let mut id = [0u8; 32]; id[..EL_MESSAGE_ID.len()].copy_from_slice(&EL_MESSAGE_ID); Ok(id) @@ -108,3 +116,92 @@ where } } } + +/// Native Token Transfer Message Processor +/// Handles inbound messages for native token transfers from Ethereum back to DataHaven +pub struct NativeTokenTransferMessageProcessor(PhantomData); + +impl NativeTokenTransferMessageProcessor +where + T: pallet_datahaven_native_transfer::Config + frame_system::Config, + T::AccountId: From, +{ + /// Extract account ID from claimer field + /// For native token transfers, the claimer contains an H160 Ethereum address + /// that needs to be converted to the runtime's AccountId format + fn extract_recipient_from_claimer(claimer: &[u8]) -> Result { + // For native token transfers, decode the claimer as an H160 Ethereum address + let eth_address = H160::decode(&mut &claimer[..]) + .map_err(|_| DispatchError::Other("Invalid Ethereum address in claimer"))?; + + Ok(T::AccountId::from(eth_address)) + } +} + +impl MessageProcessor for NativeTokenTransferMessageProcessor +where + T: pallet_datahaven_native_transfer::Config + frame_system::Config, + T::AccountId: From, +{ + fn can_process_message(_who: &AccountId, message: &SnowbridgeMessage) -> bool { + // Check if the native token is registered + let native_token_id = match T::NativeTokenId::get() { + Some(id) => id, + None => return false, // Token not registered + }; + + // Ensure all assets are the native token as ForeignTokenERC20 + !message.assets.is_empty() + && message.assets.iter().all(|asset| match asset { + EthereumAsset::ForeignTokenERC20 { token_id, .. } => *token_id == native_token_id, + _ => false, + }) + } + + fn process_message( + _who: AccountId, + snow_msg: SnowbridgeMessage, + ) -> Result<[u8; 32], DispatchError> { + let native_token_id = + T::NativeTokenId::get().ok_or(DispatchError::Other("Native token not registered"))?; + + // Extract and sum all native token assets + let token_amount: u128 = snow_msg + .assets + .iter() + .filter_map(|asset| match asset { + EthereumAsset::ForeignTokenERC20 { token_id, value } + if *token_id == native_token_id => + { + Some(*value) + } + _ => None, + }) + .sum(); + + if token_amount == 0 { + return Err(DispatchError::Other("No native token found in assets")); + } + + // Extract recipient from claimer field + let claimer = snow_msg + .claimer + .as_ref() + .ok_or(DispatchError::Other("No claimer specified in message"))?; + + let recipient = Self::extract_recipient_from_claimer(claimer.as_slice())?; + + // Convert amount to balance type + let balance_amount = token_amount + .try_into() + .map_err(|_| DispatchError::Other("Amount conversion failed"))?; + + // Unlock tokens from the sovereign account + pallet_datahaven_native_transfer::Pallet::::unlock_tokens(&recipient, balance_amount)?; + + // Return a 32-byte identifier using the native transfer message type ID + let mut id = [0u8; 32]; + id[..NATIVE_TRANSFER_MESSAGE_ID.len()].copy_from_slice(&NATIVE_TRANSFER_MESSAGE_ID); + Ok(id) + } +} diff --git a/operator/runtime/mainnet/src/configs/mod.rs b/operator/runtime/mainnet/src/configs/mod.rs index 1d27dd0e..746306cd 100644 --- a/operator/runtime/mainnet/src/configs/mod.rs +++ b/operator/runtime/mainnet/src/configs/mod.rs @@ -42,7 +42,7 @@ use datahaven_runtime_common::{ gas::WEIGHT_PER_GAS, time::{EpochDurationInBlocks, DAYS, MILLISECS_PER_BLOCK}, }; -use dhp_bridge::EigenLayerMessageProcessor; +use dhp_bridge::{EigenLayerMessageProcessor, NativeTokenTransferMessageProcessor}; use frame_support::{ derive_impl, pallet_prelude::TransactionPriority, @@ -852,7 +852,10 @@ impl snowbridge_pallet_inbound_queue_v2::Config for Runtime { type RuntimeEvent = RuntimeEvent; type Verifier = EthereumBeaconClient; type GatewayAddress = runtime_params::dynamic_params::runtime_config::EthereumGatewayAddress; - type MessageProcessor = EigenLayerMessageProcessor; + type MessageProcessor = ( + EigenLayerMessageProcessor, + NativeTokenTransferMessageProcessor, + ); type RewardKind = (); type DefaultRewardKind = DefaultRewardKind; type RewardPayment = DummyRewardPayment; diff --git a/operator/runtime/mainnet/tests/common.rs b/operator/runtime/mainnet/tests/common.rs index c784ce47..e360fbc8 100644 --- a/operator/runtime/mainnet/tests/common.rs +++ b/operator/runtime/mainnet/tests/common.rs @@ -4,7 +4,7 @@ //! Common test utilities for DataHaven mainnet runtime tests use datahaven_mainnet_runtime::{ - AccountId, Balance, Runtime, RuntimeEvent, RuntimeOrigin, Session, SessionKeys, System, UNIT, + AccountId, Balance, Runtime, RuntimeOrigin, Session, SessionKeys, System, UNIT, }; use frame_support::traits::Hooks; use pallet_im_online::sr25519::AuthorityId as ImOnlineId; @@ -137,10 +137,6 @@ pub fn root_origin() -> RuntimeOrigin { RuntimeOrigin::root() } -pub fn last_event() -> RuntimeEvent { - System::events().pop().expect("Event expected").event -} - pub fn datahaven_token_metadata() -> snowbridge_core::AssetMetadata { snowbridge_core::AssetMetadata { name: b"HAVE".to_vec().try_into().unwrap(), diff --git a/operator/runtime/mainnet/tests/native_token_transfer.rs b/operator/runtime/mainnet/tests/native_token_transfer.rs index d0c192e5..8345ebda 100644 --- a/operator/runtime/mainnet/tests/native_token_transfer.rs +++ b/operator/runtime/mainnet/tests/native_token_transfer.rs @@ -1,423 +1,444 @@ // Copyright 2025 Moonbeam Foundation. // This file is part of DataHaven. -//! Native token transfer integration tests for DataHaven mainnet runtime -//! -//! Tests for native token transfers between DataHaven and Ethereum via Snowbridge - #[path = "common.rs"] mod common; +use codec::Encode; use common::*; use datahaven_mainnet_runtime::{ - configs::EthereumSovereignAccount, AccountId, Balances, DataHavenNativeTransfer, Runtime, - RuntimeEvent, RuntimeOrigin, SnowbridgeSystemV2, System, UNIT, + configs::EthereumSovereignAccount, AccountId, Balance, Balances, DataHavenNativeTransfer, + Runtime, RuntimeEvent, RuntimeOrigin, SnowbridgeSystemV2, System, UNIT, }; +use dhp_bridge::NativeTokenTransferMessageProcessor; use frame_support::{assert_noop, assert_ok, traits::fungible::Inspect}; use pallet_datahaven_native_transfer::Event as NativeTransferEvent; use snowbridge_core::TokenIdOf; -use snowbridge_outbound_queue_primitives::v2::Command; +use snowbridge_inbound_queue_primitives::v2::{ + EthereumAsset, Message as SnowbridgeMessage, MessageProcessor, Payload, +}; use snowbridge_pallet_outbound_queue_v2::Event as OutboundQueueEvent; use snowbridge_pallet_system::NativeToForeignId; -use snowbridge_pallet_system_v2::Event as SystemV2Event; +use sp_core::Get; use sp_core::{H160, H256}; +use sp_runtime::DispatchError; use xcm::prelude::*; use xcm_executor::traits::ConvertLocation; +const TRANSFER_AMOUNT: Balance = 1000 * UNIT; +const FEE_AMOUNT: Balance = 10 * UNIT; +const ETH_ALICE: H160 = H160([0x11; 20]); +const ETH_BOB: H160 = H160([0x22; 20]); + +// Get the gateway address from runtime configuration +fn gateway_address() -> H160 { + use datahaven_mainnet_runtime::configs::runtime_params::dynamic_params::runtime_config::EthereumGatewayAddress; + EthereumGatewayAddress::get() +} + +fn register_native_token() -> H256 { + let asset_location = Location::here(); + let _ = SnowbridgeSystemV2::register_token( + root_origin(), + Box::new(VersionedLocation::V5(asset_location.clone())), + Box::new(VersionedLocation::V5(asset_location.clone())), + datahaven_token_metadata(), + ); + let reanchored = SnowbridgeSystemV2::reanchor(asset_location).unwrap(); + TokenIdOf::convert_location(&reanchored).unwrap() +} + +fn setup_sovereign_balance(amount: Balance) { + let _ = Balances::force_set_balance(root_origin(), EthereumSovereignAccount::get(), amount); +} + +fn create_message(token_id: H256, amount: Balance, claimer: H160, nonce: u64) -> SnowbridgeMessage { + SnowbridgeMessage { + gateway: gateway_address(), + nonce, + origin: H160::zero(), + assets: vec![EthereumAsset::ForeignTokenERC20 { + token_id, + value: amount, + }], + xcm: Payload::Raw(vec![0x01, 0x02, 0x03]), + claimer: Some(claimer.encode()), + value: 0, + execution_fee: 100, + relayer_fee: 50, + } +} + +// === Token Registration Tests === + #[test] -fn test_datahaven_native_token_registration_succeeds() { +fn native_token_registration_works() { ExtBuilder::default().build().execute_with(|| { - let origin = root_origin(); - let sender_location = Location::here(); let asset_location = Location::here(); - let metadata = datahaven_token_metadata(); - // Step 1: Verify preconditions - token not yet registered - let initial_reanchored = SnowbridgeSystemV2::reanchor(asset_location.clone()).unwrap(); - assert!(NativeToForeignId::::get(&initial_reanchored).is_none()); - assert!( - snowbridge_pallet_system::ForeignToNativeId::::iter() - .next() - .is_none() - ); - - // Step 2: Register the token + // Register the native HAVE token with Snowbridge assert_ok!(SnowbridgeSystemV2::register_token( - origin, - Box::new(VersionedLocation::V5(sender_location)), + root_origin(), Box::new(VersionedLocation::V5(asset_location.clone())), - metadata.clone() + Box::new(VersionedLocation::V5(asset_location.clone())), + datahaven_token_metadata() )); - // Step 3: Verify reanchoring works correctly - let reanchored_location = SnowbridgeSystemV2::reanchor(asset_location.clone()).unwrap(); - assert_eq!(reanchored_location.parent_count(), 1); - // Verify it contains GlobalConsensus junction with DataHaven network - let first_junction = reanchored_location.first_interior(); - assert!(matches!(first_junction, Some(Junction::GlobalConsensus(_)))); + // Verify token was registered and assigned a valid token ID + let reanchored = SnowbridgeSystemV2::reanchor(asset_location).unwrap(); + let token_id = TokenIdOf::convert_location(&reanchored).unwrap(); - // Step 4: Verify TokenId generation is deterministic - let token_id = TokenIdOf::convert_location(&reanchored_location).unwrap(); assert_ne!(token_id, H256::zero()); - - // Step 5: Verify bidirectional storage mappings assert_eq!( - NativeToForeignId::::get(&reanchored_location), + NativeToForeignId::::get(&reanchored), Some(token_id) ); - assert_eq!( - snowbridge_pallet_system::ForeignToNativeId::::get(&token_id), - Some(reanchored_location.clone()) - ); - - // Step 6: Verify event emission with all details - let expected_event = RuntimeEvent::SnowbridgeSystemV2(SystemV2Event::RegisterToken { - location: reanchored_location.clone().into(), - foreign_token_id: token_id, - }); - assert_eq!(last_event(), expected_event); }); } -#[test] -fn test_native_token_transfer_to_ethereum_succeeds() { - ExtBuilder::default().build().execute_with(|| { - // Register token first - register_native_token(); +// === Outbound Transfer Tests === +#[test] +fn transfer_to_ethereum_works() { + ExtBuilder::default().build().execute_with(|| { + let _token_id = register_native_token(); let alice = account_id(ALICE); - let eth_recipient = H160::from_low_u64_be(0x1234); - let transfer_amount = 1000 * UNIT; - let fee = 10 * UNIT; // Record initial balances let alice_initial = Balances::balance(&alice); let sovereign_initial = Balances::balance(&EthereumSovereignAccount::get()); - let treasury_initial = - Balances::balance(&datahaven_mainnet_runtime::configs::TreasuryAccount::get()); - // Execute transfer + // Transfer native tokens to Ethereum assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( RuntimeOrigin::signed(alice.clone()), - eth_recipient, - transfer_amount, - fee + ETH_ALICE, + TRANSFER_AMOUNT, + FEE_AMOUNT )); - // Verify balance changes + // Verify Alice's balance decreased by transfer amount + fee assert_eq!( Balances::balance(&alice), - alice_initial - transfer_amount - fee + alice_initial - TRANSFER_AMOUNT - FEE_AMOUNT ); + + // Verify tokens were locked in sovereign account assert_eq!( Balances::balance(&EthereumSovereignAccount::get()), - sovereign_initial + transfer_amount - ); - // Verify treasury received the fee - assert_eq!( - Balances::balance(&datahaven_mainnet_runtime::configs::TreasuryAccount::get()), - treasury_initial + fee + sovereign_initial + TRANSFER_AMOUNT ); - // Verify events in correct order - let events = System::events(); - - // Find the transfer events - let mut found_transfer_event = false; - let mut found_message_event = false; - - for event in events.iter().rev() { - match &event.event { - RuntimeEvent::DataHavenNativeTransfer( - NativeTransferEvent::TokensTransferredToEthereum { from, to, amount }, - ) => { - assert_eq!(from, &alice); - assert_eq!(to, ð_recipient); - assert_eq!(amount, &transfer_amount); - found_transfer_event = true; - } - RuntimeEvent::EthereumOutboundQueueV2(OutboundQueueEvent::MessageQueued { - message, - .. - }) => { - // Check if this is the transfer message (not registration) - if let Command::MintForeignToken { - recipient, amount, .. - } = &message.commands[0] - { - // Verify message structure for transfer - assert_eq!(message.fee, fee); - assert_eq!(message.commands.len(), 1); - assert_eq!(recipient, ð_recipient); - assert_eq!(amount, &transfer_amount); - found_message_event = true; - } - } - _ => {} - } - } - - assert!( - found_transfer_event, - "TokensTransferredToEthereum event not found" - ); - assert!( - found_message_event, - "OutboundQueue MessageQueued event not found" - ); + // Check event was emitted + assert!(System::events().iter().any(|e| matches!( + &e.event, + RuntimeEvent::DataHavenNativeTransfer( + NativeTransferEvent::TokensTransferredToEthereum { .. } + ) + ))); }); } #[test] -fn test_transfer_with_exact_balance_preserves_existential_deposit() { +fn transfer_fails_when_paused() { ExtBuilder::default().build().execute_with(|| { - register_native_token(); - + let _token_id = register_native_token(); let alice = account_id(ALICE); - let eth_recipient = H160::from_low_u64_be(0x5678); - - // Set Alice's balance to a specific amount - let existential_deposit = 1 * UNIT; // Assuming 1 DH is ED - let transfer_amount = 900 * UNIT; - let fee = 99 * UNIT; - let initial_balance = existential_deposit + transfer_amount + fee; - - // Reset Alice's balance to exact amount - let _ = Balances::force_set_balance(root_origin(), alice.clone(), initial_balance); - assert_eq!(Balances::balance(&alice), initial_balance); - - // Transfer should succeed and leave exactly existential deposit - assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(alice.clone()), - eth_recipient, - transfer_amount, - fee - )); - - // Verify Alice has exactly existential deposit remaining - assert_eq!(Balances::balance(&alice), existential_deposit); - assert_eq!( - Balances::balance(&EthereumSovereignAccount::get()), - transfer_amount - ); - }); -} - -#[test] -fn test_multiple_concurrent_transfers_maintain_consistency() { - ExtBuilder::default().build().execute_with(|| { - register_native_token(); - - let alice = account_id(ALICE); - let bob = account_id(BOB); - let eth_recipient1 = H160::from_low_u64_be(0xABCD); - let eth_recipient2 = H160::from_low_u64_be(0xDEAD); - - let transfer1 = 500 * UNIT; - let transfer2 = 300 * UNIT; - let fee = 5 * UNIT; - let treasury_initial = - Balances::balance(&datahaven_mainnet_runtime::configs::TreasuryAccount::get()); - - // Execute multiple transfers - assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(alice.clone()), - eth_recipient1, - transfer1, - fee - )); - - assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(bob.clone()), - eth_recipient2, - transfer2, - fee - )); - - // Verify total locked balance - assert_eq!( - Balances::balance(&EthereumSovereignAccount::get()), - transfer1 + transfer2 - ); - - // Verify treasury received all fees (2 transfers * fee) - assert_eq!( - Balances::balance(&datahaven_mainnet_runtime::configs::TreasuryAccount::get()), - treasury_initial + (fee * 2) - ); - - // Verify outbound queue has both transfer messages (excluding registration) - let events = System::events(); - let transfer_message_count = events - .iter() - .filter(|e| { - if let RuntimeEvent::EthereumOutboundQueueV2(OutboundQueueEvent::MessageQueued { - message, - .. - }) = &e.event - { - // Only count MintForeignToken messages (transfer messages, not registration) - matches!( - message.commands.get(0), - Some(Command::MintForeignToken { .. }) - ) - } else { - false - } - }) - .count(); - assert_eq!(transfer_message_count, 2); - }); -} - -#[test] -fn test_transfer_generates_unique_message_ids() { - ExtBuilder::default().build().execute_with(|| { - register_native_token(); - - let alice = account_id(ALICE); - let eth_recipient = H160::from_low_u64_be(0x9999); - let amount = 100 * UNIT; - let fee = 1 * UNIT; - - // Collect message IDs from multiple transfers - let mut message_ids = Vec::new(); - - for _ in 0..3 { - assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(alice.clone()), - eth_recipient, - amount, - fee - )); - - // Extract message ID from last event - let events = System::events(); - for event in events.iter().rev() { - if let RuntimeEvent::EthereumOutboundQueueV2(OutboundQueueEvent::MessageQueued { - message, - .. - }) = &event.event - { - message_ids.push(message.id); - break; - } - } - } - - // Verify all message IDs are unique - assert_eq!(message_ids.len(), 3); - let unique_ids: std::collections::HashSet<_> = message_ids.iter().collect(); - assert_eq!(unique_ids.len(), 3, "Message IDs should be unique"); - }); -} - -#[test] -fn test_pause_functionality_blocks_transfers() { - ExtBuilder::default().build().execute_with(|| { - register_native_token(); - - let alice = account_id(ALICE); - let eth_recipient = H160::from_low_u64_be(0x4444); // Pause the pallet assert_ok!(DataHavenNativeTransfer::pause(root_origin())); - // Verify transfer fails + // Verify transfers are disabled when paused assert_noop!( DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(alice.clone()), - eth_recipient, - 100 * UNIT, - 1 * UNIT + RuntimeOrigin::signed(alice), + ETH_ALICE, + TRANSFER_AMOUNT, + FEE_AMOUNT ), pallet_datahaven_native_transfer::Error::::TransfersDisabled ); - - // Unpause - assert_ok!(DataHavenNativeTransfer::unpause(root_origin())); - - // Verify transfer now succeeds - assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(alice), - eth_recipient, - 100 * UNIT, - 1 * UNIT - )); }); } #[test] -fn test_treasury_fee_collection() { +fn multiple_transfers_work() { ExtBuilder::default().build().execute_with(|| { - register_native_token(); + let _token_id = register_native_token(); + let alice = account_id(ALICE); + let bob = account_id(BOB); + assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( + RuntimeOrigin::signed(alice), + ETH_ALICE, + TRANSFER_AMOUNT, + FEE_AMOUNT + )); + + assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( + RuntimeOrigin::signed(bob), + ETH_BOB, + TRANSFER_AMOUNT, + FEE_AMOUNT + )); + + let expected_sovereign_balance = TRANSFER_AMOUNT * 2; + assert_eq!( + Balances::balance(&EthereumSovereignAccount::get()), + expected_sovereign_balance + ); + }); +} + +#[test] +fn treasury_collects_fees_from_multiple_transfers() { + ExtBuilder::default().build().execute_with(|| { + let _token_id = register_native_token(); let alice = account_id(ALICE); let bob = account_id(BOB); let treasury_account = datahaven_mainnet_runtime::configs::TreasuryAccount::get(); let initial_treasury_balance = Balances::balance(&treasury_account); - // Test case 1: Single transfer with fee let fee1 = 5 * UNIT; + let fee2 = 10 * UNIT; + assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(alice.clone()), - H160::from_low_u64_be(0x1111), - 100 * UNIT, + RuntimeOrigin::signed(alice), + ETH_ALICE, + TRANSFER_AMOUNT, fee1 )); - // Verify treasury received the fee - assert_eq!( - Balances::balance(&treasury_account), - initial_treasury_balance + fee1 - ); - - // Test case 2: Multiple transfers with different fees - let fee2 = 10 * UNIT; - let fee3 = 15 * UNIT; - assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(bob.clone()), - H160::from_low_u64_be(0x2222), - 200 * UNIT, + RuntimeOrigin::signed(bob), + ETH_BOB, + TRANSFER_AMOUNT, fee2 )); - assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(alice.clone()), - H160::from_low_u64_be(0x3333), - 300 * UNIT, - fee3 - )); - - // Verify treasury received all accumulated fees - let total_fees = fee1 + fee2 + fee3; + let expected_treasury_balance = initial_treasury_balance + fee1 + fee2; assert_eq!( Balances::balance(&treasury_account), - initial_treasury_balance + total_fees, - "Treasury should accumulate all fees from transfers" - ); - - // Verify treasury account is not the zero address - assert_ne!( - treasury_account, - AccountId::from([0u8; 20]), - "Treasury account should not be the zero address" + expected_treasury_balance ); }); } -// Helper function to register native token -fn register_native_token() { - let origin = root_origin(); - let sender_location = Location::here(); - let asset_location = Location::here(); - let metadata = datahaven_token_metadata(); +// === Inbound Message Processing Tests === - let _ = SnowbridgeSystemV2::register_token( - origin, - Box::new(VersionedLocation::V5(sender_location)), - Box::new(VersionedLocation::V5(asset_location)), - metadata, - ); +#[test] +fn message_processor_accepts_registered_token() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + let message = create_message(token_id, TRANSFER_AMOUNT, ETH_ALICE, 1); + + // Verify processor accepts messages containing registered native token + assert!( + NativeTokenTransferMessageProcessor::::can_process_message(&alice, &message) + ); + }); +} + +#[test] +fn message_processor_rejects_unregistered_token() { + ExtBuilder::default().build().execute_with(|| { + let fake_token_id = H256::from_low_u64_be(0x9999); + let alice = account_id(ALICE); + let message = create_message(fake_token_id, TRANSFER_AMOUNT, ETH_ALICE, 1); + + assert!( + !NativeTokenTransferMessageProcessor::::can_process_message(&alice, &message) + ); + }); +} + +#[test] +fn message_processor_rejects_empty_assets() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + + let mut message = create_message(token_id, TRANSFER_AMOUNT, ETH_ALICE, 1); + message.assets = vec![]; + + assert!( + !NativeTokenTransferMessageProcessor::::can_process_message(&alice, &message) + ); + }); +} + +#[test] +fn inbound_message_processing_works() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + let message = create_message(token_id, TRANSFER_AMOUNT, ETH_ALICE, 1); + + // Setup sovereign account with sufficient balance + setup_sovereign_balance(TRANSFER_AMOUNT * 2); + let sovereign_initial = Balances::balance(&EthereumSovereignAccount::get()); + + // Process inbound message from Ethereum + assert_ok!( + snowbridge_pallet_inbound_queue_v2::Pallet::::process_message(alice, message) + ); + + // Verify tokens were unlocked to recipient + let recipient: AccountId = ETH_ALICE.into(); + assert_eq!(Balances::balance(&recipient), TRANSFER_AMOUNT); + + // Verify sovereign balance decreased by transfer amount + assert_eq!( + Balances::balance(&EthereumSovereignAccount::get()), + sovereign_initial - TRANSFER_AMOUNT + ); + + // Check unlock event was emitted + assert!(System::events().iter().any(|e| matches!( + &e.event, + RuntimeEvent::DataHavenNativeTransfer(NativeTransferEvent::TokensUnlocked { .. }) + ))); + }); +} + +#[test] +fn multiple_assets_processing_sums_amounts() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + + let mut message = create_message(token_id, 0, ETH_ALICE, 1); + message.assets = vec![ + EthereumAsset::ForeignTokenERC20 { + token_id, + value: 300 * UNIT, + }, + EthereumAsset::ForeignTokenERC20 { + token_id, + value: 200 * UNIT, + }, + EthereumAsset::ForeignTokenERC20 { + token_id, + value: 500 * UNIT, + }, + ]; + + setup_sovereign_balance(2000 * UNIT); + + assert_ok!( + snowbridge_pallet_inbound_queue_v2::Pallet::::process_message(alice, message) + ); + + let recipient: AccountId = ETH_ALICE.into(); + let total_amount = 1000 * UNIT; + assert_eq!(Balances::balance(&recipient), total_amount); + }); +} + +#[test] +fn processing_fails_without_claimer() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + + let mut message = create_message(token_id, TRANSFER_AMOUNT, ETH_ALICE, 1); + message.claimer = None; + + assert_noop!( + snowbridge_pallet_inbound_queue_v2::Pallet::::process_message(alice, message), + DispatchError::Other("No claimer specified in message") + ); + }); +} + +#[test] +fn processing_fails_with_zero_amount() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + let message = create_message(token_id, 0, ETH_ALICE, 1); + + assert_noop!( + snowbridge_pallet_inbound_queue_v2::Pallet::::process_message(alice, message), + DispatchError::Other("No native token found in assets") + ); + }); +} + +#[test] +fn processing_fails_with_insufficient_sovereign_balance() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + let message = create_message(token_id, TRANSFER_AMOUNT, ETH_ALICE, 1); + + setup_sovereign_balance(TRANSFER_AMOUNT / 2); // Insufficient balance + + let result = + snowbridge_pallet_inbound_queue_v2::Pallet::::process_message(alice, message); + assert!(result.is_err()); + }); +} + +// === Integration Tests === + +#[test] +fn end_to_end_transfer_flow() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + + // Step 1: Outbound transfer - Alice sends tokens to Ethereum + assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( + RuntimeOrigin::signed(alice.clone()), + ETH_ALICE, + TRANSFER_AMOUNT, + FEE_AMOUNT + )); + + // Verify message was queued for Snowbridge + assert!(System::events().iter().any(|e| matches!( + &e.event, + RuntimeEvent::EthereumOutboundQueueV2(OutboundQueueEvent::MessageQueued { .. }) + ))); + + // Step 2: Simulate inbound processing - tokens returning from Ethereum + let message = create_message(token_id, TRANSFER_AMOUNT, ETH_BOB, 1); + setup_sovereign_balance(TRANSFER_AMOUNT * 3); + + assert_ok!( + snowbridge_pallet_inbound_queue_v2::Pallet::::process_message(alice, message) + ); + + // Verify tokens were delivered to recipient + let recipient: AccountId = ETH_BOB.into(); + assert_eq!(Balances::balance(&recipient), TRANSFER_AMOUNT); + }); +} + +#[test] +fn message_routing_works_correctly() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + + // Native token message should be accepted + let native_message = create_message(token_id, TRANSFER_AMOUNT, ETH_ALICE, 1); + assert!( + NativeTokenTransferMessageProcessor::::can_process_message( + &alice, + &native_message + ) + ); + + // Non-native token message should be rejected + let fake_token_id = H256::from_low_u64_be(0x8888); + let non_native_message = create_message(fake_token_id, TRANSFER_AMOUNT, ETH_ALICE, 2); + assert!( + !NativeTokenTransferMessageProcessor::::can_process_message( + &alice, + &non_native_message + ) + ); + }); } diff --git a/operator/runtime/stagenet/src/configs/mod.rs b/operator/runtime/stagenet/src/configs/mod.rs index 1703ad08..195cfc03 100644 --- a/operator/runtime/stagenet/src/configs/mod.rs +++ b/operator/runtime/stagenet/src/configs/mod.rs @@ -45,7 +45,7 @@ use datahaven_runtime_common::{ gas::WEIGHT_PER_GAS, time::{EpochDurationInBlocks, DAYS, MILLISECS_PER_BLOCK}, }; -use dhp_bridge::EigenLayerMessageProcessor; +use dhp_bridge::{EigenLayerMessageProcessor, NativeTokenTransferMessageProcessor}; use frame_support::{ derive_impl, pallet_prelude::TransactionPriority, @@ -814,7 +814,10 @@ impl snowbridge_pallet_inbound_queue_v2::Config for Runtime { type RuntimeEvent = RuntimeEvent; type Verifier = EthereumBeaconClient; type GatewayAddress = runtime_params::dynamic_params::runtime_config::EthereumGatewayAddress; - type MessageProcessor = EigenLayerMessageProcessor; + type MessageProcessor = ( + EigenLayerMessageProcessor, + NativeTokenTransferMessageProcessor, + ); type RewardKind = (); type DefaultRewardKind = DefaultRewardKind; type RewardPayment = DummyRewardPayment; diff --git a/operator/runtime/stagenet/src/configs/runtime_params.rs b/operator/runtime/stagenet/src/configs/runtime_params.rs index f76a71ba..caef8081 100644 --- a/operator/runtime/stagenet/src/configs/runtime_params.rs +++ b/operator/runtime/stagenet/src/configs/runtime_params.rs @@ -1,4 +1,4 @@ -use crate::{Balance, BlockNumber, Runtime, NANO_UNIT, UNIT}; +use crate::Runtime; use frame_support::dynamic_params::{dynamic_pallet_params, dynamic_params}; use hex_literal::hex; use sp_core::{ConstU32, H160, H256}; diff --git a/operator/runtime/stagenet/tests/common.rs b/operator/runtime/stagenet/tests/common.rs index 320a4ce9..12a89ac4 100644 --- a/operator/runtime/stagenet/tests/common.rs +++ b/operator/runtime/stagenet/tests/common.rs @@ -4,7 +4,7 @@ //! Common test utilities for DataHaven stagenet runtime tests use datahaven_stagenet_runtime::{ - AccountId, Balance, Runtime, RuntimeEvent, RuntimeOrigin, Session, SessionKeys, System, UNIT, + AccountId, Balance, Runtime, RuntimeOrigin, Session, SessionKeys, System, UNIT, }; use frame_support::traits::Hooks; use pallet_im_online::sr25519::AuthorityId as ImOnlineId; @@ -137,10 +137,6 @@ pub fn root_origin() -> RuntimeOrigin { RuntimeOrigin::root() } -pub fn last_event() -> RuntimeEvent { - System::events().pop().expect("Event expected").event -} - pub fn datahaven_token_metadata() -> snowbridge_core::AssetMetadata { snowbridge_core::AssetMetadata { name: b"HAVE".to_vec().try_into().unwrap(), diff --git a/operator/runtime/stagenet/tests/native_token_transfer.rs b/operator/runtime/stagenet/tests/native_token_transfer.rs index 4245100e..9e35d82b 100644 --- a/operator/runtime/stagenet/tests/native_token_transfer.rs +++ b/operator/runtime/stagenet/tests/native_token_transfer.rs @@ -1,423 +1,430 @@ // Copyright 2025 Moonbeam Foundation. // This file is part of DataHaven. -//! Native token transfer integration tests for DataHaven stagenet runtime -//! -//! Tests for native token transfers between DataHaven and Ethereum via Snowbridge - #[path = "common.rs"] mod common; +use codec::Encode; use common::*; use datahaven_stagenet_runtime::{ - configs::EthereumSovereignAccount, AccountId, Balances, DataHavenNativeTransfer, Runtime, - RuntimeEvent, RuntimeOrigin, SnowbridgeSystemV2, System, UNIT, + configs::EthereumSovereignAccount, AccountId, Balance, Balances, DataHavenNativeTransfer, + Runtime, RuntimeEvent, RuntimeOrigin, SnowbridgeSystemV2, System, UNIT, }; +use dhp_bridge::NativeTokenTransferMessageProcessor; use frame_support::{assert_noop, assert_ok, traits::fungible::Inspect}; use pallet_datahaven_native_transfer::Event as NativeTransferEvent; use snowbridge_core::TokenIdOf; -use snowbridge_outbound_queue_primitives::v2::Command; +use snowbridge_inbound_queue_primitives::v2::{ + EthereumAsset, Message as SnowbridgeMessage, MessageProcessor, Payload, +}; use snowbridge_pallet_outbound_queue_v2::Event as OutboundQueueEvent; use snowbridge_pallet_system::NativeToForeignId; -use snowbridge_pallet_system_v2::Event as SystemV2Event; +use sp_core::Get; use sp_core::{H160, H256}; +use sp_runtime::DispatchError; use xcm::prelude::*; use xcm_executor::traits::ConvertLocation; +const TRANSFER_AMOUNT: Balance = 1000 * UNIT; +const FEE_AMOUNT: Balance = 10 * UNIT; +const ETH_ALICE: H160 = H160([0x11; 20]); +const ETH_BOB: H160 = H160([0x22; 20]); + +// Get the gateway address from runtime configuration +fn gateway_address() -> H160 { + use datahaven_stagenet_runtime::configs::runtime_params::dynamic_params::runtime_config::EthereumGatewayAddress; + EthereumGatewayAddress::get() +} + +fn register_native_token() -> H256 { + let asset_location = Location::here(); + let _ = SnowbridgeSystemV2::register_token( + root_origin(), + Box::new(VersionedLocation::V5(asset_location.clone())), + Box::new(VersionedLocation::V5(asset_location.clone())), + datahaven_token_metadata(), + ); + let reanchored = SnowbridgeSystemV2::reanchor(asset_location).unwrap(); + TokenIdOf::convert_location(&reanchored).unwrap() +} + +fn setup_sovereign_balance(amount: Balance) { + let _ = Balances::force_set_balance(root_origin(), EthereumSovereignAccount::get(), amount); +} + +fn create_message(token_id: H256, amount: Balance, claimer: H160, nonce: u64) -> SnowbridgeMessage { + SnowbridgeMessage { + gateway: gateway_address(), + nonce, + origin: H160::zero(), + assets: vec![EthereumAsset::ForeignTokenERC20 { + token_id, + value: amount, + }], + xcm: Payload::Raw(vec![0x01, 0x02, 0x03]), + claimer: Some(claimer.encode()), + value: 0, + execution_fee: 100, + relayer_fee: 50, + } +} + +// === Token Registration Tests === + #[test] -fn test_datahaven_native_token_registration_succeeds() { +fn native_token_registration_works() { ExtBuilder::default().build().execute_with(|| { - let origin = root_origin(); - let sender_location = Location::here(); let asset_location = Location::here(); - let metadata = datahaven_token_metadata(); - // Step 1: Verify preconditions - token not yet registered - let initial_reanchored = SnowbridgeSystemV2::reanchor(asset_location.clone()).unwrap(); - assert!(NativeToForeignId::::get(&initial_reanchored).is_none()); - assert!( - snowbridge_pallet_system::ForeignToNativeId::::iter() - .next() - .is_none() - ); - - // Step 2: Register the token + // Register the native HAVE token with Snowbridge assert_ok!(SnowbridgeSystemV2::register_token( - origin, - Box::new(VersionedLocation::V5(sender_location)), + root_origin(), Box::new(VersionedLocation::V5(asset_location.clone())), - metadata.clone() + Box::new(VersionedLocation::V5(asset_location.clone())), + datahaven_token_metadata() )); - // Step 3: Verify reanchoring works correctly - let reanchored_location = SnowbridgeSystemV2::reanchor(asset_location.clone()).unwrap(); - assert_eq!(reanchored_location.parent_count(), 1); - // Verify it contains GlobalConsensus junction with DataHaven network - let first_junction = reanchored_location.first_interior(); - assert!(matches!(first_junction, Some(Junction::GlobalConsensus(_)))); + // Verify token was registered and assigned a valid token ID + let reanchored = SnowbridgeSystemV2::reanchor(asset_location).unwrap(); + let token_id = TokenIdOf::convert_location(&reanchored).unwrap(); - // Step 4: Verify TokenId generation is deterministic - let token_id = TokenIdOf::convert_location(&reanchored_location).unwrap(); assert_ne!(token_id, H256::zero()); - - // Step 5: Verify bidirectional storage mappings assert_eq!( - NativeToForeignId::::get(&reanchored_location), + NativeToForeignId::::get(&reanchored), Some(token_id) ); - assert_eq!( - snowbridge_pallet_system::ForeignToNativeId::::get(&token_id), - Some(reanchored_location.clone()) - ); - - // Step 6: Verify event emission with all details - let expected_event = RuntimeEvent::SnowbridgeSystemV2(SystemV2Event::RegisterToken { - location: reanchored_location.clone().into(), - foreign_token_id: token_id, - }); - assert_eq!(last_event(), expected_event); }); } +// === Outbound Transfer Tests === + #[test] -fn test_native_token_transfer_to_ethereum_succeeds() { +fn transfer_to_ethereum_works() { ExtBuilder::default().build().execute_with(|| { - // Register token first - register_native_token(); - + let _token_id = register_native_token(); let alice = account_id(ALICE); - let eth_recipient = H160::from_low_u64_be(0x1234); - let transfer_amount = 1000 * UNIT; - let fee = 10 * UNIT; - // Record initial balances let alice_initial = Balances::balance(&alice); let sovereign_initial = Balances::balance(&EthereumSovereignAccount::get()); - let treasury_initial = - Balances::balance(&datahaven_stagenet_runtime::configs::TreasuryAccount::get()); - // Execute transfer assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( RuntimeOrigin::signed(alice.clone()), - eth_recipient, - transfer_amount, - fee + ETH_ALICE, + TRANSFER_AMOUNT, + FEE_AMOUNT )); - // Verify balance changes assert_eq!( Balances::balance(&alice), - alice_initial - transfer_amount - fee + alice_initial - TRANSFER_AMOUNT - FEE_AMOUNT ); assert_eq!( Balances::balance(&EthereumSovereignAccount::get()), - sovereign_initial + transfer_amount - ); - // Verify treasury received the fee - assert_eq!( - Balances::balance(&datahaven_stagenet_runtime::configs::TreasuryAccount::get()), - treasury_initial + fee + sovereign_initial + TRANSFER_AMOUNT ); - // Verify events in correct order - let events = System::events(); - - // Find the transfer events - let mut found_transfer_event = false; - let mut found_message_event = false; - - for event in events.iter().rev() { - match &event.event { - RuntimeEvent::DataHavenNativeTransfer( - NativeTransferEvent::TokensTransferredToEthereum { from, to, amount }, - ) => { - assert_eq!(from, &alice); - assert_eq!(to, ð_recipient); - assert_eq!(amount, &transfer_amount); - found_transfer_event = true; - } - RuntimeEvent::EthereumOutboundQueueV2(OutboundQueueEvent::MessageQueued { - message, - .. - }) => { - // Check if this is the transfer message (not registration) - if let Command::MintForeignToken { - recipient, amount, .. - } = &message.commands[0] - { - // Verify message structure for transfer - assert_eq!(message.fee, fee); - assert_eq!(message.commands.len(), 1); - assert_eq!(recipient, ð_recipient); - assert_eq!(amount, &transfer_amount); - found_message_event = true; - } - } - _ => {} - } - } - - assert!( - found_transfer_event, - "TokensTransferredToEthereum event not found" - ); - assert!( - found_message_event, - "OutboundQueue MessageQueued event not found" - ); + // Check event was emitted + assert!(System::events().iter().any(|e| matches!( + &e.event, + RuntimeEvent::DataHavenNativeTransfer( + NativeTransferEvent::TokensTransferredToEthereum { .. } + ) + ))); }); } #[test] -fn test_transfer_with_exact_balance_preserves_existential_deposit() { +fn transfer_fails_when_paused() { ExtBuilder::default().build().execute_with(|| { - register_native_token(); - + let _token_id = register_native_token(); let alice = account_id(ALICE); - let eth_recipient = H160::from_low_u64_be(0x5678); - // Set Alice's balance to a specific amount - let existential_deposit = 1 * UNIT; // Assuming 1 DH is ED - let transfer_amount = 900 * UNIT; - let fee = 99 * UNIT; - let initial_balance = existential_deposit + transfer_amount + fee; - - // Reset Alice's balance to exact amount - let _ = Balances::force_set_balance(root_origin(), alice.clone(), initial_balance); - assert_eq!(Balances::balance(&alice), initial_balance); - - // Transfer should succeed and leave exactly existential deposit - assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(alice.clone()), - eth_recipient, - transfer_amount, - fee - )); - - // Verify Alice has exactly existential deposit remaining - assert_eq!(Balances::balance(&alice), existential_deposit); - assert_eq!( - Balances::balance(&EthereumSovereignAccount::get()), - transfer_amount - ); - }); -} - -#[test] -fn test_multiple_concurrent_transfers_maintain_consistency() { - ExtBuilder::default().build().execute_with(|| { - register_native_token(); - - let alice = account_id(ALICE); - let bob = account_id(BOB); - let eth_recipient1 = H160::from_low_u64_be(0xABCD); - let eth_recipient2 = H160::from_low_u64_be(0xDEAD); - - let transfer1 = 500 * UNIT; - let transfer2 = 300 * UNIT; - let fee = 5 * UNIT; - let treasury_initial = - Balances::balance(&datahaven_stagenet_runtime::configs::TreasuryAccount::get()); - - // Execute multiple transfers - assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(alice.clone()), - eth_recipient1, - transfer1, - fee - )); - - assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(bob.clone()), - eth_recipient2, - transfer2, - fee - )); - - // Verify total locked balance - assert_eq!( - Balances::balance(&EthereumSovereignAccount::get()), - transfer1 + transfer2 - ); - - // Verify treasury received all fees (2 transfers * fee) - assert_eq!( - Balances::balance(&datahaven_stagenet_runtime::configs::TreasuryAccount::get()), - treasury_initial + (fee * 2) - ); - - // Verify outbound queue has both transfer messages (excluding registration) - let events = System::events(); - let transfer_message_count = events - .iter() - .filter(|e| { - if let RuntimeEvent::EthereumOutboundQueueV2(OutboundQueueEvent::MessageQueued { - message, - .. - }) = &e.event - { - // Only count MintForeignToken messages (transfer messages, not registration) - matches!( - message.commands.get(0), - Some(Command::MintForeignToken { .. }) - ) - } else { - false - } - }) - .count(); - assert_eq!(transfer_message_count, 2); - }); -} - -#[test] -fn test_transfer_generates_unique_message_ids() { - ExtBuilder::default().build().execute_with(|| { - register_native_token(); - - let alice = account_id(ALICE); - let eth_recipient = H160::from_low_u64_be(0x9999); - let amount = 100 * UNIT; - let fee = 1 * UNIT; - - // Collect message IDs from multiple transfers - let mut message_ids = Vec::new(); - - for _ in 0..3 { - assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(alice.clone()), - eth_recipient, - amount, - fee - )); - - // Extract message ID from last event - let events = System::events(); - for event in events.iter().rev() { - if let RuntimeEvent::EthereumOutboundQueueV2(OutboundQueueEvent::MessageQueued { - message, - .. - }) = &event.event - { - message_ids.push(message.id); - break; - } - } - } - - // Verify all message IDs are unique - assert_eq!(message_ids.len(), 3); - let unique_ids: std::collections::HashSet<_> = message_ids.iter().collect(); - assert_eq!(unique_ids.len(), 3, "Message IDs should be unique"); - }); -} - -#[test] -fn test_pause_functionality_blocks_transfers() { - ExtBuilder::default().build().execute_with(|| { - register_native_token(); - - let alice = account_id(ALICE); - let eth_recipient = H160::from_low_u64_be(0x4444); - - // Pause the pallet assert_ok!(DataHavenNativeTransfer::pause(root_origin())); - // Verify transfer fails assert_noop!( DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(alice.clone()), - eth_recipient, - 100 * UNIT, - 1 * UNIT + RuntimeOrigin::signed(alice), + ETH_ALICE, + TRANSFER_AMOUNT, + FEE_AMOUNT ), pallet_datahaven_native_transfer::Error::::TransfersDisabled ); - - // Unpause - assert_ok!(DataHavenNativeTransfer::unpause(root_origin())); - - // Verify transfer now succeeds - assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(alice), - eth_recipient, - 100 * UNIT, - 1 * UNIT - )); }); } #[test] -fn test_treasury_fee_collection() { +fn multiple_transfers_work() { ExtBuilder::default().build().execute_with(|| { - register_native_token(); + let _token_id = register_native_token(); + let alice = account_id(ALICE); + let bob = account_id(BOB); + assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( + RuntimeOrigin::signed(alice), + ETH_ALICE, + TRANSFER_AMOUNT, + FEE_AMOUNT + )); + + assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( + RuntimeOrigin::signed(bob), + ETH_BOB, + TRANSFER_AMOUNT, + FEE_AMOUNT + )); + + let expected_sovereign_balance = TRANSFER_AMOUNT * 2; + assert_eq!( + Balances::balance(&EthereumSovereignAccount::get()), + expected_sovereign_balance + ); + }); +} + +#[test] +fn treasury_collects_fees_from_multiple_transfers() { + ExtBuilder::default().build().execute_with(|| { + let _token_id = register_native_token(); let alice = account_id(ALICE); let bob = account_id(BOB); let treasury_account = datahaven_stagenet_runtime::configs::TreasuryAccount::get(); let initial_treasury_balance = Balances::balance(&treasury_account); - // Test case 1: Single transfer with fee let fee1 = 5 * UNIT; + let fee2 = 10 * UNIT; + assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(alice.clone()), - H160::from_low_u64_be(0x1111), - 100 * UNIT, + RuntimeOrigin::signed(alice), + ETH_ALICE, + TRANSFER_AMOUNT, fee1 )); - // Verify treasury received the fee - assert_eq!( - Balances::balance(&treasury_account), - initial_treasury_balance + fee1 - ); - - // Test case 2: Multiple transfers with different fees - let fee2 = 10 * UNIT; - let fee3 = 15 * UNIT; - assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(bob.clone()), - H160::from_low_u64_be(0x2222), - 200 * UNIT, + RuntimeOrigin::signed(bob), + ETH_BOB, + TRANSFER_AMOUNT, fee2 )); - assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(alice.clone()), - H160::from_low_u64_be(0x3333), - 300 * UNIT, - fee3 - )); - - // Verify treasury received all accumulated fees - let total_fees = fee1 + fee2 + fee3; + let expected_treasury_balance = initial_treasury_balance + fee1 + fee2; assert_eq!( Balances::balance(&treasury_account), - initial_treasury_balance + total_fees, - "Treasury should accumulate all fees from transfers" - ); - - // Verify treasury account is not the zero address - assert_ne!( - treasury_account, - AccountId::from([0u8; 20]), - "Treasury account should not be the zero address" + expected_treasury_balance ); }); } -// Helper function to register native token -fn register_native_token() { - let origin = root_origin(); - let sender_location = Location::here(); - let asset_location = Location::here(); - let metadata = datahaven_token_metadata(); +// === Inbound Message Processing Tests === - let _ = SnowbridgeSystemV2::register_token( - origin, - Box::new(VersionedLocation::V5(sender_location)), - Box::new(VersionedLocation::V5(asset_location)), - metadata, - ); +#[test] +fn message_processor_accepts_registered_token() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + let message = create_message(token_id, TRANSFER_AMOUNT, ETH_ALICE, 1); + + assert!( + NativeTokenTransferMessageProcessor::::can_process_message(&alice, &message) + ); + }); +} + +#[test] +fn message_processor_rejects_unregistered_token() { + ExtBuilder::default().build().execute_with(|| { + let fake_token_id = H256::from_low_u64_be(0x9999); + let alice = account_id(ALICE); + let message = create_message(fake_token_id, TRANSFER_AMOUNT, ETH_ALICE, 1); + + assert!( + !NativeTokenTransferMessageProcessor::::can_process_message(&alice, &message) + ); + }); +} + +#[test] +fn message_processor_rejects_empty_assets() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + + let mut message = create_message(token_id, TRANSFER_AMOUNT, ETH_ALICE, 1); + message.assets = vec![]; + + assert!( + !NativeTokenTransferMessageProcessor::::can_process_message(&alice, &message) + ); + }); +} + +#[test] +fn inbound_message_processing_works() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + let message = create_message(token_id, TRANSFER_AMOUNT, ETH_ALICE, 1); + + setup_sovereign_balance(TRANSFER_AMOUNT * 2); + let sovereign_initial = Balances::balance(&EthereumSovereignAccount::get()); + + assert_ok!( + snowbridge_pallet_inbound_queue_v2::Pallet::::process_message(alice, message) + ); + + let recipient: AccountId = ETH_ALICE.into(); + assert_eq!(Balances::balance(&recipient), TRANSFER_AMOUNT); + assert_eq!( + Balances::balance(&EthereumSovereignAccount::get()), + sovereign_initial - TRANSFER_AMOUNT + ); + + // Check unlock event was emitted + assert!(System::events().iter().any(|e| matches!( + &e.event, + RuntimeEvent::DataHavenNativeTransfer(NativeTransferEvent::TokensUnlocked { .. }) + ))); + }); +} + +#[test] +fn multiple_assets_processing_sums_amounts() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + + let mut message = create_message(token_id, 0, ETH_ALICE, 1); + message.assets = vec![ + EthereumAsset::ForeignTokenERC20 { + token_id, + value: 300 * UNIT, + }, + EthereumAsset::ForeignTokenERC20 { + token_id, + value: 200 * UNIT, + }, + EthereumAsset::ForeignTokenERC20 { + token_id, + value: 500 * UNIT, + }, + ]; + + setup_sovereign_balance(2000 * UNIT); + + assert_ok!( + snowbridge_pallet_inbound_queue_v2::Pallet::::process_message(alice, message) + ); + + let recipient: AccountId = ETH_ALICE.into(); + let total_amount = 1000 * UNIT; + assert_eq!(Balances::balance(&recipient), total_amount); + }); +} + +#[test] +fn processing_fails_without_claimer() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + + let mut message = create_message(token_id, TRANSFER_AMOUNT, ETH_ALICE, 1); + message.claimer = None; + + assert_noop!( + snowbridge_pallet_inbound_queue_v2::Pallet::::process_message(alice, message), + DispatchError::Other("No claimer specified in message") + ); + }); +} + +#[test] +fn processing_fails_with_zero_amount() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + let message = create_message(token_id, 0, ETH_ALICE, 1); + + assert_noop!( + snowbridge_pallet_inbound_queue_v2::Pallet::::process_message(alice, message), + DispatchError::Other("No native token found in assets") + ); + }); +} + +#[test] +fn processing_fails_with_insufficient_sovereign_balance() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + let message = create_message(token_id, TRANSFER_AMOUNT, ETH_ALICE, 1); + + setup_sovereign_balance(TRANSFER_AMOUNT / 2); // Insufficient balance + + let result = + snowbridge_pallet_inbound_queue_v2::Pallet::::process_message(alice, message); + assert!(result.is_err()); + }); +} + +// === Integration Tests === + +#[test] +fn end_to_end_transfer_flow() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + + // Outbound transfer + assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( + RuntimeOrigin::signed(alice.clone()), + ETH_ALICE, + TRANSFER_AMOUNT, + FEE_AMOUNT + )); + + // Verify message was queued + assert!(System::events().iter().any(|e| matches!( + &e.event, + RuntimeEvent::EthereumOutboundQueueV2(OutboundQueueEvent::MessageQueued { .. }) + ))); + + // Simulate inbound processing + let message = create_message(token_id, TRANSFER_AMOUNT, ETH_BOB, 1); + setup_sovereign_balance(TRANSFER_AMOUNT * 3); + + assert_ok!( + snowbridge_pallet_inbound_queue_v2::Pallet::::process_message(alice, message) + ); + + let recipient: AccountId = ETH_BOB.into(); + assert_eq!(Balances::balance(&recipient), TRANSFER_AMOUNT); + }); +} + +#[test] +fn message_routing_works_correctly() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + + // Native token message should be accepted + let native_message = create_message(token_id, TRANSFER_AMOUNT, ETH_ALICE, 1); + assert!( + NativeTokenTransferMessageProcessor::::can_process_message( + &alice, + &native_message + ) + ); + + // Non-native token message should be rejected + let fake_token_id = H256::from_low_u64_be(0x8888); + let non_native_message = create_message(fake_token_id, TRANSFER_AMOUNT, ETH_ALICE, 2); + assert!( + !NativeTokenTransferMessageProcessor::::can_process_message( + &alice, + &non_native_message + ) + ); + }); } diff --git a/operator/runtime/testnet/src/configs/mod.rs b/operator/runtime/testnet/src/configs/mod.rs index 8fb0909d..1645f34d 100644 --- a/operator/runtime/testnet/src/configs/mod.rs +++ b/operator/runtime/testnet/src/configs/mod.rs @@ -42,7 +42,7 @@ use datahaven_runtime_common::{ gas::WEIGHT_PER_GAS, time::{EpochDurationInBlocks, DAYS, MILLISECS_PER_BLOCK}, }; -use dhp_bridge::EigenLayerMessageProcessor; +use dhp_bridge::{EigenLayerMessageProcessor, NativeTokenTransferMessageProcessor}; use frame_support::{ derive_impl, pallet_prelude::TransactionPriority, @@ -853,7 +853,10 @@ impl snowbridge_pallet_inbound_queue_v2::Config for Runtime { type RuntimeEvent = RuntimeEvent; type Verifier = EthereumBeaconClient; type GatewayAddress = runtime_params::dynamic_params::runtime_config::EthereumGatewayAddress; - type MessageProcessor = EigenLayerMessageProcessor; + type MessageProcessor = ( + EigenLayerMessageProcessor, + NativeTokenTransferMessageProcessor, + ); type RewardKind = (); type DefaultRewardKind = DefaultRewardKind; type RewardPayment = DummyRewardPayment; diff --git a/operator/runtime/testnet/tests/common.rs b/operator/runtime/testnet/tests/common.rs index f20999ff..3a94c36b 100644 --- a/operator/runtime/testnet/tests/common.rs +++ b/operator/runtime/testnet/tests/common.rs @@ -4,7 +4,7 @@ //! Common test utilities for DataHaven testnet runtime tests use datahaven_testnet_runtime::{ - AccountId, Balance, Runtime, RuntimeEvent, RuntimeOrigin, Session, SessionKeys, System, UNIT, + AccountId, Balance, Runtime, RuntimeOrigin, Session, SessionKeys, System, UNIT, }; use frame_support::traits::Hooks; use pallet_im_online::sr25519::AuthorityId as ImOnlineId; @@ -137,10 +137,6 @@ pub fn root_origin() -> RuntimeOrigin { RuntimeOrigin::root() } -pub fn last_event() -> RuntimeEvent { - System::events().pop().expect("Event expected").event -} - pub fn datahaven_token_metadata() -> snowbridge_core::AssetMetadata { snowbridge_core::AssetMetadata { name: b"HAVE".to_vec().try_into().unwrap(), diff --git a/operator/runtime/testnet/tests/native_token_transfer.rs b/operator/runtime/testnet/tests/native_token_transfer.rs index f3fd3834..24524ccb 100644 --- a/operator/runtime/testnet/tests/native_token_transfer.rs +++ b/operator/runtime/testnet/tests/native_token_transfer.rs @@ -1,423 +1,430 @@ // Copyright 2025 Moonbeam Foundation. // This file is part of DataHaven. -//! Native token transfer integration tests for DataHaven testnet runtime -//! -//! Tests for native token transfers between DataHaven and Ethereum via Snowbridge - #[path = "common.rs"] mod common; +use codec::Encode; use common::*; use datahaven_testnet_runtime::{ - configs::EthereumSovereignAccount, AccountId, Balances, DataHavenNativeTransfer, Runtime, - RuntimeEvent, RuntimeOrigin, SnowbridgeSystemV2, System, UNIT, + configs::EthereumSovereignAccount, AccountId, Balance, Balances, DataHavenNativeTransfer, + Runtime, RuntimeEvent, RuntimeOrigin, SnowbridgeSystemV2, System, UNIT, }; +use dhp_bridge::NativeTokenTransferMessageProcessor; use frame_support::{assert_noop, assert_ok, traits::fungible::Inspect}; use pallet_datahaven_native_transfer::Event as NativeTransferEvent; use snowbridge_core::TokenIdOf; -use snowbridge_outbound_queue_primitives::v2::Command; +use snowbridge_inbound_queue_primitives::v2::{ + EthereumAsset, Message as SnowbridgeMessage, MessageProcessor, Payload, +}; use snowbridge_pallet_outbound_queue_v2::Event as OutboundQueueEvent; use snowbridge_pallet_system::NativeToForeignId; -use snowbridge_pallet_system_v2::Event as SystemV2Event; +use sp_core::Get; use sp_core::{H160, H256}; +use sp_runtime::DispatchError; use xcm::prelude::*; use xcm_executor::traits::ConvertLocation; +const TRANSFER_AMOUNT: Balance = 1000 * UNIT; +const FEE_AMOUNT: Balance = 10 * UNIT; +const ETH_ALICE: H160 = H160([0x11; 20]); +const ETH_BOB: H160 = H160([0x22; 20]); + +// Get the gateway address from runtime configuration +fn gateway_address() -> H160 { + use datahaven_testnet_runtime::configs::runtime_params::dynamic_params::runtime_config::EthereumGatewayAddress; + EthereumGatewayAddress::get() +} + +fn register_native_token() -> H256 { + let asset_location = Location::here(); + let _ = SnowbridgeSystemV2::register_token( + root_origin(), + Box::new(VersionedLocation::V5(asset_location.clone())), + Box::new(VersionedLocation::V5(asset_location.clone())), + datahaven_token_metadata(), + ); + let reanchored = SnowbridgeSystemV2::reanchor(asset_location).unwrap(); + TokenIdOf::convert_location(&reanchored).unwrap() +} + +fn setup_sovereign_balance(amount: Balance) { + let _ = Balances::force_set_balance(root_origin(), EthereumSovereignAccount::get(), amount); +} + +fn create_message(token_id: H256, amount: Balance, claimer: H160, nonce: u64) -> SnowbridgeMessage { + SnowbridgeMessage { + gateway: gateway_address(), + nonce, + origin: H160::zero(), + assets: vec![EthereumAsset::ForeignTokenERC20 { + token_id, + value: amount, + }], + xcm: Payload::Raw(vec![0x01, 0x02, 0x03]), + claimer: Some(claimer.encode()), + value: 0, + execution_fee: 100, + relayer_fee: 50, + } +} + +// === Token Registration Tests === + #[test] -fn test_datahaven_native_token_registration_succeeds() { +fn native_token_registration_works() { ExtBuilder::default().build().execute_with(|| { - let origin = root_origin(); - let sender_location = Location::here(); let asset_location = Location::here(); - let metadata = datahaven_token_metadata(); - // Step 1: Verify preconditions - token not yet registered - let initial_reanchored = SnowbridgeSystemV2::reanchor(asset_location.clone()).unwrap(); - assert!(NativeToForeignId::::get(&initial_reanchored).is_none()); - assert!( - snowbridge_pallet_system::ForeignToNativeId::::iter() - .next() - .is_none() - ); - - // Step 2: Register the token + // Register the native HAVE token with Snowbridge assert_ok!(SnowbridgeSystemV2::register_token( - origin, - Box::new(VersionedLocation::V5(sender_location)), + root_origin(), Box::new(VersionedLocation::V5(asset_location.clone())), - metadata.clone() + Box::new(VersionedLocation::V5(asset_location.clone())), + datahaven_token_metadata() )); - // Step 3: Verify reanchoring works correctly - let reanchored_location = SnowbridgeSystemV2::reanchor(asset_location.clone()).unwrap(); - assert_eq!(reanchored_location.parent_count(), 1); - // Verify it contains GlobalConsensus junction with DataHaven network - let first_junction = reanchored_location.first_interior(); - assert!(matches!(first_junction, Some(Junction::GlobalConsensus(_)))); + // Verify token was registered and assigned a valid token ID + let reanchored = SnowbridgeSystemV2::reanchor(asset_location).unwrap(); + let token_id = TokenIdOf::convert_location(&reanchored).unwrap(); - // Step 4: Verify TokenId generation is deterministic - let token_id = TokenIdOf::convert_location(&reanchored_location).unwrap(); assert_ne!(token_id, H256::zero()); - - // Step 5: Verify bidirectional storage mappings assert_eq!( - NativeToForeignId::::get(&reanchored_location), + NativeToForeignId::::get(&reanchored), Some(token_id) ); - assert_eq!( - snowbridge_pallet_system::ForeignToNativeId::::get(&token_id), - Some(reanchored_location.clone()) - ); - - // Step 6: Verify event emission with all details - let expected_event = RuntimeEvent::SnowbridgeSystemV2(SystemV2Event::RegisterToken { - location: reanchored_location.clone().into(), - foreign_token_id: token_id, - }); - assert_eq!(last_event(), expected_event); }); } +// === Outbound Transfer Tests === + #[test] -fn test_native_token_transfer_to_ethereum_succeeds() { +fn transfer_to_ethereum_works() { ExtBuilder::default().build().execute_with(|| { - // Register token first - register_native_token(); - + let _token_id = register_native_token(); let alice = account_id(ALICE); - let eth_recipient = H160::from_low_u64_be(0x1234); - let transfer_amount = 1000 * UNIT; - let fee = 10 * UNIT; - // Record initial balances let alice_initial = Balances::balance(&alice); let sovereign_initial = Balances::balance(&EthereumSovereignAccount::get()); - let treasury_initial = - Balances::balance(&datahaven_testnet_runtime::configs::TreasuryAccount::get()); - // Execute transfer assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( RuntimeOrigin::signed(alice.clone()), - eth_recipient, - transfer_amount, - fee + ETH_ALICE, + TRANSFER_AMOUNT, + FEE_AMOUNT )); - // Verify balance changes assert_eq!( Balances::balance(&alice), - alice_initial - transfer_amount - fee + alice_initial - TRANSFER_AMOUNT - FEE_AMOUNT ); assert_eq!( Balances::balance(&EthereumSovereignAccount::get()), - sovereign_initial + transfer_amount - ); - // Verify treasury received the fee - assert_eq!( - Balances::balance(&datahaven_testnet_runtime::configs::TreasuryAccount::get()), - treasury_initial + fee + sovereign_initial + TRANSFER_AMOUNT ); - // Verify events in correct order - let events = System::events(); - - // Find the transfer events - let mut found_transfer_event = false; - let mut found_message_event = false; - - for event in events.iter().rev() { - match &event.event { - RuntimeEvent::DataHavenNativeTransfer( - NativeTransferEvent::TokensTransferredToEthereum { from, to, amount }, - ) => { - assert_eq!(from, &alice); - assert_eq!(to, ð_recipient); - assert_eq!(amount, &transfer_amount); - found_transfer_event = true; - } - RuntimeEvent::EthereumOutboundQueueV2(OutboundQueueEvent::MessageQueued { - message, - .. - }) => { - // Check if this is the transfer message (not registration) - if let Command::MintForeignToken { - recipient, amount, .. - } = &message.commands[0] - { - // Verify message structure for transfer - assert_eq!(message.fee, fee); - assert_eq!(message.commands.len(), 1); - assert_eq!(recipient, ð_recipient); - assert_eq!(amount, &transfer_amount); - found_message_event = true; - } - } - _ => {} - } - } - - assert!( - found_transfer_event, - "TokensTransferredToEthereum event not found" - ); - assert!( - found_message_event, - "OutboundQueue MessageQueued event not found" - ); + // Check event was emitted + assert!(System::events().iter().any(|e| matches!( + &e.event, + RuntimeEvent::DataHavenNativeTransfer( + NativeTransferEvent::TokensTransferredToEthereum { .. } + ) + ))); }); } #[test] -fn test_transfer_with_exact_balance_preserves_existential_deposit() { +fn transfer_fails_when_paused() { ExtBuilder::default().build().execute_with(|| { - register_native_token(); - + let _token_id = register_native_token(); let alice = account_id(ALICE); - let eth_recipient = H160::from_low_u64_be(0x5678); - // Set Alice's balance to a specific amount - let existential_deposit = 1 * UNIT; // Assuming 1 DH is ED - let transfer_amount = 900 * UNIT; - let fee = 99 * UNIT; - let initial_balance = existential_deposit + transfer_amount + fee; - - // Reset Alice's balance to exact amount - let _ = Balances::force_set_balance(root_origin(), alice.clone(), initial_balance); - assert_eq!(Balances::balance(&alice), initial_balance); - - // Transfer should succeed and leave exactly existential deposit - assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(alice.clone()), - eth_recipient, - transfer_amount, - fee - )); - - // Verify Alice has exactly existential deposit remaining - assert_eq!(Balances::balance(&alice), existential_deposit); - assert_eq!( - Balances::balance(&EthereumSovereignAccount::get()), - transfer_amount - ); - }); -} - -#[test] -fn test_multiple_concurrent_transfers_maintain_consistency() { - ExtBuilder::default().build().execute_with(|| { - register_native_token(); - - let alice = account_id(ALICE); - let bob = account_id(BOB); - let eth_recipient1 = H160::from_low_u64_be(0xABCD); - let eth_recipient2 = H160::from_low_u64_be(0xDEAD); - - let transfer1 = 500 * UNIT; - let transfer2 = 300 * UNIT; - let fee = 5 * UNIT; - let treasury_initial = - Balances::balance(&datahaven_testnet_runtime::configs::TreasuryAccount::get()); - - // Execute multiple transfers - assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(alice.clone()), - eth_recipient1, - transfer1, - fee - )); - - assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(bob.clone()), - eth_recipient2, - transfer2, - fee - )); - - // Verify total locked balance - assert_eq!( - Balances::balance(&EthereumSovereignAccount::get()), - transfer1 + transfer2 - ); - - // Verify treasury received all fees (2 transfers * fee) - assert_eq!( - Balances::balance(&datahaven_testnet_runtime::configs::TreasuryAccount::get()), - treasury_initial + (fee * 2) - ); - - // Verify outbound queue has both transfer messages (excluding registration) - let events = System::events(); - let transfer_message_count = events - .iter() - .filter(|e| { - if let RuntimeEvent::EthereumOutboundQueueV2(OutboundQueueEvent::MessageQueued { - message, - .. - }) = &e.event - { - // Only count MintForeignToken messages (transfer messages, not registration) - matches!( - message.commands.get(0), - Some(Command::MintForeignToken { .. }) - ) - } else { - false - } - }) - .count(); - assert_eq!(transfer_message_count, 2); - }); -} - -#[test] -fn test_transfer_generates_unique_message_ids() { - ExtBuilder::default().build().execute_with(|| { - register_native_token(); - - let alice = account_id(ALICE); - let eth_recipient = H160::from_low_u64_be(0x9999); - let amount = 100 * UNIT; - let fee = 1 * UNIT; - - // Collect message IDs from multiple transfers - let mut message_ids = Vec::new(); - - for _ in 0..3 { - assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(alice.clone()), - eth_recipient, - amount, - fee - )); - - // Extract message ID from last event - let events = System::events(); - for event in events.iter().rev() { - if let RuntimeEvent::EthereumOutboundQueueV2(OutboundQueueEvent::MessageQueued { - message, - .. - }) = &event.event - { - message_ids.push(message.id); - break; - } - } - } - - // Verify all message IDs are unique - assert_eq!(message_ids.len(), 3); - let unique_ids: std::collections::HashSet<_> = message_ids.iter().collect(); - assert_eq!(unique_ids.len(), 3, "Message IDs should be unique"); - }); -} - -#[test] -fn test_pause_functionality_blocks_transfers() { - ExtBuilder::default().build().execute_with(|| { - register_native_token(); - - let alice = account_id(ALICE); - let eth_recipient = H160::from_low_u64_be(0x4444); - - // Pause the pallet assert_ok!(DataHavenNativeTransfer::pause(root_origin())); - // Verify transfer fails assert_noop!( DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(alice.clone()), - eth_recipient, - 100 * UNIT, - 1 * UNIT + RuntimeOrigin::signed(alice), + ETH_ALICE, + TRANSFER_AMOUNT, + FEE_AMOUNT ), pallet_datahaven_native_transfer::Error::::TransfersDisabled ); - - // Unpause - assert_ok!(DataHavenNativeTransfer::unpause(root_origin())); - - // Verify transfer now succeeds - assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(alice), - eth_recipient, - 100 * UNIT, - 1 * UNIT - )); }); } #[test] -fn test_treasury_fee_collection() { +fn multiple_transfers_work() { ExtBuilder::default().build().execute_with(|| { - register_native_token(); + let _token_id = register_native_token(); + let alice = account_id(ALICE); + let bob = account_id(BOB); + assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( + RuntimeOrigin::signed(alice), + ETH_ALICE, + TRANSFER_AMOUNT, + FEE_AMOUNT + )); + + assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( + RuntimeOrigin::signed(bob), + ETH_BOB, + TRANSFER_AMOUNT, + FEE_AMOUNT + )); + + let expected_sovereign_balance = TRANSFER_AMOUNT * 2; + assert_eq!( + Balances::balance(&EthereumSovereignAccount::get()), + expected_sovereign_balance + ); + }); +} + +#[test] +fn treasury_collects_fees_from_multiple_transfers() { + ExtBuilder::default().build().execute_with(|| { + let _token_id = register_native_token(); let alice = account_id(ALICE); let bob = account_id(BOB); let treasury_account = datahaven_testnet_runtime::configs::TreasuryAccount::get(); let initial_treasury_balance = Balances::balance(&treasury_account); - // Test case 1: Single transfer with fee let fee1 = 5 * UNIT; + let fee2 = 10 * UNIT; + assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(alice.clone()), - H160::from_low_u64_be(0x1111), - 100 * UNIT, + RuntimeOrigin::signed(alice), + ETH_ALICE, + TRANSFER_AMOUNT, fee1 )); - // Verify treasury received the fee - assert_eq!( - Balances::balance(&treasury_account), - initial_treasury_balance + fee1 - ); - - // Test case 2: Multiple transfers with different fees - let fee2 = 10 * UNIT; - let fee3 = 15 * UNIT; - assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(bob.clone()), - H160::from_low_u64_be(0x2222), - 200 * UNIT, + RuntimeOrigin::signed(bob), + ETH_BOB, + TRANSFER_AMOUNT, fee2 )); - assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( - RuntimeOrigin::signed(alice.clone()), - H160::from_low_u64_be(0x3333), - 300 * UNIT, - fee3 - )); - - // Verify treasury received all accumulated fees - let total_fees = fee1 + fee2 + fee3; + let expected_treasury_balance = initial_treasury_balance + fee1 + fee2; assert_eq!( Balances::balance(&treasury_account), - initial_treasury_balance + total_fees, - "Treasury should accumulate all fees from transfers" - ); - - // Verify treasury account is not the zero address - assert_ne!( - treasury_account, - AccountId::from([0u8; 20]), - "Treasury account should not be the zero address" + expected_treasury_balance ); }); } -// Helper function to register native token -fn register_native_token() { - let origin = root_origin(); - let sender_location = Location::here(); - let asset_location = Location::here(); - let metadata = datahaven_token_metadata(); +// === Inbound Message Processing Tests === - let _ = SnowbridgeSystemV2::register_token( - origin, - Box::new(VersionedLocation::V5(sender_location)), - Box::new(VersionedLocation::V5(asset_location)), - metadata, - ); +#[test] +fn message_processor_accepts_registered_token() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + let message = create_message(token_id, TRANSFER_AMOUNT, ETH_ALICE, 1); + + assert!( + NativeTokenTransferMessageProcessor::::can_process_message(&alice, &message) + ); + }); +} + +#[test] +fn message_processor_rejects_unregistered_token() { + ExtBuilder::default().build().execute_with(|| { + let fake_token_id = H256::from_low_u64_be(0x9999); + let alice = account_id(ALICE); + let message = create_message(fake_token_id, TRANSFER_AMOUNT, ETH_ALICE, 1); + + assert!( + !NativeTokenTransferMessageProcessor::::can_process_message(&alice, &message) + ); + }); +} + +#[test] +fn message_processor_rejects_empty_assets() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + + let mut message = create_message(token_id, TRANSFER_AMOUNT, ETH_ALICE, 1); + message.assets = vec![]; + + assert!( + !NativeTokenTransferMessageProcessor::::can_process_message(&alice, &message) + ); + }); +} + +#[test] +fn inbound_message_processing_works() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + let message = create_message(token_id, TRANSFER_AMOUNT, ETH_ALICE, 1); + + setup_sovereign_balance(TRANSFER_AMOUNT * 2); + let sovereign_initial = Balances::balance(&EthereumSovereignAccount::get()); + + assert_ok!( + snowbridge_pallet_inbound_queue_v2::Pallet::::process_message(alice, message) + ); + + let recipient: AccountId = ETH_ALICE.into(); + assert_eq!(Balances::balance(&recipient), TRANSFER_AMOUNT); + assert_eq!( + Balances::balance(&EthereumSovereignAccount::get()), + sovereign_initial - TRANSFER_AMOUNT + ); + + // Check unlock event was emitted + assert!(System::events().iter().any(|e| matches!( + &e.event, + RuntimeEvent::DataHavenNativeTransfer(NativeTransferEvent::TokensUnlocked { .. }) + ))); + }); +} + +#[test] +fn multiple_assets_processing_sums_amounts() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + + let mut message = create_message(token_id, 0, ETH_ALICE, 1); + message.assets = vec![ + EthereumAsset::ForeignTokenERC20 { + token_id, + value: 300 * UNIT, + }, + EthereumAsset::ForeignTokenERC20 { + token_id, + value: 200 * UNIT, + }, + EthereumAsset::ForeignTokenERC20 { + token_id, + value: 500 * UNIT, + }, + ]; + + setup_sovereign_balance(2000 * UNIT); + + assert_ok!( + snowbridge_pallet_inbound_queue_v2::Pallet::::process_message(alice, message) + ); + + let recipient: AccountId = ETH_ALICE.into(); + let total_amount = 1000 * UNIT; + assert_eq!(Balances::balance(&recipient), total_amount); + }); +} + +#[test] +fn processing_fails_without_claimer() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + + let mut message = create_message(token_id, TRANSFER_AMOUNT, ETH_ALICE, 1); + message.claimer = None; + + assert_noop!( + snowbridge_pallet_inbound_queue_v2::Pallet::::process_message(alice, message), + DispatchError::Other("No claimer specified in message") + ); + }); +} + +#[test] +fn processing_fails_with_zero_amount() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + let message = create_message(token_id, 0, ETH_ALICE, 1); + + assert_noop!( + snowbridge_pallet_inbound_queue_v2::Pallet::::process_message(alice, message), + DispatchError::Other("No native token found in assets") + ); + }); +} + +#[test] +fn processing_fails_with_insufficient_sovereign_balance() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + let message = create_message(token_id, TRANSFER_AMOUNT, ETH_ALICE, 1); + + setup_sovereign_balance(TRANSFER_AMOUNT / 2); // Insufficient balance + + let result = + snowbridge_pallet_inbound_queue_v2::Pallet::::process_message(alice, message); + assert!(result.is_err()); + }); +} + +// === Integration Tests === + +#[test] +fn end_to_end_transfer_flow() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + + // Outbound transfer + assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum( + RuntimeOrigin::signed(alice.clone()), + ETH_ALICE, + TRANSFER_AMOUNT, + FEE_AMOUNT + )); + + // Verify message was queued + assert!(System::events().iter().any(|e| matches!( + &e.event, + RuntimeEvent::EthereumOutboundQueueV2(OutboundQueueEvent::MessageQueued { .. }) + ))); + + // Simulate inbound processing + let message = create_message(token_id, TRANSFER_AMOUNT, ETH_BOB, 1); + setup_sovereign_balance(TRANSFER_AMOUNT * 3); + + assert_ok!( + snowbridge_pallet_inbound_queue_v2::Pallet::::process_message(alice, message) + ); + + let recipient: AccountId = ETH_BOB.into(); + assert_eq!(Balances::balance(&recipient), TRANSFER_AMOUNT); + }); +} + +#[test] +fn message_routing_works_correctly() { + ExtBuilder::default().build().execute_with(|| { + let token_id = register_native_token(); + let alice = account_id(ALICE); + + // Native token message should be accepted + let native_message = create_message(token_id, TRANSFER_AMOUNT, ETH_ALICE, 1); + assert!( + NativeTokenTransferMessageProcessor::::can_process_message( + &alice, + &native_message + ) + ); + + // Non-native token message should be rejected + let fake_token_id = H256::from_low_u64_be(0x8888); + let non_native_message = create_message(fake_token_id, TRANSFER_AMOUNT, ETH_ALICE, 2); + assert!( + !NativeTokenTransferMessageProcessor::::can_process_message( + &alice, + &non_native_message + ) + ); + }); }