Skip to content

Add pallet-rate-limiting#2150

Open
ales-otf wants to merge 181 commits into
devnet-readyfrom
feat/rate-limit-pallet
Open

Add pallet-rate-limiting#2150
ales-otf wants to merge 181 commits into
devnet-readyfrom
feat/rate-limit-pallet

Conversation

@ales-otf
Copy link
Copy Markdown
Contributor

@ales-otf ales-otf commented Oct 21, 2025

Description

This PR introduces pallet-rate-limiting as a uniform rate-limiting solution and migrates a part of legacy rate limiting to this pallet.

Rate limits are set with pallet_rate_limiting::set_rate_limit(target, scope, limit). target is either one call or a group of calls. scope is optional context used to select the configured span (for example, netuid). If a call/group does not need context, it can be configured directly at runtime with target + limit and no resolver data.

There are standalone calls and groups of calls. Groups are defined at runtime, and each call can be either standalone or within a group, but not both. Groups allow multiple calls to share rate-limiting behavior. Depending on mode (ConfigOnly, UsageOnly, ConfigAndUsage), calls share config, usage tracking, or both.

For calls that need additional context, resolvers provide it. Limits can be configured either globally per target, or scoped per target+scope. The role of ScopeResolver is to provide that scope context (for example, netuid) so the extension can select the correct scoped limit entry. ScopeResolver can also adjust span (for example, tempo scaling) and define bypass behavior. UsageResolver resolves usage key(s) so LastSeen is tracked with additional context (for example, per account/per subnet/per mechanism), not only by target.

Enforcement happens via UnwrappedRateLimitTransactionExtension. It first unwraps nested calls (sudo, proxy, utility, multisig), then delegates each inner call to RateLimitTransactionExtension. The extension checks the resolved target/scope and compares current block vs LastSeen. If within span, validation fails with InvalidTransaction::Custom(1). On successful dispatch, the extension writes LastSeen for resolved usage key(s). This is what enforces rate limiting for subsequent calls.

Other pallets should use rate-limiting-interface (RateLimitingInterface) to read limits and last-seen state without depending on pallet internals. Writes through this interface should be avoided as much as possible because they introduce side-effects. The expected write path is the transaction extension itself. Manual set_last_seen writes are only for cases where usage must be updated outside the normal rate-limited call path.

pallet-rate-limiting is instanceable and is intended to be used as one instance per pallet, with local scope/usage types and resolvers. This runtime currently uses one centralized instance for pallet-subtensor + pallet-admin-utils as a transitional setup. Migration and resolvers already exist for grouped and standalone legacy limits, but this PR migrates only grouped legacy limits (GroupedRateLimitingMigration). Standalone migration/cleanup is deferred to a follow-up PR.

How to review:

I tried to organize this PR so that each major change is represented by a single commit, while avoiding meaningless commits as much as possible. You can refer to the commit history and review changes related to specific limits.

  1. To review how data is migrated: runtime/src/migrations/rate_limiting::commits_grouped. This is where you'll find everything considered "legacy" rate-limiting. You can then review each migration from the list separately.
  2. To review whether behavior is covered correctly: the migration above + runtime/src/rate_limiting. There you'll find the resolver implementations and can review how different calls are bypassed, adjusted to tempo, and scoped (for limits: only by NetUid; for last-seen timestamp: various cases).
  3. For proof of correctness and behavior: runtime/tests/rate_limiting. These are integration tests at the extrinsic level, where transaction extensions are involved. You can verify whether rate-limiting behavior is consistent with legacy behavior and whether all cases are covered.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Other (please describe):

⚠️ Breaking Changes

Deprecated extrinsics with their equivalents in pallet-rate-limiting

