Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/transaction-priority.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ In the Substrate SDK, `ChargeTransactionPayment` normally calculates transaction
However, in Subtensor, `ChargeTransactionPaymentWrapper` **overrides** this logic.
It replaces the dynamic calculation with a **flat priority scale** based only on the dispatch class.

`ChargeTransactionPaymentWrapper` also resolves fee payment for proxy calls that opt in via
`RealPaysFee`. This resolution can follow a bounded proxy chain up to three proxy levels,
including the case where the innermost supported call is a homogeneous `batch`, `batch_all`,
or `force_batch` of proxy calls. If a batch contains proxy calls for mixed real accounts, fee
propagation is not applied and the original signer pays.

#### Current priority values:
| Dispatch Class | Priority Value | Notes |
|---------------------|-------------------|--------------------------------------------------------------|
Expand All @@ -33,4 +39,4 @@ It replaces the dynamic calculation with a **flat priority scale** based only on

Special pallet_drand priority: 10_000 for `write_pulse` extrinsic.

---
---
124 changes: 78 additions & 46 deletions runtime/src/transaction_payment_wrapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ type RuntimeOriginOf<T> = <T as frame_system::Config>::RuntimeOrigin;
type AccountIdOf<T> = <T as frame_system::Config>::AccountId;
type LookupOf<T> = <T as frame_system::Config>::Lookup;

const MAX_REAL_PAYS_FEE_PROXY_DEPTH: u8 = 3;

enum FeePayerResolution<AccountId> {
Payer(AccountId),
SignerPays,
NotApplicable,
}

#[freeze_struct("f003cde1f9da4a90")]
#[derive(Encode, Decode, DecodeWithMemTracking, Clone, Eq, PartialEq, TypeInfo)]
#[scale_info(skip_type_params(T))]
Expand Down Expand Up @@ -85,58 +93,60 @@ where

