Skip to content
157 changes: 156 additions & 1 deletion integration-tests/src/non_native_fee.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
#![cfg(test)]

use crate::polkadot_test_net::*;
use frame_support::sp_runtime::codec::Encode;
use frame_support::{
assert_ok,
dispatch::DispatchInfo,
dispatch::{DispatchInfo, GetDispatchInfo},
sp_runtime::{traits::DispatchTransaction, FixedU128, Permill},
weights::Weight,
};
Expand Down Expand Up @@ -170,6 +171,160 @@ fn set_currency_should_work_in_batch_transaction_when_first_tx() {
});
}

#[test]
fn set_currency_should_work_in_dispatch_with_extra_gas() {
// Regression test for https://github.com/galacticcouncil/hydration-node/issues/1296
// dispatch_with_extra_gas wrapping set_currency should charge the fee in the NEW currency,
// not in the previously stored account currency.
TestNet::reset();

Hydra::execute_with(|| {
use frame_support::traits::OnInitialize;
hydradx_runtime::MultiTransactionPayment::on_initialize(1);

// BOB starts with no explicit fee currency (defaults to HDX).
assert_eq!(MultiTransactionPayment::get_currency(AccountId::from(BOB)), None);

let set_currency_call = hydradx_runtime::RuntimeCall::MultiTransactionPayment(
pallet_transaction_multi_payment::Call::set_currency { currency: BTC },
);

let call = hydradx_runtime::RuntimeCall::Dispatcher(pallet_dispatcher::Call::dispatch_with_extra_gas {
call: Box::new(set_currency_call),
extra_gas: 0,
});

let info = call.get_dispatch_info();
let len = call.encoded_size();

let bob_hdx_before = hydradx_runtime::Balances::free_balance(AccountId::from(BOB));
let bob_btc_before = hydradx_runtime::Tokens::free_balance(BTC, &AccountId::from(BOB));

assert_ok!(
pallet_transaction_payment::ChargeTransactionPayment::<hydradx_runtime::Runtime>::from(0)
.validate_and_prepare(Some(AccountId::from(BOB)).into(), &call, &info, len, 0,)
);

let bob_hdx_after = hydradx_runtime::Balances::free_balance(AccountId::from(BOB));
let bob_btc_after = hydradx_runtime::Tokens::free_balance(BTC, &AccountId::from(BOB));

assert!(
bob_btc_after < bob_btc_before,
"BTC balance should decrease — fee must be charged in the new currency"
);
assert_eq!(bob_hdx_after, bob_hdx_before, "HDX must not be charged");
});
}

#[test]
fn set_currency_should_work_in_dispatch_with_extra_gas_for_evm_account() {
// Regression test for https://github.com/galacticcouncil/hydration-node/issues/1293
// EVM accounts calling dispatch_with_extra_gas { set_currency } should charge the fee
// in the NEW currency, not in WETH (the default EVM account fee currency).
TestNet::reset();

Hydra::execute_with(|| {
use frame_support::traits::OnInitialize;
hydradx_runtime::MultiTransactionPayment::on_initialize(1);

let evm_acc = evm_account();

// Fund the EVM account with WETH (default EVM fee currency) and DAI
assert_ok!(hydradx_runtime::Currencies::update_balance(
hydradx_runtime::RuntimeOrigin::root(),
evm_acc.clone(),
WETH,
1_000_000_000_000_000_000i128,
));
assert_ok!(hydradx_runtime::Currencies::update_balance(
hydradx_runtime::RuntimeOrigin::root(),
evm_acc.clone(),
DAI,
1_000_000_000_000_000_000i128,
));

assert_eq!(
MultiTransactionPayment::get_currency(evm_acc.clone()),
Some(WETH),
"EVM account should have WETH set as default fee currency"
);

let set_currency_call = hydradx_runtime::RuntimeCall::MultiTransactionPayment(
pallet_transaction_multi_payment::Call::set_currency { currency: DAI },
);

let call = hydradx_runtime::RuntimeCall::Dispatcher(pallet_dispatcher::Call::dispatch_with_extra_gas {
call: Box::new(set_currency_call),
extra_gas: 0,
});

let info = call.get_dispatch_info();
let len = call.encoded_size();

let weth_before = hydradx_runtime::Tokens::free_balance(WETH, &evm_acc);
let dai_before = hydradx_runtime::Tokens::free_balance(DAI, &evm_acc);

assert_ok!(
pallet_transaction_payment::ChargeTransactionPayment::<hydradx_runtime::Runtime>::from(0)
.validate_and_prepare(Some(evm_acc.clone()).into(), &call, &info, len, 0,)
);

let weth_after = hydradx_runtime::Tokens::free_balance(WETH, &evm_acc);
let dai_after = hydradx_runtime::Tokens::free_balance(DAI, &evm_acc);

assert!(
dai_after < dai_before,
"DAI balance should decrease — fee must be charged in the new currency"
);
assert_eq!(weth_after, weth_before, "WETH must not be charged");
});
}

