datahaven/operator/runtime/mainnet/tests/native_token_transfer.rs
Ahmad Kaouk 2f6c6e39c2
fix: add explicit sovereign account balance check in unlock_tokens (#253)
Add defensive validation to ensure the Ethereum sovereign account has
sufficient balance before unlocking tokens. This addresses an audit
finding where the lack of explicit balance checking created an
unreliable security control that depended on implicit runtime behavior.

Changes:
- Add InsufficientSovereignBalance error variant for clear error
messaging
- Add explicit balance check in unlock_tokens before transfer
- Update tests across all runtimes (testnet, stagenet, mainnet) to
validate the specific error is returned when sovereign account has
insufficient funds

The explicit check provides better error messages that can propagate
through the Ethereum bridge and makes debugging sovereign account
balance issues easier.
2025-10-30 11:19:14 +00:00

445 lines
14 KiB
Rust

// Copyright 2025 Moonbeam Foundation.
// This file is part of DataHaven.
#[path = "common.rs"]
mod common;
use codec::Encode;
use common::*;
use datahaven_mainnet_runtime::{
configs::EthereumSovereignAccount, currency::HAVE, AccountId, Balance, Balances,
DataHavenNativeTransfer, Runtime, RuntimeEvent, RuntimeOrigin, SnowbridgeSystemV2, System,
};
use dhp_bridge::NativeTokenTransferMessageProcessor;
use frame_support::{assert_noop, assert_ok, traits::fungible::Inspect};
use pallet_datahaven_native_transfer::Event as NativeTransferEvent;
use snowbridge_core::TokenIdOf;
use snowbridge_inbound_queue_primitives::v2::{
EthereumAsset, Message as SnowbridgeMessage, MessageProcessor, Payload,
};
use snowbridge_pallet_outbound_queue_v2::Event as OutboundQueueEvent;
use snowbridge_pallet_system::NativeToForeignId;
use sp_core::Get;
use sp_core::{H160, H256};
use sp_runtime::DispatchError;
use xcm::prelude::*;
use xcm_executor::traits::ConvertLocation;
const TRANSFER_AMOUNT: Balance = 1000 * HAVE;
const FEE_AMOUNT: Balance = 10 * HAVE;
const ETH_ALICE: H160 = H160([0x11; 20]);
const ETH_BOB: H160 = H160([0x22; 20]);
// Get the gateway address from runtime configuration
fn gateway_address() -> H160 {
use datahaven_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 native_token_registration_works() {
ExtBuilder::default().build().execute_with(|| {
let asset_location = Location::here();
// Register the native HAVE token with Snowbridge
assert_ok!(SnowbridgeSystemV2::register_token(
root_origin(),
Box::new(VersionedLocation::V5(asset_location.clone())),
Box::new(VersionedLocation::V5(asset_location.clone())),
datahaven_token_metadata()
));
// Verify token was registered and assigned a valid token ID
let reanchored = SnowbridgeSystemV2::reanchor(asset_location).unwrap();
let token_id = TokenIdOf::convert_location(&reanchored).unwrap();
assert_ne!(token_id, H256::zero());
assert_eq!(
NativeToForeignId::<Runtime>::get(&reanchored),
Some(token_id)
);
});
}
// === Outbound Transfer Tests ===
#[test]
fn transfer_to_ethereum_works() {
ExtBuilder::default().build().execute_with(|| {
let _token_id = register_native_token();
let alice = account_id(ALICE);
// Record initial balances
let alice_initial = Balances::balance(&alice);
let sovereign_initial = Balances::balance(&EthereumSovereignAccount::get());
// Transfer native tokens to Ethereum
assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum(
RuntimeOrigin::signed(alice.clone()),
ETH_ALICE,
TRANSFER_AMOUNT,
FEE_AMOUNT
));
// Verify Alice's balance decreased by transfer amount + fee
assert_eq!(
Balances::balance(&alice),
alice_initial - TRANSFER_AMOUNT - FEE_AMOUNT
);
// Verify tokens were locked in sovereign account
assert_eq!(
Balances::balance(&EthereumSovereignAccount::get()),
sovereign_initial + TRANSFER_AMOUNT
);
// Check event was emitted
assert!(System::events().iter().any(|e| matches!(
&e.event,
RuntimeEvent::DataHavenNativeTransfer(
NativeTransferEvent::TokensTransferredToEthereum { .. }
)
)));
});
}
#[test]
fn transfer_fails_when_paused() {
ExtBuilder::default().build().execute_with(|| {
let _token_id = register_native_token();
let alice = account_id(ALICE);
// Pause the pallet
assert_ok!(DataHavenNativeTransfer::pause(root_origin()));
// Verify transfers are disabled when paused
assert_noop!(
DataHavenNativeTransfer::transfer_to_ethereum(
RuntimeOrigin::signed(alice),
ETH_ALICE,
TRANSFER_AMOUNT,
FEE_AMOUNT
),
pallet_datahaven_native_transfer::Error::<Runtime>::TransfersDisabled
);
});
}
#[test]
fn multiple_transfers_work() {
ExtBuilder::default().build().execute_with(|| {
let _token_id = register_native_token();
let alice = account_id(ALICE);
let bob = account_id(BOB);
assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum(
RuntimeOrigin::signed(alice),
ETH_ALICE,
TRANSFER_AMOUNT,
FEE_AMOUNT
));
assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum(
RuntimeOrigin::signed(bob),
ETH_BOB,
TRANSFER_AMOUNT,
FEE_AMOUNT
));
let expected_sovereign_balance = TRANSFER_AMOUNT * 2;
assert_eq!(
Balances::balance(&EthereumSovereignAccount::get()),
expected_sovereign_balance
);
});
}
#[test]
fn treasury_collects_fees_from_multiple_transfers() {
ExtBuilder::default().build().execute_with(|| {
let _token_id = register_native_token();
let alice = account_id(ALICE);
let bob = account_id(BOB);
let treasury_account = datahaven_mainnet_runtime::configs::TreasuryAccount::get();
let initial_treasury_balance = Balances::balance(&treasury_account);
let fee1 = 5 * HAVE;
let fee2 = 10 * HAVE;
assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum(
RuntimeOrigin::signed(alice),
ETH_ALICE,
TRANSFER_AMOUNT,
fee1
));
assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum(
RuntimeOrigin::signed(bob),
ETH_BOB,
TRANSFER_AMOUNT,
fee2
));
let expected_treasury_balance = initial_treasury_balance + fee1 + fee2;
assert_eq!(
Balances::balance(&treasury_account),
expected_treasury_balance
);
});
}
// === Inbound Message Processing Tests ===
#[test]
fn message_processor_accepts_registered_token() {
ExtBuilder::default().build().execute_with(|| {
let token_id = register_native_token();
let alice = account_id(ALICE);
let message = create_message(token_id, TRANSFER_AMOUNT, ETH_ALICE, 1);
// 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 * HAVE,
},
EthereumAsset::ForeignTokenERC20 {
token_id,
value: 200 * HAVE,
},
EthereumAsset::ForeignTokenERC20 {
token_id,
value: 500 * HAVE,
},
];
setup_sovereign_balance(2000 * HAVE);
assert_ok!(
snowbridge_pallet_inbound_queue_v2::Pallet::<Runtime>::process_message(alice, message)
);
let recipient: AccountId = ETH_ALICE.into();
let total_amount = 1000 * HAVE;
assert_eq!(Balances::balance(&recipient), total_amount);
});
}
#[test]
fn processing_fails_without_claimer() {
ExtBuilder::default().build().execute_with(|| {
let token_id = register_native_token();
let alice = account_id(ALICE);
let mut message = create_message(token_id, TRANSFER_AMOUNT, ETH_ALICE, 1);
message.claimer = None;
assert_noop!(
snowbridge_pallet_inbound_queue_v2::Pallet::<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
assert_noop!(
snowbridge_pallet_inbound_queue_v2::Pallet::<Runtime>::process_message(alice, message),
pallet_datahaven_native_transfer::Error::<Runtime>::InsufficientSovereignBalance
);
});
}
// === Integration Tests ===
#[test]
fn end_to_end_transfer_flow() {
ExtBuilder::default().build().execute_with(|| {
let token_id = register_native_token();
let alice = account_id(ALICE);
// 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
)
);
});
}