mirror of
https://github.com/datahaven-xyz/datahaven
synced 2026-05-24 01:38:32 +00:00
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.
431 lines
14 KiB
Rust
431 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_testnet_runtime::{
|
|
configs::EthereumSovereignAccount, currency::HAVE, AccountId, Balance, Balances,
|
|
DataHavenNativeTransfer, Runtime, RuntimeEvent, RuntimeOrigin, SnowbridgeSystemV2, System,
|
|
};
|
|
use dhp_bridge::NativeTokenTransferMessageProcessor;
|
|
use frame_support::{assert_noop, assert_ok, traits::fungible::Inspect};
|
|
use pallet_datahaven_native_transfer::Event as NativeTransferEvent;
|
|
use snowbridge_core::TokenIdOf;
|
|
use snowbridge_inbound_queue_primitives::v2::{
|
|
EthereumAsset, Message as SnowbridgeMessage, MessageProcessor, Payload,
|
|
};
|
|
use snowbridge_pallet_outbound_queue_v2::Event as OutboundQueueEvent;
|
|
use snowbridge_pallet_system::NativeToForeignId;
|
|
use sp_core::Get;
|
|
use sp_core::{H160, H256};
|
|
use sp_runtime::DispatchError;
|
|
use xcm::prelude::*;
|
|
use xcm_executor::traits::ConvertLocation;
|
|
|
|
const TRANSFER_AMOUNT: Balance = 1000 * HAVE;
|
|
const FEE_AMOUNT: Balance = 10 * HAVE;
|
|
const ETH_ALICE: H160 = H160([0x11; 20]);
|
|
const ETH_BOB: H160 = H160([0x22; 20]);
|
|
|
|
// Get the gateway address from runtime configuration
|
|
fn gateway_address() -> H160 {
|
|
use datahaven_testnet_runtime::configs::runtime_params::dynamic_params::runtime_config::EthereumGatewayAddress;
|
|
EthereumGatewayAddress::get()
|
|
}
|
|
|
|
fn register_native_token() -> H256 {
|
|
let asset_location = Location::here();
|
|
let _ = SnowbridgeSystemV2::register_token(
|
|
root_origin(),
|
|
Box::new(VersionedLocation::V5(asset_location.clone())),
|
|
Box::new(VersionedLocation::V5(asset_location.clone())),
|
|
datahaven_token_metadata(),
|
|
);
|
|
let reanchored = SnowbridgeSystemV2::reanchor(asset_location).unwrap();
|
|
TokenIdOf::convert_location(&reanchored).unwrap()
|
|
}
|
|
|
|
fn setup_sovereign_balance(amount: Balance) {
|
|
let _ = Balances::force_set_balance(root_origin(), EthereumSovereignAccount::get(), amount);
|
|
}
|
|
|
|
fn create_message(token_id: H256, amount: Balance, claimer: H160, nonce: u64) -> SnowbridgeMessage {
|
|
SnowbridgeMessage {
|
|
gateway: gateway_address(),
|
|
nonce,
|
|
origin: H160::zero(),
|
|
assets: vec![EthereumAsset::ForeignTokenERC20 {
|
|
token_id,
|
|
value: amount,
|
|
}],
|
|
xcm: Payload::Raw(vec![0x01, 0x02, 0x03]),
|
|
claimer: Some(claimer.encode()),
|
|
value: 0,
|
|
execution_fee: 100,
|
|
relayer_fee: 50,
|
|
}
|
|
}
|
|
|
|
// === Token Registration Tests ===
|
|
|
|
#[test]
|
|
fn native_token_registration_works() {
|
|
ExtBuilder::default().build().execute_with(|| {
|
|
let asset_location = Location::here();
|
|
|
|
// Register the native HAVE token with Snowbridge
|
|
assert_ok!(SnowbridgeSystemV2::register_token(
|
|
root_origin(),
|
|
Box::new(VersionedLocation::V5(asset_location.clone())),
|
|
Box::new(VersionedLocation::V5(asset_location.clone())),
|
|
datahaven_token_metadata()
|
|
));
|
|
|
|
// Verify token was registered and assigned a valid token ID
|
|
let reanchored = SnowbridgeSystemV2::reanchor(asset_location).unwrap();
|
|
let token_id = TokenIdOf::convert_location(&reanchored).unwrap();
|
|
|
|
assert_ne!(token_id, H256::zero());
|
|
assert_eq!(
|
|
NativeToForeignId::<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);
|
|
|
|
let alice_initial = Balances::balance(&alice);
|
|
let sovereign_initial = Balances::balance(&EthereumSovereignAccount::get());
|
|
|
|
assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum(
|
|
RuntimeOrigin::signed(alice.clone()),
|
|
ETH_ALICE,
|
|
TRANSFER_AMOUNT,
|
|
FEE_AMOUNT
|
|
));
|
|
|
|
assert_eq!(
|
|
Balances::balance(&alice),
|
|
alice_initial - TRANSFER_AMOUNT - FEE_AMOUNT
|
|
);
|
|
assert_eq!(
|
|
Balances::balance(&EthereumSovereignAccount::get()),
|
|
sovereign_initial + TRANSFER_AMOUNT
|
|
);
|
|
|
|
// Check event was emitted
|
|
assert!(System::events().iter().any(|e| matches!(
|
|
&e.event,
|
|
RuntimeEvent::DataHavenNativeTransfer(
|
|
NativeTransferEvent::TokensTransferredToEthereum { .. }
|
|
)
|
|
)));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn transfer_fails_when_paused() {
|
|
ExtBuilder::default().build().execute_with(|| {
|
|
let _token_id = register_native_token();
|
|
let alice = account_id(ALICE);
|
|
|
|
assert_ok!(DataHavenNativeTransfer::pause(root_origin()));
|
|
|
|
assert_noop!(
|
|
DataHavenNativeTransfer::transfer_to_ethereum(
|
|
RuntimeOrigin::signed(alice),
|
|
ETH_ALICE,
|
|
TRANSFER_AMOUNT,
|
|
FEE_AMOUNT
|
|
),
|
|
pallet_datahaven_native_transfer::Error::<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_testnet_runtime::configs::TreasuryAccount::get();
|
|
let initial_treasury_balance = Balances::balance(&treasury_account);
|
|
|
|
let fee1 = 5 * HAVE;
|
|
let fee2 = 10 * HAVE;
|
|
|
|
assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum(
|
|
RuntimeOrigin::signed(alice),
|
|
ETH_ALICE,
|
|
TRANSFER_AMOUNT,
|
|
fee1
|
|
));
|
|
|
|
assert_ok!(DataHavenNativeTransfer::transfer_to_ethereum(
|
|
RuntimeOrigin::signed(bob),
|
|
ETH_BOB,
|
|
TRANSFER_AMOUNT,
|
|
fee2
|
|
));
|
|
|
|
let expected_treasury_balance = initial_treasury_balance + fee1 + fee2;
|
|
assert_eq!(
|
|
Balances::balance(&treasury_account),
|
|
expected_treasury_balance
|
|
);
|
|
});
|
|
}
|
|
|
|
// === Inbound Message Processing Tests ===
|
|
|
|
#[test]
|
|
fn message_processor_accepts_registered_token() {
|
|
ExtBuilder::default().build().execute_with(|| {
|
|
let token_id = register_native_token();
|
|
let alice = account_id(ALICE);
|
|
let message = create_message(token_id, TRANSFER_AMOUNT, ETH_ALICE, 1);
|
|
|
|
assert!(
|
|
NativeTokenTransferMessageProcessor::<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 * 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);
|
|
|
|
// 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
|
|
)
|
|
);
|
|
});
|
|
}
|