Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ef52c7f
Adds `AllowanceBasedPriority` extension
bkchr Apr 22, 2026
a4d9ca9
Nit plus test
bkontur Apr 22, 2026
22362fb
Do not degrade user's performance in time - reset usage when refresh_…
bkontur Apr 23, 2026
5a2682e
Migration for Paseo
bkontur Apr 23, 2026
895ab04
Fix migration
bkontur Apr 23, 2026
38795c2
fmt
bkontur Apr 23, 2026
65ff285
Ping CI
bkontur Apr 23, 2026
820882e
Fix tests to match assignment semantics of `authorize`
bkontur Apr 27, 2026
398dc3a
Merge remote-tracking branch 'origin/main' into bkchr-priority-extension
bkontur Apr 27, 2026
16db0cc
Compilation for paseo after main merge
bkontur Apr 27, 2026
d78a9b1
Doc nit for refresh_authorization
bkontur Apr 27, 2026
bada2d7
Migration for paseo, which was merged meantime
bkontur Apr 27, 2026
7a3b9dc
Refactor allowance boost behind `BoostStrategy`, default to `FlatBoost`
bkontur Apr 28, 2026
0cc298b
Merge remote-tracking branch 'origin/main' into bkchr-priority-extension
bkontur Apr 28, 2026
4f0516e
Docs nits
bkontur Apr 28, 2026
955c0cc
Compute allowance boost against post-this-tx state
bkontur Apr 28, 2026
633fe32
Merge branch 'main' into bkchr-priority-extension
bkontur Apr 28, 2026
bbf39df
Minimalistic alignment with https://github.com/paritytech/individuali…
bkontur Apr 29, 2026
1d0f322
Pick transactions_used / transactions_allowance from #469
bkontur Apr 29, 2026
1de60a1
transactions_used -> transactions
bkontur Apr 29, 2026
aefb13c
Refresh: only extend expiry, do not reset consumed counters
bkontur Apr 29, 2026
428f79d
Move back to 14
bkontur Apr 29, 2026
c8d0f7c
Tighten priority constants for store / renew
bkontur Apr 29, 2026
ab29ac5
Nit
bkontur Apr 29, 2026
71e05c5
More nits
bkontur Apr 29, 2026
8556690
Nit
bkontur Apr 30, 2026
05c5893
nit
bkontur Apr 30, 2026
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
31 changes: 15 additions & 16 deletions pallets/transaction-storage/src/benchmarking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ mod benchmarks {
let origin = T::Authorizer::try_successful_origin()
.map_err(|_| BenchmarkError::Stop("unable to compute origin"))?;
let who: T::AccountId = whitelisted_caller();
let transactions = 10;
let transactions: u32 = 10;
let bytes: u64 = 1024 * 1024;

#[extrinsic_call]
Expand All @@ -215,13 +215,12 @@ mod benchmarks {
let origin = T::Authorizer::try_successful_origin()
.map_err(|_| BenchmarkError::Stop("unable to compute origin"))?;
let who: T::AccountId = whitelisted_caller();
let transactions = 10;
let bytes: u64 = 1024 * 1024;
let origin2 = origin.clone();
TransactionStorage::<T>::authorize_account(
origin2 as T::RuntimeOrigin,
who.clone(),
transactions,
0,
bytes,
)
.map_err(|_| BenchmarkError::Stop("unable to authorize account"))?;
Expand Down Expand Up @@ -273,7 +272,7 @@ mod benchmarks {
let origin = T::Authorizer::try_successful_origin()
.map_err(|_| BenchmarkError::Stop("unable to compute origin"))?;
let who: T::AccountId = whitelisted_caller();
TransactionStorage::<T>::authorize_account(origin, who.clone(), 1, 1)
TransactionStorage::<T>::authorize_account(origin, who.clone(), 0, 1)
.map_err(|_| BenchmarkError::Stop("unable to authorize account"))?;

let period = T::AuthorizationPeriod::get();
Expand Down Expand Up @@ -314,13 +313,12 @@ mod benchmarks {
.map_err(|_| BenchmarkError::Stop("unable to compute origin"))?;
let caller: T::AccountId = whitelisted_caller();
let data = vec![0u8; l as usize];
let transactions = 10;
let bytes = l as u64 * 10;
let bytes_allowance = l as u64 * 10;
TransactionStorage::<T>::authorize_account(
origin as T::RuntimeOrigin,
caller.clone(),
transactions,
bytes,
0,
bytes_allowance,
)
.map_err(|_| BenchmarkError::Stop("unable to authorize account"))?;

Expand All @@ -340,9 +338,10 @@ mod benchmarks {
.unwrap();
}

// prepare consumed one transaction worth of authorization
// prepare added `l` bytes to the used counter
let extent = TransactionStorage::<T>::account_authorization_extent(caller);
assert_eq!(extent.transactions, transactions - 1);
assert_eq!(extent.bytes, l as u64);
assert_eq!(extent.bytes_allowance, bytes_allowance);
Ok(())
}

Expand All @@ -355,13 +354,12 @@ mod benchmarks {
let origin = T::Authorizer::try_successful_origin()
.map_err(|_| BenchmarkError::Stop("unable to compute origin"))?;
let caller: T::AccountId = whitelisted_caller();
let transactions = 10;
let bytes = T::MaxTransactionSize::get() as u64 * 10;
let bytes_allowance = T::MaxTransactionSize::get() as u64 * 10;
TransactionStorage::<T>::authorize_account(
origin as T::RuntimeOrigin,
caller.clone(),
transactions,
bytes,
0,
bytes_allowance,
)
.map_err(|_| BenchmarkError::Stop("unable to authorize account"))?;

Expand All @@ -382,9 +380,10 @@ mod benchmarks {
.unwrap();
}

// prepare consumed one transaction worth of authorization
// prepare added `MaxTransactionSize` bytes to the used counter
let extent = TransactionStorage::<T>::account_authorization_extent(caller);
assert_eq!(extent.transactions, transactions - 1);
assert_eq!(extent.bytes, T::MaxTransactionSize::get() as u64);
assert_eq!(extent.bytes_allowance, bytes_allowance);
Ok(())
}

Expand Down
236 changes: 235 additions & 1 deletion pallets/transaction-storage/src/extension.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@

//! Custom transaction extension for the transaction storage pallet.

use crate::{pallet::Origin, weights::WeightInfo, Call, Config, Pallet, LOG_TARGET};
use crate::{
pallet::Origin, weights::WeightInfo, AuthorizationExtent, AuthorizationScope, Call, Config,
Pallet, LOG_TARGET,
};
use alloc::vec::Vec;
use codec::{Decode, DecodeWithMemTracking, Encode};
use core::{fmt, marker::PhantomData};
Expand Down Expand Up @@ -272,3 +275,234 @@ where
Ok(())
}
}

/// Priority bonus given to an in-budget signer. Over-budget signers get `0`.
pub const ALLOWANCE_PRIORITY_BOOST: TransactionPriority = 1_000_000_000;

/// Maps the prospective post-this-tx [`AuthorizationExtent`] to the priority bonus
/// added by [`AllowanceBasedPriority`]. Pick a concrete impl in the runtime's
/// `TxExtension` tuple.
///
/// Callers must pre-apply the call's effect to `extent` before calling `boost`:
/// `extent.bytes += size` and `extent.transactions += 1`. The boost decision
/// then reduces to "would this leave the holder in-budget on both axes?".
pub trait BoostStrategy: Clone + PartialEq + Eq {
fn boost(extent: AuthorizationExtent) -> TransactionPriority;
}

/// Returns whether `extent` (already post-this-tx) is in-budget on both the byte
/// counter and the transaction counter. The `bytes_allowance == 0` guard catches the
/// "missing or empty grant" case.
fn in_budget(extent: &AuthorizationExtent) -> bool {
if extent.bytes_allowance == 0 {
return false;
}
extent.bytes <= extent.bytes_allowance && extent.transactions <= extent.transactions_allowance
}

/// Boost scales linearly with the tighter of the byte-budget and tx-budget remainders.
/// Fresh grant yields the full boost; at-cap on either axis yields zero.
#[derive(Clone, PartialEq, Eq)]
pub struct ProportionalBoost;
impl BoostStrategy for ProportionalBoost {
fn boost(extent: AuthorizationExtent) -> TransactionPriority {
if !in_budget(&extent) {
return 0;
}
// Byte remainder: `bytes_allowance` is non-zero by `in_budget`.
let bytes_rem = extent.bytes_allowance.saturating_sub(extent.bytes);
let bytes_share =
(ALLOWANCE_PRIORITY_BOOST as u128 * bytes_rem as u128) / extent.bytes_allowance as u128;
// Tx remainder: when `transactions_allowance == 0`, treat as no boost.
let tx_share = if extent.transactions_allowance == 0 {
0
} else {
let tx_rem = extent.transactions_allowance.saturating_sub(extent.transactions);
(ALLOWANCE_PRIORITY_BOOST as u128 * tx_rem as u128) /
extent.transactions_allowance as u128
};
bytes_share.min(tx_share) as u64
}
}

/// Flat boost while in-budget on both byte and tx axes, `0` otherwise.
#[derive(Clone, PartialEq, Eq)]
pub struct FlatBoost;
impl BoostStrategy for FlatBoost {
fn boost(extent: AuthorizationExtent) -> TransactionPriority {
if in_budget(&extent) {
ALLOWANCE_PRIORITY_BOOST
} else {
0
}
}
}

/// Boosts signed `store` / `store_with_cid_config` priority via a runtime-selected
/// [`BoostStrategy`]. Over-allowance txs still validate; they just don't get the boost.
#[derive(Clone, PartialEq, Eq, Encode, Decode, DecodeWithMemTracking, scale_info::TypeInfo)]
#[codec(encode_bound())]
#[codec(decode_bound())]
#[codec(mel_bound())]
#[scale_info(skip_type_params(T, B))]
pub struct AllowanceBasedPriority<T, B = FlatBoost>(PhantomData<(T, B)>);

impl<T, B> Default for AllowanceBasedPriority<T, B> {
fn default() -> Self {
Self(PhantomData)
}
}

impl<T: Config + Send + Sync, B: BoostStrategy> fmt::Debug for AllowanceBasedPriority<T, B> {
#[cfg(feature = "std")]
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "AllowanceBasedPriority")
}