/// Determine who should pay the transaction fee for a proxy call.
///
/// Follows the RealPaysFee chain up to 2 levels deep:
/// Follows the RealPaysFee chain up to three proxy levels deep:
/// - Case 1: `proxy(real=A, call)` → A pays if `RealPaysFee<A, signer>`
/// - Case 2: `proxy(real=B, proxy(real=A, call))` → A pays if both
/// `RealPaysFee<B, signer>` and `RealPaysFee<A, B>` are set; B pays if only the former.
/// - Case 3: `proxy(real=B, batch([proxy(real=A, ..), ..]))` → A pays if
/// `RealPaysFee<B, signer>`, all batch items are proxy calls with the same real A,
/// and `RealPaysFee<A, B>` is set; B pays if only the first condition holds.
/// - Case 4: `proxy(real=C, proxy(real=B, batch([proxy(real=A, ..), ..])))`
/// → A pays if all three `RealPaysFee` relationships are set and the batch is homogeneous.
///
/// Returns `None` if the signer should pay (no RealPaysFee opt-in).
fn extract_real_fee_payer(
call: &RuntimeCallOf<T>,
origin: &RuntimeOriginOf<T>,
) -> Option<AccountIdOf<T>> {
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::<T>::is_real_pays_fee(&outer_real, &delegate) {
return None;
match Self::resolve_real_fee_payer(call, signer, MAX_REAL_PAYS_FEE_PROXY_DEPTH) {
FeePayerResolution::Payer(payer) => Some(payer),
FeePayerResolution::SignerPays | FeePayerResolution::NotApplicable => None,
}
}

// outer_real pays. Try to push the fee deeper into nested proxy structures.
let inner_call: &RuntimeCallOf<T> = (*inner_call).as_ref().into_ref();
fn resolve_real_fee_payer(
call: &RuntimeCallOf<T>,
delegate: &AccountIdOf<T>,
remaining_proxy_depth: u8,
) -> FeePayerResolution<AccountIdOf<T>> {
let Some((real, _, inner_call)) = Self::extract_proxy_parts(call, delegate) else {
return FeePayerResolution::NotApplicable;
};

// 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);
if !pallet_proxy::Pallet::<T>::is_real_pays_fee(&real, delegate) {
return FeePayerResolution::NotApplicable;
}

// 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);
if remaining_proxy_depth <= 1 {
return FeePayerResolution::Payer(real);
}

// Case 1: simple proxy, outer_real pays.
Some(outer_real)
}
let inner_call: &RuntimeCallOf<T> = (*inner_call).as_ref().into_ref();

/// 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<T>,
outer_real: &AccountIdOf<T>,
) -> Option<AccountIdOf<T>> {
let (inner_real, inner_delegate, _call) =
Self::extract_proxy_parts(inner_call, outer_real)?;
match Self::resolve_real_fee_payer(inner_call, &real, remaining_proxy_depth - 1) {
FeePayerResolution::Payer(payer) => return FeePayerResolution::Payer(payer),
FeePayerResolution::SignerPays => return FeePayerResolution::SignerPays,
FeePayerResolution::NotApplicable => {}
}

if pallet_proxy::Pallet::<T>::is_real_pays_fee(&inner_real, &inner_delegate) {
Some(inner_real)
} else {
None
match Self::extract_batch_proxy_payer(inner_call, &real, remaining_proxy_depth - 1) {
FeePayerResolution::Payer(payer) => return FeePayerResolution::Payer(payer),
FeePayerResolution::SignerPays => return FeePayerResolution::SignerPays,
FeePayerResolution::NotApplicable => {}
}

FeePayerResolution::Payer(real)
}

/// Check if an inner call is a batch where ALL items are proxy calls with the same real
Expand All @@ -145,42 +155,64 @@ where
fn extract_batch_proxy_payer(
inner_call: &RuntimeCallOf<T>,
outer_real: &AccountIdOf<T>,
) -> Option<AccountIdOf<T>> {
let calls: &Vec<<T as pallet_utility::Config>::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,
};
remaining_proxy_depth: u8,
) -> FeePayerResolution<AccountIdOf<T>> {
let Some(utility_call) = inner_call.is_sub_type() else {
return FeePayerResolution::NotApplicable;
};
let calls: &Vec<<T as pallet_utility::Config>::RuntimeCall> = match utility_call {
pallet_utility::Call::batch { calls }
| pallet_utility::Call::batch_all { calls }
| pallet_utility::Call::force_batch { calls } => calls,
_ => return FeePayerResolution::NotApplicable,
};

if calls.is_empty() {
return None;
return FeePayerResolution::NotApplicable;
}

let mut common_real: Option<AccountIdOf<T>> = None;
let mut first_proxy_call: Option<&RuntimeCallOf<T>> = None;

for call in calls.iter() {
let call_ref: &RuntimeCallOf<T> = call.into_ref();
let (inner_real, inner_delegate, _) = Self::extract_proxy_parts(call_ref, outer_real)?;
let Some((inner_real, inner_delegate, _)) =
Self::extract_proxy_parts(call_ref, outer_real)
else {
return FeePayerResolution::NotApplicable;
};

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::<T>::is_real_pays_fee(&inner_real, &inner_delegate) {
return None;
return FeePayerResolution::NotApplicable;
}
first_proxy_call = Some(call_ref);
common_real = Some(inner_real);
}
// All items must share the same real account.
Some(existing) if *existing != inner_real => return None,
// Mixed real accounts intentionally leave the original signer paying fees.
Some(existing) if *existing != inner_real => return FeePayerResolution::SignerPays,
_ => {}
}
}

common_real
let Some(common_real) = common_real else {
return FeePayerResolution::NotApplicable;
};
if remaining_proxy_depth > 1
&& let Some(first_call) = first_proxy_call
{
match Self::resolve_real_fee_payer(first_call, outer_real, remaining_proxy_depth) {
FeePayerResolution::Payer(payer) => return FeePayerResolution::Payer(payer),
FeePayerResolution::SignerPays => return FeePayerResolution::SignerPays,
FeePayerResolution::NotApplicable => {}
}
}

FeePayerResolution::Payer(common_real)
}
}

Expand All @@ -200,11 +232,11 @@ where
type Pre = Pre<T>;

