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.

---
---
92 changes: 48 additions & 44 deletions runtime/src/transaction_payment_wrapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ 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;

#[freeze_struct("f003cde1f9da4a90")]
#[derive(Encode, Decode, DecodeWithMemTracking, Clone, Eq, PartialEq, TypeInfo)]
#[scale_info(skip_type_params(T))]
Expand Down Expand Up @@ -85,58 +87,55 @@ 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)?;
Self::resolve_real_fee_payer(call, signer, MAX_REAL_PAYS_FEE_PROXY_DEPTH)
}

// 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) {
fn resolve_real_fee_payer(
call: &RuntimeCallOf<T>,
delegate: &AccountIdOf<T>,
remaining_proxy_depth: u8,
) -> Option<AccountIdOf<T>> {
let Some((real, _, inner_call)) = Self::extract_proxy_parts(call, delegate) else {
return 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();
};

// 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 None;
}

// 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 Some(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)?;
if let Some(payer) =
Self::resolve_real_fee_payer(inner_call, &real, remaining_proxy_depth - 1)
{
return Some(payer);
}

if pallet_proxy::Pallet::<T>::is_real_pays_fee(&inner_real, &inner_delegate) {
Some(inner_real)
} else {
None
if let Some(payer) = Self::extract_batch_proxy_payer(inner_call, &real) {
return Some(payer);
}

Some(real)
}

/// Check if an inner call is a batch where ALL items are proxy calls with the same real
Expand All @@ -146,13 +145,15 @@ where
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,
};
let Some(utility_call) = inner_call.is_sub_type() else {
return None;
};
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 None,
};

if calls.is_empty() {
return None;
Expand All @@ -162,19 +163,22 @@ where

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 None;
};

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.
// Check RealPaysFee once on the first item and memoize. Batch fee
// propagation only supports homogeneous `proxy` calls, so a single
// read covers the entire batch.
if !pallet_proxy::Pallet::<T>::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,
_ => {}
}
Expand All @@ -200,11 +204,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
110 changes: 109 additions & 1 deletion runtime/tests/transaction_payment_wrapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ fn batch_charges_outer_real_when_mixed_inner_reals() {
add_proxy(&other(), &real_b());
enable_real_pays_fee(&other(), &real_b());

// Different inner reals can't push deeper
// Different inner reals -> can't push deeper, so keep the nearest proven payer.
let batch = batch_call(vec![
proxy_call(real_a(), call_remark()),
proxy_call(other(), call_remark()),
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_middle_real_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), real_a());
});
}

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