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 + ) + ); + }); }