#[cfg(not(feature = "std"))]
fn fmt(&self, _: &mut fmt::Formatter) -> fmt::Result {
Ok(())
}
}

impl<T: Config + Send + Sync, B: BoostStrategy + Send + Sync + 'static>
TransactionExtension<RuntimeCallOf<T>> for AllowanceBasedPriority<T, B>
where
RuntimeCallOf<T>: IsSubType<Call<T>>,
T::RuntimeOrigin: OriginTrait,
<T::RuntimeOrigin as OriginTrait>::PalletsOrigin: Clone + TryInto<Origin<T>>,
{
const IDENTIFIER: &'static str = "AllowanceBasedPriority";

type Implicit = ();
fn implicit(&self) -> Result<Self::Implicit, TransactionValidityError> {
Ok(())
}

type Val = ();
type Pre = ();

fn weight(&self, _call: &RuntimeCallOf<T>) -> Weight {
<T as frame_system::Config>::DbWeight::get().reads(1)
}

fn validate(
&self,
origin: T::RuntimeOrigin,
call: &RuntimeCallOf<T>,
_info: &DispatchInfoOf<RuntimeCallOf<T>>,
_len: usize,
_self_implicit: Self::Implicit,
_inherited_implication: &impl Implication,
_source: TransactionSource,
) -> ValidateResult<Self::Val, RuntimeCallOf<T>> {
let Some(inner_call) = call.is_sub_type() else {
return Ok((ValidTransaction::default(), (), origin));
};
// Only `store` / `store_with_cid_config` get the boost. `renew` also carries
// `Origin::Authorized` and does consume allowance, but it operates on
// already-stored data and shouldn't compete for the same priority slots as
// new submissions.
Comment on lines +402 to +405
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually argue renewals should have the same priority. We can change it in a future PR though after discussion

let this_tx_bytes = match inner_call {
Call::store { data } | Call::store_with_cid_config { data, .. } => data.len() as u64,
_ => return Ok((ValidTransaction::default(), (), origin)),
};

// `ValidateStorageCalls` earlier in the pipeline rewrites the origin to
// `Origin::Authorized`; only the account-scoped variant consumes the caller's allowance.
// Boost against the post-this-tx state so a single oversized tx is demoted on entry.
let priority = match origin.caller().clone().try_into() {
Ok(Origin::<T>::Authorized { who, scope: AuthorizationScope::Account(_) }) => {
let mut extent = Pallet::<T>::account_authorization_extent(who);
extent.bytes = extent.bytes.saturating_add(this_tx_bytes);
extent.transactions = extent.transactions.saturating_add(1);
B::boost(extent)
},
_ => 0,
};

Ok((ValidTransaction { priority, ..Default::default() }, (), origin))
}

fn prepare(
self,
_val: Self::Val,
_origin: &T::RuntimeOrigin,
_call: &RuntimeCallOf<T>,
_info: &DispatchInfoOf<RuntimeCallOf<T>>,
_len: usize,
) -> Result<Self::Pre, TransactionValidityError> {
Ok(())
}
}

