From de838d7abdf8c744906cb2399a266db10aa768f5 Mon Sep 17 00:00:00 2001 From: Paolo La Camera Date: Wed, 25 Feb 2026 10:24:39 +0100 Subject: [PATCH 1/4] Add MinSetKeysBond check in rc_client::set_keys to prevent relay chain storage spam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With minValidatorBond = 0, an attacker can bond ED then loop validate → set_keys → chill to store unlimited session keys on the relay chain at negligible cost. Add a configurable MinSetKeysBond threshold (hardcoded to 10k DOT/WND on asset-hub-westend) that rejects set_keys when active bond is insufficient. Set to 0 to disable (useful e.g. for asset-hub-kusama). --- .../assets/asset-hub-westend/src/staking.rs | 3 ++ .../integration-tests/src/ah/mock.rs | 5 ++ .../integration-tests/src/ah/test.rs | 54 ++++++++++++++++++- .../frame/staking-async/rc-client/src/lib.rs | 30 ++++++++++- .../runtimes/parachain/src/staking.rs | 1 + .../frame/staking-async/src/pallet/impls.rs | 5 ++ 6 files changed, 96 insertions(+), 2 deletions(-) diff --git a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs index 8c9b95ba71bc5..28c0691668e77 100644 --- a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs +++ b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs @@ -328,6 +328,9 @@ impl pallet_staking_async_rc_client::Config for Runtime { type ValidatorSetExportSession = ConstU32<4>; type RelayChainSessionKeys = RelayChainSessionKeys; type Balance = Balance; + // Hardcoded anti-spam threshold: 10k WND active bond to set session keys. + // Prevents unbounded relay chain storage growth via bond-validate-setkeys-chill loops. + type MinSetKeysBond = ConstU128<{ 10_000 * UNITS }>; // | Key | Crypto | Public Key | Signature | // |---------------------|---------|------------|-----------| // | grandpa | Ed25519 | 32 bytes | 64 bytes | diff --git a/substrate/frame/staking-async/integration-tests/src/ah/mock.rs b/substrate/frame/staking-async/integration-tests/src/ah/mock.rs index c240ab79ca591..f091fc0089471 100644 --- a/substrate/frame/staking-async/integration-tests/src/ah/mock.rs +++ b/substrate/frame/staking-async/integration-tests/src/ah/mock.rs @@ -491,6 +491,10 @@ frame::deps::sp_runtime::impl_opaque_keys! { } } +parameter_types! { + pub static MinSetKeysBond: Balance = 0; +} + impl pallet_staking_async_rc_client::Config for Runtime { type AHStakingInterface = Staking; type SendToRelayChain = DeliverToRelay; @@ -499,6 +503,7 @@ impl pallet_staking_async_rc_client::Config for Runtime { type ValidatorSetExportSession = ValidatorSetExportSession; type RelayChainSessionKeys = RCSessionKeys; type Balance = Balance; + type MinSetKeysBond = MinSetKeysBond; type WeightInfo = (); } diff --git a/substrate/frame/staking-async/integration-tests/src/ah/test.rs b/substrate/frame/staking-async/integration-tests/src/ah/test.rs index dadc2c998c17d..9be57d5d84672 100644 --- a/substrate/frame/staking-async/integration-tests/src/ah/test.rs +++ b/substrate/frame/staking-async/integration-tests/src/ah/test.rs @@ -1415,7 +1415,7 @@ mod poll_operations { mod session_keys { use super::*; use crate::ah::mock::{ - Balances, LocalQueue, OutgoingMessages, ProxyType, PurgeKeysExecutionCost, + Balances, LocalQueue, MinSetKeysBond, OutgoingMessages, ProxyType, PurgeKeysExecutionCost, SetKeysExecutionCost, }; use codec::Encode; @@ -1881,6 +1881,58 @@ mod session_keys { }); } + #[test] + fn set_keys_insufficient_bond() { + ExtBuilder::default().local_queue().build().execute_with(|| { + let validator: AccountId = 1; + let (keys, proof) = make_session_keys_and_proof(validator); + + // GIVEN: MinSetKeysBond is set higher than the validator's active bond (100) + MinSetKeysBond::set(101); + + // WHEN: Validator tries to set keys + // THEN: InsufficientBond error is returned + assert_noop!( + rc_client::Pallet::::set_keys( + RuntimeOrigin::signed(validator), + keys.clone(), + proof.clone(), + None, + ), + rc_client::Error::::InsufficientBond + ); + + // GIVEN: MinSetKeysBond equals the validator's active bond + MinSetKeysBond::set(100); + + // WHEN: Validator sets keys with exact bond + // THEN: Succeeds + assert_ok!(rc_client::Pallet::::set_keys( + RuntimeOrigin::signed(validator), + keys.clone(), + proof.clone(), + None, + )); + }); + } + + #[test] + fn set_keys_min_bond_zero_disables_check() { + ExtBuilder::default().local_queue().build().execute_with(|| { + // GIVEN: MinSetKeysBond is 0 (default in tests) — check is disabled + let validator: AccountId = 1; + let (keys, proof) = make_session_keys_and_proof(validator); + + // WHEN/THEN: set_keys succeeds regardless of bond amount + assert_ok!(rc_client::Pallet::::set_keys( + RuntimeOrigin::signed(validator), + keys, + proof, + None, + )); + }); + } + /// End-to-end test: set keys on AssetHub, verify on RelayChain, then purge and verify. #[test] fn set_and_purge_keys_e2e() { diff --git a/substrate/frame/staking-async/rc-client/src/lib.rs b/substrate/frame/staking-async/rc-client/src/lib.rs index 94c788a7ea7fa..453135fa6b27e 100644 --- a/substrate/frame/staking-async/rc-client/src/lib.rs +++ b/substrate/frame/staking-async/rc-client/src/lib.rs @@ -835,6 +835,8 @@ where pub trait AHStakingInterface { /// The validator account id type. type AccountId; + /// The balance type. + type Balance: BalanceTrait; /// Maximum number of validators that the staking system may have. type MaxValidatorSet: Get; @@ -868,6 +870,9 @@ pub trait AHStakingInterface { /// /// Returns true if the account has called `validate()` and is in the `Validators` storage. fn is_validator(who: &Self::AccountId) -> bool; + + /// Returns the active bonded amount for a stash, or `None` if not bonded. + fn active_bond(who: &Self::AccountId) -> Option; } /// The communication trait of `pallet-staking-async` -> `pallet-staking-async-rc-client`. @@ -1010,7 +1015,10 @@ pub mod pallet { type RelayChainOrigin: EnsureOrigin; /// Our communication handle to the local staking pallet. - type AHStakingInterface: AHStakingInterface; + type AHStakingInterface: AHStakingInterface< + AccountId = Self::AccountId, + Balance = Self::Balance, + >; /// Our communication handle to the relay chain. type SendToRelayChain: SendToRelayChain< @@ -1058,6 +1066,16 @@ pub mod pallet { /// The balance type used for delivery fee limits. type Balance: BalanceTrait; + /// Minimum active bond required to call `set_keys`. + /// + /// Prevents relay chain storage spam: without this, an attacker could bond the + /// existential deposit, call `validate → set_keys → chill` in a loop, storing + /// unlimited session keys on the relay chain at negligible cost. + /// + /// Set to 0 to disable the check. + #[pallet::constant] + type MinSetKeysBond: Get>; + /// Weight information for extrinsics in this pallet. type WeightInfo: WeightInfo; } @@ -1078,6 +1096,8 @@ pub mod pallet { InvalidProof, /// Delivery fees exceeded the specified maximum. FeesExceededMax, + /// The stash's active bond is below `MinSetKeysBond`. + InsufficientBond, } #[pallet::event] @@ -1297,6 +1317,14 @@ pub mod pallet { // Only registered validators can set session keys ensure!(T::AHStakingInterface::is_validator(&stash), Error::::NotValidator); + // Ensure active bond meets the minimum to prevent RC storage spam + let min_bond = T::MinSetKeysBond::get(); + if !min_bond.is_zero() { + let active = T::AHStakingInterface::active_bond(&stash) + .ok_or(Error::::InsufficientBond)?; + ensure!(active >= min_bond, Error::::InsufficientBond); + } + // Validate keys: decode as RelayChainSessionKeys to ensure correct format let session_keys = T::RelayChainSessionKeys::decode(&mut &keys[..]) .map_err(|_| Error::::InvalidKeys)?; diff --git a/substrate/frame/staking-async/runtimes/parachain/src/staking.rs b/substrate/frame/staking-async/runtimes/parachain/src/staking.rs index 48b262ab1ed6e..6d6b1a025a70a 100644 --- a/substrate/frame/staking-async/runtimes/parachain/src/staking.rs +++ b/substrate/frame/staking-async/runtimes/parachain/src/staking.rs @@ -487,6 +487,7 @@ impl pallet_staking_async_rc_client::Config for Runtime { type ValidatorSetExportSession = ConstU32<4>; type RelayChainSessionKeys = RelayChainSessionKeys; type Balance = Balance; + type MinSetKeysBond = ConstU128<{ 10_000 * UNITS }>; type WeightInfo = (); } diff --git a/substrate/frame/staking-async/src/pallet/impls.rs b/substrate/frame/staking-async/src/pallet/impls.rs index 19e4420e2ba91..233b72c08d488 100644 --- a/substrate/frame/staking-async/src/pallet/impls.rs +++ b/substrate/frame/staking-async/src/pallet/impls.rs @@ -1112,6 +1112,7 @@ impl ElectionDataProvider for Pallet { impl rc_client::AHStakingInterface for Pallet { type AccountId = T::AccountId; + type Balance = BalanceOf; type MaxValidatorSet = T::MaxValidatorSet; /// When we receive a session report from the relay chain, it kicks off the next session. @@ -1345,6 +1346,10 @@ impl rc_client::AHStakingInterface for Pallet { fn is_validator(who: &Self::AccountId) -> bool { Validators::::contains_key(who) } + + fn active_bond(who: &Self::AccountId) -> Option> { + Self::ledger(StakingAccount::Stash(who.clone())).ok().map(|l| l.active) + } } impl ScoreProvider for Pallet { From a45889fa8c92cd63a7d95a08cc1ab12b53280f6d Mon Sep 17 00:00:00 2001 From: "cmd[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:32:05 +0000 Subject: [PATCH 2/4] Update from github-actions[bot] running command 'prdoc --audience runtime_dev --bump major' --- prdoc/pr_11168.prdoc | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 prdoc/pr_11168.prdoc diff --git a/prdoc/pr_11168.prdoc b/prdoc/pr_11168.prdoc new file mode 100644 index 0000000000000..bcf59d7301421 --- /dev/null +++ b/prdoc/pr_11168.prdoc @@ -0,0 +1,16 @@ +title: Add MinSetKeysBond check in rc_client::set_keys to prevent relay chain storage + spam +doc: +- audience: Runtime Dev + description: "With minValidatorBond = 0, an attacker can bond ED then loop validate\ + \ \u2192 set_keys \u2192 chill to store unlimited session keys on the relay chain\ + \ at negligible cost.\nAdd a configurable MinSetKeysBond threshold (hardcoded\ + \ to 10k DOT/WND on asset-hub-westend) that rejects set_keys when active bond\ + \ is insufficient.\nSet to 0 to disable (useful e.g. for asset-hub-kusama)." +crates: +- name: asset-hub-westend-runtime + bump: major +- name: pallet-staking-async-rc-client + bump: major +- name: pallet-staking-async + bump: major From c910a72bae611f2596de77c2bfeebf21847dae36 Mon Sep 17 00:00:00 2001 From: Paolo La Camera Date: Wed, 25 Feb 2026 10:43:10 +0100 Subject: [PATCH 3/4] 10WND as threashold on WAH --- .../runtimes/assets/asset-hub-westend/src/staking.rs | 6 ++++-- prdoc/pr_11168.prdoc | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs index 28c0691668e77..53ff6e34004ad 100644 --- a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs +++ b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs @@ -329,8 +329,10 @@ impl pallet_staking_async_rc_client::Config for Runtime { type RelayChainSessionKeys = RelayChainSessionKeys; type Balance = Balance; // Hardcoded anti-spam threshold: 10k WND active bond to set session keys. - // Prevents unbounded relay chain storage growth via bond-validate-setkeys-chill loops. - type MinSetKeysBond = ConstU128<{ 10_000 * UNITS }>; + // Prevents unbounded relay chain storage growth via bond-validate-set_keys-chill loops. + // For a testnet like Westend AH though, we just prioritize ease of testing so we set it + // ridiculously low. On Polkadot, this should be set to a more meaningful value. + type MinSetKeysBond = ConstU128<{ 10 * UNITS }>; // | Key | Crypto | Public Key | Signature | // |---------------------|---------|------------|-----------| // | grandpa | Ed25519 | 32 bytes | 64 bytes | diff --git a/prdoc/pr_11168.prdoc b/prdoc/pr_11168.prdoc index bcf59d7301421..f5f1c6f48e00c 100644 --- a/prdoc/pr_11168.prdoc +++ b/prdoc/pr_11168.prdoc @@ -5,7 +5,7 @@ doc: description: "With minValidatorBond = 0, an attacker can bond ED then loop validate\ \ \u2192 set_keys \u2192 chill to store unlimited session keys on the relay chain\ \ at negligible cost.\nAdd a configurable MinSetKeysBond threshold (hardcoded\ - \ to 10k DOT/WND on asset-hub-westend) that rejects set_keys when active bond\ + \ to 10 WND on asset-hub-westend) that rejects set_keys when active bond\ \ is insufficient.\nSet to 0 to disable (useful e.g. for asset-hub-kusama)." crates: - name: asset-hub-westend-runtime From 68709ea00e5379f8510bc9b024b843e2fabafa6e Mon Sep 17 00:00:00 2001 From: Paolo La Camera Date: Wed, 25 Feb 2026 10:49:38 +0100 Subject: [PATCH 4/4] fix review comments --- .../assets/asset-hub-westend/src/staking.rs | 4 ---- prdoc/pr_11168.prdoc | 11 ++++------- substrate/frame/staking-async/rc-client/src/lib.rs | 13 +++---------- substrate/frame/staking-async/src/pallet/impls.rs | 2 +- 4 files changed, 8 insertions(+), 22 deletions(-) diff --git a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs index 53ff6e34004ad..5ddba7e1fea49 100644 --- a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs +++ b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs @@ -328,10 +328,6 @@ impl pallet_staking_async_rc_client::Config for Runtime { type ValidatorSetExportSession = ConstU32<4>; type RelayChainSessionKeys = RelayChainSessionKeys; type Balance = Balance; - // Hardcoded anti-spam threshold: 10k WND active bond to set session keys. - // Prevents unbounded relay chain storage growth via bond-validate-set_keys-chill loops. - // For a testnet like Westend AH though, we just prioritize ease of testing so we set it - // ridiculously low. On Polkadot, this should be set to a more meaningful value. type MinSetKeysBond = ConstU128<{ 10 * UNITS }>; // | Key | Crypto | Public Key | Signature | // |---------------------|---------|------------|-----------| diff --git a/prdoc/pr_11168.prdoc b/prdoc/pr_11168.prdoc index f5f1c6f48e00c..9f5988b9b9e80 100644 --- a/prdoc/pr_11168.prdoc +++ b/prdoc/pr_11168.prdoc @@ -1,12 +1,9 @@ -title: Add MinSetKeysBond check in rc_client::set_keys to prevent relay chain storage - spam +title: Add MinSetKeysBond check in rc_client::set_keys doc: - audience: Runtime Dev - description: "With minValidatorBond = 0, an attacker can bond ED then loop validate\ - \ \u2192 set_keys \u2192 chill to store unlimited session keys on the relay chain\ - \ at negligible cost.\nAdd a configurable MinSetKeysBond threshold (hardcoded\ - \ to 10 WND on asset-hub-westend) that rejects set_keys when active bond\ - \ is insufficient.\nSet to 0 to disable (useful e.g. for asset-hub-kusama)." + description: |- + Add a configurable MinSetKeysBond threshold (hardcoded to 10 WND on asset-hub-westend) that rejects set_keys when active bond + is insufficient. Set to 0 to disable. crates: - name: asset-hub-westend-runtime bump: major diff --git a/substrate/frame/staking-async/rc-client/src/lib.rs b/substrate/frame/staking-async/rc-client/src/lib.rs index 453135fa6b27e..12afda79b85f3 100644 --- a/substrate/frame/staking-async/rc-client/src/lib.rs +++ b/substrate/frame/staking-async/rc-client/src/lib.rs @@ -872,7 +872,7 @@ pub trait AHStakingInterface { fn is_validator(who: &Self::AccountId) -> bool; /// Returns the active bonded amount for a stash, or `None` if not bonded. - fn active_bond(who: &Self::AccountId) -> Option; + fn active_stake(who: &Self::AccountId) -> Option; } /// The communication trait of `pallet-staking-async` -> `pallet-staking-async-rc-client`. @@ -1066,13 +1066,7 @@ pub mod pallet { /// The balance type used for delivery fee limits. type Balance: BalanceTrait; - /// Minimum active bond required to call `set_keys`. - /// - /// Prevents relay chain storage spam: without this, an attacker could bond the - /// existential deposit, call `validate → set_keys → chill` in a loop, storing - /// unlimited session keys on the relay chain at negligible cost. - /// - /// Set to 0 to disable the check. + /// Minimum active bond required to call `set_keys`. Set to 0 to disable. #[pallet::constant] type MinSetKeysBond: Get>; @@ -1317,10 +1311,9 @@ pub mod pallet { // Only registered validators can set session keys ensure!(T::AHStakingInterface::is_validator(&stash), Error::::NotValidator); - // Ensure active bond meets the minimum to prevent RC storage spam let min_bond = T::MinSetKeysBond::get(); if !min_bond.is_zero() { - let active = T::AHStakingInterface::active_bond(&stash) + let active = T::AHStakingInterface::active_stake(&stash) .ok_or(Error::::InsufficientBond)?; ensure!(active >= min_bond, Error::::InsufficientBond); } diff --git a/substrate/frame/staking-async/src/pallet/impls.rs b/substrate/frame/staking-async/src/pallet/impls.rs index 233b72c08d488..949bcc9c5f9d7 100644 --- a/substrate/frame/staking-async/src/pallet/impls.rs +++ b/substrate/frame/staking-async/src/pallet/impls.rs @@ -1347,7 +1347,7 @@ impl rc_client::AHStakingInterface for Pallet { Validators::::contains_key(who) } - fn active_bond(who: &Self::AccountId) -> Option> { + fn active_stake(who: &Self::AccountId) -> Option> { Self::ledger(StakingAccount::Stash(who.clone())).ok().map(|l| l.active) } }