use crate::{NORMAL_DISPATCH_BASE_PRIORITY, OPERATIONAL_DISPATCH_PRIORITY, Weight}; use codec::{Decode, DecodeWithMemTracking, Encode}; use frame_election_provider_support::private::sp_arithmetic::traits::SaturatedConversion; use frame_support::dispatch::{DispatchClass, DispatchInfo, PostDispatchInfo}; use frame_support::pallet_prelude::TypeInfo; use frame_support::traits::{Get, IsSubType, IsType}; use pallet_subtensor_proxy as pallet_proxy; use pallet_subtensor_utility as pallet_utility; use pallet_transaction_payment::OnChargeTransaction; use pallet_transaction_payment::{ChargeTransactionPayment, Config, Pre, Val}; use sp_runtime::DispatchResult; use sp_runtime::traits::{ AsSystemOriginSigner, DispatchInfoOf, DispatchOriginOf, Dispatchable, Implication, PostDispatchInfoOf, StaticLookup, TransactionExtension, TransactionExtensionMetadata, ValidateResult, Zero, }; use sp_runtime::transaction_validity::{ TransactionPriority, TransactionSource, TransactionValidity, TransactionValidityError, }; use sp_std::boxed::Box; use sp_std::vec::Vec; use subtensor_macros::freeze_struct; type BalanceOf = <::OnChargeTransaction as OnChargeTransaction>::Balance; type RuntimeCallOf = ::RuntimeCall; type RuntimeOriginOf = ::RuntimeOrigin; type AccountIdOf = ::AccountId; type LookupOf = ::Lookup; /// Runtime-supplied policy: which calls have their fee charged to the signing /// hotkey's coldkey. Keeping the concrete list in the runtime (see `fee_filters`) /// lets this generic extension stay free of a hardcoded allow-list. pub trait ColdkeyFeeCallFilter { fn charges_coldkey(call: &Call) -> bool; } #[freeze_struct("f003cde1f9da4a90")] #[derive(Encode, Decode, DecodeWithMemTracking, Clone, Eq, PartialEq, TypeInfo)] #[scale_info(skip_type_params(T))] pub struct ChargeTransactionPaymentWrapper { inner: ChargeTransactionPayment, } impl core::fmt::Debug for ChargeTransactionPaymentWrapper { #[cfg(feature = "std")] fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { write!(f, "ChargeTransactionPaymentWrapper",) } #[cfg(not(feature = "std"))] fn fmt(&self, _: &mut core::fmt::Formatter) -> core::fmt::Result { Ok(()) } } impl ChargeTransactionPaymentWrapper where T::RuntimeCall: Dispatchable, BalanceOf: Send + Sync, { pub fn new(fee: BalanceOf) -> Self { let inner = ChargeTransactionPayment::::from(fee); Self { inner } } } impl ChargeTransactionPaymentWrapper where RuntimeCallOf: IsSubType> + IsSubType>, T: ColdkeyFeeCallFilter>, RuntimeOriginOf: AsSystemOriginSigner> + Clone, { /// Extract (real, delegate, inner_call) from a `proxy` call. /// `signer` is used as the delegate since it is implicit (the caller). /// `proxy_announced` is intentionally not handled here; fee propagation /// only applies to `proxy` calls to keep the logic simple. fn extract_proxy_parts<'a>( call: &'a RuntimeCallOf, signer: &AccountIdOf, ) -> Option<( AccountIdOf, AccountIdOf, &'a Box<::RuntimeCall>, )> { match call.is_sub_type()? { pallet_proxy::Call::proxy { real, call, .. } => { let real = LookupOf::::lookup(real.clone()).ok()?; Some((real, signer.clone(), call)) } _ => None, } } /// Determine who should pay the transaction fee for a proxy call. /// /// Follows the RealPaysFee chain up to 2 levels deep: /// - Case 1: `proxy(real=A, call)` → A pays if `RealPaysFee` /// - Case 2: `proxy(real=B, proxy(real=A, call))` → A pays if both /// `RealPaysFee` and `RealPaysFee` are set; B pays if only the former. /// - Case 3: `proxy(real=B, batch([proxy(real=A, ..), ..]))` → A pays if /// `RealPaysFee`, all batch items are proxy calls with the same real A, /// and `RealPaysFee` is set; B pays if only the first condition holds. /// /// Returns `None` if the signer should pay (no RealPaysFee opt-in). fn extract_real_fee_payer( call: &RuntimeCallOf, origin: &RuntimeOriginOf, ) -> Option> { let signer = origin.as_system_origin_signer()?; let (outer_real, delegate, inner_call) = Self::extract_proxy_parts(call, signer)?; // Check if the outer real account has opted in to pay for the delegate. if !pallet_proxy::Pallet::::is_real_pays_fee(&outer_real, &delegate) { return None; } // outer_real pays. Try to push the fee deeper into nested proxy structures. let inner_call: &RuntimeCallOf = (*inner_call).as_ref().into_ref(); // Case 2: inner call is another proxy call. if let Some(inner_payer) = Self::extract_inner_proxy_payer(inner_call, &outer_real) { return Some(inner_payer); } // Case 3: inner call is a batch of proxy calls with the same real. if let Some(batch_payer) = Self::extract_batch_proxy_payer(inner_call, &outer_real) { return Some(batch_payer); } // Case 1: simple proxy, outer_real pays. Some(outer_real) } /// Check if an inner call is a proxy call where the inner real has opted in to pay. /// `outer_real` is used as the implicit delegate for `proxy` calls. fn extract_inner_proxy_payer( inner_call: &RuntimeCallOf, outer_real: &AccountIdOf, ) -> Option> { let (inner_real, inner_delegate, _call) = Self::extract_proxy_parts(inner_call, outer_real)?; if pallet_proxy::Pallet::::is_real_pays_fee(&inner_real, &inner_delegate) { Some(inner_real) } else { None } } /// Check if an inner call is a batch where ALL items are proxy calls with the same real /// account, and that real account has opted in to pay. /// `outer_real` is used as the implicit delegate for `proxy` calls within the batch. fn extract_batch_proxy_payer( inner_call: &RuntimeCallOf, outer_real: &AccountIdOf, ) -> Option> { let calls: &Vec<::RuntimeCall> = match inner_call.is_sub_type()? { pallet_utility::Call::batch { calls } | pallet_utility::Call::batch_all { calls } | pallet_utility::Call::force_batch { calls } => calls, _ => return None, }; if calls.is_empty() { return None; } let mut common_real: Option> = None; for call in calls.iter() { let call_ref: &RuntimeCallOf = call.into_ref(); let (inner_real, inner_delegate, _) = Self::extract_proxy_parts(call_ref, outer_real)?; match &common_real { None => { // Check RealPaysFee once on the first item and memoize. For `proxy` // calls the delegate is always `outer_real`, so a single read covers // the entire batch; for `proxy_announced` it uses the explicit delegate. if !pallet_proxy::Pallet::::is_real_pays_fee(&inner_real, &inner_delegate) { return None; } common_real = Some(inner_real); } // All items must share the same real account. Some(existing) if *existing != inner_real => return None, _ => {} } } common_real } /// Determine the coldkey that should pay the fee when a hotkey is the origin. /// /// Returns `Some(coldkey)` only when the call is runtime-marked as coldkey-paid /// (via [`ColdkeyFeeCallFilter`]) and the signer (hotkey) has an owner. The coldkey /// covers the protocol fee only; the signer-chosen tip is dropped by the caller /// (see `validate`) so a hotkey cannot spend coldkey funds through the tip. Returns /// `None` otherwise, so the signer pays. fn extract_coldkey_fee_payer( call: &RuntimeCallOf, origin: &RuntimeOriginOf, ) -> Option> { if !T::charges_coldkey(call) { return None; } let signer = origin.as_system_origin_signer()?; pallet_subtensor::Pallet::::maybe_coldkey_for_hotkey(signer) } } impl TransactionExtension> for ChargeTransactionPaymentWrapper where RuntimeCallOf: Dispatchable + IsSubType> + IsSubType>, T: ColdkeyFeeCallFilter>, BalanceOf: Zero + Send + Sync, RuntimeOriginOf: AsSystemOriginSigner> + Clone + From>>, { const IDENTIFIER: &'static str = "ChargeTransactionPaymentWrapper"; type Implicit = (); type Val = Val; type Pre = Pre; fn weight(&self, call: &RuntimeCallOf) -> Weight { // Account for up to 3 storage reads in the worst-case fee payer resolution // (outer is_real_pays_fee + inner/batch is_real_pays_fee + margin). self.inner .weight(call) .saturating_add(T::DbWeight::get().reads(3)) } fn validate( &self, origin: DispatchOriginOf>, call: &RuntimeCallOf, info: &DispatchInfoOf>, len: usize, self_implicit: Self::Implicit, inherited_implication: &impl Implication, source: TransactionSource, ) -> ValidateResult> { let overridden_priority = { let base: TransactionPriority = match info.class { DispatchClass::Normal => NORMAL_DISPATCH_BASE_PRIORITY, DispatchClass::Mandatory => NORMAL_DISPATCH_BASE_PRIORITY, DispatchClass::Operational => OPERATIONAL_DISPATCH_PRIORITY, }; base.saturated_into::() }; // Resolve the fee payer. A proxy `RealPaysFee` opt-in takes precedence; otherwise an // owned hotkey's coldkey pays for allow-listed calls. In that case the coldkey covers // the protocol fee only — the signer-chosen tip is dropped, since a tip does not buy // priority in this wrapper (priority is overridden above) and billing it to the coldkey // would let a hotkey drain coldkey funds. Other payers keep the original tip. let (fee_origin, tip) = if let Some(real) = Self::extract_real_fee_payer(call, &origin) { ( frame_system::RawOrigin::Signed(real).into(), self.inner.tip(), ) } else if let Some(coldkey) = Self::extract_coldkey_fee_payer(call, &origin) { ( frame_system::RawOrigin::Signed(coldkey).into(), Zero::zero(), ) } else { (origin.clone(), self.inner.tip()) }; let (mut valid_transaction, val, _fee_origin) = ChargeTransactionPayment::::from(tip) .validate( fee_origin, call, info, len, self_implicit, inherited_implication, source, )?; valid_transaction.priority = overridden_priority; // Always return the original origin so the actual signer remains // the origin for dispatch and all subsequent extensions. Ok((valid_transaction, val, origin)) } fn prepare( self, val: Self::Val, origin: &DispatchOriginOf>, call: &RuntimeCallOf, info: &DispatchInfoOf>, len: usize, ) -> Result { self.inner.prepare(val, origin, call, info, len) } fn metadata() -> Vec { ChargeTransactionPayment::::metadata() } fn post_dispatch_details( pre: Self::Pre, info: &DispatchInfoOf>, post_info: &PostDispatchInfoOf>, len: usize, result: &DispatchResult, ) -> Result { ChargeTransactionPayment::::post_dispatch_details(pre, info, post_info, len, result) } fn post_dispatch( pre: Self::Pre, info: &DispatchInfoOf>, post_info: &mut PostDispatchInfoOf>, len: usize, result: &DispatchResult, ) -> Result<(), TransactionValidityError> { ChargeTransactionPayment::::post_dispatch(pre, info, post_info, len, result) } fn bare_validate( call: &RuntimeCallOf, info: &DispatchInfoOf>, len: usize, ) -> TransactionValidity { ChargeTransactionPayment::::bare_validate(call, info, len) } fn bare_validate_and_prepare( call: &RuntimeCallOf, info: &DispatchInfoOf>, len: usize, ) -> Result<(), TransactionValidityError> { ChargeTransactionPayment::::bare_validate_and_prepare(call, info, len) } fn bare_post_dispatch( info: &DispatchInfoOf>, post_info: &mut PostDispatchInfoOf>, len: usize, result: &DispatchResult, ) -> Result<(), TransactionValidityError> { ChargeTransactionPayment::::bare_post_dispatch(info, post_info, len, result) } } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::ChargeTransactionPaymentWrapper; use crate::{ BuildStorage, NORMAL_DISPATCH_BASE_PRIORITY, OPERATIONAL_DISPATCH_PRIORITY, Proxy, Runtime, RuntimeCall, RuntimeGenesisConfig, RuntimeOrigin, System, SystemCall, }; use frame_support::{ assert_ok, dispatch::{DispatchClass, DispatchInfo, GetDispatchInfo, Pays}, }; use pallet_subtensor_proxy as pallet_proxy; use pallet_subtensor_utility as pallet_utility; use pallet_transaction_payment::Val; use sp_runtime::Saturating; use sp_runtime::traits::{DispatchTransaction, TransactionExtension, TxBaseImplication}; use sp_runtime::transaction_validity::{ TransactionSource, TransactionValidityError, ValidTransaction, }; use subtensor_runtime_common::{AccountId, NetUid, ProxyType, TaoBalance}; const SIGNER: [u8; 32] = [1_u8; 32]; const REAL_A: [u8; 32] = [2_u8; 32]; const REAL_B: [u8; 32] = [3_u8; 32]; const OTHER: [u8; 32] = [4_u8; 32]; const BALANCE: TaoBalance = TaoBalance::new(1_000_000_000_000_u64); fn new_test_ext() -> sp_io::TestExternalities { sp_tracing::try_init_simple(); let mut ext: sp_io::TestExternalities = RuntimeGenesisConfig { balances: pallet_balances::GenesisConfig { balances: vec![ (AccountId::from(SIGNER), BALANCE), (AccountId::from(REAL_A), BALANCE), (AccountId::from(REAL_B), BALANCE), (AccountId::from(OTHER), BALANCE), ], dev_accounts: None, }, ..Default::default() } .build_storage() .unwrap() .into(); ext.execute_with(|| System::set_block_number(1)); ext } fn signer() -> AccountId { AccountId::from(SIGNER) } fn real_a() -> AccountId { AccountId::from(REAL_A) } fn real_b() -> AccountId { AccountId::from(REAL_B) } fn other() -> AccountId { AccountId::from(OTHER) } // -- Call builders -- fn call_remark() -> RuntimeCall { RuntimeCall::System(SystemCall::remark { remark: vec![1, 2, 3], }) } fn call_set_weights() -> RuntimeCall { RuntimeCall::SubtensorModule(pallet_subtensor::Call::set_weights { netuid: NetUid::from(1), dests: vec![0], weights: vec![1], version_key: 0, }) } fn call_commit_weights() -> RuntimeCall { RuntimeCall::SubtensorModule(pallet_subtensor::Call::commit_weights { netuid: NetUid::from(1), commit_hash: sp_core::H256::zero(), }) } fn proxy_call(real: AccountId, inner: RuntimeCall) -> RuntimeCall { RuntimeCall::Proxy(pallet_proxy::Call::proxy { real: real.into(), force_proxy_type: None, call: Box::new(inner), }) } fn proxy_announced_call( delegate: AccountId, real: AccountId, inner: RuntimeCall, ) -> RuntimeCall { RuntimeCall::Proxy(pallet_proxy::Call::proxy_announced { delegate: delegate.into(), real: real.into(), force_proxy_type: None, call: Box::new(inner), }) } fn batch_call(calls: Vec) -> RuntimeCall { RuntimeCall::Utility(pallet_utility::Call::batch { calls }) } fn batch_all_call(calls: Vec) -> RuntimeCall { RuntimeCall::Utility(pallet_utility::Call::batch_all { calls }) } fn force_batch_call(calls: Vec) -> RuntimeCall { RuntimeCall::Utility(pallet_utility::Call::force_batch { calls }) } // -- Setup helpers -- fn add_proxy(real: &AccountId, delegate: &AccountId) { assert_ok!(Proxy::add_proxy( RuntimeOrigin::signed(real.clone()), delegate.clone().into(), ProxyType::Any, 0, )); } fn enable_real_pays_fee(real: &AccountId, delegate: &AccountId) { assert_ok!(Proxy::set_real_pays_fee( RuntimeOrigin::signed(real.clone()), delegate.clone().into(), true, )); } // -- Validate helpers -- fn validate_call( origin: RuntimeOrigin, call: &RuntimeCall, ) -> Result<(ValidTransaction, Val), TransactionValidityError> { validate_call_with_info(origin, call, &call.get_dispatch_info()) } fn validate_call_with_info( origin: RuntimeOrigin, call: &RuntimeCall, info: &DispatchInfo, ) -> Result<(ValidTransaction, Val), TransactionValidityError> { let ext = ChargeTransactionPaymentWrapper::::new(TaoBalance::new(0)); let (valid_tx, val, _origin) = ext.validate( origin, call, info, 100, (), &TxBaseImplication(()), TransactionSource::External, )?; Ok((valid_tx, val)) } /// Extract the fee payer from the validate result. fn fee_payer(val: &Val) -> AccountId { match val { Val::Charge { who, .. } => who.clone(), _ => panic!("expected Val::Charge"), } } // ============================================================ // Case 0: Non-proxy calls // ============================================================ #[test] fn non_proxy_call_charges_signer() { new_test_ext().execute_with(|| { let call = call_remark(); let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(signer()), &call).unwrap(); assert_eq!(fee_payer(&val), signer()); }); } // ============================================================ // Case 1: Simple proxy (1 level) // ============================================================ #[test] fn simple_proxy_charges_real_when_opted_in() { new_test_ext().execute_with(|| { add_proxy(&real_a(), &signer()); enable_real_pays_fee(&real_a(), &signer()); let call = proxy_call(real_a(), call_remark()); let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(signer()), &call).unwrap(); assert_eq!(fee_payer(&val), real_a()); }); } #[test] fn simple_proxy_charges_signer_when_not_opted_in() { new_test_ext().execute_with(|| { add_proxy(&real_a(), &signer()); // No enable_real_pays_fee let call = proxy_call(real_a(), call_remark()); let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(signer()), &call).unwrap(); assert_eq!(fee_payer(&val), signer()); }); } #[test] fn proxy_announced_always_charges_signer() { new_test_ext().execute_with(|| { add_proxy(&real_a(), &real_b()); enable_real_pays_fee(&real_a(), &real_b()); // Fee propagation intentionally ignores proxy_announced; signer always pays. let call = proxy_announced_call(real_b(), real_a(), call_remark()); let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(signer()), &call).unwrap(); assert_eq!(fee_payer(&val), signer()); }); } // ============================================================ // Case 2: Nested proxy (2 levels) // ============================================================ #[test] fn nested_proxy_charges_inner_real_when_both_opted_in() { new_test_ext().execute_with(|| { // Chain: signer → real_b → real_a add_proxy(&real_b(), &signer()); enable_real_pays_fee(&real_b(), &signer()); add_proxy(&real_a(), &real_b()); enable_real_pays_fee(&real_a(), &real_b()); let call = proxy_call(real_b(), proxy_call(real_a(), call_remark())); let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(signer()), &call).unwrap(); assert_eq!(fee_payer(&val), real_a()); }); } #[test] fn nested_proxy_charges_outer_real_when_only_outer_opted_in() { new_test_ext().execute_with(|| { add_proxy(&real_b(), &signer()); enable_real_pays_fee(&real_b(), &signer()); add_proxy(&real_a(), &real_b()); // No enable_real_pays_fee for A→B let call = proxy_call(real_b(), proxy_call(real_a(), call_remark())); let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(signer()), &call).unwrap(); assert_eq!(fee_payer(&val), real_b()); }); } #[test] fn nested_proxy_charges_signer_when_neither_opted_in() { new_test_ext().execute_with(|| { add_proxy(&real_b(), &signer()); add_proxy(&real_a(), &real_b()); // No enable_real_pays_fee at all let call = proxy_call(real_b(), proxy_call(real_a(), call_remark())); let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(signer()), &call).unwrap(); assert_eq!(fee_payer(&val), signer()); }); } #[test] fn nested_proxy_charges_signer_when_only_inner_opted_in() { new_test_ext().execute_with(|| { add_proxy(&real_b(), &signer()); // No enable_real_pays_fee for B→signer add_proxy(&real_a(), &real_b()); enable_real_pays_fee(&real_a(), &real_b()); let call = proxy_call(real_b(), proxy_call(real_a(), call_remark())); let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(signer()), &call).unwrap(); // Outer RealPaysFee not set → signer pays (inner opt-in is irrelevant) assert_eq!(fee_payer(&val), signer()); }); } // ============================================================ // Case 3: Batch of proxy calls // ============================================================ #[test] fn batch_charges_inner_real_when_all_opted_in() { new_test_ext().execute_with(|| { add_proxy(&real_b(), &signer()); enable_real_pays_fee(&real_b(), &signer()); add_proxy(&real_a(), &real_b()); enable_real_pays_fee(&real_a(), &real_b()); let batch = batch_call(vec![ proxy_call(real_a(), call_remark()), proxy_call(real_a(), call_remark()), ]); let call = proxy_call(real_b(), batch); let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(signer()), &call).unwrap(); assert_eq!(fee_payer(&val), real_a()); }); } #[test] fn batch_all_charges_inner_real_when_all_opted_in() { new_test_ext().execute_with(|| { add_proxy(&real_b(), &signer()); enable_real_pays_fee(&real_b(), &signer()); add_proxy(&real_a(), &real_b()); enable_real_pays_fee(&real_a(), &real_b()); let batch = batch_all_call(vec![ proxy_call(real_a(), call_remark()), proxy_call(real_a(), call_remark()), ]); let call = proxy_call(real_b(), batch); let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(signer()), &call).unwrap(); assert_eq!(fee_payer(&val), real_a()); }); } #[test] fn force_batch_charges_inner_real_when_all_opted_in() { new_test_ext().execute_with(|| { add_proxy(&real_b(), &signer()); enable_real_pays_fee(&real_b(), &signer()); add_proxy(&real_a(), &real_b()); enable_real_pays_fee(&real_a(), &real_b()); let batch = force_batch_call(vec![ proxy_call(real_a(), call_remark()), proxy_call(real_a(), call_remark()), ]); let call = proxy_call(real_b(), batch); let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(signer()), &call).unwrap(); assert_eq!(fee_payer(&val), real_a()); }); } #[test] fn batch_charges_outer_real_when_only_outer_opted_in() { new_test_ext().execute_with(|| { add_proxy(&real_b(), &signer()); enable_real_pays_fee(&real_b(), &signer()); add_proxy(&real_a(), &real_b()); // No enable_real_pays_fee for A→B let batch = batch_call(vec![ proxy_call(real_a(), call_remark()), proxy_call(real_a(), call_remark()), ]); let call = proxy_call(real_b(), batch); let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(signer()), &call).unwrap(); assert_eq!(fee_payer(&val), real_b()); }); } #[test] fn batch_charges_outer_real_when_mixed_inner_reals() { new_test_ext().execute_with(|| { add_proxy(&real_b(), &signer()); enable_real_pays_fee(&real_b(), &signer()); add_proxy(&real_a(), &real_b()); enable_real_pays_fee(&real_a(), &real_b()); add_proxy(&other(), &real_b()); enable_real_pays_fee(&other(), &real_b()); // Different inner reals → can't push deeper let batch = batch_call(vec![ proxy_call(real_a(), call_remark()), proxy_call(other(), call_remark()), ]); let call = proxy_call(real_b(), batch); let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(signer()), &call).unwrap(); assert_eq!(fee_payer(&val), real_b()); }); } #[test] fn batch_charges_outer_real_when_non_proxy_in_batch() { new_test_ext().execute_with(|| { add_proxy(&real_b(), &signer()); enable_real_pays_fee(&real_b(), &signer()); // Batch contains a non-proxy call → extract_proxy_parts fails let batch = batch_call(vec![proxy_call(real_a(), call_remark()), call_remark()]); let call = proxy_call(real_b(), batch); let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(signer()), &call).unwrap(); assert_eq!(fee_payer(&val), real_b()); }); } #[test] fn batch_charges_outer_real_when_empty() { new_test_ext().execute_with(|| { add_proxy(&real_b(), &signer()); enable_real_pays_fee(&real_b(), &signer()); let batch = batch_call(vec![]); let call = proxy_call(real_b(), batch); let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(signer()), &call).unwrap(); assert_eq!(fee_payer(&val), real_b()); }); } #[test] fn batch_charges_outer_real_when_inner_real_not_opted_in() { new_test_ext().execute_with(|| { add_proxy(&real_b(), &signer()); enable_real_pays_fee(&real_b(), &signer()); add_proxy(&real_a(), &real_b()); // real_a has NOT opted in to pay for real_b // Even with same real in all batch items, if RealPaysFee not set → outer_real pays let batch = batch_call(vec![ proxy_call(real_a(), call_remark()), proxy_call(real_a(), call_remark()), ]); let call = proxy_call(real_b(), batch); let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(signer()), &call).unwrap(); assert_eq!(fee_payer(&val), real_b()); }); } // ============================================================ // Priority override // ============================================================ #[test] fn priority_override_normal_dispatch() { new_test_ext().execute_with(|| { let call = call_remark(); let (valid_tx, _val) = validate_call(RuntimeOrigin::signed(signer()), &call).unwrap(); assert_eq!(valid_tx.priority, NORMAL_DISPATCH_BASE_PRIORITY); }); } #[test] fn priority_override_operational_dispatch() { new_test_ext().execute_with(|| { let call = call_remark(); let mut info = call.get_dispatch_info(); info.class = DispatchClass::Operational; let (valid_tx, _val) = validate_call_with_info(RuntimeOrigin::signed(signer()), &call, &info).unwrap(); assert_eq!(valid_tx.priority, OPERATIONAL_DISPATCH_PRIORITY); }); } #[test] fn priority_override_mandatory_dispatch() { new_test_ext().execute_with(|| { let call = call_remark(); let mut info = call.get_dispatch_info(); info.class = DispatchClass::Mandatory; let (valid_tx, _val) = validate_call_with_info(RuntimeOrigin::signed(signer()), &call, &info).unwrap(); // Mandatory uses the same base as Normal assert_eq!(valid_tx.priority, NORMAL_DISPATCH_BASE_PRIORITY); }); } #[test] fn priority_override_applies_with_real_pays_fee() { new_test_ext().execute_with(|| { add_proxy(&real_a(), &signer()); enable_real_pays_fee(&real_a(), &signer()); let call = proxy_call(real_a(), call_remark()); let (valid_tx, _val) = validate_call(RuntimeOrigin::signed(signer()), &call).unwrap(); // Priority override should still apply when real pays fee assert_eq!(valid_tx.priority, NORMAL_DISPATCH_BASE_PRIORITY); }); } // ============================================================ // Coldkey pays the fee when a hotkey is the origin // ============================================================ #[test] fn hotkey_with_owner_charges_coldkey() { new_test_ext().execute_with(|| { let hotkey = signer(); let coldkey = other(); pallet_subtensor::Owner::::insert(hotkey.clone(), coldkey.clone()); let call = call_set_weights(); let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(hotkey), &call).unwrap(); assert_eq!(fee_payer(&val), coldkey); }); } // Guards against the gate only recognizing `set_weights` rather than the whole group. #[test] fn other_group_member_charges_coldkey() { new_test_ext().execute_with(|| { let hotkey = signer(); let coldkey = other(); pallet_subtensor::Owner::::insert(hotkey.clone(), coldkey.clone()); let call = call_commit_weights(); let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(hotkey), &call).unwrap(); assert_eq!(fee_payer(&val), coldkey); }); } #[test] fn hotkey_without_owner_charges_signer() { new_test_ext().execute_with(|| { let hotkey = signer(); let call = call_set_weights(); let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(hotkey.clone()), &call).unwrap(); assert_eq!(fee_payer(&val), hotkey); }); } // Full lifecycle (validate → prepare → post_dispatch) via `DispatchTransaction::test_run`, // which validate-only tests cannot reach: a `Pays::Yes` allow-listed call debits the coldkey // and leaves the hotkey untouched, and a signer-chosen tip is excluded from the debit. #[test] fn full_lifecycle_debits_coldkey_excluding_tip() { new_test_ext().execute_with(|| { let hotkey = signer(); let coldkey = other(); pallet_subtensor::Owner::::insert(hotkey.clone(), coldkey.clone()); let call = call_set_weights(); // Force a fee-bearing call; the real `set_weights` is `Pays::No` in this PR. let info = DispatchInfo { pays_fee: Pays::Yes, ..call.get_dispatch_info() }; let hotkey_before = pallet_balances::Pallet::::free_balance(&hotkey); let coldkey_before = pallet_balances::Pallet::::free_balance(&coldkey); // Sign with a large tip; it must not reach the coldkey. let ext = ChargeTransactionPaymentWrapper::::new(TaoBalance::new(1_000_000)); assert_ok!(ext.test_run( RuntimeOrigin::signed(hotkey.clone()), &call, &info, 0, 0, |_origin| Ok(Default::default()), )); let tipless_fee = pallet_transaction_payment::Pallet::::compute_fee( 0, &info, TaoBalance::new(0), ); let coldkey_after = pallet_balances::Pallet::::free_balance(&coldkey); assert_eq!( pallet_balances::Pallet::::free_balance(&hotkey), hotkey_before ); assert!(coldkey_after < coldkey_before, "coldkey should be debited"); assert_eq!( coldkey_before.saturating_sub(coldkey_after), tipless_fee, "coldkey pays the tipless fee, not the tip" ); }); } // Owner is set, yet a call outside the group is never redirected: the signer pays. #[test] fn ineligible_call_charges_signer() { new_test_ext().execute_with(|| { let hotkey = signer(); let coldkey = other(); pallet_subtensor::Owner::::insert(hotkey.clone(), coldkey); let call = call_remark(); let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(hotkey.clone()), &call).unwrap(); assert_eq!(fee_payer(&val), hotkey); }); } }