#[cfg(test)]
mod boost_tests {
use super::*;

/// Build a post-this-tx extent on the byte axis. The tx counter is parked at
/// `(0, u32::MAX)` so the tx axis is never the binding constraint in byte-focused
/// tests; tx-axis behaviour is covered by the dedicated test below.
fn extent(bytes: u64, allowance: u64) -> AuthorizationExtent {
AuthorizationExtent {
bytes,
bytes_allowance: allowance,
transactions: 0,
transactions_allowance: u32::MAX,
}
}

const A: u64 = 1_000;
const BOOST: u64 = ALLOWANCE_PRIORITY_BOOST;

#[test]
fn proportional_scales_with_remaining_allowance() {
assert_eq!(ProportionalBoost::boost(extent(0, 0)), 0); // no auth
assert_eq!(ProportionalBoost::boost(extent(A, A)), 0); // at cap (post-tx)
assert_eq!(ProportionalBoost::boost(extent(A + 1, A)), 0); // over cap
assert_eq!(ProportionalBoost::boost(extent(0, A)), BOOST); // unused
assert_eq!(ProportionalBoost::boost(extent(A / 2, A)), BOOST / 2); // half
assert_eq!(ProportionalBoost::boost(extent(A * 3 / 4, A)), BOOST / 4); // three-quarters
}

#[test]
fn flat_is_constant_while_in_budget() {
assert_eq!(FlatBoost::boost(extent(0, 0)), 0); // no auth
assert_eq!(FlatBoost::boost(extent(A, A)), BOOST); // at cap (still in-budget)
assert_eq!(FlatBoost::boost(extent(A + 1, A)), 0); // over cap
assert_eq!(FlatBoost::boost(extent(0, A)), BOOST); // unused
assert_eq!(FlatBoost::boost(extent(A / 2, A)), BOOST); // half
assert_eq!(FlatBoost::boost(extent(A - 1, A)), BOOST); // just below cap
}

#[test]
fn flat_does_not_let_fresh_outrank_partly_used() {
let fresh = extent(0, A);
let partly_used = extent(A / 2, A);
assert!(ProportionalBoost::boost(fresh) > ProportionalBoost::boost(partly_used));
assert_eq!(FlatBoost::boost(fresh), FlatBoost::boost(partly_used));
}

#[test]
fn tx_axis_gates_boost_independently() {
// In-budget on bytes, over on transactions → no boost.
let over_tx = AuthorizationExtent {
bytes: 0,
bytes_allowance: A,
transactions: 11,
transactions_allowance: 10,
};
assert_eq!(FlatBoost::boost(over_tx), 0);
assert_eq!(ProportionalBoost::boost(over_tx), 0);

// In-budget on both axes; the tighter remainder caps the proportional share.
let tight_tx = AuthorizationExtent {
bytes: 0,
bytes_allowance: A,
transactions: 9,
transactions_allowance: 10,
};
assert_eq!(FlatBoost::boost(tight_tx), BOOST);
assert_eq!(ProportionalBoost::boost(tight_tx), BOOST / 10);
}
}
Loading
Loading