code/runtime/src/staking_fee.rs
//! Fee discounts that leave admission and execution weight alone.
//!
//! Four subsidies, applied per fee-bearing leaf call (descending through batch and
//! proxy wrappers whose declared weight includes their inner calls):
//! 1. the `StakingHotkeys` scan is billed at [`STAKING_HOTKEYS_FEE_ALLOWANCE`] keys;
//! 2. root claims are billed at [`ROOT_CLAIM_FEE_ALLOWANCE`] claim units;
//! 3. basket deposits and trades are billed over [`BASKET_FEE_ALLOWANCE`] fund rows;
//! 4. every call whose declared weight grew after spec 459 is billed at most its 459
//! declared weight ([`fee_weight_cap_459`]).
use crate::transaction_payment_wrapper::{FeeWeightDiscount, fee_dispatch_info};
use crate::{Balance, Runtime, RuntimeCall, TransactionPayment, Weight};
use frame_support::dispatch::{DispatchInfo, GetDispatchInfo};
use pallet_subtensor::Call as SubtensorCall;
use pallet_subtensor::staking::BasketFlushWork;
use pallet_subtensor_proxy::Call as ProxyCall;
use pallet_subtensor_utility::Call as UtilityCall;
use pallet_transaction_payment::{FeeDetails, RuntimeDispatchInfo};
use sp_std::vec;
use sp_std::vec::Vec;
use subtensor_runtime_common::Token;
/// Fee allowance only. The admission cap and the execution scan remain 256 keys.
pub const STAKING_HOTKEYS_FEE_ALLOWANCE: u32 = 4;
/// Fee allowance for root claims, in claim units (hotkeys plus basket holding rows), the
/// same count the admission envelope uses. Admission and execution keep the full 256-unit
/// (coldkey-wide) or 129-unit (single hotkey) envelope plus the flat flush allowance.
pub const ROOT_CLAIM_FEE_ALLOWANCE: u32 = STAKING_HOTKEYS_FEE_ALLOWANCE;
/// Weight a claim is charged for: `units` claim units plus the flush work one hotkey with
/// `units` queued credits and `units` holdings can do (`4Q + 2H` quotes, `Q` rows; see
/// `basket_flush_work_bound`).
pub fn root_claim_fee_weight(units: u32) -> Weight {
let rows = u64::from(units);
pallet_subtensor::Pallet::<Runtime>::root_claim_weight_for_work(
units,
BasketFlushWork::new(rows.saturating_mul(6), rows),
)
}
fn root_claim_discount(limit: u32) -> Weight {
pallet_subtensor::Pallet::<Runtime>::root_claim_declared_weight_for(limit)
.saturating_sub(root_claim_fee_weight(ROOT_CLAIM_FEE_ALLOWANCE))
}
/// Fee allowance for `stake_into_basket` and `swap_basket`, in fund rows. Both declare a
/// `MAX_BASKET_ROWS` (256) envelope plus the flat flush allowance for admission and
/// refund to the rows they touched; the fee is capped as if the fund had this many rows
/// (and as many queued credits to flush), the same figure the claim allowance uses.
pub const BASKET_FEE_ALLOWANCE: u32 = STAKING_HOTKEYS_FEE_ALLOWANCE;
/// Flush work one hotkey with `rows` queued credits and `rows` holdings can do (`4Q + 2H`
/// quotes, `Q` rows; see `basket_flush_work_bound`), the same shape the claim allowance
/// prices.
fn basket_fee_flush_weight(rows: u32) -> Weight {
let rows = u64::from(rows);
pallet_subtensor::Pallet::<Runtime>::basket_flush_weight(BasketFlushWork::new(
rows.saturating_mul(6),
rows,
))
}
/// Weight a `swap_basket` is charged for: one trade over [`BASKET_FEE_ALLOWANCE`] rows
/// (the NAV sweep, both AMM legs and the two re-quotes) plus that many rows of flush.
pub fn swap_basket_fee_weight() -> Weight {
pallet_subtensor::Pallet::<Runtime>::swap_basket_weight(u64::from(BASKET_FEE_ALLOWANCE))
.saturating_add(basket_fee_flush_weight(BASKET_FEE_ALLOWANCE))
}
/// Weight a `stake_into_basket` is charged for: one deployment across
/// [`BASKET_FEE_ALLOWANCE`] slots (each slot may open one holding, so twice as many rows
/// are swept) plus that many rows of flush.
pub fn stake_into_basket_fee_weight() -> Weight {
let rows = u64::from(BASKET_FEE_ALLOWANCE);
pallet_subtensor::Pallet::<Runtime>::stake_into_basket_weight(rows, rows.saturating_mul(2))
.saturating_add(basket_fee_flush_weight(BASKET_FEE_ALLOWANCE))
}
fn basket_discount(call: &RuntimeCall) -> Weight {
match call {
RuntimeCall::SubtensorModule(SubtensorCall::swap_basket { .. }) => {
pallet_subtensor::Pallet::<Runtime>::swap_basket_declared_weight()
.saturating_sub(swap_basket_fee_weight())
}
RuntimeCall::SubtensorModule(SubtensorCall::stake_into_basket { .. }) => {
pallet_subtensor::Pallet::<Runtime>::stake_into_basket_declared_weight()
.saturating_sub(stake_into_basket_fee_weight())
}
_ => Weight::zero(),
}
}
/// Declared `call_weight` (ref_time) each call quoted on spec 459: `payment_queryInfo`
/// on finney at block 9 088 000 via the archive node, one unit of every argument.
/// `payment_queryInfo` reports the declared call weight, so these are the fee weights
/// users paid before the 464-466 benchmark regen and security envelopes. A call listed
/// here is never billed more than this (the 467 halving then applies on top). Calls
/// that did not exist on 459, or that got cheaper, are not listed.
pub fn fee_weight_cap_459(call: &RuntimeCall) -> Option<Weight> {
let ref_time: u64 = match call {
RuntimeCall::SubtensorModule(call) => match call {
SubtensorCall::burned_register { .. } => 4_175_124_000,
SubtensorCall::register_limit { .. } => 4_155_012_000,
SubtensorCall::register { .. } => 4_174_683_000,
SubtensorCall::swap_hotkey { .. } | SubtensorCall::swap_hotkey_v2 { .. } => {
672_080_000_000
}
SubtensorCall::swap_coldkey { .. } => 27_790_000_000,
SubtensorCall::swap_coldkey_announced { .. } => 26_760_000_000,
SubtensorCall::terminate_lease { .. } => 64_056_000_937,
SubtensorCall::stake_into_basket { .. } => 147_420_000_000,
SubtensorCall::claim_root { .. } | SubtensorCall::claim_root_with_hotkey { .. } => {
249_916_000_000
}
SubtensorCall::unstake_all { .. } => 225_000_000,
SubtensorCall::unstake_all_alpha { .. } => 4_069_000_000,
SubtensorCall::remove_stake { .. } => 2_409_000_000,
SubtensorCall::remove_stake_limit { .. } => 2_472_000_000,
SubtensorCall::remove_stake_full_limit { .. } => 2_500_000_000,
SubtensorCall::move_stake { .. } => 1_370_000_000,
SubtensorCall::move_stake_limit { .. } => 4_194_000_000,
SubtensorCall::transfer_stake { .. } => 1_427_000_000,
SubtensorCall::transfer_stake_and_hotkey { .. } => 1_506_000_000,
SubtensorCall::swap_stake { .. } => 4_073_000_000,
SubtensorCall::swap_stake_limit { .. } => 4_169_000_000,
SubtensorCall::burn_alpha { .. } => 912_000_000,
SubtensorCall::recycle_alpha { .. } => 1_133_000_000,
SubtensorCall::set_min_collateral { .. } => 281_000_000,
_ => return None,
},
_ => return None,
};
// Only ref_time prices; leave proof_size alone.
Some(Weight::from_parts(ref_time, u64::MAX))
}
/// Number of `StakingHotkeys` walks the declaration of `call` includes.
fn staking_walks(call: &RuntimeCall) -> u64 {
match call {
RuntimeCall::SubtensorModule(
SubtensorCall::remove_stake { .. }
| SubtensorCall::remove_stake_limit { .. }
| SubtensorCall::remove_stake_full_limit { .. }
| SubtensorCall::move_stake { .. }
| SubtensorCall::move_stake_limit { .. }
| SubtensorCall::transfer_stake { .. }
| SubtensorCall::transfer_stake_and_hotkey { .. }
| SubtensorCall::swap_stake { .. }
| SubtensorCall::swap_stake_limit { .. },
) => 1,
RuntimeCall::SubtensorModule(
SubtensorCall::unstake_all { .. } | SubtensorCall::unstake_all_alpha { .. },
) => {
// Match unstake_all_worst_case_work exactly, including small networks.
u64::from(pallet_subtensor::TotalNetworks::<Runtime>::get())
.min(u64::from(pallet_subtensor::MAX_UNSTAKE_ALL_LEGS))
}
_ => 0,
}
}
fn is_bulk_unstake(call: &RuntimeCall) -> bool {
matches!(
call,
RuntimeCall::SubtensorModule(
SubtensorCall::unstake_all { .. } | SubtensorCall::unstake_all_alpha { .. }
)
)
}
fn staking_scan_discount(call: &RuntimeCall) -> Weight {
let subsidized_keys =
pallet_subtensor::MAX_STAKING_HOTKEYS.saturating_sub(STAKING_HOTKEYS_FEE_ALLOWANCE);
<Runtime as frame_system::Config>::DbWeight::get().reads(
u64::from(subsidized_keys)
.saturating_mul(pallet_subtensor::STAKING_HOTKEYS_WALK_READS_PER_ENTRY)
.saturating_mul(staking_walks(call)),
)
}
fn claim_discount(call: &RuntimeCall) -> Weight {
match call {
RuntimeCall::SubtensorModule(SubtensorCall::claim_root { .. }) => {
root_claim_discount(pallet_subtensor::Pallet::<Runtime>::root_claim_declared_work())
}
RuntimeCall::SubtensorModule(SubtensorCall::claim_root_with_hotkey { .. }) => {
root_claim_discount(
pallet_subtensor::Pallet::<Runtime>::root_claim_hotkey_declared_work(),
)
}
_ => Weight::zero(),
}
}
/// The weight one leaf call is billed for, given its declared `call_weight`.
pub fn leaf_fee_weight(call: &RuntimeCall, declared: Weight) -> Weight {
let fee_weight = declared
.saturating_sub(staking_scan_discount(call))
.saturating_sub(claim_discount(call))
.saturating_sub(basket_discount(call));
match fee_weight_cap_459(call) {
Some(cap) => fee_weight.min(cap),
None => fee_weight,
}
}
/// Fee-bearing leaves of `call`. Only descend through wrappers whose declared weight
/// includes their inner calls' weights; in particular, never descend into `with_weight`.
fn leaves(call: &RuntimeCall) -> Vec<&RuntimeCall> {
let mut pending = vec![call];
let mut out = Vec::new();
while let Some(call) = pending.pop() {
match call {
RuntimeCall::Utility(
UtilityCall::batch { calls }
| UtilityCall::batch_all { calls }
| UtilityCall::force_batch { calls },
) => pending.extend(calls),
RuntimeCall::Utility(UtilityCall::if_else { main, fallback }) => {
pending.push(main);
pending.push(fallback);
}
RuntimeCall::Utility(
UtilityCall::as_derivative { call, .. }
| UtilityCall::dispatch_as { call, .. }
| UtilityCall::dispatch_as_fallible { call, .. },
)
| RuntimeCall::Proxy(
ProxyCall::proxy { call, .. } | ProxyCall::proxy_announced { call, .. },
) => pending.push(call),
_ => out.push(call),
}
}
out
}
impl FeeWeightDiscount<RuntimeCall> for Runtime {
fn fee_weight_discount(call: &RuntimeCall, info: &DispatchInfo) -> Weight {
let leaves = leaves(call);
if let [leaf] = leaves.as_slice()
&& core::ptr::eq(*leaf, call)
{
return info
.call_weight
.saturating_sub(leaf_fee_weight(call, info.call_weight));
}
// Wrapped leaves: their declared weight is re-derived here. This repeats the
// computation FRAME already did for the wrapper's own weight, so every storage
// read hits the overlay cache.
leaves.iter().fold(Weight::zero(), |discount, leaf| {
let declared = leaf.get_dispatch_info().call_weight;
discount.saturating_add(declared.saturating_sub(leaf_fee_weight(leaf, declared)))
})
}
fn fee_discount_overhead(call: &RuntimeCall) -> Weight {
// One TotalNetworks read in validation and another in preparation. The
// declaration already prices the scan itself at the full execution cap.
// Claim discounts and 459 caps are pure arithmetic on the declared weights.
if leaves(call).into_iter().any(is_bulk_unstake) {
<Runtime as frame_system::Config>::DbWeight::get().reads(2)
} else {
Weight::zero()
}
}
}
pub fn query_fee_details(
call: &RuntimeCall,
info: &DispatchInfo,
len: u32,
is_bare: bool,
) -> FeeDetails<Balance> {
if is_bare {
FeeDetails {
inclusion_fee: None,
tip: Balance::ZERO,
}
} else {
TransactionPayment::compute_fee_details(
len,
&fee_dispatch_info(info, Runtime::fee_weight_discount(call, info)),
Balance::ZERO,
)
}
}
pub fn query_info(
call: &RuntimeCall,
info: &DispatchInfo,
len: u32,
is_bare: bool,
) -> RuntimeDispatchInfo<Balance> {
RuntimeDispatchInfo {
// RPC callers still need the real weight for block capacity estimates.
weight: info.total_weight(),
class: info.class,
partial_fee: query_fee_details(call, info, len, is_bare).final_fee(),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::transaction_payment_wrapper::ChargeTransactionPaymentWrapper;
use crate::{
Balances, BuildStorage, RuntimeGenesisConfig, RuntimeOrigin, SubtensorModule, System,
};
use frame_support::{
assert_ok,
dispatch::{GetDispatchInfo, Pays, PostDispatchInfo},
};
use pallet_subtensor::weights::WeightInfo;
use sp_runtime::traits::{DispatchTransaction, TransactionExtension};
use subtensor_runtime_common::{AccountId, AlphaBalance, NetUid};
fn signer() -> AccountId {
AccountId::from([1_u8; 32])
}
fn new_test_ext() -> sp_io::TestExternalities {
let mut ext: sp_io::TestExternalities = RuntimeGenesisConfig {
balances: pallet_balances::GenesisConfig {
balances: vec![(signer(), Balance::new(1_000_000_000))],
dev_accounts: None,
},
..Default::default()
}
.build_storage()
.unwrap()
.into();
ext.execute_with(|| System::set_block_number(1));
ext
}
fn remove_stake() -> RuntimeCall {
RuntimeCall::SubtensorModule(SubtensorCall::remove_stake {
hotkey: AccountId::from([2_u8; 32]),
netuid: NetUid::from(1),
amount_unstaked: AlphaBalance::new(1_000_000),
})
}
/// Discount for a call priced from its own dispatch info.
fn discount(call: &RuntimeCall) -> Weight {
Runtime::fee_weight_discount(call, &call.get_dispatch_info())
}
/// Cap every listed call's fee weight at its 459 declaration.
fn capped(call: &RuntimeCall, fee_weight: Weight) -> Weight {
match fee_weight_cap_459(call) {
Some(cap) => fee_weight.min(cap),
None => fee_weight,
}
}
fn remove_base() -> Weight {
<Runtime as pallet_subtensor::Config>::WeightInfo::remove_stake().saturating_add(
<Runtime as pallet_subtensor::Config>::WeightInfo::check_coldkey_swap_extension(),
)
}
fn scan(keys: u64) -> Weight {
<Runtime as frame_system::Config>::DbWeight::get()
.reads(keys.saturating_mul(14).saturating_add(1))
}
fn claim_root_with_hotkey() -> RuntimeCall {
RuntimeCall::SubtensorModule(SubtensorCall::claim_root_with_hotkey {
hotkey: AccountId::from([2_u8; 32]),
})
}
fn claim_root() -> RuntimeCall {
RuntimeCall::SubtensorModule(SubtensorCall::claim_root {
subnets: Default::default(),
})
}
fn extension() -> Weight {
<Runtime as pallet_subtensor::Config>::WeightInfo::check_coldkey_swap_extension()
}
/// Each claim call with the envelope its dispatch declares.
fn claim_cases() -> [(RuntimeCall, Weight); 2] {
[
(
claim_root_with_hotkey(),
SubtensorModule::root_claim_declared_weight_for(
SubtensorModule::root_claim_hotkey_declared_work(),
),
),
(
claim_root(),
SubtensorModule::root_claim_declared_weight_for(
SubtensorModule::root_claim_declared_work(),
),
),
]
}
#[test]
fn claim_fee_estimate_prices_the_allowance_and_reports_full_execution_weight() {
new_test_ext().execute_with(|| {
let fee_weight = root_claim_fee_weight(ROOT_CLAIM_FEE_ALLOWANCE);
assert_eq!(SubtensorModule::root_claim_declared_work(), 256);
assert_eq!(SubtensorModule::root_claim_hotkey_declared_work(), 129);
for (call, declared) in claim_cases() {
assert!(fee_weight.all_lt(declared));
let info = call.get_dispatch_info();
assert_eq!(info.call_weight, declared.saturating_add(extension()));
let discount = discount(&call);
assert_eq!(discount, declared.saturating_sub(fee_weight));
assert!(discount.all_lt(info.call_weight));
assert_eq!(Runtime::fee_discount_overhead(&call), Weight::zero());
let expected_info = DispatchInfo {
call_weight: fee_weight.saturating_add(extension()),
..info
};
let quote = query_info(&call, &info, 100, false);
assert_eq!(quote.weight, info.total_weight());
assert_eq!(
quote.partial_fee,
TransactionPayment::compute_fee(100, &expected_info, Balance::ZERO)
);
assert!(
quote.partial_fee < TransactionPayment::compute_fee(100, &info, Balance::ZERO)
);
}
});
}
#[test]
fn claim_payment_refunds_light_work_and_caps_heavy_work() {
let light = <Runtime as pallet_subtensor::Config>::WeightInfo::claim_root(1)
.saturating_add(<Runtime as pallet_subtensor::Config>::WeightInfo::claim_root_scan(2));
for (call, declared) in claim_cases() {
for execution_weight in [light, declared] {
new_test_ext().execute_with(|| {
let tip = Balance::new(1_000_000);
let payment = ChargeTransactionPaymentWrapper::<Runtime>::new(tip);
let info = DispatchInfo {
extension_weight: payment.weight(&call),
..call.get_dispatch_info()
};
let fee_info = fee_dispatch_info(&info, discount(&call));
let charged_info = DispatchInfo {
call_weight: execution_weight.min(fee_info.call_weight),
..info
};
let before = Balances::free_balance(signer());
let post = payment
.test_run(
RuntimeOrigin::signed(signer()),
&call,
&info,
100,
0,
|_| {
Ok(PostDispatchInfo {
actual_weight: Some(execution_weight),
pays_fee: Pays::Yes,
})
},
)
.unwrap()
.unwrap();
let charged = before.saturating_sub(Balances::free_balance(signer()));
assert_eq!(
charged,
TransactionPayment::compute_fee(100, &charged_info, tip)
);
let capped = TransactionPayment::compute_fee(100, &fee_info, tip);
if execution_weight == light {
assert!(charged < capped, "light claims refund below the allowance");
} else {
assert_eq!(charged, capped, "heavy claims pay the allowance only");
}
assert_eq!(
post.actual_weight,
Some(execution_weight.saturating_add(info.extension_weight)),
"fee discount must not reclaim block execution capacity"
);
});
}
}
}
#[test]
fn batched_claims_receive_each_inner_discount() {
new_test_ext().execute_with(|| {
let hotkey_discount = discount(&claim_root_with_hotkey());
let coldkey_discount = discount(&claim_root());
assert!(hotkey_discount.all_lt(coldkey_discount));
let call = RuntimeCall::Proxy(ProxyCall::proxy {
real: signer().into(),
force_proxy_type: None,
call: Box::new(RuntimeCall::Utility(UtilityCall::batch_all {
calls: vec![
claim_root_with_hotkey(),
claim_root_with_hotkey(),
claim_root(),
],
})),
});
let discount = discount(&call);
assert_eq!(
discount,
hotkey_discount
.saturating_mul(2)
.saturating_add(coldkey_discount)
);
let info = call.get_dispatch_info();
assert!(discount.all_lt(info.call_weight));
assert_eq!(
fee_dispatch_info(&info, discount).call_weight,
info.call_weight.saturating_sub(discount)
);
assert_eq!(Runtime::fee_discount_overhead(&call), Weight::zero());
});
}
fn swap_basket() -> RuntimeCall {
RuntimeCall::SubtensorModule(SubtensorCall::swap_basket {
hotkey: AccountId::from([2_u8; 32]),
origin_netuid: NetUid::from(1),
destination_netuid: NetUid::from(2),
amount: AlphaBalance::new(1_000_000),
min_amount_out: 0,
})
}
fn stake_into_basket() -> RuntimeCall {
RuntimeCall::SubtensorModule(SubtensorCall::stake_into_basket {
hotkey: AccountId::from([2_u8; 32]),
amount_staked: Balance::new(1_000_000),
})
}
/// Each basket call with the envelope its dispatch declares and the weight it is billed.
fn basket_cases() -> [(RuntimeCall, Weight, Weight); 2] {
[
(
swap_basket(),
SubtensorModule::swap_basket_declared_weight(),
swap_basket_fee_weight(),
),
(
stake_into_basket(),
SubtensorModule::stake_into_basket_declared_weight(),
stake_into_basket_fee_weight(),
),
]
}
#[test]
fn basket_fee_estimate_prices_the_allowance_and_reports_full_execution_weight() {
new_test_ext().execute_with(|| {
assert_eq!(BASKET_FEE_ALLOWANCE, 4);
for (call, declared, fee_weight) in basket_cases() {
assert!(fee_weight.all_lt(declared));
let info = call.get_dispatch_info();
assert_eq!(info.call_weight, declared.saturating_add(extension()));
let discount = discount(&call);
assert_eq!(discount, declared.saturating_sub(fee_weight));
assert!(discount.all_lt(info.call_weight));
assert_eq!(Runtime::fee_discount_overhead(&call), Weight::zero());
let expected_info = DispatchInfo {
call_weight: capped(&call, fee_weight.saturating_add(extension())),
..info
};
let quote = query_info(&call, &info, 100, false);
assert_eq!(quote.weight, info.total_weight());
assert_eq!(
quote.partial_fee,
TransactionPayment::compute_fee(100, &expected_info, Balance::ZERO)
);
assert!(
quote.partial_fee < TransactionPayment::compute_fee(100, &info, Balance::ZERO)
);
}
});
}
/// A basket call that fails after reporting its actual weight is charged that weight
/// when it is below the allowance, and the allowance otherwise — never the envelope.
#[test]
fn failed_basket_calls_pay_their_reported_work_capped_at_the_allowance() {
let light = SubtensorModule::swap_basket_precheck_weight();
for (call, declared, fee_weight) in basket_cases() {
for execution_weight in [light, fee_weight, declared] {
new_test_ext().execute_with(|| {
let tip = Balance::new(1_000_000);
let payment = ChargeTransactionPaymentWrapper::<Runtime>::new(tip);
let info = DispatchInfo {
extension_weight: payment.weight(&call),
..call.get_dispatch_info()
};
let fee_info = fee_dispatch_info(&info, discount(&call));
let charged_info = DispatchInfo {
call_weight: execution_weight.min(fee_info.call_weight),
..info
};
let before = Balances::free_balance(signer());
let result = payment
.test_run(
RuntimeOrigin::signed(signer()),
&call,
&info,
100,
0,
|_| {
Err(SubtensorModule::fail_with_weight(
sp_runtime::DispatchError::Other("failed"),
execution_weight,
))
},
)
.unwrap();
assert!(result.is_err());
let charged = before.saturating_sub(Balances::free_balance(signer()));
assert_eq!(
charged,
TransactionPayment::compute_fee(100, &charged_info, tip)
);
let allowance = TransactionPayment::compute_fee(100, &fee_info, tip);
if execution_weight == light {
assert!(charged < allowance, "a refused call pays its pre-checks");
} else if execution_weight == declared {
assert_eq!(charged, allowance, "heavy failures pay the allowance only");
} else {
assert!(charged <= allowance, "never above the allowance");
}
});
}
}
}
#[test]
fn fee_estimate_prices_four_keys_and_reports_full_execution_weight() {
new_test_ext().execute_with(|| {
let call = remove_stake();
let info = call.get_dispatch_info();
assert_eq!(pallet_subtensor::MAX_STAKING_HOTKEYS, 256);
assert_eq!(info.call_weight, remove_base().saturating_add(scan(256)));
let expected_info = DispatchInfo {
call_weight: capped(&call, remove_base().saturating_add(scan(4))),
..info
};
assert_eq!(
discount(&call),
info.call_weight.saturating_sub(expected_info.call_weight)
);
let quote = query_info(&call, &info, 100, false);
assert_eq!(quote.weight, info.total_weight());
assert_eq!(
quote.partial_fee,
TransactionPayment::compute_fee(100, &expected_info, Balance::ZERO)
);
assert_eq!(
query_info(&call, &info, 100, true).partial_fee,
Balance::ZERO
);
assert!(
query_fee_details(&call, &info, 100, true)
.inclusion_fee
.is_none()
);
});
}
#[test]
fn payment_caps_fees_without_refunding_execution_weight_or_tips() {
for keys in [1_u64, 4, 256] {
new_test_ext().execute_with(|| {
let call = remove_stake();
let tip = Balance::new(1_000_000);
let payment = ChargeTransactionPaymentWrapper::<Runtime>::new(tip);
let info = DispatchInfo {
extension_weight: payment.weight(&call),
..call.get_dispatch_info()
};
let expected_info = DispatchInfo {
call_weight: capped(&call, remove_base().saturating_add(scan(keys.min(4)))),
..info
};
let before = Balances::free_balance(signer());
let execution_weight = remove_base().saturating_add(scan(keys));
let post = payment
.test_run(
RuntimeOrigin::signed(signer()),
&call,
&info,
100,
0,
|_| {
Ok(PostDispatchInfo {
actual_weight: Some(execution_weight),
pays_fee: Pays::Yes,
})
},
)
.unwrap()
.unwrap();
assert_eq!(
before.saturating_sub(Balances::free_balance(signer())),
TransactionPayment::compute_fee(100, &expected_info, tip)
);
assert_eq!(
post.actual_weight,
Some(execution_weight.saturating_add(info.extension_weight)),
"fee discount must not reclaim block execution capacity"
);
});
}
}
#[test]
fn failed_calls_still_pay_only_the_discounted_declaration() {
new_test_ext().execute_with(|| {
let call = remove_stake();
let info = call.get_dispatch_info();
let expected = query_info(&call, &info, 100, false).partial_fee;
let before = Balances::free_balance(signer());
let result = ChargeTransactionPaymentWrapper::<Runtime>::new(Balance::ZERO).test_run(
RuntimeOrigin::signed(signer()),
&call,
&info,
100,
0,
|_| Err(sp_runtime::DispatchError::Other("failed").into()),
);
assert!(result.unwrap().is_err());
assert_eq!(
before.saturating_sub(Balances::free_balance(signer())),
expected
);
});
}
#[test]
fn zero_fee_post_dispatch_is_preserved() {
new_test_ext().execute_with(|| {
let call = remove_stake();
let info = call.get_dispatch_info();
let before = Balances::free_balance(signer());
assert_ok!(
ChargeTransactionPaymentWrapper::<Runtime>::new(Balance::ZERO).test_run(
RuntimeOrigin::signed(signer()),
&call,
&info,
100,
0,
|_| Ok(PostDispatchInfo {
actual_weight: None,
pays_fee: Pays::No
})
)
);
assert_eq!(before, Balances::free_balance(signer()));
});
}
#[test]
fn nested_batches_and_proxies_receive_each_inner_discount() {
new_test_ext().execute_with(|| {
let inner = remove_stake();
let one = discount(&inner);
let call = RuntimeCall::Proxy(ProxyCall::proxy {
real: signer().into(),
force_proxy_type: None,
call: Box::new(RuntimeCall::Utility(UtilityCall::batch_all {
calls: vec![inner.clone(), inner.clone()],
})),
});
assert_eq!(discount(&call), one.saturating_mul(2));
let info = call.get_dispatch_info();
let fee_info = fee_dispatch_info(&info, discount(&call));
assert_eq!(
fee_info.call_weight,
info.call_weight.saturating_sub(one.saturating_mul(2))
);
let overridden = RuntimeCall::Utility(UtilityCall::with_weight {
call: Box::new(inner),
weight: Weight::from_parts(1, 0),
});
assert_eq!(discount(&overridden), Weight::zero());
});
}
#[test]
fn bulk_discount_matches_the_declared_network_envelope() {
new_test_ext().execute_with(|| {
let call =
RuntimeCall::SubtensorModule(SubtensorCall::unstake_all { hotkey: signer() });
let per_walk = scan(256).saturating_sub(scan(4));
for networks in [0_u16, 1, 2, 16, 100] {
pallet_subtensor::TotalNetworks::<Runtime>::put(networks);
let legs = u64::from(networks).min(16);
let declared = call.get_dispatch_info().call_weight;
let fee_weight = capped(&call, declared.saturating_sub(per_walk.saturating_mul(legs)));
assert_eq!(discount(&call), declared.saturating_sub(fee_weight));
assert!(fee_weight.ref_time() <= 225_000_000, "unstake_all bills at most its 459 weight");
assert_eq!(
call.get_dispatch_info().call_weight,
SubtensorModule::unstake_all_declared_weight().saturating_add(
<Runtime as pallet_subtensor::Config>::WeightInfo::check_coldkey_swap_extension(),
)
);
}
});
}
/// Calls whose declared weight grew after 459 (benchmark regen or security envelopes).
fn regressed_calls() -> Vec<RuntimeCall> {
let other = AccountId::from([2_u8; 32]);
vec![
RuntimeCall::SubtensorModule(SubtensorCall::burned_register {
netuid: NetUid::from(1),
hotkey: other.clone(),
}),
RuntimeCall::SubtensorModule(SubtensorCall::register_limit {
netuid: NetUid::from(1),
hotkey: other.clone(),
limit_price: 1,
}),
RuntimeCall::SubtensorModule(SubtensorCall::swap_hotkey {
hotkey: other.clone(),
new_hotkey: signer(),
netuid: None,
}),
RuntimeCall::SubtensorModule(SubtensorCall::swap_coldkey {
old_coldkey: other.clone(),
new_coldkey: signer(),
swap_cost: Balance::new(1),
}),
RuntimeCall::SubtensorModule(SubtensorCall::stake_into_basket {
hotkey: other.clone(),
amount_staked: Balance::new(1),
}),
RuntimeCall::SubtensorModule(SubtensorCall::unstake_all_alpha {
hotkey: other.clone(),
}),
RuntimeCall::SubtensorModule(SubtensorCall::recycle_alpha {
hotkey: other.clone(),
amount: AlphaBalance::new(1),
netuid: NetUid::from(1),
}),
RuntimeCall::SubtensorModule(SubtensorCall::burn_alpha {
hotkey: other.clone(),
amount: AlphaBalance::new(1),
netuid: NetUid::from(1),
}),
RuntimeCall::SubtensorModule(SubtensorCall::set_min_collateral {
netuid: NetUid::from(1),
hotkey: other,
min_locked: AlphaBalance::new(1),
}),
remove_stake(),
claim_root_with_hotkey(),
]
}
#[test]
fn regressed_calls_bill_at_most_their_459_weight() {
new_test_ext().execute_with(|| {
pallet_subtensor::TotalNetworks::<Runtime>::put(100);
for call in regressed_calls() {
let info = call.get_dispatch_info();
let cap = fee_weight_cap_459(&call).unwrap();
let fee_weight = info.call_weight.saturating_sub(discount(&call));
assert!(
fee_weight.ref_time() <= cap.ref_time(),
"{call:?} bills {} > 459 cap {}",
fee_weight.ref_time(),
cap.ref_time()
);
assert_eq!(cap.proof_size(), u64::MAX, "the cap prices ref_time only");
let expected_info = DispatchInfo {
call_weight: fee_weight,
..info
};
let quote = query_info(&call, &info, 100, false);
assert_eq!(quote.weight, info.total_weight());
assert_eq!(
quote.partial_fee,
TransactionPayment::compute_fee(100, &expected_info, Balance::ZERO)
);
}
});
}
#[test]
fn batched_regressed_calls_are_capped_leaf_by_leaf() {
new_test_ext().execute_with(|| {
let calls = regressed_calls();
let expected = calls.iter().fold(Weight::zero(), |acc, call| {
acc.saturating_add(discount(call))
});
let batch = RuntimeCall::Utility(UtilityCall::batch_all { calls });
let info = batch.get_dispatch_info();
let batch_discount = Runtime::fee_weight_discount(&batch, &info);
assert_eq!(batch_discount, expected);
assert!(batch_discount.all_lt(info.call_weight));
});
}
#[test]
fn add_stake_and_unrelated_fees_are_unchanged() {
new_test_ext().execute_with(|| {
let calls = [
RuntimeCall::SubtensorModule(SubtensorCall::add_stake {
hotkey: signer(),
netuid: NetUid::from(1),
amount_staked: Balance::new(1_000_000),
}),
RuntimeCall::System(frame_system::Call::remark { remark: vec![] }),
];
for call in calls {
let info = call.get_dispatch_info();
assert_eq!(discount(&call), Weight::zero());
assert_eq!(Runtime::fee_discount_overhead(&call), Weight::zero());
assert_eq!(
query_info(&call, &info, 100, false).partial_fee,
TransactionPayment::compute_fee(100, &info, Balance::ZERO)
);
}
});
}
}