From 85dcb93a0866387743864040944c72c3b904b191 Mon Sep 17 00:00:00 2001 From: kubernautis Date: Wed, 20 May 2026 13:42:49 -0400 Subject: [PATCH] Support real-pays-fee through three proxy levels --- docs/transaction-priority.md | 8 +- runtime/src/transaction_payment_wrapper.rs | 92 ++++++++-------- runtime/tests/transaction_payment_wrapper.rs | 110 ++++++++++++++++++- 3 files changed, 164 insertions(+), 46 deletions(-) diff --git a/docs/transaction-priority.md b/docs/transaction-priority.md index 36ebf79e64..7eb4164ec3 100644 --- a/docs/transaction-priority.md +++ b/docs/transaction-priority.md @@ -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 | |---------------------|-------------------|--------------------------------------------------------------| @@ -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. ---- \ No newline at end of file +--- diff --git a/runtime/src/transaction_payment_wrapper.rs b/runtime/src/transaction_payment_wrapper.rs index b16773daf9..01ed1aedaf 100644 --- a/runtime/src/transaction_payment_wrapper.rs +++ b/runtime/src/transaction_payment_wrapper.rs @@ -28,6 +28,8 @@ type RuntimeOriginOf = ::RuntimeOrigin; type AccountIdOf = ::AccountId; type LookupOf = ::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))] @@ -85,13 +87,15 @@ 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` /// - 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. + /// - 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( @@ -99,44 +103,39 @@ where origin: &RuntimeOriginOf, ) -> Option> { 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::::is_real_pays_fee(&outer_real, &delegate) { + fn resolve_real_fee_payer( + call: &RuntimeCallOf, + delegate: &AccountIdOf, + remaining_proxy_depth: u8, + ) -> Option> { + 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 = (*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::::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 = (*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, - outer_real: &AccountIdOf, - ) -> Option> { - 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::::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 @@ -146,13 +145,15 @@ where 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, - }; + let Some(utility_call) = inner_call.is_sub_type() else { + return None; + }; + let calls: &Vec<::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; @@ -162,19 +163,22 @@ where 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)?; + 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::::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, _ => {} } @@ -200,11 +204,11 @@ where 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). + // 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( diff --git a/runtime/tests/transaction_payment_wrapper.rs b/runtime/tests/transaction_payment_wrapper.rs index bbc9798a3e..c02f803fd9 100644 --- a/runtime/tests/transaction_payment_wrapper.rs +++ b/runtime/tests/transaction_payment_wrapper.rs @@ -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()), @@ -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 // ============================================================