#[test]
fn set_currency_should_not_work_in_dispatch_with_extra_gas_when_not_direct_inner_call() {
// set_currency must only be recognised when it is the direct inner call of
// dispatch_with_extra_gas, not when it is nested deeper (e.g. inside a batch inside the
// dispatcher). In that case the fee should fall back to the previously stored currency.
TestNet::reset();

Hydra::execute_with(|| {
use frame_support::traits::OnInitialize;
hydradx_runtime::MultiTransactionPayment::on_initialize(1);

let set_currency_call = hydradx_runtime::RuntimeCall::MultiTransactionPayment(
pallet_transaction_multi_payment::Call::set_currency { currency: BTC },
);

// Wrap set_currency inside a batch, then wrap that inside dispatch_with_extra_gas.
// The resolver only looks one level deep, so BTC should NOT be picked up.
let batch = hydradx_runtime::RuntimeCall::Utility(pallet_utility::Call::batch {
calls: vec![set_currency_call],
});

let call = hydradx_runtime::RuntimeCall::Dispatcher(pallet_dispatcher::Call::dispatch_with_extra_gas {
call: Box::new(batch),
extra_gas: 0,
});

let info = call.get_dispatch_info();
let len = call.encoded_size();

let bob_btc_before = hydradx_runtime::Tokens::free_balance(BTC, &AccountId::from(BOB));

assert_ok!(
pallet_transaction_payment::ChargeTransactionPayment::<hydradx_runtime::Runtime>::from(0)
.validate_and_prepare(Some(AccountId::from(BOB)).into(), &call, &info, len, 0,)
);

let bob_btc_after = hydradx_runtime::Tokens::free_balance(BTC, &AccountId::from(BOB));

assert_eq!(
bob_btc_after, bob_btc_before,
"BTC must not be charged when set_currency is nested deeper than the direct inner call"
);
});
}

