feat: Add native token transfer support from Ethereum to DataHaven (#97)

## 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: <registered_native_token_id>, // Must match pre-registered
token ID
      amount: <transfer_amount>
    }],
claimer: <datahaven_recipient_as_h160>, // 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<Runtime>,      // Handles validator operations
    NativeTokenTransferMessageProcessor<Runtime>, // 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 <noreply@anthropic.com>
This commit is contained in:
Ahmad Kaouk 2025-07-03 16:41:51 +02:00 committed by GitHub
parent 141e78682f
commit 6ad4e07ed8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1148 additions and 1013 deletions

2
operator/Cargo.lock generated
View file

@ -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",
]

View file

@ -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",
]

View file

@ -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<T>
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<T>(PhantomData<T>);
impl<T> NativeTokenTransferMessageProcessor<T>
where
T: pallet_datahaven_native_transfer::Config + frame_system::Config,
T::AccountId: From<H160>,
{
/// 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<T::AccountId, DispatchError> {
// 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<T, AccountId> MessageProcessor<AccountId> for NativeTokenTransferMessageProcessor<T>
where
T: pallet_datahaven_native_transfer::Config + frame_system::Config,
T::AccountId: From<H160>,
{
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::<T>::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)
}
}

View file

@ -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<Runtime>;
type MessageProcessor = (
EigenLayerMessageProcessor<Runtime>,
NativeTokenTransferMessageProcessor<Runtime>,
);
type RewardKind = ();
type DefaultRewardKind = DefaultRewardKind;
type RewardPayment = DummyRewardPayment;

View file

@ -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(),

View file

@ -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::<Runtime>::get(&initial_reanchored).is_none());
assert!(
snowbridge_pallet_system::ForeignToNativeId::<Runtime>::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::<Runtime>::get(&reanchored_location),
NativeToForeignId::<Runtime>::get(&reanchored),
Some(token_id)
);
assert_eq!(
snowbridge_pallet_system::ForeignToNativeId::<Runtime>::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, &eth_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, &eth_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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::can_process_message(
&alice,
&non_native_message
)
);
});
}

View file

@ -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<Runtime>;
type MessageProcessor = (
EigenLayerMessageProcessor<Runtime>,
NativeTokenTransferMessageProcessor<Runtime>,
);
type RewardKind = ();
type DefaultRewardKind = DefaultRewardKind;
type RewardPayment = DummyRewardPayment;

View file

@ -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};

View file

@ -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(),

View file

@ -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::<Runtime>::get(&initial_reanchored).is_none());
assert!(
snowbridge_pallet_system::ForeignToNativeId::<Runtime>::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::<Runtime>::get(&reanchored_location),
NativeToForeignId::<Runtime>::get(&reanchored),
Some(token_id)
);
assert_eq!(
snowbridge_pallet_system::ForeignToNativeId::<Runtime>::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, &eth_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, &eth_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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::can_process_message(
&alice,
&non_native_message
)
);
});
}

View file

@ -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<Runtime>;
type MessageProcessor = (
EigenLayerMessageProcessor<Runtime>,
NativeTokenTransferMessageProcessor<Runtime>,
);
type RewardKind = ();
type DefaultRewardKind = DefaultRewardKind;
type RewardPayment = DummyRewardPayment;

View file

@ -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(),

View file

@ -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::<Runtime>::get(&initial_reanchored).is_none());
assert!(
snowbridge_pallet_system::ForeignToNativeId::<Runtime>::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::<Runtime>::get(&reanchored_location),
NativeToForeignId::<Runtime>::get(&reanchored),
Some(token_id)
);
assert_eq!(
snowbridge_pallet_system::ForeignToNativeId::<Runtime>::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, &eth_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, &eth_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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::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::<Runtime>::can_process_message(
&alice,
&non_native_message
)
);
});
}