fn weight(&self, call: &RuntimeCallOf<T>) -> 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).
// Account for up to four storage reads in the worst-case fee payer resolution
// (three proxy hops + margin).
self.inner
.weight(call)
.saturating_add(T::DbWeight::get().reads(3))
.saturating_add(T::DbWeight::get().reads(4))
}

fn validate(
Expand Down
112 changes: 110 additions & 2 deletions runtime/tests/transaction_payment_wrapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ fn batch_charges_outer_real_when_only_outer_opted_in() {
}

#[test]
fn batch_charges_outer_real_when_mixed_inner_reals() {
fn batch_charges_signer_when_mixed_inner_reals() {
new_test_ext().execute_with(|| {
add_proxy(&real_b(), &signer());
enable_real_pays_fee(&real_b(), &signer());
Expand All @@ -357,7 +357,7 @@ fn batch_charges_outer_real_when_mixed_inner_reals() {
]);
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());
assert_eq!(fee_payer(&val), signer());
});
}

Expand Down Expand Up @@ -407,6 +407,114 @@ fn batch_charges_outer_real_when_inner_real_not_opted_in() {
});
}

// ============================================================
// Case 4: Three-level proxy chain ending in a batch of proxy calls
// ============================================================

#[test]
fn three_level_proxy_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());
add_proxy(&other(), &real_a());
enable_real_pays_fee(&other(), &real_a());

let batch = force_batch_call(vec![
proxy_call(other(), call_remark()),
proxy_call(other(), call_remark()),
]);
let call = proxy_call(real_b(), proxy_call(real_a(), batch));

let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(signer()), &call).unwrap();
assert_eq!(fee_payer(&val), other());
});
}

#[test]
fn three_level_proxy_batch_charges_middle_real_when_inner_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());
enable_real_pays_fee(&real_a(), &real_b());
add_proxy(&other(), &real_a());

let batch = force_batch_call(vec![
proxy_call(other(), call_remark()),
proxy_call(other(), call_remark()),
]);
let call = proxy_call(real_b(), proxy_call(real_a(), batch));

let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(signer()), &call).unwrap();
assert_eq!(fee_payer(&val), real_a());
});
}

#[test]
fn three_level_proxy_batch_charges_outer_real_when_middle_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());
add_proxy(&other(), &real_a());
enable_real_pays_fee(&other(), &real_a());

let batch = force_batch_call(vec![
proxy_call(other(), call_remark()),
proxy_call(other(), call_remark()),
]);
let call = proxy_call(real_b(), proxy_call(real_a(), batch));

let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(signer()), &call).unwrap();
assert_eq!(fee_payer(&val), real_b());
});
}

#[test]
fn three_level_proxy_batch_charges_signer_when_outer_not_opted_in() {
new_test_ext().execute_with(|| {
add_proxy(&real_b(), &signer());
add_proxy(&real_a(), &real_b());
enable_real_pays_fee(&real_a(), &real_b());
add_proxy(&other(), &real_a());
enable_real_pays_fee(&other(), &real_a());

let batch = force_batch_call(vec![
proxy_call(other(), call_remark()),
proxy_call(other(), call_remark()),
]);
let call = proxy_call(real_b(), proxy_call(real_a(), batch));

let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(signer()), &call).unwrap();
assert_eq!(fee_payer(&val), signer());
});
}

#[test]
fn three_level_proxy_batch_charges_signer_when_batch_reals_are_mixed() {
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_a());
enable_real_pays_fee(&other(), &real_a());
add_proxy(&signer(), &real_a());
enable_real_pays_fee(&signer(), &real_a());

let batch = force_batch_call(vec![
proxy_call(other(), call_remark()),
proxy_call(signer(), call_remark()),
]);
let call = proxy_call(real_b(), proxy_call(real_a(), batch));

let (_valid_tx, val) = validate_call(RuntimeOrigin::signed(signer()), &call).unwrap();
assert_eq!(fee_payer(&val), signer());
});
}

// ============================================================
// Priority override
// ============================================================
Expand Down
Loading