extrinsic (pallet-admin-utils) pallet-rate-limiting::set_rate_limit params
sudo_set_tx_rate_limit(tx_rate_limit) target = Group(GROUP_SWAP_KEYS), scope = None, limit = Exact(tx_rate_limit)
sudo_set_serving_rate_limit(netuid, serving_rate_limit) target = Group(GROUP_SERVE), scope = Some(netuid), limit = Exact(serving_rate_limit)
sudo_set_weights_set_rate_limit(netuid, weights_set_rate_limit) target = Group(GROUP_WEIGHTS_SET), scope = Some(netuid), limit = Exact(weights_set_rate_limit)
sudo_set_network_rate_limit(limit) target = Group(GROUP_REGISTER_NETWORK), scope = None, limit = Exact(limit)
sudo_set_tx_delegate_take_rate_limit(tx_rate_limit) target = Group(GROUP_DELEGATE_TAKE), scope = None, limit = Exact(tx_rate_limit)
sudo_set_owner_hparam_rate_limit(epochs) target = Group(GROUP_OWNER_HPARAMS), scope = Some(netuid), limit = Exact(epochs)

You can find the values of GROUP_* constants in common/src/rate_limiting.rs.

From the client's perspective, you can query pallet-rate-limiting::Groups storage to list all groups with their IDs and configuration, or pallet-rate-limiting::GroupNameIndex to get the ID of a particular group by name.

Removed storages from pallet-subtensor

On the client side, use pallet-rate-limiting::Limits storage to fetch limits, or pallet-rate-limiting::LastSeen to fetch last-seen timestamps.

  • NetworkRateLimit -> Limits({ Group: GROUP_REGISTER_NETWORK })
  • OwnerHyperparamRateLimit -> Limits({ Group: GROUP_OWNER_HPARAMS })
  • ServingRateLimit -> Limits({ Group: GROUP_SERVE }) then scoped value for netuid
  • StakingOperationRateLimiter -> LastSeen({ Group: GROUP_STAKING_OPS }, { ColdkeyHotkeySubnet: { coldkey, hotkey, netuid } })
  • TxDelegateTakeRateLimit -> Limits({ Group: GROUP_DELEGATE_TAKE })
  • TxRateLimit -> Limits({ Group: GROUP_SWAP_KEYS })
  • WeightsSetRateLimit -> Limits({ Group: GROUP_WEIGHTS_SET }) then scoped value for netuid

pallet-subtensor::Config changes

  • added type RateLimiting: RateLimitingInterface
  • removed:
    • InitialServingRateLimit
    • InitialTxRateLimit
    • InitialTxDelegateTakeRateLimit
    • InitialNetworkRateLimit

Removed events

They moved to pallet-rate-limiting::RateLimitSet event like this:

  • NetworkRateLimitSet -> { target: Group(GROUP_REGISTER_NETWORK), scope: None, limit: Exact(span) }
  • OwnerHyperparamRateLimitSet -> { target: Group(GROUP_OWNER_HPARAMS), scope: None, limit: Exact(epochs) }
  • ServingRateLimitSet -> { target: Group(GROUP_SERVE), scope: Some(netuid), limit: Exact(span) }
  • TxDelegateTakeRateLimitSet -> { target: Group(GROUP_DELEGATE_TAKE), scope: None, limit: Exact(span) }
  • TxRateLimitSet -> { target: Group(GROUP_SWAP_KEYS), scope: None, limit: Exact(span) }
  • WeightsSetRateLimitSet -> { target: Group(GROUP_WEIGHTS_SET), scope: Some(netuid), limit: Exact(span) }

Additional changes

get_network_lock_cost() now uses T::RateLimiting::last_seen(GROUP_REGISTER_NETWORK, None) instead of legacy RateLimitKey::NetworkLastRegistered.

pallet_admin_utils::sudo_set_tx_rate_limit -> pallet_rate_limiting::set_rate_limit(... GROUP_SWAP_KEYS) is now limit + 1:

  • before (pallet_admin_utils::sudo_set_tx_rate_limit(N)) - delta <= limit: a swap was still blocked when exactly N blocks had passed, and it became allowed only after N + 1 blocks (delta > limit);
  • now (pallet_rate_limiting::set_rate_limit(... GROUP_SWAP_KEYS, Exact(S))) - delta < span: a swap is blocked while fewer than S blocks have passed, and it is allowed at exactly S blocks (delta >= span).

