mirror of
https://github.com/datahaven-xyz/datahaven
synced 2026-05-24 01:38:32 +00:00
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:
parent
141e78682f
commit
6ad4e07ed8
13 changed files with 1148 additions and 1013 deletions
2
operator/Cargo.lock
generated
2
operator/Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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, ð_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::<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
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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, ð_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::<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
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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, ð_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::<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
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue