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.
445 lines
14 KiB
Rust
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
|
|
)
|
|
);
|
|
});
|
|
}
|