// Copyright 2025 DataHaven
// This file is part of DataHaven.
// DataHaven is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// DataHaven is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with DataHaven. If not, see .
#[path = "common.rs"]
mod common;
use codec::Encode;
use common::*;
use datahaven_testnet_runtime::{
configs::EthereumSovereignAccount, currency::HAVE, AccountId, Balance, Balances,
DataHavenNativeTransfer, Runtime, RuntimeEvent, RuntimeOrigin, SnowbridgeSystemV2, System,
};
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_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 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 * HAVE;
const FEE_AMOUNT: Balance = 10 * HAVE;
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 native_token_registration_works() {
ExtBuilder::default().build().execute_with(|| {
let asset_location = Location::here();
// Register the native HAVE token with Snowbridge
assert_ok!(SnowbridgeSystemV2::register_token(
root_origin(),
Box::new(VersionedLocation::V5(asset_location.clone())),
Box::new(VersionedLocation::V5(asset_location.clone())),
datahaven_token_metadata()
));
// 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();
assert_ne!(token_id, H256::zero());
assert_eq!(
NativeToForeignId::::get(&reanchored),
Some(token_id)
);
});
}
// === 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 alice_initial = Balances::balance(&alice);
let sovereign_initial = Balances::balance(&EthereumSovereignAccount::get());
assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum(
RuntimeOrigin::signed(alice.clone()),
ETH_ALICE,
TRANSFER_AMOUNT,
FEE_AMOUNT
));
assert_eq!(
Balances::balance(&alice),
alice_initial - TRANSFER_AMOUNT - FEE_AMOUNT
);
assert_eq!(
Balances::balance(&EthereumSovereignAccount::get()),
sovereign_initial + TRANSFER_AMOUNT
);
// Check event was emitted
assert!(System::events().iter().any(|e| matches!(
&e.event,
RuntimeEvent::DataHavenNativeTransfer(
NativeTransferEvent::TokensTransferredToEthereum { .. }
)
)));
});
}
#[test]
fn transfer_fails_when_paused() {
ExtBuilder::default().build().execute_with(|| {
let _token_id = register_native_token();
let alice = account_id(ALICE);
assert_ok!(DataHavenNativeTransfer::pause(root_origin()));
assert_noop!(
DataHavenNativeTransfer::transfer_to_ethereum(
RuntimeOrigin::signed(alice),
ETH_ALICE,
TRANSFER_AMOUNT,
FEE_AMOUNT
),
pallet_datahaven_native_transfer::Error::::TransfersDisabled
);
});
}
#[test]
fn multiple_transfers_work() {
ExtBuilder::default().build().execute_with(|| {
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);
let fee1 = 5 * HAVE;
let fee2 = 10 * HAVE;
assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum(
RuntimeOrigin::signed(alice),
ETH_ALICE,
TRANSFER_AMOUNT,
fee1
));
assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum(
RuntimeOrigin::signed(bob),
ETH_BOB,
TRANSFER_AMOUNT,
fee2
));
let expected_treasury_balance = initial_treasury_balance + fee1 + fee2;
assert_eq!(
Balances::balance(&treasury_account),
expected_treasury_balance
);
});
}
// === Inbound Message Processing Tests ===
#[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 * HAVE,
},
EthereumAsset::ForeignTokenERC20 {
token_id,
value: 200 * HAVE,
},
EthereumAsset::ForeignTokenERC20 {
token_id,
value: 500 * HAVE,
},
];
setup_sovereign_balance(2000 * HAVE);
assert_ok!(
snowbridge_pallet_inbound_queue_v2::Pallet::::process_message(alice, message)
);
let recipient: AccountId = ETH_ALICE.into();
let total_amount = 1000 * HAVE;
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
assert_noop!(
snowbridge_pallet_inbound_queue_v2::Pallet::::process_message(alice, message),
pallet_datahaven_native_transfer::Error::::InsufficientSovereignBalance
);
});
}
// === 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
)
);
});
}