#[test]
fn set_currency_should_not_work_in_batch_transaction_when_not_first_tx() {
TestNet::reset();
Expand Down
5 changes: 4 additions & 1 deletion pallets/transaction-multi-payment/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ pallet-utility = { workspace = true }
pallet-evm = { workspace = true, optional = true }

[dev-dependencies]
pallet-currencies = { workspace = true }
pallet-currencies = { workspace = true, features = ["std"] }
orml-tokens = { workspace = true, features = ["std"] }
pallet-balances = { workspace = true, features = ["std"] }
pallet-evm-accounts = { workspace = true, features = ["std"] }
Expand All @@ -58,6 +58,9 @@ std = [
"scale-info/std",
"pallet-evm/std",
"primitives/std",
"pallet-utility/std",
"pallet-xyk/std",
"hydra-dx-math/std",
]
try-runtime = ["frame-support/try-runtime"]
evm = ["pallet-evm"]
Expand Down
79 changes: 43 additions & 36 deletions pallets/transaction-multi-payment/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@

let encoded = data.clone();
let mut encoded_extrinsic = encoded.as_slice();
let maybe_call: Result<<T as frame_system::Config>::RuntimeCall, _> =
let maybe_call: Result<T::RuntimeCall, _> =
DecodeLimit::decode_all_with_depth_limit(32, &mut encoded_extrinsic);

let currency = if let Ok(call) = maybe_call {
Expand Down Expand Up @@ -484,7 +484,7 @@

let encoded = data.clone();
let mut encoded_extrinsic = encoded.as_slice();
let maybe_call: Result<<T as frame_system::Config>::RuntimeCall, _> =
let maybe_call: Result<T::RuntimeCall, _> =
DecodeLimit::decode_all_with_depth_limit(32, &mut encoded_extrinsic);

let currency = if let Ok(call) = maybe_call {
Expand Down Expand Up @@ -586,7 +586,7 @@
where
BalanceOf<T>: FixedPointOperand,
{
if let Some(price) = Self::price(currency) {
if let Some(price) = <Pallet<T> as NativePriceOracle<AssetIdOf<T>, Price>>::price(currency) {
Some(price)
} else {
// If not loaded in on_init, let's try first the spot price provider again
Expand Down Expand Up @@ -635,14 +635,12 @@

impl<T, MC, DF, FR, WF> OnChargeTransaction<T> for TransferFees<T, MC, DF, FR, WF>
where
T: Config + pallet_utility::Config,
T: Config,
MC: MultiCurrency<<T as frame_system::Config>::AccountId>,
AssetIdOf<T>: Into<MC::CurrencyId>,
MC::Balance: FixedPointOperand,
FR: Get<T::AccountId>,
DF: DepositFee<T::AccountId, MC::CurrencyId, MC::Balance>,
<T as frame_system::Config>::RuntimeCall: IsSubType<Call<T>> + IsSubType<pallet_utility::pallet::Call<T>>,
<T as pallet_utility::Config>::RuntimeCall: IsSubType<Call<T>>,
BalanceOf<T>: FixedPointOperand,
BalanceOf<T>: From<MC::Balance>,
WF: WithdrawFuseControl,
Expand All @@ -655,8 +653,8 @@
/// Note: The `fee` already includes the `tip`.
fn withdraw_fee(
who: &T::AccountId,
call: &<T as frame_system::Config>::RuntimeCall,
_info: &DispatchInfoOf<<T as frame_system::Config>::RuntimeCall>,
call: &T::RuntimeCall,
_info: &DispatchInfoOf<T::RuntimeCall>,
fee: Self::Balance,
_tip: Self::Balance,
) -> Result<Self::LiquidityInfo, TransactionValidityError> {
Expand Down Expand Up @@ -727,8 +725,8 @@
/// Note: The `fee` already includes the `tip`.
fn correct_and_deposit_fee(
who: &T::AccountId,
_dispatch_info: &DispatchInfoOf<<T as frame_system::Config>::RuntimeCall>,
_post_info: &PostDispatchInfoOf<<T as frame_system::Config>::RuntimeCall>,
_dispatch_info: &DispatchInfoOf<T::RuntimeCall>,
_post_info: &PostDispatchInfoOf<T::RuntimeCall>,
corrected_fee: Self::Balance,
tip: Self::Balance,
already_withdrawn: Self::LiquidityInfo,
Expand Down Expand Up @@ -779,8 +777,8 @@

fn can_withdraw_fee(
who: &T::AccountId,
call: &<T as frame_system::Config>::RuntimeCall,
_info: &DispatchInfoOf<<T as frame_system::Config>::RuntimeCall>,
call: &T::RuntimeCall,
_info: &DispatchInfoOf<T::RuntimeCall>,
fee: Self::Balance,
_tip: Self::Balance,
) -> Result<(), TransactionValidityError> {
Expand Down Expand Up @@ -817,32 +815,14 @@
}
}

impl<T, MC, DF, FR, WF> TransferFees<T, MC, DF, FR, WF>
impl<T: Config, MC, DF, FR, WF> TransferFees<T, MC, DF, FR, WF>
where
T: Config + pallet_utility::Config,
MC: MultiCurrency<<T as frame_system::Config>::AccountId>,
MC: MultiCurrency<T::AccountId>,
AssetIdOf<T>: Into<MC::CurrencyId>,
<T as frame_system::Config>::RuntimeCall: IsSubType<Call<T>> + IsSubType<pallet_utility::pallet::Call<T>>,
<T as pallet_utility::Config>::RuntimeCall: IsSubType<Call<T>>,
WF: WithdrawFuseControl,
{
fn resolve_currency_from_call(who: &T::AccountId, call: &<T as frame_system::Config>::RuntimeCall) -> AssetIdOf<T> {
if let Some(Call::set_currency { currency }) = call.is_sub_type() {
*currency
} else if let Some(pallet_utility::pallet::Call::batch { calls })
| Some(pallet_utility::pallet::Call::batch_all { calls })
| Some(pallet_utility::pallet::Call::force_batch { calls }) = call.is_sub_type()
{
match calls.first() {
Some(first_call) => match first_call.is_sub_type() {
Some(Call::set_currency { currency }) => *currency,
_ => Pallet::<T>::account_currency(who),
},
None => Pallet::<T>::account_currency(who),
}
} else {
Pallet::<T>::account_currency(who)
}
fn resolve_currency_from_call(who: &T::AccountId, call: &T::RuntimeCall) -> AssetIdOf<T> {
T::TryCallCurrency::try_convert(call).unwrap_or_else(|_| Pallet::<T>::account_currency(who))
}
}

Expand Down Expand Up @@ -929,11 +909,38 @@
}
}

pub struct NoCallCurrency<T>(PhantomData<T>);
impl<T: Config> TryConvert<&<T as frame_system::Config>::RuntimeCall, AssetIdOf<T>> for NoCallCurrency<T> {
/// Test-only implementation of `TryCallCurrency` for unit tests.
/// Handles `set_currency` and batch patterns, but not `dispatch_with_extra_gas`
/// (which requires `pallet_dispatcher` dependency).
pub struct TestCallCurrency<T>(PhantomData<T>);
impl<T: Config + pallet_utility::Config> TryConvert<&<T as frame_system::Config>::RuntimeCall, AssetIdOf<T>>
for TestCallCurrency<T>
where
<T as frame_system::Config>::RuntimeCall: IsSubType<Call<T>> + IsSubType<pallet_utility::pallet::Call<T>>,
<T as pallet_utility::Config>::RuntimeCall: IsSubType<Call<T>>,
{
fn try_convert(
call: &<T as frame_system::Config>::RuntimeCall,
) -> Result<AssetIdOf<T>, &<T as frame_system::Config>::RuntimeCall> {
// Handle direct set_currency calls
if let Some(Call::set_currency { currency }) = call.is_sub_type() {
return Ok(*currency);
}

// Handle batch/batch_all/force_batch with set_currency as first call
if let Some(pallet_utility::pallet::Call::batch { calls })
| Some(pallet_utility::pallet::Call::batch_all { calls })
| Some(pallet_utility::pallet::Call::force_batch { calls }) = call.is_sub_type()
{
match calls.first() {

Check failure on line 935 in pallets/transaction-multi-payment/src/lib.rs

View workflow job for this annotation

GitHub Actions / build

you seem to be trying to use `match` for destructuring a single pattern. Consider using `if let`
Some(first_call) => match first_call.is_sub_type() {

Check failure on line 936 in pallets/transaction-multi-payment/src/lib.rs

View workflow job for this annotation

GitHub Actions / build

you seem to be trying to use `match` for destructuring a single pattern. Consider using `if let`
Some(Call::set_currency { currency }) => return Ok(*currency),
_ => {}
},
_ => {}
}
}

Err(call)
}
}
2 changes: 1 addition & 1 deletion pallets/transaction-multi-payment/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ impl Config for Test {
type EvmAssetId = EvmAssetId;
type InspectEvmAccounts = EVMAccounts;
type EvmPermit = PermitDispatchHandler;
type TryCallCurrency<'a> = NoCallCurrency<Test>;
type TryCallCurrency<'a> = TestCallCurrency<Test>;
type SwappablePaymentAssetSupport = MockedInsufficientAssetSupport;
}

Expand Down
Loading