So to keep the same real wait time as old N, you must set S = N + 1 (N = 0 stays 0).

The reason for this change is that pallet-rate-limiting uses one unified comparison rule (delta < span) for all limits. We keep that consistent and compensate for this specific legacy boundary in migration (+1).

Checklist

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have run cargo fmt and cargo clippy to ensure my code is formatted and linted correctly
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

@ales-otf ales-otf self-assigned this Oct 27, 2025
@ales-otf ales-otf added the skip-cargo-audit This PR fails cargo audit but needs to be merged anyway label Oct 27, 2025
Comment on lines +597 to +601
for entry in pre {
pallet_rate_limiting::RateLimitTransactionExtension::<Runtime>::post_dispatch(
entry, info, post_info, len, result,
)?;
}
Copy link
Copy Markdown
Collaborator

@l0r1s l0r1s Mar 16, 2026

Choose a reason for hiding this comment

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

If there are some storage writes, we need to account for them in the extension weight fn

Comment on lines +237 to +239
/// Scope identifier used to namespace stored rate limits.
type LimitScope: Parameter + Clone + PartialEq + Eq + Ord + MaybeSerializeDeserialize;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is kind of hardcoded even though it is behind a config type, no? Right now, it is defined as NetUid, but what will happen if we decide to have some calls scoped to NetUid and some other calls scoped to NetUid + MechId?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is the same as with the usage key. It's now NetUid only, because we didn't have other use cases for that. if you want to introduce something like NetUid + MechId, you'd introduce an enum, something like:

enum Scope {
    NetUid(NetUid),
    NetUidMechId(NetUid, MechId),
}

@sam0x17
Copy link
Copy Markdown
Contributor

sam0x17 commented Mar 23, 2026

just a few merge conflicts now

@ales-otf
Copy link
Copy Markdown
Contributor Author

just a few merge conflicts now

yeah, i have them after almost every push to devnet-ready... anyway i still need to address Loris' comments and adopt a few things we introduced while this PR was in review

ales-otf and others added 25 commits March 30, 2026 13:43
# Conflicts:
#	Cargo.lock
#	chain-extensions/src/tests.rs
#	contract-tests/src/subtensor.ts
#	contract-tests/test/subnet.precompile.hyperparameter.test.ts
#	contract-tests/test/wasm.contract.test.ts
#	pallets/admin-utils/Cargo.toml
#	pallets/admin-utils/src/lib.rs
#	pallets/subtensor/src/macros/config.rs
#	pallets/subtensor/src/migrations/mod.rs
#	pallets/subtensor/src/staking/add_stake.rs
#	pallets/subtensor/src/subnets/subnet.rs
#	pallets/subtensor/src/swap/swap_hotkey.rs
#	pallets/subtensor/src/tests/mechanism.rs
#	pallets/subtensor/src/tests/migration.rs
#	pallets/subtensor/src/tests/mock.rs
#	pallets/subtensor/src/tests/move_stake.rs
#	pallets/subtensor/src/tests/staking.rs
#	pallets/subtensor/src/tests/swap_hotkey.rs
#	pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs
#	pallets/subtensor/src/tests/weights.rs
#	pallets/subtensor/src/utils/misc.rs
#	pallets/transaction-fee/src/tests/mod.rs
#	precompiles/Cargo.toml
#	precompiles/src/lib.rs
#	runtime/src/lib.rs
# Conflicts:
#	pallets/subtensor/src/utils/misc.rs
# Conflicts:
#	pallets/subtensor/src/subnets/serving.rs
- merge devnet
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking-change This PR introduces a noteworthy breaking change skip-cargo-audit This PR fails cargo audit but needs to be merged anyway

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants